@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.
- package/README.md +44 -0
- package/ai/build-docs-content.ts +96 -0
- package/ai/build-llms-full.ts +42 -0
- package/ai/docs-content.json +278 -0
- package/ai/rules/advanced.md +132 -0
- package/ai/rules/basic.md +159 -0
- package/ai/rules/react-dashboard.md +83 -0
- package/ai/skills/dataqueue-advanced/SKILL.md +320 -0
- package/ai/skills/dataqueue-core/SKILL.md +234 -0
- package/ai/skills/dataqueue-react/SKILL.md +189 -0
- package/dist/cli.cjs +1149 -14
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.d.cts +66 -1
- package/dist/cli.d.ts +66 -1
- package/dist/cli.js +1146 -13
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +4630 -928
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1033 -15
- package/dist/index.d.ts +1033 -15
- package/dist/index.js +4626 -929
- package/dist/index.js.map +1 -1
- package/dist/mcp-server.cjs +186 -0
- package/dist/mcp-server.cjs.map +1 -0
- package/dist/mcp-server.d.cts +32 -0
- package/dist/mcp-server.d.ts +32 -0
- package/dist/mcp-server.js +175 -0
- package/dist/mcp-server.js.map +1 -0
- package/migrations/1751131910825_add_timeout_seconds_to_job_queue.sql +2 -2
- package/migrations/1751186053000_add_job_events_table.sql +12 -8
- package/migrations/1751984773000_add_tags_to_job_queue.sql +1 -1
- package/migrations/1765809419000_add_force_kill_on_timeout_to_job_queue.sql +1 -1
- package/migrations/1771100000000_add_idempotency_key_to_job_queue.sql +7 -0
- package/migrations/1781200000000_add_wait_support.sql +12 -0
- package/migrations/1781200000001_create_waitpoints_table.sql +18 -0
- package/migrations/1781200000002_add_performance_indexes.sql +34 -0
- package/migrations/1781200000003_add_progress_to_job_queue.sql +7 -0
- package/migrations/1781200000004_create_cron_schedules_table.sql +33 -0
- package/migrations/1781200000005_add_retry_config_to_job_queue.sql +17 -0
- package/package.json +40 -23
- package/src/backend.ts +328 -0
- package/src/backends/postgres.ts +2040 -0
- package/src/backends/redis-scripts.ts +865 -0
- package/src/backends/redis.test.ts +1906 -0
- package/src/backends/redis.ts +1792 -0
- package/src/cli.test.ts +82 -6
- package/src/cli.ts +73 -10
- package/src/cron.test.ts +126 -0
- package/src/cron.ts +40 -0
- package/src/db-util.ts +4 -2
- package/src/index.test.ts +688 -1
- package/src/index.ts +277 -39
- package/src/init-command.test.ts +449 -0
- package/src/init-command.ts +709 -0
- package/src/install-mcp-command.test.ts +216 -0
- package/src/install-mcp-command.ts +185 -0
- package/src/install-rules-command.test.ts +218 -0
- package/src/install-rules-command.ts +233 -0
- package/src/install-skills-command.test.ts +176 -0
- package/src/install-skills-command.ts +124 -0
- package/src/mcp-server.test.ts +162 -0
- package/src/mcp-server.ts +231 -0
- package/src/processor.test.ts +559 -18
- package/src/processor.ts +456 -49
- package/src/queue.test.ts +682 -6
- package/src/queue.ts +135 -944
- package/src/supervisor.test.ts +340 -0
- package/src/supervisor.ts +162 -0
- package/src/test-util.ts +32 -0
- package/src/types.ts +726 -17
- package/src/wait.test.ts +698 -0
- 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
|
+
`;
|