@nicnocquee/dataqueue 1.24.0 → 1.26.0-beta.20260223195940

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +44 -0
  2. package/ai/build-docs-content.ts +96 -0
  3. package/ai/build-llms-full.ts +42 -0
  4. package/ai/docs-content.json +278 -0
  5. package/ai/rules/advanced.md +132 -0
  6. package/ai/rules/basic.md +159 -0
  7. package/ai/rules/react-dashboard.md +83 -0
  8. package/ai/skills/dataqueue-advanced/SKILL.md +320 -0
  9. package/ai/skills/dataqueue-core/SKILL.md +234 -0
  10. package/ai/skills/dataqueue-react/SKILL.md +189 -0
  11. package/dist/cli.cjs +1149 -14
  12. package/dist/cli.cjs.map +1 -1
  13. package/dist/cli.d.cts +66 -1
  14. package/dist/cli.d.ts +66 -1
  15. package/dist/cli.js +1146 -13
  16. package/dist/cli.js.map +1 -1
  17. package/dist/index.cjs +4630 -928
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.cts +1033 -15
  20. package/dist/index.d.ts +1033 -15
  21. package/dist/index.js +4626 -929
  22. package/dist/index.js.map +1 -1
  23. package/dist/mcp-server.cjs +186 -0
  24. package/dist/mcp-server.cjs.map +1 -0
  25. package/dist/mcp-server.d.cts +32 -0
  26. package/dist/mcp-server.d.ts +32 -0
  27. package/dist/mcp-server.js +175 -0
  28. package/dist/mcp-server.js.map +1 -0
  29. package/migrations/1751131910825_add_timeout_seconds_to_job_queue.sql +2 -2
  30. package/migrations/1751186053000_add_job_events_table.sql +12 -8
  31. package/migrations/1751984773000_add_tags_to_job_queue.sql +1 -1
  32. package/migrations/1765809419000_add_force_kill_on_timeout_to_job_queue.sql +1 -1
  33. package/migrations/1771100000000_add_idempotency_key_to_job_queue.sql +7 -0
  34. package/migrations/1781200000000_add_wait_support.sql +12 -0
  35. package/migrations/1781200000001_create_waitpoints_table.sql +18 -0
  36. package/migrations/1781200000002_add_performance_indexes.sql +34 -0
  37. package/migrations/1781200000003_add_progress_to_job_queue.sql +7 -0
  38. package/migrations/1781200000004_create_cron_schedules_table.sql +33 -0
  39. package/migrations/1781200000005_add_retry_config_to_job_queue.sql +17 -0
  40. package/package.json +40 -23
  41. package/src/backend.ts +328 -0
  42. package/src/backends/postgres.ts +2040 -0
  43. package/src/backends/redis-scripts.ts +865 -0
  44. package/src/backends/redis.test.ts +1906 -0
  45. package/src/backends/redis.ts +1792 -0
  46. package/src/cli.test.ts +82 -6
  47. package/src/cli.ts +73 -10
  48. package/src/cron.test.ts +126 -0
  49. package/src/cron.ts +40 -0
  50. package/src/db-util.ts +4 -2
  51. package/src/index.test.ts +688 -1
  52. package/src/index.ts +277 -39
  53. package/src/init-command.test.ts +449 -0
  54. package/src/init-command.ts +709 -0
  55. package/src/install-mcp-command.test.ts +216 -0
  56. package/src/install-mcp-command.ts +185 -0
  57. package/src/install-rules-command.test.ts +218 -0
  58. package/src/install-rules-command.ts +233 -0
  59. package/src/install-skills-command.test.ts +176 -0
  60. package/src/install-skills-command.ts +124 -0
  61. package/src/mcp-server.test.ts +162 -0
  62. package/src/mcp-server.ts +231 -0
  63. package/src/processor.test.ts +559 -18
  64. package/src/processor.ts +456 -49
  65. package/src/queue.test.ts +682 -6
  66. package/src/queue.ts +135 -944
  67. package/src/supervisor.test.ts +340 -0
  68. package/src/supervisor.ts +162 -0
  69. package/src/test-util.ts +32 -0
  70. package/src/types.ts +726 -17
  71. package/src/wait.test.ts +698 -0
  72. package/LICENSE +0 -21
