batch_image_queue.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. package repository
  2. import (
  3. "context"
  4. "crypto/rand"
  5. "encoding/hex"
  6. "errors"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/config"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. "github.com/redis/go-redis/v9"
  11. )
  12. const (
  13. defaultBatchImageReadyKey = "batch_image:queue:ready"
  14. defaultBatchImageDelayedKey = "batch_image:queue:delayed"
  15. defaultBatchImageActiveKey = "batch_image:queue:active"
  16. defaultBatchImageInflightPrefix = "batch_image:queue:inflight:"
  17. defaultBatchImageLockPrefix = "batch_image:queue:lock:"
  18. defaultBatchImageInflightTTL = 7 * 24 * time.Hour
  19. defaultBatchImageJobLockTTL = 5 * time.Minute
  20. // batchImageReservePollInterval 是原子 Reserve 脚本空轮询的间隔。
  21. // 用轮询替代 BRPop 是为了保证 "弹出 + 写 active" 的原子性。
  22. batchImageReservePollInterval = time.Second
  23. )
  24. var batchImageMoveDueDelayedScript = redis.NewScript(`
  25. local jobs = redis.call("ZRANGEBYSCORE", KEYS[1], "-inf", ARGV[1], "LIMIT", 0, ARGV[2])
  26. for _, job in ipairs(jobs) do
  27. redis.call("ZREM", KEYS[1], job)
  28. redis.call("LPUSH", KEYS[2], job)
  29. end
  30. return #jobs
  31. `)
  32. var batchImageRecoverStaleActiveScript = redis.NewScript(`
  33. local jobs = redis.call("ZRANGEBYSCORE", KEYS[1], "-inf", ARGV[1], "LIMIT", 0, ARGV[2])
  34. for _, job in ipairs(jobs) do
  35. redis.call("ZREM", KEYS[1], job)
  36. redis.call("LPUSH", KEYS[2], job)
  37. end
  38. return #jobs
  39. `)
  40. var batchImageReleaseLockScript = redis.NewScript(`
  41. if redis.call("GET", KEYS[1]) == ARGV[1] then
  42. return redis.call("DEL", KEYS[1])
  43. end
  44. return 0
  45. `)
  46. var batchImageRefreshLockScript = redis.NewScript(`
  47. if redis.call("GET", KEYS[1]) == ARGV[1] then
  48. return redis.call("PEXPIRE", KEYS[1], ARGV[2])
  49. end
  50. return 0
  51. `)
  52. // batchImageReserveScript 原子地从 ready 弹出并写入 active zset。
  53. // BRPop + ZAdd 两步方案在两步之间进程崩溃时 job 会脱离所有队列结构,
  54. // 且 inflight 去重键(默认 7 天)会挡住所有重新入队。
  55. var batchImageReserveScript = redis.NewScript(`
  56. local job = redis.call("RPOP", KEYS[1])
  57. if not job then
  58. return nil
  59. end
  60. redis.call("ZADD", KEYS[2], ARGV[1], job)
  61. return job
  62. `)
  63. // batchImageEnqueueScript 原子地设置 inflight 去重键并推入 ready。
  64. // SetNX + LPush 两步方案在两步之间进程崩溃时,inflight 键(默认 7 天)
  65. // 会挡住所有后续入队,而 job 从未进入 ready。
  66. var batchImageEnqueueScript = redis.NewScript(`
  67. if redis.call("SET", KEYS[1], ARGV[1], "NX", "PX", ARGV[2]) then
  68. redis.call("LPUSH", KEYS[2], ARGV[1])
  69. return 1
  70. end
  71. return 0
  72. `)
  73. type batchImageQueue struct {
  74. rdb *redis.Client
  75. readyKey string
  76. delayedKey string
  77. activeKey string
  78. inflightPrefix string
  79. lockPrefix string
  80. inflightTTL time.Duration
  81. lockTTL time.Duration
  82. }
  83. func NewBatchImageQueue(rdb *redis.Client, cfg *config.Config) service.BatchImageQueue {
  84. return newBatchImageQueueWithOptions(rdb, batchImageQueueOptionsFromConfig(cfg))
  85. }
  86. type batchImageQueueOptions struct {
  87. ReadyKey string
  88. DelayedKey string
  89. ActiveKey string
  90. InflightPrefix string
  91. LockPrefix string
  92. InflightTTL time.Duration
  93. LockTTL time.Duration
  94. }
  95. func newBatchImageQueueWithOptions(rdb *redis.Client, opts batchImageQueueOptions) *batchImageQueue {
  96. opts = normalizeBatchImageQueueOptions(opts)
  97. return &batchImageQueue{
  98. rdb: rdb,
  99. readyKey: opts.ReadyKey,
  100. delayedKey: opts.DelayedKey,
  101. activeKey: opts.ActiveKey,
  102. inflightPrefix: opts.InflightPrefix,
  103. lockPrefix: opts.LockPrefix,
  104. inflightTTL: opts.InflightTTL,
  105. lockTTL: opts.LockTTL,
  106. }
  107. }
  108. func batchImageQueueOptionsFromConfig(cfg *config.Config) batchImageQueueOptions {
  109. if cfg == nil {
  110. return batchImageQueueOptions{}
  111. }
  112. return batchImageQueueOptions{
  113. ReadyKey: cfg.BatchImage.QueueReadyKey,
  114. DelayedKey: cfg.BatchImage.QueueDelayedKey,
  115. ActiveKey: cfg.BatchImage.QueueActiveKey,
  116. InflightPrefix: cfg.BatchImage.InflightKeyPrefix,
  117. LockPrefix: cfg.BatchImage.LockKeyPrefix,
  118. InflightTTL: time.Duration(cfg.BatchImage.InflightTTLSeconds) * time.Second,
  119. LockTTL: time.Duration(cfg.BatchImage.JobLockTTLSeconds) * time.Second,
  120. }
  121. }
  122. func normalizeBatchImageQueueOptions(opts batchImageQueueOptions) batchImageQueueOptions {
  123. if opts.ReadyKey == "" {
  124. opts.ReadyKey = defaultBatchImageReadyKey
  125. }
  126. if opts.DelayedKey == "" {
  127. opts.DelayedKey = defaultBatchImageDelayedKey
  128. }
  129. if opts.ActiveKey == "" {
  130. opts.ActiveKey = defaultBatchImageActiveKey
  131. }
  132. if opts.InflightPrefix == "" {
  133. opts.InflightPrefix = defaultBatchImageInflightPrefix
  134. }
  135. if opts.LockPrefix == "" {
  136. opts.LockPrefix = defaultBatchImageLockPrefix
  137. }
  138. if opts.InflightTTL <= 0 {
  139. opts.InflightTTL = defaultBatchImageInflightTTL
  140. }
  141. if opts.LockTTL <= 0 {
  142. opts.LockTTL = defaultBatchImageJobLockTTL
  143. }
  144. return opts
  145. }
  146. func (q *batchImageQueue) Enqueue(ctx context.Context, batchID string) error {
  147. if !service.IsValidBatchImageID(batchID) {
  148. return service.ErrInvalidBatchImageQueuePayload
  149. }
  150. applied, err := batchImageEnqueueScript.Run(ctx, q.rdb,
  151. []string{q.inflightKey(batchID), q.readyKey},
  152. batchID, q.inflightTTL.Milliseconds(),
  153. ).Int()
  154. if err != nil {
  155. return err
  156. }
  157. if applied == 0 {
  158. return service.ErrBatchImageAlreadyQueued
  159. }
  160. return nil
  161. }
  162. func (q *batchImageQueue) Reserve(ctx context.Context, blockTimeout time.Duration) (service.ReservedBatchImageJob, error) {
  163. deadline := time.Now().Add(blockTimeout)
  164. for {
  165. batchID, err := q.reserveOnce(ctx)
  166. if err == nil {
  167. return service.ReservedBatchImageJob{BatchID: batchID}, nil
  168. }
  169. if !errors.Is(err, service.ErrBatchImageQueueEmpty) {
  170. return service.ReservedBatchImageJob{}, err
  171. }
  172. remaining := time.Until(deadline)
  173. if remaining <= 0 {
  174. return service.ReservedBatchImageJob{}, service.ErrBatchImageQueueEmpty
  175. }
  176. wait := batchImageReservePollInterval
  177. if remaining < wait {
  178. wait = remaining
  179. }
  180. timer := time.NewTimer(wait)
  181. select {
  182. case <-ctx.Done():
  183. timer.Stop()
  184. return service.ReservedBatchImageJob{}, ctx.Err()
  185. case <-timer.C:
  186. }
  187. }
  188. }
  189. func (q *batchImageQueue) reserveOnce(ctx context.Context) (string, error) {
  190. raw, err := batchImageReserveScript.Run(ctx, q.rdb, []string{q.readyKey, q.activeKey}, time.Now().UnixMilli()).Result()
  191. if errors.Is(err, redis.Nil) {
  192. return "", service.ErrBatchImageQueueEmpty
  193. }
  194. if err != nil {
  195. return "", err
  196. }
  197. batchID, ok := raw.(string)
  198. if !ok || !service.IsValidBatchImageID(batchID) {
  199. // 非法 payload 已被脚本写入 active,必须移除,否则 stale 恢复会把它
  200. // 无限重投回 ready。
  201. if ok && batchID != "" {
  202. _ = q.rdb.ZRem(ctx, q.activeKey, batchID).Err()
  203. }
  204. return "", service.ErrInvalidBatchImageQueuePayload
  205. }
  206. return batchID, nil
  207. }
  208. func (q *batchImageQueue) RequeueAfter(ctx context.Context, batchID string, delay time.Duration) error {
  209. if !service.IsValidBatchImageID(batchID) {
  210. return service.ErrInvalidBatchImageQueuePayload
  211. }
  212. pipe := q.rdb.TxPipeline()
  213. pipe.ZRem(ctx, q.activeKey, batchID)
  214. pipe.ZRem(ctx, q.delayedKey, batchID)
  215. if delay <= 0 {
  216. pipe.LPush(ctx, q.readyKey, batchID)
  217. } else {
  218. pipe.ZAdd(ctx, q.delayedKey, redis.Z{
  219. Score: float64(time.Now().Add(delay).UnixMilli()),
  220. Member: batchID,
  221. })
  222. }
  223. _, err := pipe.Exec(ctx)
  224. return err
  225. }
  226. func (q *batchImageQueue) Ack(ctx context.Context, batchID string) error {
  227. if !service.IsValidBatchImageID(batchID) {
  228. return service.ErrInvalidBatchImageQueuePayload
  229. }
  230. pipe := q.rdb.TxPipeline()
  231. pipe.ZRem(ctx, q.activeKey, batchID)
  232. pipe.ZRem(ctx, q.delayedKey, batchID)
  233. pipe.Del(ctx, q.inflightKey(batchID))
  234. _, err := pipe.Exec(ctx)
  235. return err
  236. }
  237. func (q *batchImageQueue) Heartbeat(ctx context.Context, batchID string) error {
  238. if !service.IsValidBatchImageID(batchID) {
  239. return service.ErrInvalidBatchImageQueuePayload
  240. }
  241. // XX:只刷新已存在的 active 成员。无条件 ZAdd 会在 Ack/Requeue 之后的
  242. // 竞态心跳里把幽灵成员塞回 active zset。
  243. return q.rdb.ZAddXX(ctx, q.activeKey, redis.Z{
  244. Score: float64(time.Now().UnixMilli()),
  245. Member: batchID,
  246. }).Err()
  247. }
  248. func (q *batchImageQueue) MoveDueDelayedToReady(ctx context.Context, limit int) (int, error) {
  249. if limit <= 0 {
  250. limit = 100
  251. }
  252. return batchImageMoveDueDelayedScript.Run(ctx, q.rdb, []string{q.delayedKey, q.readyKey}, time.Now().UnixMilli(), limit).Int()
  253. }
  254. func (q *batchImageQueue) RecoverStaleActive(ctx context.Context, staleAfter time.Duration, limit int) (int, error) {
  255. if staleAfter <= 0 {
  256. return 0, service.ErrInvalidBatchImageQueuePayload
  257. }
  258. if limit <= 0 {
  259. limit = 100
  260. }
  261. cutoff := time.Now().Add(-staleAfter).UnixMilli()
  262. return batchImageRecoverStaleActiveScript.Run(ctx, q.rdb, []string{q.activeKey, q.readyKey}, cutoff, limit).Int()
  263. }
  264. func (q *batchImageQueue) TryAcquireJobLock(ctx context.Context, batchID string, ttl time.Duration) (service.BatchImageJobLock, bool, error) {
  265. if !service.IsValidBatchImageID(batchID) {
  266. return nil, false, service.ErrInvalidBatchImageQueuePayload
  267. }
  268. if ttl <= 0 {
  269. ttl = q.lockTTL
  270. }
  271. token, err := newBatchImageLockToken()
  272. if err != nil {
  273. return nil, false, err
  274. }
  275. key := q.lockKey(batchID)
  276. ok, err := q.rdb.SetNX(ctx, key, token, ttl).Result()
  277. if err != nil {
  278. return nil, false, err
  279. }
  280. if !ok {
  281. return nil, false, nil
  282. }
  283. return &batchImageRedisJobLock{rdb: q.rdb, key: key, token: token}, true, nil
  284. }
  285. func (q *batchImageQueue) inflightKey(batchID string) string {
  286. return q.inflightPrefix + batchID
  287. }
  288. func (q *batchImageQueue) lockKey(batchID string) string {
  289. return q.lockPrefix + batchID
  290. }
  291. type batchImageRedisJobLock struct {
  292. rdb *redis.Client
  293. key string
  294. token string
  295. }
  296. func (l *batchImageRedisJobLock) Release(ctx context.Context) error {
  297. if l == nil || l.rdb == nil || l.key == "" || l.token == "" {
  298. return nil
  299. }
  300. return batchImageReleaseLockScript.Run(ctx, l.rdb, []string{l.key}, l.token).Err()
  301. }
  302. // Refresh 在仍持有锁(token 匹配)时续期 TTL,供长处理任务的心跳调用。
  303. func (l *batchImageRedisJobLock) Refresh(ctx context.Context, ttl time.Duration) error {
  304. if l == nil || l.rdb == nil || l.key == "" || l.token == "" {
  305. return nil
  306. }
  307. if ttl <= 0 {
  308. ttl = defaultBatchImageJobLockTTL
  309. }
  310. return batchImageRefreshLockScript.Run(ctx, l.rdb, []string{l.key}, l.token, ttl.Milliseconds()).Err()
  311. }
  312. var _ service.BatchImageJobLockRefresher = (*batchImageRedisJobLock)(nil)
  313. func newBatchImageLockToken() (string, error) {
  314. var b [16]byte
  315. if _, err := rand.Read(b[:]); err != nil {
  316. return "", err
  317. }
  318. return hex.EncodeToString(b[:]), nil
  319. }
  320. var _ service.BatchImageQueue = (*batchImageQueue)(nil)