@@ -0,0 +1,865 @@
1
+ /**
2
+ * Lua scripts for atomic Redis operations.
3
+ *
4
+ * Key naming convention (all prefixed with the configurable keyPrefix, default "dq:"):
5
+ * dq:id_seq – INCR counter for auto-increment IDs
6
+ * dq:job:{id} – Hash with all job fields
7
+ * dq:queue – Sorted Set of ready-to-process job IDs (score = priority composite)
8
+ * dq:delayed – Sorted Set of future-scheduled job IDs (score = run_at ms)
9
+ * dq:retry – Sorted Set of retry-waiting job IDs (score = next_attempt_at ms)
10
+ * dq:status:{status} – Set of job IDs per status
11
+ * dq:type:{jobType} – Set of job IDs per type
12
+ * dq:tag:{tag} – Set of job IDs per tag
13
+ * dq:job:{id}:tags – Set of tags for a specific job
14
+ * dq:events:{id} – List of JSON event objects
15
+ * dq:idempotency:{key} – String mapping idempotency key → job ID
16
+ * dq:all – Sorted Set of all jobs (score = createdAt ms, for ordering)
17
+ * dq:event_id_seq – INCR counter for event IDs
18
+ * dq:waiting – Sorted Set of time-based waiting job IDs (score = waitUntil ms)
19
+ * dq:waitpoint:{id} – Hash with waitpoint fields (id, jobId, status, output, timeoutAt, etc.)
20
+ * dq:waitpoint_timeout – Sorted Set of waitpoint IDs with timeouts (score = timeoutAt ms)
21
+ * dq:waitpoint_id_seq – INCR counter for waitpoint sequence (used if needed)
22
+ */
23
+
24
+ // ─── Score helpers ──────────────────────────────────────────────────────
25
+ // For the ready queue we need: higher priority first, then earlier createdAt.
26
+ // Score = priority * 1e15 + (1e15 - createdAtMs)
27
+ // ZPOPMAX gives highest score → highest priority, earliest created.
28
+ const SCORE_RANGE = '1000000000000000'; // 1e15
29
+
30
+ /**
31
+ * ADD JOB
32
+ * KEYS: [prefix]
33
+ * ARGV: [jobType, payloadJson, maxAttempts, priority, runAtMs, timeoutMs,
34
+ * forceKillOnTimeout, tagsJson, idempotencyKey, nowMs,
35
+ * retryDelay, retryBackoff, retryDelayMax]
36
+ * Returns: job ID (number)
37
+ */
38
+ export const ADD_JOB_SCRIPT = `
39
+ local prefix = KEYS[1]
40
+ local jobType = ARGV[1]
41
+ local payloadJson = ARGV[2]
42
+ local maxAttempts = tonumber(ARGV[3])
43
+ local priority = tonumber(ARGV[4])
44
+ local runAtMs = ARGV[5] -- "0" means now
45
+ local timeoutMs = ARGV[6] -- "null" string if not set
46
+ local forceKillOnTimeout = ARGV[7]
47
+ local tagsJson = ARGV[8] -- "null" or JSON array string
48
+ local idempotencyKey = ARGV[9] -- "null" string if not set
49
+ local nowMs = tonumber(ARGV[10])
50
+ local retryDelay = ARGV[11] -- "null" or seconds string
51
+ local retryBackoff = ARGV[12] -- "null" or "true"/"false"
52
+ local retryDelayMax = ARGV[13] -- "null" or seconds string
53
+
54
+ -- Idempotency check
55
+ if idempotencyKey ~= "null" then
56
+ local existing = redis.call('GET', prefix .. 'idempotency:' .. idempotencyKey)
57
+ if existing then
58
+ return existing
59
+ end
60
+ end
61
+
62
+ -- Generate ID
63
+ local id = redis.call('INCR', prefix .. 'id_seq')
64
+ local jobKey = prefix .. 'job:' .. id
65
+ local runAt = runAtMs ~= "0" and tonumber(runAtMs) or nowMs
66
+
67
+ -- Store the job hash
68
+ redis.call('HMSET', jobKey,
69
+ 'id', id,
70
+ 'jobType', jobType,
71
+ 'payload', payloadJson,
72
+ 'status', 'pending',
73
+ 'maxAttempts', maxAttempts,
74
+ 'attempts', 0,
75
+ 'priority', priority,
76
+ 'runAt', runAt,
77
+ 'timeoutMs', timeoutMs,
78
+ 'forceKillOnTimeout', forceKillOnTimeout,
79
+ 'createdAt', nowMs,
80
+ 'updatedAt', nowMs,
81
+ 'lockedAt', 'null',
82
+ 'lockedBy', 'null',
83
+ 'nextAttemptAt', 'null',
84
+ 'pendingReason', 'null',
85
+ 'errorHistory', '[]',
86
+ 'failureReason', 'null',
87
+ 'completedAt', 'null',
88
+ 'startedAt', 'null',
89
+ 'lastRetriedAt', 'null',
90
+ 'lastFailedAt', 'null',
91
+ 'lastCancelledAt', 'null',
92
+ 'tags', tagsJson,
93
+ 'idempotencyKey', idempotencyKey,
94
+ 'waitUntil', 'null',
95
+ 'waitTokenId', 'null',
96
+ 'stepData', 'null',
97
+ 'retryDelay', retryDelay,
98
+ 'retryBackoff', retryBackoff,
99
+ 'retryDelayMax', retryDelayMax
100
+ )
101
+
102
+ -- Status index
103
+ redis.call('SADD', prefix .. 'status:pending', id)
104
+
105
+ -- Type index
106
+ redis.call('SADD', prefix .. 'type:' .. jobType, id)
107
+
108
+ -- Tag indexes
109
+ if tagsJson ~= "null" then
110
+ local tags = cjson.decode(tagsJson)
111
+ for _, tag in ipairs(tags) do
112
+ redis.call('SADD', prefix .. 'tag:' .. tag, id)
113
+ end
114
+ -- Store tags for exact-match queries
115
+ for _, tag in ipairs(tags) do
116
+ redis.call('SADD', prefix .. 'job:' .. id .. ':tags', tag)
117
+ end
118
+ end
119
+
120
+ -- Idempotency mapping
121
+ if idempotencyKey ~= "null" then
122
+ redis.call('SET', prefix .. 'idempotency:' .. idempotencyKey, id)
123
+ end
124
+
125
+ -- All-jobs sorted set (for ordering by createdAt)
126
+ redis.call('ZADD', prefix .. 'all', nowMs, id)
127
+
128
+ -- Queue or delayed
129
+ if runAt <= nowMs then
130
+ -- Ready now: add to queue with priority score
131
+ local score = priority * ${SCORE_RANGE} + (${SCORE_RANGE} - nowMs)
132
+ redis.call('ZADD', prefix .. 'queue', score, id)
133
+ else
134
+ -- Future: add to delayed set
135
+ redis.call('ZADD', prefix .. 'delayed', runAt, id)
136
+ end
137
+
138
+ return id
139
+ `;
140
+
141
+ /**
142
+ * ADD JOBS (batch)
143
+ * KEYS: [prefix]
144
+ * ARGV: [jobsJson, nowMs]
145
+ * jobsJson is a JSON array of objects, each with:
146
+ * jobType, payload (already JSON string), maxAttempts, priority,
147
+ * runAtMs, timeoutMs, forceKillOnTimeout, tags (JSON or "null"),
148
+ * idempotencyKey
149
+ * Returns: array of job IDs (one per input job, in order)
150
+ */
151
+ export const ADD_JOBS_SCRIPT = `
152
+ local prefix = KEYS[1]
153
+ local jobsJson = ARGV[1]
154
+ local nowMs = tonumber(ARGV[2])
155
+
156
+ local jobs = cjson.decode(jobsJson)
157
+ local results = {}
158
+
159
+ for i, job in ipairs(jobs) do
160
+ local jobType = job.jobType
161
+ local payloadJson = job.payload
162
+ local maxAttempts = tonumber(job.maxAttempts)
163
+ local priority = tonumber(job.priority)
164
+ local runAtMs = tostring(job.runAtMs)
165
+ local timeoutMs = tostring(job.timeoutMs)
166
+ local forceKillOnTimeout = tostring(job.forceKillOnTimeout)
167
+ local tagsJson = tostring(job.tags)
168
+ local idempotencyKey = tostring(job.idempotencyKey)
169
+ local retryDelay = tostring(job.retryDelay)
170
+ local retryBackoff = tostring(job.retryBackoff)
171
+ local retryDelayMax = tostring(job.retryDelayMax)
172
+
173
+ -- Idempotency check
174
+ local skip = false
175
+ if idempotencyKey ~= "null" then
176
+ local existing = redis.call('GET', prefix .. 'idempotency:' .. idempotencyKey)
177
+ if existing then
178
+ results[i] = tonumber(existing)
179
+ skip = true
180
+ end
181
+ end
182
+
183
+ if not skip then
184
+ -- Generate ID
185
+ local id = redis.call('INCR', prefix .. 'id_seq')
186
+ local jobKey = prefix .. 'job:' .. id
187
+ local runAt = runAtMs ~= "0" and tonumber(runAtMs) or nowMs
188
+
189
+ -- Store the job hash
190
+ redis.call('HMSET', jobKey,
191
+ 'id', id,
192
+ 'jobType', jobType,
193
+ 'payload', payloadJson,
194
+ 'status', 'pending',
195
+ 'maxAttempts', maxAttempts,
196
+ 'attempts', 0,
197
+ 'priority', priority,
198
+ 'runAt', runAt,
199
+ 'timeoutMs', timeoutMs,
200
+ 'forceKillOnTimeout', forceKillOnTimeout,
201
+ 'createdAt', nowMs,
202
+ 'updatedAt', nowMs,
203
+ 'lockedAt', 'null',
204
+ 'lockedBy', 'null',
205
+ 'nextAttemptAt', 'null',
206
+ 'pendingReason', 'null',
207
+ 'errorHistory', '[]',
208
+ 'failureReason', 'null',
209
+ 'completedAt', 'null',
210
+ 'startedAt', 'null',
211
+ 'lastRetriedAt', 'null',
212
+ 'lastFailedAt', 'null',
213
+ 'lastCancelledAt', 'null',
214
+ 'tags', tagsJson,
215
+ 'idempotencyKey', idempotencyKey,
216
+ 'waitUntil', 'null',
217
+ 'waitTokenId', 'null',
218
+ 'stepData', 'null',
219
+ 'retryDelay', retryDelay,
220
+ 'retryBackoff', retryBackoff,
221
+ 'retryDelayMax', retryDelayMax
222
+ )
223
+
224
+ -- Status index
225
+ redis.call('SADD', prefix .. 'status:pending', id)
226
+
227
+ -- Type index
228
+ redis.call('SADD', prefix .. 'type:' .. jobType, id)
229
+
230
+ -- Tag indexes
231
+ if tagsJson ~= "null" then
232
+ local tags = cjson.decode(tagsJson)
233
+ for _, tag in ipairs(tags) do
234
+ redis.call('SADD', prefix .. 'tag:' .. tag, id)
235
+ end
236
+ for _, tag in ipairs(tags) do
237
+ redis.call('SADD', prefix .. 'job:' .. id .. ':tags', tag)
238
+ end
239
+ end
240
+
241
+ -- Idempotency mapping
242
+ if idempotencyKey ~= "null" then
243
+ redis.call('SET', prefix .. 'idempotency:' .. idempotencyKey, id)
244
+ end
245
+
246
+ -- All-jobs sorted set
247
+ redis.call('ZADD', prefix .. 'all', nowMs, id)
248
+
249
+ -- Queue or delayed
250
+ if runAt <= nowMs then
251
+ local score = priority * ${SCORE_RANGE} + (${SCORE_RANGE} - nowMs)
252
+ redis.call('ZADD', prefix .. 'queue', score, id)
253
+ else
254
+ redis.call('ZADD', prefix .. 'delayed', runAt, id)
255
+ end
256
+
257
+ results[i] = id
258
+ end
259
+ end
260
+
261
+ return results
262
+ `;
263
+
264
+ /**
265
+ * GET NEXT BATCH
266
+ * Atomically: move ready delayed/retry jobs into queue, then pop N jobs.
267
+ * KEYS: [prefix]
268
+ * ARGV: [workerId, batchSize, nowMs, jobTypeFilter]
269
+ * jobTypeFilter: "null" or a JSON array like ["email","sms"] or a string like "email"
270
+ * Returns: array of job field arrays (flat: [field1, val1, field2, val2, ...] per job)
271
+ */
272
+ export const GET_NEXT_BATCH_SCRIPT = `
273
+ local prefix = KEYS[1]
274
+ local workerId = ARGV[1]
275
+ local batchSize = tonumber(ARGV[2])
276
+ local nowMs = tonumber(ARGV[3])
277
+ local jobTypeFilter = ARGV[4] -- "null" or JSON array or single string
278
+
279
+ -- 1. Move ready delayed jobs into queue
280
+ local delayed = redis.call('ZRANGEBYSCORE', prefix .. 'delayed', '-inf', nowMs, 'LIMIT', 0, 200)
281
+ for _, jobId in ipairs(delayed) do
282
+ local jk = prefix .. 'job:' .. jobId
283
+ local status = redis.call('HGET', jk, 'status')
284
+ local attempts = tonumber(redis.call('HGET', jk, 'attempts'))
285
+ local maxAttempts = tonumber(redis.call('HGET', jk, 'maxAttempts'))
286
+ if status == 'pending' and attempts < maxAttempts then
287
+ local pri = tonumber(redis.call('HGET', jk, 'priority') or '0')
288
+ local ca = tonumber(redis.call('HGET', jk, 'createdAt'))
289
+ local score = pri * ${SCORE_RANGE} + (${SCORE_RANGE} - ca)
290
+ redis.call('ZADD', prefix .. 'queue', score, jobId)
291
+ end
292
+ redis.call('ZREM', prefix .. 'delayed', jobId)
293
+ end
294
+
295
+ -- 2. Move ready retry jobs into queue
296
+ local retries = redis.call('ZRANGEBYSCORE', prefix .. 'retry', '-inf', nowMs, 'LIMIT', 0, 200)
297
+ for _, jobId in ipairs(retries) do
298
+ local jk = prefix .. 'job:' .. jobId
299
+ local status = redis.call('HGET', jk, 'status')
300
+ local attempts = tonumber(redis.call('HGET', jk, 'attempts'))
301
+ local maxAttempts = tonumber(redis.call('HGET', jk, 'maxAttempts'))
302
+ if status == 'failed' and attempts < maxAttempts then
303
+ local pri = tonumber(redis.call('HGET', jk, 'priority') or '0')
304
+ local ca = tonumber(redis.call('HGET', jk, 'createdAt'))
305
+ local score = pri * ${SCORE_RANGE} + (${SCORE_RANGE} - ca)
306
+ redis.call('ZADD', prefix .. 'queue', score, jobId)
307
+ redis.call('SREM', prefix .. 'status:failed', jobId)
308
+ redis.call('SADD', prefix .. 'status:pending', jobId)
309
+ redis.call('HMSET', jk, 'status', 'pending')
310
+ end
311
+ redis.call('ZREM', prefix .. 'retry', jobId)
312
+ end
313
+
314
+ -- 3. Move ready waiting jobs (time-based, no token) into queue
315
+ local waitingJobs = redis.call('ZRANGEBYSCORE', prefix .. 'waiting', '-inf', nowMs, 'LIMIT', 0, 200)
316
+ for _, jobId in ipairs(waitingJobs) do
317
+ local jk = prefix .. 'job:' .. jobId
318
+ local status = redis.call('HGET', jk, 'status')
319
+ local waitTokenId = redis.call('HGET', jk, 'waitTokenId')
320
+ if status == 'waiting' and (waitTokenId == false or waitTokenId == 'null') then
321
+ local pri = tonumber(redis.call('HGET', jk, 'priority') or '0')
322
+ local ca = tonumber(redis.call('HGET', jk, 'createdAt'))
323
+ local score = pri * ${SCORE_RANGE} + (${SCORE_RANGE} - ca)
324
+ redis.call('ZADD', prefix .. 'queue', score, jobId)
325
+ redis.call('SREM', prefix .. 'status:waiting', jobId)
326
+ redis.call('SADD', prefix .. 'status:pending', jobId)
327
+ redis.call('HMSET', jk, 'status', 'pending', 'waitUntil', 'null')
328
+ end
329
+ redis.call('ZREM', prefix .. 'waiting', jobId)
330
+ end
331
+
332
+ -- 4. Parse job type filter
333
+ local filterTypes = nil
334
+ if jobTypeFilter ~= "null" then
335
+ -- Could be a JSON array or a plain string
336
+ local ok, decoded = pcall(cjson.decode, jobTypeFilter)
337
+ if ok and type(decoded) == 'table' then
338
+ filterTypes = {}
339
+ for _, t in ipairs(decoded) do filterTypes[t] = true end
340
+ else
341
+ filterTypes = { [jobTypeFilter] = true }
342
+ end
343
+ end
344
+
345
+ -- 5. Pop candidates from queue (highest score first)
346
+ -- We pop more than batchSize because some may be filtered out
347
+ local popCount = batchSize * 3
348
+ local candidates = redis.call('ZPOPMAX', prefix .. 'queue', popCount)
349
+ -- candidates: [member1, score1, member2, score2, ...]
350
+
351
+ local results = {}
352
+ local jobsClaimed = 0
353
+ local putBack = {} -- {score, id} pairs to put back
354
+
355
+ for i = 1, #candidates, 2 do
356
+ local jobId = candidates[i]
357
+ local score = candidates[i + 1]
358
+ local jk = prefix .. 'job:' .. jobId
359
+
360
+ if jobsClaimed >= batchSize then
361
+ -- We have enough; put the rest back
362
+ table.insert(putBack, score)
363
+ table.insert(putBack, jobId)
364
+ else
365
+ -- Check job type filter
366
+ local jt = redis.call('HGET', jk, 'jobType')
367
+ if filterTypes and not filterTypes[jt] then
368
+ -- Doesn't match filter: put back
369
+ table.insert(putBack, score)
370
+ table.insert(putBack, jobId)
371
+ else
372
+ -- Check run_at
373
+ local runAt = tonumber(redis.call('HGET', jk, 'runAt'))
374
+ if runAt > nowMs then
375
+ -- Not ready yet: move to delayed
376
+ redis.call('ZADD', prefix .. 'delayed', runAt, jobId)
377
+ else
378
+ -- Claim this job
379
+ local attempts = tonumber(redis.call('HGET', jk, 'attempts'))
380
+ local startedAt = redis.call('HGET', jk, 'startedAt')
381
+ local lastRetriedAt = redis.call('HGET', jk, 'lastRetriedAt')
382
+ if startedAt == 'null' then startedAt = nowMs end
383
+ if attempts > 0 then lastRetriedAt = nowMs end
384
+
385
+ redis.call('HMSET', jk,
386
+ 'status', 'processing',
387
+ 'lockedAt', nowMs,
388
+ 'lockedBy', workerId,
389
+ 'attempts', attempts + 1,
390
+ 'updatedAt', nowMs,
391
+ 'pendingReason', 'null',
392
+ 'startedAt', startedAt,
393
+ 'lastRetriedAt', lastRetriedAt
394
+ )
395
+
396
+ -- Update status sets
397
+ redis.call('SREM', prefix .. 'status:pending', jobId)
398
+ redis.call('SADD', prefix .. 'status:processing', jobId)
399
+
400
+ -- Return job data as flat array
401
+ local data = redis.call('HGETALL', jk)
402
+ for _, v in ipairs(data) do
403
+ table.insert(results, v)
404
+ end
405
+ -- Separator
406
+ table.insert(results, '__JOB_SEP__')
407
+ jobsClaimed = jobsClaimed + 1
408
+ end
409
+ end
410
+ end
411
+ end
412
+
413
+ -- Put back jobs we didn't claim
414
+ if #putBack > 0 then
415
+ redis.call('ZADD', prefix .. 'queue', unpack(putBack))
416
+ end
417
+
418
+ return results
419
+ `;
420
+
421
+ /**
422
+ * COMPLETE JOB
423
+ * KEYS: [prefix]
424
+ * ARGV: [jobId, nowMs]
425
+ */
426
+ export const COMPLETE_JOB_SCRIPT = `
427
+ local prefix = KEYS[1]
428
+ local jobId = ARGV[1]
429
+ local nowMs = ARGV[2]
430
+ local jk = prefix .. 'job:' .. jobId
431
+
432
+ redis.call('HMSET', jk,
433
+ 'status', 'completed',
434
+ 'updatedAt', nowMs,
435
+ 'completedAt', nowMs,
436
+ 'stepData', 'null',
437
+ 'waitUntil', 'null',
438
+ 'waitTokenId', 'null'
439
+ )
440
+ redis.call('SREM', prefix .. 'status:processing', jobId)
441
+ redis.call('SADD', prefix .. 'status:completed', jobId)
442
+
443
+ return 1
444
+ `;
445
+
446
+ /**
447
+ * FAIL JOB
448
+ * KEYS: [prefix]
449
+ * ARGV: [jobId, errorJson, failureReason, nowMs]
450
+ * errorJson: JSON array like [{"message":"...", "timestamp":"..."}]
451
+ */
452
+ export const FAIL_JOB_SCRIPT = `
453
+ local prefix = KEYS[1]
454
+ local jobId = ARGV[1]
455
+ local errorJson = ARGV[2]
456
+ local failureReason = ARGV[3]
457
+ local nowMs = tonumber(ARGV[4])
458
+ local jk = prefix .. 'job:' .. jobId
459
+
460
+ local attempts = tonumber(redis.call('HGET', jk, 'attempts'))
461
+ local maxAttempts = tonumber(redis.call('HGET', jk, 'maxAttempts'))
462
+
463
+ -- Read per-job retry config (may be "null")
464
+ local rdRaw = redis.call('HGET', jk, 'retryDelay')
465
+ local rbRaw = redis.call('HGET', jk, 'retryBackoff')
466
+ local rmRaw = redis.call('HGET', jk, 'retryDelayMax')
467
+
468
+ local nextAttemptAt = 'null'
469
+ if attempts < maxAttempts then
470
+ local allNull = (rdRaw == 'null' or rdRaw == false)
471
+ and (rbRaw == 'null' or rbRaw == false)
472
+ and (rmRaw == 'null' or rmRaw == false)
473
+ if allNull then
474
+ -- Legacy formula: 2^attempts minutes
475
+ local delayMs = math.pow(2, attempts) * 60000
476
+ nextAttemptAt = nowMs + delayMs
477
+ else
478
+ local retryDelaySec = 60
479
+ if rdRaw and rdRaw ~= 'null' then retryDelaySec = tonumber(rdRaw) end
480
+ local useBackoff = true
481
+ if rbRaw and rbRaw ~= 'null' then useBackoff = (rbRaw == 'true') end
482
+ local maxDelaySec = nil
483
+ if rmRaw and rmRaw ~= 'null' then maxDelaySec = tonumber(rmRaw) end
484
+
485
+ local delaySec
486
+ if useBackoff then
487
+ delaySec = retryDelaySec * math.pow(2, attempts)
488
+ if maxDelaySec then delaySec = math.min(delaySec, maxDelaySec) end
489
+ delaySec = delaySec * (0.5 + 0.5 * math.random())
490
+ else
491
+ delaySec = retryDelaySec
492
+ end
493
+ nextAttemptAt = nowMs + math.floor(delaySec * 1000)
494
+ end
495
+ end
496
+
497
+ -- Append to error_history
498
+ local history = redis.call('HGET', jk, 'errorHistory') or '[]'
499
+ local ok, arr = pcall(cjson.decode, history)
500
+ if not ok then arr = {} end
501
+ local newErrors = cjson.decode(errorJson)
502
+ for _, e in ipairs(newErrors) do
503
+ table.insert(arr, e)
504
+ end
505
+
506
+ redis.call('HMSET', jk,
507
+ 'status', 'failed',
508
+ 'updatedAt', nowMs,
509
+ 'nextAttemptAt', tostring(nextAttemptAt),
510
+ 'errorHistory', cjson.encode(arr),
511
+ 'failureReason', failureReason,
512
+ 'lastFailedAt', nowMs
513
+ )
514
+ redis.call('SREM', prefix .. 'status:processing', jobId)
515
+ redis.call('SADD', prefix .. 'status:failed', jobId)
516
+
517
+ -- Schedule retry if applicable
518
+ if nextAttemptAt ~= 'null' then
519
+ redis.call('ZADD', prefix .. 'retry', nextAttemptAt, jobId)
520
+ end
521
+
522
+ return 1
523
+ `;
524
+
525
+ /**
526
+ * RETRY JOB (only if failed or processing)
527
+ * KEYS: [prefix]
528
+ * ARGV: [jobId, nowMs]
529
+ */
530
+ export const RETRY_JOB_SCRIPT = `
531
+ local prefix = KEYS[1]
532
+ local jobId = ARGV[1]
533
+ local nowMs = tonumber(ARGV[2])
534
+ local jk = prefix .. 'job:' .. jobId
535
+
536
+ local oldStatus = redis.call('HGET', jk, 'status')
537
+ if oldStatus ~= 'failed' and oldStatus ~= 'processing' then return 0 end
538
+
539
+ redis.call('HMSET', jk,
540
+ 'status', 'pending',
541
+ 'updatedAt', nowMs,
542
+ 'lockedAt', 'null',
543
+ 'lockedBy', 'null',
544
+ 'nextAttemptAt', nowMs,
545
+ 'lastRetriedAt', nowMs
546
+ )
547
+
548
+ -- Remove from old status, add to pending
549
+ redis.call('SREM', prefix .. 'status:' .. oldStatus, jobId)
550
+ redis.call('SADD', prefix .. 'status:pending', jobId)
551
+
552
+ -- Remove from retry sorted set if present
553
+ redis.call('ZREM', prefix .. 'retry', jobId)
554
+
555
+ -- Add to queue (ready now)
556
+ local priority = tonumber(redis.call('HGET', jk, 'priority') or '0')
557
+ local createdAt = tonumber(redis.call('HGET', jk, 'createdAt'))
558
+ local score = priority * ${SCORE_RANGE} + (${SCORE_RANGE} - createdAt)
559
+ redis.call('ZADD', prefix .. 'queue', score, jobId)
560
+
561
+ return 1
562
+ `;
563
+
564
+ /**
565
+ * CANCEL JOB (only if pending or waiting)
566
+ * KEYS: [prefix]
567
+ * ARGV: [jobId, nowMs]
568
+ */
569
+ export const CANCEL_JOB_SCRIPT = `
570
+ local prefix = KEYS[1]
571
+ local jobId = ARGV[1]
572
+ local nowMs = ARGV[2]
573
+ local jk = prefix .. 'job:' .. jobId
574
+
575
+ local status = redis.call('HGET', jk, 'status')
576
+ if status ~= 'pending' and status ~= 'waiting' then return 0 end
577
+
578
+ redis.call('HMSET', jk,
579
+ 'status', 'cancelled',
580
+ 'updatedAt', nowMs,
581
+ 'lastCancelledAt', nowMs,
582
+ 'waitUntil', 'null',
583
+ 'waitTokenId', 'null'
584
+ )
585
+ redis.call('SREM', prefix .. 'status:' .. status, jobId)
586
+ redis.call('SADD', prefix .. 'status:cancelled', jobId)
587
+ -- Remove from queue / delayed / waiting
588
+ redis.call('ZREM', prefix .. 'queue', jobId)
589
+ redis.call('ZREM', prefix .. 'delayed', jobId)
590
+ redis.call('ZREM', prefix .. 'waiting', jobId)
591
+
592
+ return 1
593
+ `;
594
+
595
+ /**
596
+ * PROLONG JOB
597
+ * KEYS: [prefix]
598
+ * ARGV: [jobId, nowMs]
599
+ */
600
+ export const PROLONG_JOB_SCRIPT = `
601
+ local prefix = KEYS[1]
602
+ local jobId = ARGV[1]
603
+ local nowMs = ARGV[2]
604
+ local jk = prefix .. 'job:' .. jobId
605
+
606
+ local status = redis.call('HGET', jk, 'status')
607
+ if status ~= 'processing' then return 0 end
608
+
609
+ redis.call('HMSET', jk,
610
+ 'lockedAt', nowMs,
611
+ 'updatedAt', nowMs
612
+ )
613
+
614
+ return 1
615
+ `;
616
+
617
+ /**
618
+ * RECLAIM STUCK JOBS
619
+ * KEYS: [prefix]
620
+ * ARGV: [maxAgeMs, nowMs]
621
+ * Returns: count of reclaimed jobs
622
+ */
623
+ export const RECLAIM_STUCK_JOBS_SCRIPT = `
624
+ local prefix = KEYS[1]
625
+ local maxAgeMs = tonumber(ARGV[1])
626
+ local nowMs = tonumber(ARGV[2])
627
+
628
+ local processing = redis.call('SMEMBERS', prefix .. 'status:processing')
629
+ local count = 0
630
+
631
+ for _, jobId in ipairs(processing) do
632
+ local jk = prefix .. 'job:' .. jobId
633
+ local lockedAt = redis.call('HGET', jk, 'lockedAt')
634
+ if lockedAt and lockedAt ~= 'null' then
635
+ local lockedAtNum = tonumber(lockedAt)
636
+ if lockedAtNum then
637
+ -- Use the greater of maxAgeMs and the job's own timeoutMs
638
+ local jobMaxAge = maxAgeMs
639
+ local timeoutMs = redis.call('HGET', jk, 'timeoutMs')
640
+ if timeoutMs and timeoutMs ~= 'null' then
641
+ local tMs = tonumber(timeoutMs)
642
+ if tMs and tMs > jobMaxAge then
643
+ jobMaxAge = tMs
644
+ end
645
+ end
646
+ local cutoff = nowMs - jobMaxAge
647
+ if lockedAtNum < cutoff then
648
+ redis.call('HMSET', jk,
649
+ 'status', 'pending',
650
+ 'lockedAt', 'null',
651
+ 'lockedBy', 'null',
652
+ 'updatedAt', nowMs
653
+ )
654
+ redis.call('SREM', prefix .. 'status:processing', jobId)
655
+ redis.call('SADD', prefix .. 'status:pending', jobId)
656
+
657
+ -- Re-add to queue
658
+ local priority = tonumber(redis.call('HGET', jk, 'priority') or '0')
659
+ local createdAt = tonumber(redis.call('HGET', jk, 'createdAt'))
660
+ local score = priority * ${SCORE_RANGE} + (${SCORE_RANGE} - createdAt)
661
+ redis.call('ZADD', prefix .. 'queue', score, jobId)
662
+
663
+ count = count + 1
664
+ end
665
+ end
666
+ end
667
+ end
668
+
669
+ return count
670
+ `;
671
+
672
+ /**
673
+ * CLEANUP OLD JOBS (batched)
674
+ *
675
+ * Processes a batch of candidate job IDs from the completed set, deleting
676
+ * those whose updatedAt is older than the cutoff. This script is called
677
+ * repeatedly from TypeScript with batches obtained via SSCAN to avoid
678
+ * loading the entire completed set into memory at once.
679
+ *
680
+ * KEYS: [prefix]
681
+ * ARGV: [cutoffMs, id1, id2, ...]
682
+ * Returns: count of deleted jobs in this batch
683
+ */
684
+ export const CLEANUP_OLD_JOBS_BATCH_SCRIPT = `
685
+ local prefix = KEYS[1]
686
+ local cutoffMs = tonumber(ARGV[1])
687
+ local count = 0
688
+
689
+ for i = 2, #ARGV do
690
+ local jobId = ARGV[i]
691
+ local jk = prefix .. 'job:' .. jobId
692
+ local updatedAt = tonumber(redis.call('HGET', jk, 'updatedAt'))
693
+ if updatedAt and updatedAt < cutoffMs then
694
+ local jobType = redis.call('HGET', jk, 'jobType')
695
+ local tagsJson = redis.call('HGET', jk, 'tags')
696
+ local idempotencyKey = redis.call('HGET', jk, 'idempotencyKey')
697
+
698
+ redis.call('DEL', jk)
699
+ redis.call('SREM', prefix .. 'status:completed', jobId)
700
+ redis.call('ZREM', prefix .. 'all', jobId)
701
+ if jobType then
702
+ redis.call('SREM', prefix .. 'type:' .. jobType, jobId)
703
+ end
704
+ if tagsJson and tagsJson ~= 'null' then
705
+ local ok, tags = pcall(cjson.decode, tagsJson)
706
+ if ok and type(tags) == 'table' then
707
+ for _, tag in ipairs(tags) do
708
+ redis.call('SREM', prefix .. 'tag:' .. tag, jobId)
709
+ end
710
+ end
711
+ redis.call('DEL', prefix .. 'job:' .. jobId .. ':tags')
712
+ end
713
+ if idempotencyKey and idempotencyKey ~= 'null' then
714
+ redis.call('DEL', prefix .. 'idempotency:' .. idempotencyKey)
715
+ end
716
+ redis.call('DEL', prefix .. 'events:' .. jobId)
717
+
718
+ count = count + 1
719
+ end
720
+ end
721
+
722
+ return count
723
+ `;
724
+
725
+ /**
726
+ * WAIT JOB — Transition a job from 'processing' to 'waiting'.
727
+ * KEYS: [prefix]
728
+ * ARGV: [jobId, waitUntilMs, waitTokenId, stepDataJson, nowMs]
729
+ * waitUntilMs: timestamp ms or "null"
730
+ * waitTokenId: string or "null"
731
+ * Returns: 1 if successful, 0 if job was not in 'processing' state
732
+ */
733
+ export const WAIT_JOB_SCRIPT = `
734
+ local prefix = KEYS[1]
735
+ local jobId = ARGV[1]
736
+ local waitUntilMs = ARGV[2]
737
+ local waitTokenId = ARGV[3]
738
+ local stepDataJson = ARGV[4]
739
+ local nowMs = ARGV[5]
740
+ local jk = prefix .. 'job:' .. jobId
741
+
742
+ local status = redis.call('HGET', jk, 'status')
743
+ if status ~= 'processing' then return 0 end
744
+
745
+ redis.call('HMSET', jk,
746
+ 'status', 'waiting',
747
+ 'waitUntil', waitUntilMs,
748
+ 'waitTokenId', waitTokenId,
749
+ 'stepData', stepDataJson,
750
+ 'lockedAt', 'null',
751
+ 'lockedBy', 'null',
752
+ 'updatedAt', nowMs
753
+ )
754
+ redis.call('SREM', prefix .. 'status:processing', jobId)
755
+ redis.call('SADD', prefix .. 'status:waiting', jobId)
756
+
757
+ -- Add to waiting sorted set if time-based wait
758
+ if waitUntilMs ~= 'null' then
759
+ redis.call('ZADD', prefix .. 'waiting', tonumber(waitUntilMs), jobId)
760
+ end
761
+
762
+ return 1
763
+ `;
764
+
765
+ /**
766
+ * COMPLETE WAITPOINT — Mark a waitpoint as completed and resume associated job.
767
+ * KEYS: [prefix]
768
+ * ARGV: [tokenId, outputJson, nowMs]
769
+ * outputJson: JSON string or "null"
770
+ * Returns: 1 if successful, 0 if waitpoint not found or already completed
771
+ */
772
+ export const COMPLETE_WAITPOINT_SCRIPT = `
773
+ local prefix = KEYS[1]
774
+ local tokenId = ARGV[1]
775
+ local outputJson = ARGV[2]
776
+ local nowMs = ARGV[3]
777
+ local wpk = prefix .. 'waitpoint:' .. tokenId
778
+
779
+ local wpStatus = redis.call('HGET', wpk, 'status')
780
+ if not wpStatus or wpStatus ~= 'waiting' then return 0 end
781
+
782
+ redis.call('HMSET', wpk,
783
+ 'status', 'completed',
784
+ 'output', outputJson,
785
+ 'completedAt', nowMs
786
+ )
787
+
788
+ -- Move associated job back to pending
789
+ local jobId = redis.call('HGET', wpk, 'jobId')
790
+ if jobId and jobId ~= 'null' then
791
+ local jk = prefix .. 'job:' .. jobId
792
+ local jobStatus = redis.call('HGET', jk, 'status')
793
+ if jobStatus == 'waiting' then
794
+ redis.call('HMSET', jk,
795
+ 'status', 'pending',
796
+ 'waitTokenId', 'null',
797
+ 'waitUntil', 'null',
798
+ 'updatedAt', nowMs
799
+ )
800
+ redis.call('SREM', prefix .. 'status:waiting', jobId)
801
+ redis.call('SADD', prefix .. 'status:pending', jobId)
802
+ redis.call('ZREM', prefix .. 'waiting', jobId)
803
+
804
+ -- Re-add to queue
805
+ local priority = tonumber(redis.call('HGET', jk, 'priority') or '0')
806
+ local createdAt = tonumber(redis.call('HGET', jk, 'createdAt'))
807
+ local score = priority * ${SCORE_RANGE} + (${SCORE_RANGE} - createdAt)
808
+ redis.call('ZADD', prefix .. 'queue', score, jobId)
809
+ end
810
+ end
811
+
812
+ return 1
813
+ `;
814
+
815
+ /**
816
+ * EXPIRE TIMED OUT WAITPOINTS — Expire waitpoints past their timeout and resume jobs.
817
+ * KEYS: [prefix]
818
+ * ARGV: [nowMs]
819
+ * Returns: count of expired waitpoints
820
+ */
821
+ export const EXPIRE_TIMED_OUT_WAITPOINTS_SCRIPT = `
822
+ local prefix = KEYS[1]
823
+ local nowMs = tonumber(ARGV[1])
824
+
825
+ local expiredIds = redis.call('ZRANGEBYSCORE', prefix .. 'waitpoint_timeout', '-inf', nowMs)
826
+ local count = 0
827
+
828
+ for _, tokenId in ipairs(expiredIds) do
829
+ local wpk = prefix .. 'waitpoint:' .. tokenId
830
+ local wpStatus = redis.call('HGET', wpk, 'status')
831
+ if wpStatus == 'waiting' then
832
+ redis.call('HMSET', wpk,
833
+ 'status', 'timed_out'
834
+ )
835
+
836
+ -- Move associated job back to pending
837
+ local jobId = redis.call('HGET', wpk, 'jobId')
838
+ if jobId and jobId ~= 'null' then
839
+ local jk = prefix .. 'job:' .. jobId
840
+ local jobStatus = redis.call('HGET', jk, 'status')
841
+ if jobStatus == 'waiting' then
842
+ redis.call('HMSET', jk,
843
+ 'status', 'pending',
844
+ 'waitTokenId', 'null',
845
+ 'waitUntil', 'null',
846
+ 'updatedAt', nowMs
847
+ )
848
+ redis.call('SREM', prefix .. 'status:waiting', jobId)
849
+ redis.call('SADD', prefix .. 'status:pending', jobId)
850
+ redis.call('ZREM', prefix .. 'waiting', jobId)
851
+
852
+ local priority = tonumber(redis.call('HGET', jk, 'priority') or '0')
853
+ local createdAt = tonumber(redis.call('HGET', jk, 'createdAt'))
854
+ local score = priority * ${SCORE_RANGE} + (${SCORE_RANGE} - createdAt)
855
+ redis.call('ZADD', prefix .. 'queue', score, jobId)
856
+ end
857
+ end
858
+
859
+ count = count + 1
860
+ end
861
+ redis.call('ZREM', prefix .. 'waitpoint_timeout', tokenId)
862
+ end
863
+
864
+ return count
865
+ `;