enqiu 0.1.0
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/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +245 -0
- package/dist/api.d.ts +236 -0
- package/dist/api.js +519 -0
- package/dist/codec.d.ts +8 -0
- package/dist/codec.js +74 -0
- package/dist/cron.d.ts +19 -0
- package/dist/cron.js +217 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/memory-scheduler.d.ts +24 -0
- package/dist/memory-scheduler.js +163 -0
- package/dist/memory.d.ts +344 -0
- package/dist/memory.js +1201 -0
- package/dist/redis.d.ts +202 -0
- package/dist/redis.js +2180 -0
- package/package.json +72 -0
package/dist/redis.js
ADDED
|
@@ -0,0 +1,2180 @@
|
|
|
1
|
+
import { JobCancelledError, JobExpiredError, JobFailedError, JobTimeoutError, QueueClosedError, } from "./memory.js";
|
|
2
|
+
import { decodeJobValue as decode, encodeJobValue as encode, } from "./codec.js";
|
|
3
|
+
import { nextCronOccurrence, parseCron, validateTimeZone, } from "./cron.js";
|
|
4
|
+
const ENQUEUE_SCRIPT = `
|
|
5
|
+
local meta = KEYS[1] .. ARGV[1]
|
|
6
|
+
if redis.call('EXISTS', meta) == 1 then
|
|
7
|
+
return {'duplicate', ARGV[1]}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
if ARGV[9] ~= '' then
|
|
11
|
+
local existing = redis.call('HGET', KEYS[7], ARGV[9])
|
|
12
|
+
if existing then
|
|
13
|
+
local existing_meta = KEYS[1] .. existing
|
|
14
|
+
local state = redis.call('HGET', existing_meta, 'status')
|
|
15
|
+
local trailing_update = (
|
|
16
|
+
ARGV[21] == 'trailing' and
|
|
17
|
+
ARGV[19] ~= '' and
|
|
18
|
+
(state == 'queued' or state == 'scheduled')
|
|
19
|
+
)
|
|
20
|
+
if (
|
|
21
|
+
state == 'running' or
|
|
22
|
+
((state == 'queued' or state == 'scheduled') and not trailing_update)
|
|
23
|
+
) then
|
|
24
|
+
return {'deduplicated', existing}
|
|
25
|
+
end
|
|
26
|
+
if not trailing_update then
|
|
27
|
+
local key_expires_at = tonumber(
|
|
28
|
+
redis.call('HGET', existing_meta, 'keyExpiresAt') or '0'
|
|
29
|
+
)
|
|
30
|
+
if key_expires_at > tonumber(ARGV[6]) then
|
|
31
|
+
return {'deduplicated', existing}
|
|
32
|
+
end
|
|
33
|
+
redis.call('HDEL', KEYS[7], ARGV[9])
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
local debounce_key = ARGV[19]
|
|
39
|
+
if debounce_key ~= '' then
|
|
40
|
+
local existing = redis.call('HGET', KEYS[9], debounce_key)
|
|
41
|
+
if existing then
|
|
42
|
+
local existing_meta = KEYS[1] .. existing
|
|
43
|
+
local state = redis.call('HGET', existing_meta, 'status')
|
|
44
|
+
local until_at = tonumber(
|
|
45
|
+
redis.call('HGET', existing_meta, 'debounceUntil') or '0'
|
|
46
|
+
)
|
|
47
|
+
if ARGV[21] == 'leading' and until_at > tonumber(ARGV[6]) then
|
|
48
|
+
return {'deduplicated', existing}
|
|
49
|
+
end
|
|
50
|
+
if ARGV[21] == 'trailing' and (
|
|
51
|
+
state == 'queued' or state == 'scheduled'
|
|
52
|
+
) then
|
|
53
|
+
local member = redis.call('HGET', existing_meta, 'member')
|
|
54
|
+
local next_run = math.max(
|
|
55
|
+
tonumber(ARGV[5]),
|
|
56
|
+
tonumber(ARGV[6]) + tonumber(ARGV[20])
|
|
57
|
+
)
|
|
58
|
+
local next_until = tonumber(ARGV[6]) + tonumber(ARGV[20])
|
|
59
|
+
redis.call('ZREM', KEYS[3], member)
|
|
60
|
+
redis.call('ZREM', KEYS[4], existing)
|
|
61
|
+
redis.call(
|
|
62
|
+
'HSET',
|
|
63
|
+
existing_meta,
|
|
64
|
+
'input', ARGV[3],
|
|
65
|
+
'priority', ARGV[4],
|
|
66
|
+
'runAt', tostring(next_run),
|
|
67
|
+
'status', 'scheduled',
|
|
68
|
+
'retries', ARGV[7],
|
|
69
|
+
'backoff', ARGV[8],
|
|
70
|
+
'timeout', ARGV[10],
|
|
71
|
+
'expiresAt', ARGV[11],
|
|
72
|
+
'keyRetention', ARGV[12],
|
|
73
|
+
'concurrencyKey', ARGV[13],
|
|
74
|
+
'concurrencyLimit', ARGV[14],
|
|
75
|
+
'throttleKey', ARGV[15],
|
|
76
|
+
'throttleLimit', ARGV[16],
|
|
77
|
+
'throttleInterval', ARGV[17],
|
|
78
|
+
'throttleBurst', ARGV[18],
|
|
79
|
+
'debounceUntil', tostring(next_until),
|
|
80
|
+
'debounceMode', ARGV[21]
|
|
81
|
+
)
|
|
82
|
+
redis.call('ZADD', KEYS[4], next_run, existing)
|
|
83
|
+
if ARGV[11] ~= '' then
|
|
84
|
+
redis.call('ZADD', KEYS[8], ARGV[11], existing)
|
|
85
|
+
else
|
|
86
|
+
redis.call('ZREM', KEYS[8], existing)
|
|
87
|
+
end
|
|
88
|
+
redis.call('ZADD', KEYS[10], next_until, debounce_key)
|
|
89
|
+
if ARGV[9] ~= '' then
|
|
90
|
+
redis.call('HSET', KEYS[7], ARGV[9], existing)
|
|
91
|
+
redis.call('HSET', existing_meta, 'key', ARGV[9])
|
|
92
|
+
end
|
|
93
|
+
redis.call(
|
|
94
|
+
'XADD', KEYS[11], 'MAXLEN', '~', '10000', '*',
|
|
95
|
+
'type', 'added', 'id', existing, 'at', ARGV[6]
|
|
96
|
+
)
|
|
97
|
+
return {'debounced', existing}
|
|
98
|
+
end
|
|
99
|
+
if not state or until_at <= tonumber(ARGV[6]) then
|
|
100
|
+
redis.call('HDEL', KEYS[9], debounce_key)
|
|
101
|
+
redis.call('ZREM', KEYS[10], debounce_key)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
local sequence = redis.call('INCR', KEYS[2])
|
|
107
|
+
local member = string.format('%020d', sequence) .. '|' .. ARGV[1]
|
|
108
|
+
local state = 'queued'
|
|
109
|
+
local run_at = tonumber(ARGV[5])
|
|
110
|
+
if ARGV[21] == 'trailing' then
|
|
111
|
+
run_at = math.max(run_at, tonumber(ARGV[6]) + tonumber(ARGV[20]))
|
|
112
|
+
end
|
|
113
|
+
if run_at > tonumber(ARGV[6]) then
|
|
114
|
+
state = 'scheduled'
|
|
115
|
+
end
|
|
116
|
+
local debounce_until = ''
|
|
117
|
+
if debounce_key ~= '' then
|
|
118
|
+
debounce_until = tostring(tonumber(ARGV[6]) + tonumber(ARGV[20]))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
redis.call(
|
|
122
|
+
'HSET',
|
|
123
|
+
meta,
|
|
124
|
+
'id', ARGV[1],
|
|
125
|
+
'name', ARGV[2],
|
|
126
|
+
'input', ARGV[3],
|
|
127
|
+
'priority', ARGV[4],
|
|
128
|
+
'runAt', tostring(run_at),
|
|
129
|
+
'createdAt', ARGV[6],
|
|
130
|
+
'status', state,
|
|
131
|
+
'attempt', '0',
|
|
132
|
+
'retries', ARGV[7],
|
|
133
|
+
'backoff', ARGV[8],
|
|
134
|
+
'timeout', ARGV[10],
|
|
135
|
+
'member', member,
|
|
136
|
+
'key', ARGV[9],
|
|
137
|
+
'expiresAt', ARGV[11],
|
|
138
|
+
'keyRetention', ARGV[12],
|
|
139
|
+
'concurrencyKey', ARGV[13],
|
|
140
|
+
'concurrencyLimit', ARGV[14],
|
|
141
|
+
'throttleKey', ARGV[15],
|
|
142
|
+
'throttleLimit', ARGV[16],
|
|
143
|
+
'throttleInterval', ARGV[17],
|
|
144
|
+
'throttleBurst', ARGV[18],
|
|
145
|
+
'debounceKey', debounce_key,
|
|
146
|
+
'debounceUntil', debounce_until,
|
|
147
|
+
'debounceMode', ARGV[21]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if ARGV[9] ~= '' then
|
|
151
|
+
redis.call('HSET', KEYS[7], ARGV[9], ARGV[1])
|
|
152
|
+
end
|
|
153
|
+
if ARGV[11] ~= '' then
|
|
154
|
+
redis.call('ZADD', KEYS[8], ARGV[11], ARGV[1])
|
|
155
|
+
end
|
|
156
|
+
if debounce_key ~= '' then
|
|
157
|
+
redis.call('HSET', KEYS[9], debounce_key, ARGV[1])
|
|
158
|
+
redis.call('ZADD', KEYS[10], debounce_until, debounce_key)
|
|
159
|
+
end
|
|
160
|
+
if state == 'scheduled' then
|
|
161
|
+
redis.call('ZADD', KEYS[4], run_at, ARGV[1])
|
|
162
|
+
else
|
|
163
|
+
redis.call('ZADD', KEYS[3], -tonumber(ARGV[4]), member)
|
|
164
|
+
end
|
|
165
|
+
redis.call('ZADD', KEYS[12], ARGV[6], ARGV[1])
|
|
166
|
+
redis.call(
|
|
167
|
+
'XADD', KEYS[11], 'MAXLEN', '~', '10000', '*',
|
|
168
|
+
'type', 'added', 'id', ARGV[1], 'at', ARGV[6]
|
|
169
|
+
)
|
|
170
|
+
return {'added', ARGV[1]}
|
|
171
|
+
`;
|
|
172
|
+
const CLAIM_SCRIPT = `
|
|
173
|
+
local now = tonumber(ARGV[1])
|
|
174
|
+
local token = ARGV[2]
|
|
175
|
+
local visibility = tonumber(ARGV[3])
|
|
176
|
+
local batch = tonumber(ARGV[6])
|
|
177
|
+
local retention = tonumber(ARGV[8])
|
|
178
|
+
local history_limit = tonumber(ARGV[9])
|
|
179
|
+
|
|
180
|
+
local function release_concurrency(meta)
|
|
181
|
+
local concurrency_key = redis.call('HGET', meta, 'concurrencyKey')
|
|
182
|
+
if concurrency_key and concurrency_key ~= '' then
|
|
183
|
+
local active = redis.call('HINCRBY', KEYS[12], concurrency_key, -1)
|
|
184
|
+
if active <= 0 then
|
|
185
|
+
redis.call('HDEL', KEYS[12], concurrency_key)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
local function finish_key(meta, finished_at)
|
|
191
|
+
local dedupe = redis.call('HGET', meta, 'key')
|
|
192
|
+
local key_retention = tonumber(
|
|
193
|
+
redis.call('HGET', meta, 'keyRetention') or '0'
|
|
194
|
+
)
|
|
195
|
+
if dedupe and dedupe ~= '' then
|
|
196
|
+
if key_retention > 0 then
|
|
197
|
+
redis.call(
|
|
198
|
+
'HSET',
|
|
199
|
+
meta,
|
|
200
|
+
'keyExpiresAt',
|
|
201
|
+
tostring(finished_at + key_retention)
|
|
202
|
+
)
|
|
203
|
+
else
|
|
204
|
+
redis.call('HDEL', KEYS[8], dedupe)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
return math.max(retention, key_retention)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
local expired_debounce = redis.call(
|
|
211
|
+
'ZRANGEBYSCORE', KEYS[17], '-inf', now, 'LIMIT', 0, batch
|
|
212
|
+
)
|
|
213
|
+
for _, debounce_key in ipairs(expired_debounce) do
|
|
214
|
+
local id = redis.call('HGET', KEYS[16], debounce_key)
|
|
215
|
+
if id then
|
|
216
|
+
local meta = KEYS[1] .. id
|
|
217
|
+
local until_at = tonumber(
|
|
218
|
+
redis.call('HGET', meta, 'debounceUntil') or '0'
|
|
219
|
+
)
|
|
220
|
+
if until_at <= now then
|
|
221
|
+
redis.call('HDEL', KEYS[16], debounce_key)
|
|
222
|
+
redis.call('ZREM', KEYS[17], debounce_key)
|
|
223
|
+
end
|
|
224
|
+
else
|
|
225
|
+
redis.call('ZREM', KEYS[17], debounce_key)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
local due = redis.call('ZRANGEBYSCORE', KEYS[3], '-inf', now, 'LIMIT', 0, batch)
|
|
230
|
+
for _, id in ipairs(due) do
|
|
231
|
+
local meta = KEYS[1] .. id
|
|
232
|
+
if redis.call('HGET', meta, 'status') == 'scheduled' then
|
|
233
|
+
local member = redis.call('HGET', meta, 'member')
|
|
234
|
+
local priority = tonumber(redis.call('HGET', meta, 'priority') or '0')
|
|
235
|
+
redis.call('ZADD', KEYS[2], -priority, member)
|
|
236
|
+
redis.call('HSET', meta, 'status', 'queued')
|
|
237
|
+
end
|
|
238
|
+
redis.call('ZREM', KEYS[3], id)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
local stale = redis.call(
|
|
242
|
+
'ZRANGEBYSCORE', KEYS[9], '-inf', now, 'LIMIT', 0, batch
|
|
243
|
+
)
|
|
244
|
+
for _, id in ipairs(stale) do
|
|
245
|
+
local meta = KEYS[1] .. id
|
|
246
|
+
local state = redis.call('HGET', meta, 'status')
|
|
247
|
+
if state == 'queued' or state == 'scheduled' then
|
|
248
|
+
local member = redis.call('HGET', meta, 'member')
|
|
249
|
+
redis.call('ZREM', KEYS[2], member)
|
|
250
|
+
redis.call('ZREM', KEYS[3], id)
|
|
251
|
+
redis.call(
|
|
252
|
+
'HSET',
|
|
253
|
+
meta,
|
|
254
|
+
'status', 'expired',
|
|
255
|
+
'finishedAt', ARGV[1],
|
|
256
|
+
'error', ARGV[10]
|
|
257
|
+
)
|
|
258
|
+
local ttl = finish_key(meta, now)
|
|
259
|
+
redis.call('LPUSH', KEYS[10], id)
|
|
260
|
+
redis.call('LTRIM', KEYS[10], 0, history_limit - 1)
|
|
261
|
+
redis.call('PEXPIRE', meta, ttl)
|
|
262
|
+
redis.call(
|
|
263
|
+
'XADD', KEYS[15], 'MAXLEN', '~', '10000', '*',
|
|
264
|
+
'type', 'expired', 'id', id, 'at', ARGV[1]
|
|
265
|
+
)
|
|
266
|
+
end
|
|
267
|
+
redis.call('ZREM', KEYS[9], id)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
local expired = redis.call('ZRANGEBYSCORE', KEYS[4], '-inf', now, 'LIMIT', 0, batch)
|
|
271
|
+
for _, id in ipairs(expired) do
|
|
272
|
+
local meta = KEYS[1] .. id
|
|
273
|
+
if redis.call('HGET', meta, 'status') == 'running' then
|
|
274
|
+
release_concurrency(meta)
|
|
275
|
+
local attempt = tonumber(redis.call('HGET', meta, 'attempt') or '1')
|
|
276
|
+
local retries = tonumber(redis.call('HGET', meta, 'retries') or '0')
|
|
277
|
+
redis.call('HDEL', meta, 'token')
|
|
278
|
+
if attempt <= retries then
|
|
279
|
+
local member = redis.call('HGET', meta, 'member')
|
|
280
|
+
local priority = tonumber(redis.call('HGET', meta, 'priority') or '0')
|
|
281
|
+
redis.call('ZADD', KEYS[2], -priority, member)
|
|
282
|
+
redis.call('HSET', meta, 'status', 'queued', 'error', ARGV[7])
|
|
283
|
+
local expires_at = redis.call('HGET', meta, 'expiresAt')
|
|
284
|
+
if expires_at and expires_at ~= '' then
|
|
285
|
+
redis.call('ZADD', KEYS[9], expires_at, id)
|
|
286
|
+
end
|
|
287
|
+
redis.call(
|
|
288
|
+
'XADD', KEYS[15], 'MAXLEN', '~', '10000', '*',
|
|
289
|
+
'type', 'recovered', 'id', id, 'at', ARGV[1]
|
|
290
|
+
)
|
|
291
|
+
else
|
|
292
|
+
redis.call(
|
|
293
|
+
'HSET',
|
|
294
|
+
meta,
|
|
295
|
+
'status', 'failed',
|
|
296
|
+
'finishedAt', ARGV[1],
|
|
297
|
+
'error', ARGV[7]
|
|
298
|
+
)
|
|
299
|
+
redis.call('LPUSH', KEYS[7], id)
|
|
300
|
+
redis.call('LTRIM', KEYS[7], 0, history_limit - 1)
|
|
301
|
+
local ttl = finish_key(meta, now)
|
|
302
|
+
redis.call('PEXPIRE', meta, ttl)
|
|
303
|
+
redis.call(
|
|
304
|
+
'XADD', KEYS[15], 'MAXLEN', '~', '10000', '*',
|
|
305
|
+
'type', 'failed', 'id', id, 'at', ARGV[1]
|
|
306
|
+
)
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
redis.call('ZREM', KEYS[4], id)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
if redis.call('HGET', KEYS[11], 'paused') == '1' then
|
|
313
|
+
return {'paused'}
|
|
314
|
+
end
|
|
315
|
+
local global_concurrency = tonumber(
|
|
316
|
+
redis.call('HGET', KEYS[11], 'concurrency') or '0'
|
|
317
|
+
)
|
|
318
|
+
if global_concurrency > 0 and redis.call('ZCARD', KEYS[4]) >= global_concurrency then
|
|
319
|
+
return {'concurrency'}
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
local rate_limit = tonumber(ARGV[4])
|
|
323
|
+
if rate_limit > 0 then
|
|
324
|
+
local interval = tonumber(ARGV[5])
|
|
325
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[5], '-inf', now - interval)
|
|
326
|
+
if redis.call('ZCARD', KEYS[5]) >= rate_limit then
|
|
327
|
+
return {'rate'}
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
local entries = redis.call('ZRANGE', KEYS[2], 0, batch - 1)
|
|
332
|
+
if #entries == 0 then
|
|
333
|
+
return {'empty'}
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
local member = nil
|
|
337
|
+
local id = nil
|
|
338
|
+
for _, candidate in ipairs(entries) do
|
|
339
|
+
local separator = string.find(candidate, '|', 1, true)
|
|
340
|
+
local candidate_id = string.sub(candidate, separator + 1)
|
|
341
|
+
local candidate_meta = KEYS[1] .. candidate_id
|
|
342
|
+
if redis.call('HGET', candidate_meta, 'status') == 'queued' then
|
|
343
|
+
local allowed = true
|
|
344
|
+
local concurrency_key = redis.call(
|
|
345
|
+
'HGET', candidate_meta, 'concurrencyKey'
|
|
346
|
+
)
|
|
347
|
+
local concurrency_limit = tonumber(
|
|
348
|
+
redis.call('HGET', candidate_meta, 'concurrencyLimit') or '0'
|
|
349
|
+
)
|
|
350
|
+
if concurrency_key and concurrency_key ~= '' and concurrency_limit > 0 then
|
|
351
|
+
local active = tonumber(
|
|
352
|
+
redis.call('HGET', KEYS[12], concurrency_key) or '0'
|
|
353
|
+
)
|
|
354
|
+
if active >= concurrency_limit then
|
|
355
|
+
allowed = false
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
local throttle_key = redis.call('HGET', candidate_meta, 'throttleKey')
|
|
360
|
+
local throttle_limit = tonumber(
|
|
361
|
+
redis.call('HGET', candidate_meta, 'throttleLimit') or '0'
|
|
362
|
+
)
|
|
363
|
+
if allowed and throttle_key and throttle_key ~= '' and throttle_limit > 0 then
|
|
364
|
+
local interval = tonumber(
|
|
365
|
+
redis.call('HGET', candidate_meta, 'throttleInterval') or '1'
|
|
366
|
+
)
|
|
367
|
+
local burst = tonumber(
|
|
368
|
+
redis.call('HGET', candidate_meta, 'throttleBurst') or '1'
|
|
369
|
+
)
|
|
370
|
+
local tokens = tonumber(
|
|
371
|
+
redis.call('HGET', KEYS[13], throttle_key) or tostring(burst)
|
|
372
|
+
)
|
|
373
|
+
local updated = tonumber(
|
|
374
|
+
redis.call('HGET', KEYS[14], throttle_key) or tostring(now)
|
|
375
|
+
)
|
|
376
|
+
tokens = math.min(
|
|
377
|
+
burst,
|
|
378
|
+
tokens + math.max(0, now - updated) * (throttle_limit / interval)
|
|
379
|
+
)
|
|
380
|
+
redis.call('HSET', KEYS[13], throttle_key, tostring(tokens))
|
|
381
|
+
redis.call('HSET', KEYS[14], throttle_key, tostring(now))
|
|
382
|
+
if tokens < 1 then
|
|
383
|
+
allowed = false
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
if allowed then
|
|
388
|
+
member = candidate
|
|
389
|
+
id = candidate_id
|
|
390
|
+
break
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
if not member or not id then
|
|
396
|
+
return {'policy'}
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
redis.call('ZREM', KEYS[2], member)
|
|
400
|
+
local meta = KEYS[1] .. id
|
|
401
|
+
if redis.call('HGET', meta, 'status') ~= 'queued' then
|
|
402
|
+
return {'empty'}
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
local concurrency_key = redis.call('HGET', meta, 'concurrencyKey')
|
|
406
|
+
if concurrency_key and concurrency_key ~= '' then
|
|
407
|
+
redis.call('HINCRBY', KEYS[12], concurrency_key, 1)
|
|
408
|
+
end
|
|
409
|
+
local throttle_key = redis.call('HGET', meta, 'throttleKey')
|
|
410
|
+
if throttle_key and throttle_key ~= '' then
|
|
411
|
+
redis.call('HINCRBYFLOAT', KEYS[13], throttle_key, -1)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
local attempt = redis.call('HINCRBY', meta, 'attempt', 1)
|
|
415
|
+
redis.call(
|
|
416
|
+
'HSET',
|
|
417
|
+
meta,
|
|
418
|
+
'status', 'running',
|
|
419
|
+
'startedAt', ARGV[1],
|
|
420
|
+
'token', token
|
|
421
|
+
)
|
|
422
|
+
redis.call('ZADD', KEYS[4], now + visibility, id)
|
|
423
|
+
redis.call('ZREM', KEYS[9], id)
|
|
424
|
+
if rate_limit > 0 then
|
|
425
|
+
redis.call('ZADD', KEYS[5], now, token)
|
|
426
|
+
end
|
|
427
|
+
redis.call(
|
|
428
|
+
'XADD', KEYS[15], 'MAXLEN', '~', '10000', '*',
|
|
429
|
+
'type', 'started', 'id', id, 'at', ARGV[1]
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
'job',
|
|
434
|
+
id,
|
|
435
|
+
redis.call('HGET', meta, 'name'),
|
|
436
|
+
redis.call('HGET', meta, 'input'),
|
|
437
|
+
tostring(attempt),
|
|
438
|
+
redis.call('HGET', meta, 'retries'),
|
|
439
|
+
redis.call('HGET', meta, 'backoff'),
|
|
440
|
+
redis.call('HGET', meta, 'timeout')
|
|
441
|
+
}
|
|
442
|
+
`;
|
|
443
|
+
const HEARTBEAT_SCRIPT = `
|
|
444
|
+
local meta = KEYS[1] .. ARGV[1]
|
|
445
|
+
if redis.call('HGET', meta, 'token') ~= ARGV[2] then
|
|
446
|
+
return 0
|
|
447
|
+
end
|
|
448
|
+
if redis.call('HGET', meta, 'status') ~= 'running' then
|
|
449
|
+
return 0
|
|
450
|
+
end
|
|
451
|
+
redis.call('ZADD', KEYS[2], ARGV[3], ARGV[1])
|
|
452
|
+
return 1
|
|
453
|
+
`;
|
|
454
|
+
const COMPLETE_SCRIPT = `
|
|
455
|
+
local meta = KEYS[1] .. ARGV[1]
|
|
456
|
+
if redis.call('HGET', meta, 'token') ~= ARGV[2] then
|
|
457
|
+
return 0
|
|
458
|
+
end
|
|
459
|
+
redis.call('ZREM', KEYS[2], ARGV[1])
|
|
460
|
+
local concurrency_key = redis.call('HGET', meta, 'concurrencyKey')
|
|
461
|
+
if concurrency_key and concurrency_key ~= '' then
|
|
462
|
+
local active = redis.call('HINCRBY', KEYS[5], concurrency_key, -1)
|
|
463
|
+
if active <= 0 then
|
|
464
|
+
redis.call('HDEL', KEYS[5], concurrency_key)
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
redis.call(
|
|
468
|
+
'HSET',
|
|
469
|
+
meta,
|
|
470
|
+
'status', 'succeeded',
|
|
471
|
+
'finishedAt', ARGV[3],
|
|
472
|
+
'output', ARGV[4]
|
|
473
|
+
)
|
|
474
|
+
redis.call('HDEL', meta, 'token')
|
|
475
|
+
local dedupe = redis.call('HGET', meta, 'key')
|
|
476
|
+
local key_retention = tonumber(
|
|
477
|
+
redis.call('HGET', meta, 'keyRetention') or '0'
|
|
478
|
+
)
|
|
479
|
+
if dedupe and dedupe ~= '' then
|
|
480
|
+
if key_retention > 0 then
|
|
481
|
+
redis.call(
|
|
482
|
+
'HSET',
|
|
483
|
+
meta,
|
|
484
|
+
'keyExpiresAt',
|
|
485
|
+
tostring(tonumber(ARGV[3]) + key_retention)
|
|
486
|
+
)
|
|
487
|
+
else
|
|
488
|
+
redis.call('HDEL', KEYS[4], dedupe)
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
redis.call('LPUSH', KEYS[3], ARGV[1])
|
|
492
|
+
redis.call('LTRIM', KEYS[3], 0, tonumber(ARGV[6]) - 1)
|
|
493
|
+
redis.call('PEXPIRE', meta, math.max(tonumber(ARGV[5]), key_retention))
|
|
494
|
+
redis.call(
|
|
495
|
+
'XADD', KEYS[6], 'MAXLEN', '~', '10000', '*',
|
|
496
|
+
'type', 'succeeded', 'id', ARGV[1], 'at', ARGV[3]
|
|
497
|
+
)
|
|
498
|
+
return 1
|
|
499
|
+
`;
|
|
500
|
+
const FAIL_SCRIPT = `
|
|
501
|
+
local meta = KEYS[1] .. ARGV[1]
|
|
502
|
+
if redis.call('HGET', meta, 'token') ~= ARGV[2] then
|
|
503
|
+
return 0
|
|
504
|
+
end
|
|
505
|
+
redis.call('ZREM', KEYS[2], ARGV[1])
|
|
506
|
+
redis.call('HDEL', meta, 'token')
|
|
507
|
+
redis.call('HSET', meta, 'error', ARGV[4])
|
|
508
|
+
local concurrency_key = redis.call('HGET', meta, 'concurrencyKey')
|
|
509
|
+
if concurrency_key and concurrency_key ~= '' then
|
|
510
|
+
local active = redis.call('HINCRBY', KEYS[7], concurrency_key, -1)
|
|
511
|
+
if active <= 0 then
|
|
512
|
+
redis.call('HDEL', KEYS[7], concurrency_key)
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
if ARGV[3] == '1' then
|
|
517
|
+
local run_at = tonumber(ARGV[5])
|
|
518
|
+
local member = redis.call('HGET', meta, 'member')
|
|
519
|
+
local priority = tonumber(redis.call('HGET', meta, 'priority') or '0')
|
|
520
|
+
if run_at > tonumber(ARGV[6]) then
|
|
521
|
+
redis.call('HSET', meta, 'status', 'scheduled', 'runAt', ARGV[5])
|
|
522
|
+
redis.call('ZADD', KEYS[4], run_at, ARGV[1])
|
|
523
|
+
else
|
|
524
|
+
redis.call('HSET', meta, 'status', 'queued', 'runAt', ARGV[5])
|
|
525
|
+
redis.call('ZADD', KEYS[3], -priority, member)
|
|
526
|
+
end
|
|
527
|
+
local expires_at = redis.call('HGET', meta, 'expiresAt')
|
|
528
|
+
if expires_at and expires_at ~= '' then
|
|
529
|
+
redis.call('ZADD', KEYS[8], expires_at, ARGV[1])
|
|
530
|
+
end
|
|
531
|
+
redis.call(
|
|
532
|
+
'XADD', KEYS[9], 'MAXLEN', '~', '10000', '*',
|
|
533
|
+
'type', 'retry', 'id', ARGV[1], 'at', ARGV[6]
|
|
534
|
+
)
|
|
535
|
+
return 1
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
redis.call(
|
|
539
|
+
'HSET',
|
|
540
|
+
meta,
|
|
541
|
+
'status', 'failed',
|
|
542
|
+
'finishedAt', ARGV[6]
|
|
543
|
+
)
|
|
544
|
+
local dedupe = redis.call('HGET', meta, 'key')
|
|
545
|
+
local key_retention = tonumber(
|
|
546
|
+
redis.call('HGET', meta, 'keyRetention') or '0'
|
|
547
|
+
)
|
|
548
|
+
if dedupe and dedupe ~= '' then
|
|
549
|
+
if key_retention > 0 then
|
|
550
|
+
redis.call(
|
|
551
|
+
'HSET',
|
|
552
|
+
meta,
|
|
553
|
+
'keyExpiresAt',
|
|
554
|
+
tostring(tonumber(ARGV[6]) + key_retention)
|
|
555
|
+
)
|
|
556
|
+
else
|
|
557
|
+
redis.call('HDEL', KEYS[6], dedupe)
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
redis.call('LPUSH', KEYS[5], ARGV[1])
|
|
561
|
+
redis.call('LTRIM', KEYS[5], 0, tonumber(ARGV[8]) - 1)
|
|
562
|
+
redis.call('PEXPIRE', meta, math.max(tonumber(ARGV[7]), key_retention))
|
|
563
|
+
redis.call(
|
|
564
|
+
'XADD', KEYS[9], 'MAXLEN', '~', '10000', '*',
|
|
565
|
+
'type', 'failed', 'id', ARGV[1], 'at', ARGV[6]
|
|
566
|
+
)
|
|
567
|
+
return 1
|
|
568
|
+
`;
|
|
569
|
+
const CANCEL_SCRIPT = `
|
|
570
|
+
local meta = KEYS[1] .. ARGV[1]
|
|
571
|
+
local state = redis.call('HGET', meta, 'status')
|
|
572
|
+
if not state then
|
|
573
|
+
return 0
|
|
574
|
+
end
|
|
575
|
+
if state == 'succeeded' or state == 'failed' or state == 'cancelled' or state == 'expired' then
|
|
576
|
+
return 0
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
local member = redis.call('HGET', meta, 'member')
|
|
580
|
+
redis.call('ZREM', KEYS[2], member)
|
|
581
|
+
redis.call('ZREM', KEYS[3], ARGV[1])
|
|
582
|
+
redis.call('ZREM', KEYS[4], ARGV[1])
|
|
583
|
+
redis.call('ZREM', KEYS[7], ARGV[1])
|
|
584
|
+
local concurrency_key = redis.call('HGET', meta, 'concurrencyKey')
|
|
585
|
+
if state == 'running' and concurrency_key and concurrency_key ~= '' then
|
|
586
|
+
local active = redis.call('HINCRBY', KEYS[8], concurrency_key, -1)
|
|
587
|
+
if active <= 0 then
|
|
588
|
+
redis.call('HDEL', KEYS[8], concurrency_key)
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
redis.call(
|
|
592
|
+
'HSET',
|
|
593
|
+
meta,
|
|
594
|
+
'status', 'cancelled',
|
|
595
|
+
'finishedAt', ARGV[2],
|
|
596
|
+
'error', ARGV[3]
|
|
597
|
+
)
|
|
598
|
+
redis.call('HDEL', meta, 'token')
|
|
599
|
+
local dedupe = redis.call('HGET', meta, 'key')
|
|
600
|
+
local key_retention = tonumber(
|
|
601
|
+
redis.call('HGET', meta, 'keyRetention') or '0'
|
|
602
|
+
)
|
|
603
|
+
if dedupe and dedupe ~= '' then
|
|
604
|
+
if key_retention > 0 then
|
|
605
|
+
redis.call(
|
|
606
|
+
'HSET',
|
|
607
|
+
meta,
|
|
608
|
+
'keyExpiresAt',
|
|
609
|
+
tostring(tonumber(ARGV[2]) + key_retention)
|
|
610
|
+
)
|
|
611
|
+
else
|
|
612
|
+
redis.call('HDEL', KEYS[6], dedupe)
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
redis.call('LPUSH', KEYS[5], ARGV[1])
|
|
616
|
+
redis.call('LTRIM', KEYS[5], 0, tonumber(ARGV[5]) - 1)
|
|
617
|
+
redis.call('PEXPIRE', meta, math.max(tonumber(ARGV[4]), key_retention))
|
|
618
|
+
redis.call(
|
|
619
|
+
'XADD', KEYS[9], 'MAXLEN', '~', '10000', '*',
|
|
620
|
+
'type', 'cancelled', 'id', ARGV[1], 'at', ARGV[2]
|
|
621
|
+
)
|
|
622
|
+
return 1
|
|
623
|
+
`;
|
|
624
|
+
const STATS_SCRIPT = `
|
|
625
|
+
return {
|
|
626
|
+
redis.call('ZCARD', KEYS[1]),
|
|
627
|
+
redis.call('ZCARD', KEYS[2]),
|
|
628
|
+
redis.call('ZCARD', KEYS[3]),
|
|
629
|
+
redis.call('LLEN', KEYS[4]),
|
|
630
|
+
redis.call('LLEN', KEYS[5]),
|
|
631
|
+
redis.call('LLEN', KEYS[6]),
|
|
632
|
+
redis.call('LLEN', KEYS[7])
|
|
633
|
+
}
|
|
634
|
+
`;
|
|
635
|
+
const REDRIVE_SCRIPT = `
|
|
636
|
+
local meta = KEYS[1] .. ARGV[1]
|
|
637
|
+
local state = redis.call('HGET', meta, 'status')
|
|
638
|
+
if state ~= 'failed' and state ~= 'cancelled' and state ~= 'expired' then
|
|
639
|
+
return 0
|
|
640
|
+
end
|
|
641
|
+
local sequence = redis.call('INCR', KEYS[2])
|
|
642
|
+
local member = string.format('%020d', sequence) .. '|' .. ARGV[1]
|
|
643
|
+
local priority = tonumber(redis.call('HGET', meta, 'priority') or '0')
|
|
644
|
+
redis.call('LREM', KEYS[4], 0, ARGV[1])
|
|
645
|
+
redis.call('LREM', KEYS[5], 0, ARGV[1])
|
|
646
|
+
redis.call('LREM', KEYS[6], 0, ARGV[1])
|
|
647
|
+
redis.call(
|
|
648
|
+
'HSET',
|
|
649
|
+
meta,
|
|
650
|
+
'status', 'queued',
|
|
651
|
+
'attempt', '0',
|
|
652
|
+
'runAt', ARGV[2],
|
|
653
|
+
'member', member,
|
|
654
|
+
'expiresAt', ''
|
|
655
|
+
)
|
|
656
|
+
redis.call(
|
|
657
|
+
'HDEL',
|
|
658
|
+
meta,
|
|
659
|
+
'startedAt',
|
|
660
|
+
'finishedAt',
|
|
661
|
+
'output',
|
|
662
|
+
'error',
|
|
663
|
+
'progress',
|
|
664
|
+
'token',
|
|
665
|
+
'keyExpiresAt'
|
|
666
|
+
)
|
|
667
|
+
redis.call('PERSIST', meta)
|
|
668
|
+
redis.call('ZADD', KEYS[3], -priority, member)
|
|
669
|
+
redis.call(
|
|
670
|
+
'XADD', KEYS[7], 'MAXLEN', '~', '10000', '*',
|
|
671
|
+
'type', 'added', 'id', ARGV[1], 'at', ARGV[2]
|
|
672
|
+
)
|
|
673
|
+
return 1
|
|
674
|
+
`;
|
|
675
|
+
const REMOVE_SCRIPT = `
|
|
676
|
+
local meta = KEYS[1] .. ARGV[1]
|
|
677
|
+
local state = redis.call('HGET', meta, 'status')
|
|
678
|
+
if state ~= 'succeeded' and state ~= 'failed' and state ~= 'cancelled' and state ~= 'expired' then
|
|
679
|
+
return 0
|
|
680
|
+
end
|
|
681
|
+
local dedupe = redis.call('HGET', meta, 'key')
|
|
682
|
+
if dedupe and dedupe ~= '' then
|
|
683
|
+
redis.call('HDEL', KEYS[9], dedupe)
|
|
684
|
+
end
|
|
685
|
+
redis.call('LREM', KEYS[5], 0, ARGV[1])
|
|
686
|
+
redis.call('LREM', KEYS[6], 0, ARGV[1])
|
|
687
|
+
redis.call('LREM', KEYS[7], 0, ARGV[1])
|
|
688
|
+
redis.call('LREM', KEYS[8], 0, ARGV[1])
|
|
689
|
+
redis.call('ZREM', KEYS[2], redis.call('HGET', meta, 'member'))
|
|
690
|
+
redis.call('ZREM', KEYS[3], ARGV[1])
|
|
691
|
+
redis.call('ZREM', KEYS[4], ARGV[1])
|
|
692
|
+
redis.call('ZREM', KEYS[10], ARGV[1])
|
|
693
|
+
redis.call('ZREM', KEYS[11], ARGV[1])
|
|
694
|
+
redis.call('DEL', meta)
|
|
695
|
+
return 1
|
|
696
|
+
`;
|
|
697
|
+
const UPSERT_SCHEDULE_SCRIPT = `
|
|
698
|
+
local meta = KEYS[1] .. ARGV[1]
|
|
699
|
+
local owner = redis.call('HGET', meta, 'jobName')
|
|
700
|
+
if owner and owner ~= ARGV[2] then
|
|
701
|
+
return {'conflict', owner}
|
|
702
|
+
end
|
|
703
|
+
redis.call(
|
|
704
|
+
'HSET',
|
|
705
|
+
meta,
|
|
706
|
+
'id', ARGV[1],
|
|
707
|
+
'jobName', ARGV[2],
|
|
708
|
+
'cron', ARGV[3],
|
|
709
|
+
'timezone', ARGV[4],
|
|
710
|
+
'status', 'active',
|
|
711
|
+
'nextRunAt', ARGV[5],
|
|
712
|
+
'input', ARGV[6],
|
|
713
|
+
'catchUp', ARGV[7],
|
|
714
|
+
'submit', ARGV[8]
|
|
715
|
+
)
|
|
716
|
+
redis.call('ZADD', KEYS[2], ARGV[5], ARGV[1])
|
|
717
|
+
return {'ok', ARGV[1]}
|
|
718
|
+
`;
|
|
719
|
+
const ADVANCE_SCHEDULE_SCRIPT = `
|
|
720
|
+
local meta = KEYS[1] .. ARGV[1]
|
|
721
|
+
if redis.call('HGET', meta, 'status') ~= 'active' then
|
|
722
|
+
return 0
|
|
723
|
+
end
|
|
724
|
+
local current = tonumber(redis.call('HGET', meta, 'nextRunAt') or '0')
|
|
725
|
+
if current ~= tonumber(ARGV[2]) then
|
|
726
|
+
return 0
|
|
727
|
+
end
|
|
728
|
+
redis.call('HSET', meta, 'nextRunAt', ARGV[3])
|
|
729
|
+
redis.call('ZADD', KEYS[2], ARGV[3], ARGV[1])
|
|
730
|
+
return 1
|
|
731
|
+
`;
|
|
732
|
+
export function redis(client, options = {}) {
|
|
733
|
+
if (typeof client.send !== "function" &&
|
|
734
|
+
typeof client.sendCommand !== "function") {
|
|
735
|
+
throw new TypeError("Redis client must expose send(command, args) or sendCommand(args)");
|
|
736
|
+
}
|
|
737
|
+
const prefix = options.prefix?.trim() || "enqiu";
|
|
738
|
+
const pollInterval = options.pollInterval ?? 100;
|
|
739
|
+
const visibilityTimeout = options.visibilityTimeout ?? 30_000;
|
|
740
|
+
const retention = options.retention ?? 7 * 24 * 60 * 60 * 1000;
|
|
741
|
+
positive("pollInterval", pollInterval);
|
|
742
|
+
positive("visibilityTimeout", visibilityTimeout);
|
|
743
|
+
positive("retention", retention);
|
|
744
|
+
return {
|
|
745
|
+
kind: "redis",
|
|
746
|
+
client,
|
|
747
|
+
prefix,
|
|
748
|
+
pollInterval,
|
|
749
|
+
visibilityTimeout,
|
|
750
|
+
retention,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
export class RedisQueue {
|
|
754
|
+
name;
|
|
755
|
+
handlers;
|
|
756
|
+
driver;
|
|
757
|
+
workerEnabled;
|
|
758
|
+
concurrency;
|
|
759
|
+
retry;
|
|
760
|
+
timeout;
|
|
761
|
+
rateLimit;
|
|
762
|
+
historyLimit;
|
|
763
|
+
logLimit;
|
|
764
|
+
keys;
|
|
765
|
+
local = new Map();
|
|
766
|
+
listeners = new Map();
|
|
767
|
+
running = new Set();
|
|
768
|
+
started;
|
|
769
|
+
closed = false;
|
|
770
|
+
workerLoop;
|
|
771
|
+
eventLoop;
|
|
772
|
+
eventCursor;
|
|
773
|
+
sequence = 0;
|
|
774
|
+
constructor(handlers, options) {
|
|
775
|
+
if (Object.keys(handlers).length === 0) {
|
|
776
|
+
throw new TypeError("At least one job handler is required");
|
|
777
|
+
}
|
|
778
|
+
this.handlers = handlers;
|
|
779
|
+
this.driver = options.driver;
|
|
780
|
+
this.name = options.name?.trim() || "default";
|
|
781
|
+
this.workerEnabled = options.worker ?? true;
|
|
782
|
+
this.concurrency = options.concurrency ?? Number.POSITIVE_INFINITY;
|
|
783
|
+
this.retry = normalizeRetry(options.retry);
|
|
784
|
+
this.timeout = options.timeout;
|
|
785
|
+
this.rateLimit = options.rateLimit;
|
|
786
|
+
this.historyLimit = Math.max(1, options.historyLimit ?? 1000);
|
|
787
|
+
this.logLimit = options.logLimit ?? 100;
|
|
788
|
+
this.started = options.autoStart ?? true;
|
|
789
|
+
this.keys = queueKeys(this.driver.prefix, this.name);
|
|
790
|
+
positiveIntegerOrInfinity("concurrency", this.concurrency);
|
|
791
|
+
nonNegativeInteger("logLimit", this.logLimit);
|
|
792
|
+
optionalPositive("timeout", this.timeout);
|
|
793
|
+
if (this.rateLimit) {
|
|
794
|
+
positiveInteger("rateLimit.limit", this.rateLimit.limit);
|
|
795
|
+
positive("rateLimit.interval", this.rateLimit.interval);
|
|
796
|
+
}
|
|
797
|
+
if (this.started && this.workerEnabled) {
|
|
798
|
+
this.ensureWorker();
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
add(name, input, options = {}) {
|
|
802
|
+
this.assertOpen();
|
|
803
|
+
if (typeof this.handlers[name] !== "function") {
|
|
804
|
+
throw new TypeError(`Unknown job "${name}"`);
|
|
805
|
+
}
|
|
806
|
+
const now = Date.now();
|
|
807
|
+
const requestedRunAt = runAt(options.delay, now);
|
|
808
|
+
const resolvedRunAt = options.debounce?.mode === "trailing"
|
|
809
|
+
? Math.max(requestedRunAt, now + options.debounce.wait)
|
|
810
|
+
: requestedRunAt;
|
|
811
|
+
const record = {
|
|
812
|
+
id: options.id ?? createId(this.name, String(name), now, this.sequence++),
|
|
813
|
+
name: String(name),
|
|
814
|
+
input,
|
|
815
|
+
status: resolvedRunAt > now ? "scheduled" : "queued",
|
|
816
|
+
priority: options.priority ?? 0,
|
|
817
|
+
attempt: 0,
|
|
818
|
+
retry: options.retry === undefined
|
|
819
|
+
? this.retry
|
|
820
|
+
: normalizeRetry(options.retry),
|
|
821
|
+
timeout: options.timeout ?? this.timeout,
|
|
822
|
+
expiresAt: options.expiresIn === undefined
|
|
823
|
+
? undefined
|
|
824
|
+
: now + options.expiresIn,
|
|
825
|
+
keyRetention: options.keyRetention ?? 0,
|
|
826
|
+
concurrency: options.concurrency,
|
|
827
|
+
throttle: options.throttle,
|
|
828
|
+
debounce: options.debounce,
|
|
829
|
+
createdAt: now,
|
|
830
|
+
runAt: resolvedRunAt,
|
|
831
|
+
startedAt: undefined,
|
|
832
|
+
finishedAt: undefined,
|
|
833
|
+
progress: undefined,
|
|
834
|
+
output: undefined,
|
|
835
|
+
error: undefined,
|
|
836
|
+
logs: [],
|
|
837
|
+
deduplicated: false,
|
|
838
|
+
submission: Promise.resolve(),
|
|
839
|
+
submissionError: undefined,
|
|
840
|
+
};
|
|
841
|
+
if (!record.id) {
|
|
842
|
+
throw new TypeError("Job ID must not be empty");
|
|
843
|
+
}
|
|
844
|
+
if (!Number.isFinite(record.priority)) {
|
|
845
|
+
throw new RangeError("priority must be a finite number");
|
|
846
|
+
}
|
|
847
|
+
optionalPositive("timeout", record.timeout);
|
|
848
|
+
optionalPositive("expiresIn", options.expiresIn);
|
|
849
|
+
if (record.keyRetention < 0 || !Number.isFinite(record.keyRetention)) {
|
|
850
|
+
throw new RangeError("keyRetention must be a non-negative finite number");
|
|
851
|
+
}
|
|
852
|
+
record.submission = this.enqueue(record, options.key).catch((cause) => {
|
|
853
|
+
const error = toError(cause);
|
|
854
|
+
record.submissionError = error;
|
|
855
|
+
record.status = "failed";
|
|
856
|
+
record.error = serializeError(error);
|
|
857
|
+
record.finishedAt = Date.now();
|
|
858
|
+
this.emit("error", error);
|
|
859
|
+
throw error;
|
|
860
|
+
});
|
|
861
|
+
// Mark the rejection handled until a consumer reads `accepted` or `result`.
|
|
862
|
+
void record.submission.catch(() => undefined);
|
|
863
|
+
this.local.set(record.id, record);
|
|
864
|
+
return this.handle(record);
|
|
865
|
+
}
|
|
866
|
+
addMany(name, inputs, options) {
|
|
867
|
+
return inputs.map((input) => this.add(name, input, options));
|
|
868
|
+
}
|
|
869
|
+
async get(id) {
|
|
870
|
+
const values = await this.command("HMGET", [
|
|
871
|
+
this.keys.meta + id,
|
|
872
|
+
"id",
|
|
873
|
+
"name",
|
|
874
|
+
"input",
|
|
875
|
+
"status",
|
|
876
|
+
"priority",
|
|
877
|
+
"attempt",
|
|
878
|
+
"retries",
|
|
879
|
+
"createdAt",
|
|
880
|
+
"runAt",
|
|
881
|
+
"expiresAt",
|
|
882
|
+
"startedAt",
|
|
883
|
+
"finishedAt",
|
|
884
|
+
"progress",
|
|
885
|
+
"output",
|
|
886
|
+
"error",
|
|
887
|
+
"logs",
|
|
888
|
+
]);
|
|
889
|
+
if (!Array.isArray(values) || values[0] === null) {
|
|
890
|
+
return undefined;
|
|
891
|
+
}
|
|
892
|
+
const result = snapshotFromFields(values);
|
|
893
|
+
const local = this.local.get(id);
|
|
894
|
+
if (local) {
|
|
895
|
+
applySnapshot(local, result);
|
|
896
|
+
}
|
|
897
|
+
return result;
|
|
898
|
+
}
|
|
899
|
+
async list(options = {}) {
|
|
900
|
+
const limit = options.limit ?? 100;
|
|
901
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
|
|
902
|
+
throw new RangeError("list.limit must be an integer between 1 and 1000");
|
|
903
|
+
}
|
|
904
|
+
const offset = options.cursor === undefined
|
|
905
|
+
? 0
|
|
906
|
+
: Number.parseInt(options.cursor, 10);
|
|
907
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
908
|
+
throw new TypeError("Invalid list cursor");
|
|
909
|
+
}
|
|
910
|
+
const minimum = options.after === undefined
|
|
911
|
+
? "-inf"
|
|
912
|
+
: `(${options.after}`;
|
|
913
|
+
const maximum = options.before === undefined
|
|
914
|
+
? "+inf"
|
|
915
|
+
: `(${options.before}`;
|
|
916
|
+
const scanSize = Math.min(4000, Math.max(limit * 4, limit));
|
|
917
|
+
const raw = await this.command("ZRANGEBYSCORE", [
|
|
918
|
+
this.keys.all,
|
|
919
|
+
minimum,
|
|
920
|
+
maximum,
|
|
921
|
+
"LIMIT",
|
|
922
|
+
String(offset),
|
|
923
|
+
String(scanSize),
|
|
924
|
+
]);
|
|
925
|
+
const ids = Array.isArray(raw) ? raw.map(String) : [];
|
|
926
|
+
const jobs = [];
|
|
927
|
+
for (const id of ids) {
|
|
928
|
+
const snapshot = await this.get(id);
|
|
929
|
+
if (!snapshot) {
|
|
930
|
+
await this.command("ZREM", [this.keys.all, id]);
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
if ((options.status && snapshot.status !== options.status) ||
|
|
934
|
+
(options.name && snapshot.name !== options.name)) {
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
jobs.push(snapshot);
|
|
938
|
+
if (jobs.length >= limit) {
|
|
939
|
+
break;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
const nextOffset = offset + ids.length;
|
|
943
|
+
return ids.length === scanSize
|
|
944
|
+
? { jobs, cursor: String(nextOffset) }
|
|
945
|
+
: { jobs };
|
|
946
|
+
}
|
|
947
|
+
async cleanup(options = {}) {
|
|
948
|
+
const olderThan = options.olderThan ?? 0;
|
|
949
|
+
const limit = options.limit ?? 1000;
|
|
950
|
+
if (!Number.isFinite(olderThan) || olderThan < 0) {
|
|
951
|
+
throw new RangeError("cleanup.olderThan must be non-negative");
|
|
952
|
+
}
|
|
953
|
+
if (!Number.isInteger(limit) || limit < 0 || limit > 10_000) {
|
|
954
|
+
throw new RangeError("cleanup.limit must be an integer between 0 and 10000");
|
|
955
|
+
}
|
|
956
|
+
const statuses = new Set(options.status === undefined
|
|
957
|
+
? ["succeeded", "failed", "cancelled", "expired"]
|
|
958
|
+
: Array.isArray(options.status)
|
|
959
|
+
? options.status
|
|
960
|
+
: [options.status]);
|
|
961
|
+
const threshold = Date.now() - olderThan;
|
|
962
|
+
const removed = [];
|
|
963
|
+
let cursor;
|
|
964
|
+
do {
|
|
965
|
+
const page = await this.list(cursor === undefined ? { limit: 1000 } : { limit: 1000, cursor });
|
|
966
|
+
for (const job of page.jobs) {
|
|
967
|
+
if (removed.length >= limit ||
|
|
968
|
+
!statuses.has(job.status) ||
|
|
969
|
+
(job.finishedAt ?? Number.POSITIVE_INFINITY) > threshold) {
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
const result = await this.eval(REMOVE_SCRIPT, [
|
|
973
|
+
this.keys.meta,
|
|
974
|
+
this.keys.ready,
|
|
975
|
+
this.keys.delayed,
|
|
976
|
+
this.keys.active,
|
|
977
|
+
this.keys.completed,
|
|
978
|
+
this.keys.failed,
|
|
979
|
+
this.keys.cancelled,
|
|
980
|
+
this.keys.expired,
|
|
981
|
+
this.keys.dedupe,
|
|
982
|
+
this.keys.expiring,
|
|
983
|
+
this.keys.all,
|
|
984
|
+
], [job.id]);
|
|
985
|
+
if (Number(result) === 1) {
|
|
986
|
+
this.local.delete(job.id);
|
|
987
|
+
removed.push(job.id);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
cursor = page.cursor;
|
|
991
|
+
} while (cursor && removed.length < limit);
|
|
992
|
+
return removed;
|
|
993
|
+
}
|
|
994
|
+
async redrive(id) {
|
|
995
|
+
this.assertOpen();
|
|
996
|
+
const result = await this.eval(REDRIVE_SCRIPT, [
|
|
997
|
+
this.keys.meta,
|
|
998
|
+
this.keys.sequence,
|
|
999
|
+
this.keys.ready,
|
|
1000
|
+
this.keys.failed,
|
|
1001
|
+
this.keys.cancelled,
|
|
1002
|
+
this.keys.expired,
|
|
1003
|
+
this.keys.events,
|
|
1004
|
+
], [id, String(Date.now())]);
|
|
1005
|
+
if (Number(result) !== 1) {
|
|
1006
|
+
throw new Error(`Job "${id}" cannot be redriven`);
|
|
1007
|
+
}
|
|
1008
|
+
const snapshot = await this.get(id);
|
|
1009
|
+
if (!snapshot) {
|
|
1010
|
+
throw new Error(`Job "${id}" no longer exists`);
|
|
1011
|
+
}
|
|
1012
|
+
const record = this.recordFromSnapshot(snapshot);
|
|
1013
|
+
this.local.set(id, record);
|
|
1014
|
+
return this.handle(record);
|
|
1015
|
+
}
|
|
1016
|
+
async pauseQueue() {
|
|
1017
|
+
await this.command("HSET", [this.keys.config, "paused", "1"]);
|
|
1018
|
+
}
|
|
1019
|
+
async resumeQueue() {
|
|
1020
|
+
await this.command("HDEL", [this.keys.config, "paused"]);
|
|
1021
|
+
}
|
|
1022
|
+
async setGlobalConcurrency(limit) {
|
|
1023
|
+
positiveInteger("global concurrency", limit);
|
|
1024
|
+
await this.command("HSET", [
|
|
1025
|
+
this.keys.config,
|
|
1026
|
+
"concurrency",
|
|
1027
|
+
String(limit),
|
|
1028
|
+
]);
|
|
1029
|
+
}
|
|
1030
|
+
async upsertSchedule(registration) {
|
|
1031
|
+
this.assertOpen();
|
|
1032
|
+
parseCron(registration.cron);
|
|
1033
|
+
const timezone = validateTimeZone(registration.timezone ?? "UTC");
|
|
1034
|
+
const id = registration.id?.trim() || registration.jobName;
|
|
1035
|
+
if (!id) {
|
|
1036
|
+
throw new TypeError("schedule.id must not be empty");
|
|
1037
|
+
}
|
|
1038
|
+
const nextRunAt = nextCronOccurrence(registration.cron, timezone, Date.now());
|
|
1039
|
+
const result = await this.eval(UPSERT_SCHEDULE_SCRIPT, [this.keys.scheduleMeta, this.keys.schedules], [
|
|
1040
|
+
id,
|
|
1041
|
+
registration.jobName,
|
|
1042
|
+
registration.cron,
|
|
1043
|
+
timezone,
|
|
1044
|
+
String(nextRunAt),
|
|
1045
|
+
encode(registration.input),
|
|
1046
|
+
registration.catchUp ? "1" : "0",
|
|
1047
|
+
encode(registration.submit),
|
|
1048
|
+
]);
|
|
1049
|
+
if (!Array.isArray(result) ||
|
|
1050
|
+
String(result[0]) !== "ok") {
|
|
1051
|
+
const owner = Array.isArray(result) ? String(result[1]) : "another job";
|
|
1052
|
+
throw new Error(`Schedule "${id}" already belongs to job "${owner}"`);
|
|
1053
|
+
}
|
|
1054
|
+
return new RedisScheduleHandleImpl(this, id, nextRunAt);
|
|
1055
|
+
}
|
|
1056
|
+
async getSchedule(id) {
|
|
1057
|
+
const values = await this.command("HMGET", [
|
|
1058
|
+
this.keys.scheduleMeta + id,
|
|
1059
|
+
"id",
|
|
1060
|
+
"jobName",
|
|
1061
|
+
"cron",
|
|
1062
|
+
"timezone",
|
|
1063
|
+
"status",
|
|
1064
|
+
"nextRunAt",
|
|
1065
|
+
"input",
|
|
1066
|
+
"catchUp",
|
|
1067
|
+
]);
|
|
1068
|
+
if (!Array.isArray(values) || values[0] === null) {
|
|
1069
|
+
return undefined;
|
|
1070
|
+
}
|
|
1071
|
+
return {
|
|
1072
|
+
id: String(values[0]),
|
|
1073
|
+
jobName: String(values[1]),
|
|
1074
|
+
cron: String(values[2]),
|
|
1075
|
+
timezone: String(values[3]),
|
|
1076
|
+
status: String(values[4]),
|
|
1077
|
+
nextRunAt: Number(values[5]),
|
|
1078
|
+
input: decode(String(values[6])),
|
|
1079
|
+
catchUp: String(values[7]) === "1",
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
async pauseSchedule(id) {
|
|
1083
|
+
if (!(await this.getSchedule(id))) {
|
|
1084
|
+
throw new Error(`Schedule "${id}" does not exist`);
|
|
1085
|
+
}
|
|
1086
|
+
await this.command("HSET", [
|
|
1087
|
+
this.keys.scheduleMeta + id,
|
|
1088
|
+
"status",
|
|
1089
|
+
"paused",
|
|
1090
|
+
]);
|
|
1091
|
+
await this.command("ZREM", [this.keys.schedules, id]);
|
|
1092
|
+
}
|
|
1093
|
+
async resumeSchedule(id) {
|
|
1094
|
+
const schedule = await this.getSchedule(id);
|
|
1095
|
+
if (!schedule) {
|
|
1096
|
+
throw new Error(`Schedule "${id}" does not exist`);
|
|
1097
|
+
}
|
|
1098
|
+
const nextRunAt = nextCronOccurrence(schedule.cron, schedule.timezone, Date.now());
|
|
1099
|
+
await this.command("HSET", [
|
|
1100
|
+
this.keys.scheduleMeta + id,
|
|
1101
|
+
"status",
|
|
1102
|
+
"active",
|
|
1103
|
+
"nextRunAt",
|
|
1104
|
+
String(nextRunAt),
|
|
1105
|
+
]);
|
|
1106
|
+
await this.command("ZADD", [
|
|
1107
|
+
this.keys.schedules,
|
|
1108
|
+
String(nextRunAt),
|
|
1109
|
+
id,
|
|
1110
|
+
]);
|
|
1111
|
+
return nextRunAt;
|
|
1112
|
+
}
|
|
1113
|
+
async removeSchedule(id) {
|
|
1114
|
+
if (!(await this.getSchedule(id))) {
|
|
1115
|
+
throw new Error(`Schedule "${id}" does not exist`);
|
|
1116
|
+
}
|
|
1117
|
+
await this.command("ZREM", [this.keys.schedules, id]);
|
|
1118
|
+
await this.command("DEL", [this.keys.scheduleMeta + id]);
|
|
1119
|
+
}
|
|
1120
|
+
async cancel(id, reason = "Job was cancelled") {
|
|
1121
|
+
const error = serializeError(new JobCancelledError(id, reason));
|
|
1122
|
+
const result = await this.eval(CANCEL_SCRIPT, [
|
|
1123
|
+
this.keys.meta,
|
|
1124
|
+
this.keys.ready,
|
|
1125
|
+
this.keys.delayed,
|
|
1126
|
+
this.keys.active,
|
|
1127
|
+
this.keys.cancelled,
|
|
1128
|
+
this.keys.dedupe,
|
|
1129
|
+
this.keys.expiring,
|
|
1130
|
+
this.keys.activeKeys,
|
|
1131
|
+
this.keys.events,
|
|
1132
|
+
], [
|
|
1133
|
+
id,
|
|
1134
|
+
String(Date.now()),
|
|
1135
|
+
JSON.stringify(error),
|
|
1136
|
+
String(this.driver.retention),
|
|
1137
|
+
String(this.historyLimit),
|
|
1138
|
+
]);
|
|
1139
|
+
const cancelled = Number(result) === 1;
|
|
1140
|
+
if (cancelled) {
|
|
1141
|
+
const local = this.local.get(id);
|
|
1142
|
+
if (local) {
|
|
1143
|
+
local.status = "cancelled";
|
|
1144
|
+
local.error = error;
|
|
1145
|
+
local.finishedAt = Date.now();
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
return cancelled;
|
|
1149
|
+
}
|
|
1150
|
+
async stats() {
|
|
1151
|
+
const result = await this.eval(STATS_SCRIPT, [
|
|
1152
|
+
this.keys.ready,
|
|
1153
|
+
this.keys.delayed,
|
|
1154
|
+
this.keys.active,
|
|
1155
|
+
this.keys.completed,
|
|
1156
|
+
this.keys.failed,
|
|
1157
|
+
this.keys.cancelled,
|
|
1158
|
+
this.keys.expired,
|
|
1159
|
+
], []);
|
|
1160
|
+
const values = Array.isArray(result) ? result.map(Number) : [];
|
|
1161
|
+
const stats = {
|
|
1162
|
+
queued: values[0] ?? 0,
|
|
1163
|
+
scheduled: values[1] ?? 0,
|
|
1164
|
+
running: values[2] ?? 0,
|
|
1165
|
+
succeeded: values[3] ?? 0,
|
|
1166
|
+
failed: values[4] ?? 0,
|
|
1167
|
+
cancelled: values[5] ?? 0,
|
|
1168
|
+
expired: values[6] ?? 0,
|
|
1169
|
+
total: 0,
|
|
1170
|
+
};
|
|
1171
|
+
stats.total =
|
|
1172
|
+
stats.queued +
|
|
1173
|
+
stats.scheduled +
|
|
1174
|
+
stats.running +
|
|
1175
|
+
stats.succeeded +
|
|
1176
|
+
stats.failed +
|
|
1177
|
+
stats.cancelled +
|
|
1178
|
+
stats.expired;
|
|
1179
|
+
return stats;
|
|
1180
|
+
}
|
|
1181
|
+
pause() {
|
|
1182
|
+
this.assertOpen();
|
|
1183
|
+
this.started = false;
|
|
1184
|
+
return this;
|
|
1185
|
+
}
|
|
1186
|
+
start() {
|
|
1187
|
+
this.assertOpen();
|
|
1188
|
+
this.started = true;
|
|
1189
|
+
if (this.workerEnabled) {
|
|
1190
|
+
this.ensureWorker();
|
|
1191
|
+
}
|
|
1192
|
+
return this;
|
|
1193
|
+
}
|
|
1194
|
+
setWorkerConcurrency(limit) {
|
|
1195
|
+
positiveIntegerOrInfinity("worker concurrency", limit);
|
|
1196
|
+
this.concurrency = limit;
|
|
1197
|
+
}
|
|
1198
|
+
async onIdle() {
|
|
1199
|
+
while (this.running.size > 0) {
|
|
1200
|
+
await sleep(this.driver.pollInterval);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
async close(options = {}) {
|
|
1204
|
+
if (this.closed) {
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
this.started = false;
|
|
1208
|
+
if (options.drain ?? true) {
|
|
1209
|
+
await this.onIdle();
|
|
1210
|
+
}
|
|
1211
|
+
this.closed = true;
|
|
1212
|
+
await Promise.allSettled(this.running);
|
|
1213
|
+
await this.workerLoop;
|
|
1214
|
+
await this.eventLoop;
|
|
1215
|
+
}
|
|
1216
|
+
on(event, listener) {
|
|
1217
|
+
let group = this.listeners.get(event);
|
|
1218
|
+
if (!group) {
|
|
1219
|
+
group = new Set();
|
|
1220
|
+
this.listeners.set(event, group);
|
|
1221
|
+
}
|
|
1222
|
+
group.add(listener);
|
|
1223
|
+
if (event !== "error" && event !== "idle") {
|
|
1224
|
+
this.ensureEventLoop();
|
|
1225
|
+
}
|
|
1226
|
+
return () => {
|
|
1227
|
+
group?.delete(listener);
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
/** @internal */
|
|
1231
|
+
async resultFor(record) {
|
|
1232
|
+
await record.submission;
|
|
1233
|
+
while (true) {
|
|
1234
|
+
const current = await this.get(record.id);
|
|
1235
|
+
if (!current) {
|
|
1236
|
+
throw new Error(`Job "${record.id}" no longer exists`);
|
|
1237
|
+
}
|
|
1238
|
+
if (current.status === "succeeded") {
|
|
1239
|
+
return current.output;
|
|
1240
|
+
}
|
|
1241
|
+
if (current.status === "failed") {
|
|
1242
|
+
throw new JobFailedError(record.id, current.error?.message ?? `Job "${record.id}" failed`);
|
|
1243
|
+
}
|
|
1244
|
+
if (current.status === "cancelled") {
|
|
1245
|
+
throw new JobCancelledError(record.id, current.error?.message);
|
|
1246
|
+
}
|
|
1247
|
+
if (current.status === "expired") {
|
|
1248
|
+
throw new JobExpiredError(record.id);
|
|
1249
|
+
}
|
|
1250
|
+
await sleep(this.driver.pollInterval);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
handle(record) {
|
|
1254
|
+
return new RedisJobHandle(this, record);
|
|
1255
|
+
}
|
|
1256
|
+
async enqueue(record, key) {
|
|
1257
|
+
const result = await this.eval(ENQUEUE_SCRIPT, [
|
|
1258
|
+
this.keys.meta,
|
|
1259
|
+
this.keys.sequence,
|
|
1260
|
+
this.keys.ready,
|
|
1261
|
+
this.keys.delayed,
|
|
1262
|
+
this.keys.active,
|
|
1263
|
+
this.keys.completed,
|
|
1264
|
+
this.keys.dedupe,
|
|
1265
|
+
this.keys.expiring,
|
|
1266
|
+
this.keys.debounce,
|
|
1267
|
+
this.keys.debounceExpiry,
|
|
1268
|
+
this.keys.events,
|
|
1269
|
+
this.keys.all,
|
|
1270
|
+
], [
|
|
1271
|
+
record.id,
|
|
1272
|
+
record.name,
|
|
1273
|
+
encode(record.input),
|
|
1274
|
+
String(record.priority),
|
|
1275
|
+
String(record.runAt),
|
|
1276
|
+
String(record.createdAt),
|
|
1277
|
+
String(record.retry.retries),
|
|
1278
|
+
encode(record.retry.backoff),
|
|
1279
|
+
key ? `${record.name}:${key}` : "",
|
|
1280
|
+
record.timeout === undefined ? "" : String(record.timeout),
|
|
1281
|
+
record.expiresAt === undefined ? "" : String(record.expiresAt),
|
|
1282
|
+
String(record.keyRetention),
|
|
1283
|
+
record.concurrency?.key ?? "",
|
|
1284
|
+
record.concurrency === undefined
|
|
1285
|
+
? ""
|
|
1286
|
+
: String(record.concurrency.limit),
|
|
1287
|
+
record.throttle?.key ?? "",
|
|
1288
|
+
record.throttle === undefined
|
|
1289
|
+
? ""
|
|
1290
|
+
: String(record.throttle.limit),
|
|
1291
|
+
record.throttle === undefined
|
|
1292
|
+
? ""
|
|
1293
|
+
: String(record.throttle.interval),
|
|
1294
|
+
record.throttle === undefined
|
|
1295
|
+
? ""
|
|
1296
|
+
: String(record.throttle.burst),
|
|
1297
|
+
record.debounce
|
|
1298
|
+
? `${record.name}:${record.debounce.key}`
|
|
1299
|
+
: "",
|
|
1300
|
+
record.debounce === undefined
|
|
1301
|
+
? ""
|
|
1302
|
+
: String(record.debounce.wait),
|
|
1303
|
+
record.debounce?.mode ?? "",
|
|
1304
|
+
]);
|
|
1305
|
+
if (!Array.isArray(result)) {
|
|
1306
|
+
throw new Error("Redis returned an invalid enqueue response");
|
|
1307
|
+
}
|
|
1308
|
+
const outcome = String(result[0]);
|
|
1309
|
+
const id = String(result[1]);
|
|
1310
|
+
if (outcome === "duplicate") {
|
|
1311
|
+
throw new Error(`Job ID "${id}" already exists`);
|
|
1312
|
+
}
|
|
1313
|
+
if (outcome === "deduplicated" || outcome === "debounced") {
|
|
1314
|
+
this.local.delete(record.id);
|
|
1315
|
+
record.id = id;
|
|
1316
|
+
record.deduplicated = true;
|
|
1317
|
+
const existing = await this.get(id);
|
|
1318
|
+
if (existing) {
|
|
1319
|
+
applySnapshot(record, existing);
|
|
1320
|
+
}
|
|
1321
|
+
this.local.set(id, record);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
ensureWorker() {
|
|
1325
|
+
if (this.workerLoop || this.closed || !this.workerEnabled) {
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
this.workerLoop = this.work().finally(() => {
|
|
1329
|
+
this.workerLoop = undefined;
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
async work() {
|
|
1333
|
+
while (!this.closed) {
|
|
1334
|
+
if (!this.started) {
|
|
1335
|
+
await sleep(this.driver.pollInterval);
|
|
1336
|
+
continue;
|
|
1337
|
+
}
|
|
1338
|
+
if (this.running.size >= this.concurrency) {
|
|
1339
|
+
await Promise.race(this.running);
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
try {
|
|
1343
|
+
await this.processSchedules();
|
|
1344
|
+
const claimed = await this.claim();
|
|
1345
|
+
if (!claimed) {
|
|
1346
|
+
if (this.running.size > 0) {
|
|
1347
|
+
await Promise.race([
|
|
1348
|
+
...this.running,
|
|
1349
|
+
sleep(this.driver.pollInterval),
|
|
1350
|
+
]);
|
|
1351
|
+
}
|
|
1352
|
+
else {
|
|
1353
|
+
await sleep(this.driver.pollInterval);
|
|
1354
|
+
}
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1357
|
+
const execution = this.execute(claimed).finally(() => {
|
|
1358
|
+
this.running.delete(execution);
|
|
1359
|
+
});
|
|
1360
|
+
this.running.add(execution);
|
|
1361
|
+
}
|
|
1362
|
+
catch (cause) {
|
|
1363
|
+
this.emit("error", toError(cause));
|
|
1364
|
+
await sleep(Math.max(1000, this.driver.pollInterval));
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
async claim() {
|
|
1369
|
+
const token = randomToken();
|
|
1370
|
+
const leaseError = serializeError(new Error("Worker lease expired before the job completed"));
|
|
1371
|
+
leaseError.name = "WorkerLeaseExpiredError";
|
|
1372
|
+
const result = await this.eval(CLAIM_SCRIPT, [
|
|
1373
|
+
this.keys.meta,
|
|
1374
|
+
this.keys.ready,
|
|
1375
|
+
this.keys.delayed,
|
|
1376
|
+
this.keys.active,
|
|
1377
|
+
this.keys.starts,
|
|
1378
|
+
this.keys.completed,
|
|
1379
|
+
this.keys.failed,
|
|
1380
|
+
this.keys.dedupe,
|
|
1381
|
+
this.keys.expiring,
|
|
1382
|
+
this.keys.expired,
|
|
1383
|
+
this.keys.config,
|
|
1384
|
+
this.keys.activeKeys,
|
|
1385
|
+
this.keys.throttleTokens,
|
|
1386
|
+
this.keys.throttleUpdated,
|
|
1387
|
+
this.keys.events,
|
|
1388
|
+
this.keys.debounce,
|
|
1389
|
+
this.keys.debounceExpiry,
|
|
1390
|
+
], [
|
|
1391
|
+
String(Date.now()),
|
|
1392
|
+
token,
|
|
1393
|
+
String(this.driver.visibilityTimeout),
|
|
1394
|
+
String(this.rateLimit?.limit ?? 0),
|
|
1395
|
+
String(this.rateLimit?.interval ?? 0),
|
|
1396
|
+
"100",
|
|
1397
|
+
JSON.stringify(leaseError),
|
|
1398
|
+
String(this.driver.retention),
|
|
1399
|
+
String(this.historyLimit),
|
|
1400
|
+
JSON.stringify({
|
|
1401
|
+
name: "JobExpiredError",
|
|
1402
|
+
message: "Job expired before it could start",
|
|
1403
|
+
}),
|
|
1404
|
+
]);
|
|
1405
|
+
if (!Array.isArray(result) || result[0] !== "job") {
|
|
1406
|
+
return undefined;
|
|
1407
|
+
}
|
|
1408
|
+
return {
|
|
1409
|
+
id: String(result[1]),
|
|
1410
|
+
name: String(result[2]),
|
|
1411
|
+
input: decode(String(result[3])),
|
|
1412
|
+
attempt: Number(result[4]),
|
|
1413
|
+
retry: {
|
|
1414
|
+
retries: Number(result[5]),
|
|
1415
|
+
backoff: decode(String(result[6])),
|
|
1416
|
+
},
|
|
1417
|
+
timeout: result[7] === "" || result[7] === null
|
|
1418
|
+
? undefined
|
|
1419
|
+
: Number(result[7]),
|
|
1420
|
+
token,
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
async processSchedules() {
|
|
1424
|
+
const now = Date.now();
|
|
1425
|
+
const raw = await this.command("ZRANGEBYSCORE", [
|
|
1426
|
+
this.keys.schedules,
|
|
1427
|
+
"-inf",
|
|
1428
|
+
String(now),
|
|
1429
|
+
"LIMIT",
|
|
1430
|
+
"0",
|
|
1431
|
+
"20",
|
|
1432
|
+
]);
|
|
1433
|
+
const ids = Array.isArray(raw) ? raw.map(String) : [];
|
|
1434
|
+
for (const id of ids) {
|
|
1435
|
+
const values = await this.command("HMGET", [
|
|
1436
|
+
this.keys.scheduleMeta + id,
|
|
1437
|
+
"jobName",
|
|
1438
|
+
"cron",
|
|
1439
|
+
"timezone",
|
|
1440
|
+
"status",
|
|
1441
|
+
"nextRunAt",
|
|
1442
|
+
"input",
|
|
1443
|
+
"catchUp",
|
|
1444
|
+
"submit",
|
|
1445
|
+
]);
|
|
1446
|
+
if (!Array.isArray(values) || values[0] === null) {
|
|
1447
|
+
await this.command("ZREM", [this.keys.schedules, id]);
|
|
1448
|
+
continue;
|
|
1449
|
+
}
|
|
1450
|
+
if (String(values[3]) !== "active") {
|
|
1451
|
+
await this.command("ZREM", [this.keys.schedules, id]);
|
|
1452
|
+
continue;
|
|
1453
|
+
}
|
|
1454
|
+
const jobName = String(values[0]);
|
|
1455
|
+
const cron = String(values[1]);
|
|
1456
|
+
const timezone = String(values[2]);
|
|
1457
|
+
const occurrence = Number(values[4]);
|
|
1458
|
+
const input = decode(String(values[5]));
|
|
1459
|
+
const catchUp = String(values[6]) === "1";
|
|
1460
|
+
const submit = decode(String(values[7]));
|
|
1461
|
+
const nextRunAt = nextCronOccurrence(cron, timezone, catchUp ? occurrence : now);
|
|
1462
|
+
const occurrenceId = `${this.name}:schedule:${id}:${occurrence}`;
|
|
1463
|
+
let accepted = false;
|
|
1464
|
+
try {
|
|
1465
|
+
const handle = this.add(jobName, input, {
|
|
1466
|
+
...submit,
|
|
1467
|
+
id: occurrenceId,
|
|
1468
|
+
});
|
|
1469
|
+
await handle.accepted;
|
|
1470
|
+
accepted = true;
|
|
1471
|
+
}
|
|
1472
|
+
catch (cause) {
|
|
1473
|
+
const error = toError(cause);
|
|
1474
|
+
if (error.message.includes(`Job ID "${occurrenceId}" already exists`)) {
|
|
1475
|
+
accepted = true;
|
|
1476
|
+
}
|
|
1477
|
+
else {
|
|
1478
|
+
this.emit("error", error);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
if (!accepted) {
|
|
1482
|
+
continue;
|
|
1483
|
+
}
|
|
1484
|
+
await this.eval(ADVANCE_SCHEDULE_SCRIPT, [this.keys.scheduleMeta, this.keys.schedules], [id, String(occurrence), String(nextRunAt)]);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
async execute(claimed) {
|
|
1488
|
+
const handler = this.handlers[claimed.name];
|
|
1489
|
+
if (typeof handler !== "function") {
|
|
1490
|
+
await this.fail(claimed, new Error(`No handler registered for job "${claimed.name}"`), false, 0);
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
const local = this.local.get(claimed.id) ??
|
|
1494
|
+
this.localRecordFromClaim(claimed);
|
|
1495
|
+
local.status = "running";
|
|
1496
|
+
local.attempt = claimed.attempt;
|
|
1497
|
+
local.startedAt = Date.now();
|
|
1498
|
+
const controller = new AbortController();
|
|
1499
|
+
const heartbeat = setInterval(() => {
|
|
1500
|
+
void this.heartbeat(claimed, controller);
|
|
1501
|
+
}, Math.max(100, Math.floor(this.driver.visibilityTimeout / 3)));
|
|
1502
|
+
let timeoutTimer;
|
|
1503
|
+
try {
|
|
1504
|
+
const context = {
|
|
1505
|
+
id: claimed.id,
|
|
1506
|
+
name: claimed.name,
|
|
1507
|
+
attempt: claimed.attempt,
|
|
1508
|
+
signal: controller.signal,
|
|
1509
|
+
progress: (value) => {
|
|
1510
|
+
local.progress = value;
|
|
1511
|
+
void this.command("HSET", [
|
|
1512
|
+
this.keys.meta + claimed.id,
|
|
1513
|
+
"progress",
|
|
1514
|
+
encode(value),
|
|
1515
|
+
])
|
|
1516
|
+
.then(() => this.publishEvent("progress", claimed.id))
|
|
1517
|
+
.catch((cause) => this.emit("error", toError(cause)));
|
|
1518
|
+
},
|
|
1519
|
+
log: (entry) => {
|
|
1520
|
+
if (this.logLimit === 0) {
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
local.logs.push(entry);
|
|
1524
|
+
if (local.logs.length > this.logLimit) {
|
|
1525
|
+
local.logs.splice(0, local.logs.length - this.logLimit);
|
|
1526
|
+
}
|
|
1527
|
+
void this.command("HSET", [
|
|
1528
|
+
this.keys.meta + claimed.id,
|
|
1529
|
+
"logs",
|
|
1530
|
+
encode(local.logs),
|
|
1531
|
+
])
|
|
1532
|
+
.then(() => this.publishEvent("log", claimed.id))
|
|
1533
|
+
.catch((cause) => this.emit("error", toError(cause)));
|
|
1534
|
+
},
|
|
1535
|
+
};
|
|
1536
|
+
const execution = Promise.resolve(handler(claimed.input, context));
|
|
1537
|
+
const output = claimed.timeout === undefined
|
|
1538
|
+
? await execution
|
|
1539
|
+
: await Promise.race([
|
|
1540
|
+
execution,
|
|
1541
|
+
new Promise((_, reject) => {
|
|
1542
|
+
timeoutTimer = setTimeout(() => {
|
|
1543
|
+
const error = new JobTimeoutError(claimed.id, claimed.timeout);
|
|
1544
|
+
controller.abort(error);
|
|
1545
|
+
reject(error);
|
|
1546
|
+
}, claimed.timeout);
|
|
1547
|
+
}),
|
|
1548
|
+
]);
|
|
1549
|
+
const completed = await this.complete(claimed, output);
|
|
1550
|
+
if (completed) {
|
|
1551
|
+
local.status = "succeeded";
|
|
1552
|
+
local.output = output;
|
|
1553
|
+
local.finishedAt = Date.now();
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
catch (cause) {
|
|
1557
|
+
const error = toError(cause);
|
|
1558
|
+
const retry = claimed.attempt <= claimed.retry.retries;
|
|
1559
|
+
const delay = retry
|
|
1560
|
+
? await retryDelay(claimed.retry.backoff, claimed.attempt)
|
|
1561
|
+
: 0;
|
|
1562
|
+
const failed = await this.fail(claimed, error, retry, delay);
|
|
1563
|
+
if (failed) {
|
|
1564
|
+
local.error = serializeError(error);
|
|
1565
|
+
if (retry) {
|
|
1566
|
+
local.status = delay > 0 ? "scheduled" : "queued";
|
|
1567
|
+
local.runAt = Date.now() + delay;
|
|
1568
|
+
}
|
|
1569
|
+
else {
|
|
1570
|
+
local.status = "failed";
|
|
1571
|
+
local.finishedAt = Date.now();
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
finally {
|
|
1576
|
+
clearInterval(heartbeat);
|
|
1577
|
+
if (timeoutTimer !== undefined) {
|
|
1578
|
+
clearTimeout(timeoutTimer);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
async heartbeat(job, controller) {
|
|
1583
|
+
try {
|
|
1584
|
+
const result = await this.eval(HEARTBEAT_SCRIPT, [this.keys.meta, this.keys.active], [
|
|
1585
|
+
job.id,
|
|
1586
|
+
job.token,
|
|
1587
|
+
String(Date.now() + this.driver.visibilityTimeout),
|
|
1588
|
+
]);
|
|
1589
|
+
if (Number(result) !== 1 && !controller.signal.aborted) {
|
|
1590
|
+
controller.abort(new JobCancelledError(job.id, "Job ownership lost"));
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
catch (cause) {
|
|
1594
|
+
this.emit("error", toError(cause));
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
async complete(job, output) {
|
|
1598
|
+
const result = await this.eval(COMPLETE_SCRIPT, [
|
|
1599
|
+
this.keys.meta,
|
|
1600
|
+
this.keys.active,
|
|
1601
|
+
this.keys.completed,
|
|
1602
|
+
this.keys.dedupe,
|
|
1603
|
+
this.keys.activeKeys,
|
|
1604
|
+
this.keys.events,
|
|
1605
|
+
], [
|
|
1606
|
+
job.id,
|
|
1607
|
+
job.token,
|
|
1608
|
+
String(Date.now()),
|
|
1609
|
+
encode(output),
|
|
1610
|
+
String(this.driver.retention),
|
|
1611
|
+
String(this.historyLimit),
|
|
1612
|
+
]);
|
|
1613
|
+
return Number(result) === 1;
|
|
1614
|
+
}
|
|
1615
|
+
async fail(job, error, retry, delay) {
|
|
1616
|
+
const now = Date.now();
|
|
1617
|
+
const result = await this.eval(FAIL_SCRIPT, [
|
|
1618
|
+
this.keys.meta,
|
|
1619
|
+
this.keys.active,
|
|
1620
|
+
this.keys.ready,
|
|
1621
|
+
this.keys.delayed,
|
|
1622
|
+
this.keys.failed,
|
|
1623
|
+
this.keys.dedupe,
|
|
1624
|
+
this.keys.activeKeys,
|
|
1625
|
+
this.keys.expiring,
|
|
1626
|
+
this.keys.events,
|
|
1627
|
+
], [
|
|
1628
|
+
job.id,
|
|
1629
|
+
job.token,
|
|
1630
|
+
retry ? "1" : "0",
|
|
1631
|
+
JSON.stringify(serializeError(error)),
|
|
1632
|
+
String(now + delay),
|
|
1633
|
+
String(now),
|
|
1634
|
+
String(this.driver.retention),
|
|
1635
|
+
String(this.historyLimit),
|
|
1636
|
+
]);
|
|
1637
|
+
return Number(result) === 1;
|
|
1638
|
+
}
|
|
1639
|
+
localRecordFromClaim(claimed) {
|
|
1640
|
+
const now = Date.now();
|
|
1641
|
+
const record = {
|
|
1642
|
+
id: claimed.id,
|
|
1643
|
+
name: claimed.name,
|
|
1644
|
+
input: claimed.input,
|
|
1645
|
+
status: "running",
|
|
1646
|
+
priority: 0,
|
|
1647
|
+
attempt: claimed.attempt,
|
|
1648
|
+
retry: claimed.retry,
|
|
1649
|
+
timeout: claimed.timeout,
|
|
1650
|
+
expiresAt: undefined,
|
|
1651
|
+
keyRetention: 0,
|
|
1652
|
+
concurrency: undefined,
|
|
1653
|
+
throttle: undefined,
|
|
1654
|
+
debounce: undefined,
|
|
1655
|
+
createdAt: now,
|
|
1656
|
+
runAt: now,
|
|
1657
|
+
startedAt: now,
|
|
1658
|
+
finishedAt: undefined,
|
|
1659
|
+
progress: undefined,
|
|
1660
|
+
output: undefined,
|
|
1661
|
+
error: undefined,
|
|
1662
|
+
logs: [],
|
|
1663
|
+
deduplicated: false,
|
|
1664
|
+
submission: Promise.resolve(),
|
|
1665
|
+
submissionError: undefined,
|
|
1666
|
+
};
|
|
1667
|
+
this.local.set(record.id, record);
|
|
1668
|
+
return record;
|
|
1669
|
+
}
|
|
1670
|
+
recordFromSnapshot(snapshot) {
|
|
1671
|
+
return {
|
|
1672
|
+
id: snapshot.id,
|
|
1673
|
+
name: snapshot.name,
|
|
1674
|
+
input: snapshot.input,
|
|
1675
|
+
status: snapshot.status,
|
|
1676
|
+
priority: snapshot.priority,
|
|
1677
|
+
attempt: snapshot.attempt,
|
|
1678
|
+
retry: {
|
|
1679
|
+
retries: snapshot.retries,
|
|
1680
|
+
backoff: undefined,
|
|
1681
|
+
},
|
|
1682
|
+
timeout: undefined,
|
|
1683
|
+
expiresAt: snapshot.expiresAt,
|
|
1684
|
+
keyRetention: 0,
|
|
1685
|
+
concurrency: undefined,
|
|
1686
|
+
throttle: undefined,
|
|
1687
|
+
debounce: undefined,
|
|
1688
|
+
createdAt: snapshot.createdAt,
|
|
1689
|
+
runAt: snapshot.runAt,
|
|
1690
|
+
startedAt: snapshot.startedAt,
|
|
1691
|
+
finishedAt: snapshot.finishedAt,
|
|
1692
|
+
progress: snapshot.progress,
|
|
1693
|
+
output: snapshot.output,
|
|
1694
|
+
error: snapshot.error,
|
|
1695
|
+
logs: [...(snapshot.logs ?? [])],
|
|
1696
|
+
deduplicated: false,
|
|
1697
|
+
submission: Promise.resolve(),
|
|
1698
|
+
submissionError: undefined,
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
ensureEventLoop() {
|
|
1702
|
+
if (this.eventLoop || this.closed) {
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
this.eventLoop = this.readEvents().finally(() => {
|
|
1706
|
+
this.eventLoop = undefined;
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
1709
|
+
async readEvents() {
|
|
1710
|
+
if (this.eventCursor === undefined) {
|
|
1711
|
+
const latest = await this.command("XREVRANGE", [
|
|
1712
|
+
this.keys.events,
|
|
1713
|
+
"+",
|
|
1714
|
+
"-",
|
|
1715
|
+
"COUNT",
|
|
1716
|
+
"1",
|
|
1717
|
+
]);
|
|
1718
|
+
this.eventCursor = firstStreamEntryId(latest) ?? "0-0";
|
|
1719
|
+
}
|
|
1720
|
+
while (!this.closed && this.hasEventListeners()) {
|
|
1721
|
+
try {
|
|
1722
|
+
const result = await this.command("XREAD", [
|
|
1723
|
+
"COUNT",
|
|
1724
|
+
"100",
|
|
1725
|
+
"STREAMS",
|
|
1726
|
+
this.keys.events,
|
|
1727
|
+
this.eventCursor,
|
|
1728
|
+
]);
|
|
1729
|
+
const entries = streamEntries(result);
|
|
1730
|
+
for (const entry of entries) {
|
|
1731
|
+
this.eventCursor = entry.id;
|
|
1732
|
+
await this.dispatchRemoteEvent(entry.fields);
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
catch (cause) {
|
|
1736
|
+
this.emit("error", toError(cause));
|
|
1737
|
+
}
|
|
1738
|
+
await sleep(this.driver.pollInterval);
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
hasEventListeners() {
|
|
1742
|
+
for (const [event, listeners] of this.listeners) {
|
|
1743
|
+
if (event !== "error" && event !== "idle" && listeners.size > 0) {
|
|
1744
|
+
return true;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
return false;
|
|
1748
|
+
}
|
|
1749
|
+
async dispatchRemoteEvent(fields) {
|
|
1750
|
+
const type = fields.get("type");
|
|
1751
|
+
const id = fields.get("id");
|
|
1752
|
+
if (!type || !id || type === "error" || type === "idle") {
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
const value = await this.get(id);
|
|
1756
|
+
if (!value) {
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
const at = Number(fields.get("at") ?? Date.now());
|
|
1760
|
+
const snapshot = snapshotForEvent(type, value, at);
|
|
1761
|
+
if (type === "retry") {
|
|
1762
|
+
const error = errorFromSerialized(snapshot.error);
|
|
1763
|
+
this.emit("retry", {
|
|
1764
|
+
job: snapshot,
|
|
1765
|
+
error,
|
|
1766
|
+
delay: Math.max(0, snapshot.runAt - at),
|
|
1767
|
+
});
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
if (type === "log") {
|
|
1771
|
+
const entry = snapshot.logs?.at(-1);
|
|
1772
|
+
if (entry) {
|
|
1773
|
+
this.emit("log", { job: snapshot, entry });
|
|
1774
|
+
}
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
if (type === "recovered") {
|
|
1778
|
+
this.emit("recovered", snapshot);
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
this.emit(type, snapshot);
|
|
1782
|
+
}
|
|
1783
|
+
async publishEvent(type, id) {
|
|
1784
|
+
await this.command("XADD", [
|
|
1785
|
+
this.keys.events,
|
|
1786
|
+
"MAXLEN",
|
|
1787
|
+
"~",
|
|
1788
|
+
"10000",
|
|
1789
|
+
"*",
|
|
1790
|
+
"type",
|
|
1791
|
+
type,
|
|
1792
|
+
"id",
|
|
1793
|
+
id,
|
|
1794
|
+
"at",
|
|
1795
|
+
String(Date.now()),
|
|
1796
|
+
]);
|
|
1797
|
+
}
|
|
1798
|
+
async command(command, arguments_) {
|
|
1799
|
+
if (this.driver.client.send) {
|
|
1800
|
+
return this.driver.client.send(command, arguments_);
|
|
1801
|
+
}
|
|
1802
|
+
if (this.driver.client.sendCommand) {
|
|
1803
|
+
return this.driver.client.sendCommand([command, ...arguments_]);
|
|
1804
|
+
}
|
|
1805
|
+
throw new TypeError("Invalid Redis command client");
|
|
1806
|
+
}
|
|
1807
|
+
eval(script, keys, arguments_) {
|
|
1808
|
+
return this.command("EVAL", [
|
|
1809
|
+
script,
|
|
1810
|
+
String(keys.length),
|
|
1811
|
+
...keys,
|
|
1812
|
+
...arguments_,
|
|
1813
|
+
]);
|
|
1814
|
+
}
|
|
1815
|
+
emit(event, payload) {
|
|
1816
|
+
const group = this.listeners.get(event);
|
|
1817
|
+
if (!group) {
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
for (const listener of group) {
|
|
1821
|
+
try {
|
|
1822
|
+
listener(payload);
|
|
1823
|
+
}
|
|
1824
|
+
catch {
|
|
1825
|
+
// Observers cannot interrupt queue processing.
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
assertOpen() {
|
|
1830
|
+
if (this.closed) {
|
|
1831
|
+
throw new QueueClosedError(this.name);
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
class RedisJobHandle {
|
|
1836
|
+
owner;
|
|
1837
|
+
record;
|
|
1838
|
+
constructor(owner, record) {
|
|
1839
|
+
this.owner = owner;
|
|
1840
|
+
this.record = record;
|
|
1841
|
+
}
|
|
1842
|
+
get id() {
|
|
1843
|
+
return this.record.id;
|
|
1844
|
+
}
|
|
1845
|
+
get name() {
|
|
1846
|
+
return this.record.name;
|
|
1847
|
+
}
|
|
1848
|
+
get input() {
|
|
1849
|
+
return this.record.input;
|
|
1850
|
+
}
|
|
1851
|
+
get status() {
|
|
1852
|
+
return this.record.status;
|
|
1853
|
+
}
|
|
1854
|
+
get deduplicated() {
|
|
1855
|
+
return this.record.deduplicated;
|
|
1856
|
+
}
|
|
1857
|
+
get accepted() {
|
|
1858
|
+
return this.record.submission;
|
|
1859
|
+
}
|
|
1860
|
+
get result() {
|
|
1861
|
+
return this.owner.resultFor(this.record);
|
|
1862
|
+
}
|
|
1863
|
+
cancel(reason) {
|
|
1864
|
+
return this.owner.cancel(this.id, reason);
|
|
1865
|
+
}
|
|
1866
|
+
async refresh() {
|
|
1867
|
+
await this.accepted;
|
|
1868
|
+
const value = await this.owner.get(this.id);
|
|
1869
|
+
if (!value) {
|
|
1870
|
+
throw new Error(`Job "${this.id}" no longer exists`);
|
|
1871
|
+
}
|
|
1872
|
+
return value;
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
class RedisScheduleHandleImpl {
|
|
1876
|
+
owner;
|
|
1877
|
+
id;
|
|
1878
|
+
cachedNextRunAt;
|
|
1879
|
+
constructor(owner, id, nextRunAt) {
|
|
1880
|
+
this.owner = owner;
|
|
1881
|
+
this.id = id;
|
|
1882
|
+
this.cachedNextRunAt = nextRunAt;
|
|
1883
|
+
}
|
|
1884
|
+
get nextRunAt() {
|
|
1885
|
+
return this.cachedNextRunAt;
|
|
1886
|
+
}
|
|
1887
|
+
async pause() {
|
|
1888
|
+
await this.owner.pauseSchedule(this.id);
|
|
1889
|
+
}
|
|
1890
|
+
async resume() {
|
|
1891
|
+
this.cachedNextRunAt = await this.owner.resumeSchedule(this.id);
|
|
1892
|
+
}
|
|
1893
|
+
async remove() {
|
|
1894
|
+
await this.owner.removeSchedule(this.id);
|
|
1895
|
+
}
|
|
1896
|
+
async refresh() {
|
|
1897
|
+
const value = await this.owner.getSchedule(this.id);
|
|
1898
|
+
if (!value) {
|
|
1899
|
+
throw new Error(`Schedule "${this.id}" does not exist`);
|
|
1900
|
+
}
|
|
1901
|
+
this.cachedNextRunAt = value.nextRunAt;
|
|
1902
|
+
return value;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
function queueKeys(prefix, name) {
|
|
1906
|
+
const base = `${prefix}:{${name}}`;
|
|
1907
|
+
return {
|
|
1908
|
+
meta: `${base}:job:`,
|
|
1909
|
+
sequence: `${base}:sequence`,
|
|
1910
|
+
ready: `${base}:ready`,
|
|
1911
|
+
delayed: `${base}:delayed`,
|
|
1912
|
+
active: `${base}:active`,
|
|
1913
|
+
starts: `${base}:starts`,
|
|
1914
|
+
completed: `${base}:completed`,
|
|
1915
|
+
failed: `${base}:failed`,
|
|
1916
|
+
cancelled: `${base}:cancelled`,
|
|
1917
|
+
expired: `${base}:expired`,
|
|
1918
|
+
dedupe: `${base}:dedupe`,
|
|
1919
|
+
expiring: `${base}:expiring`,
|
|
1920
|
+
activeKeys: `${base}:active-keys`,
|
|
1921
|
+
throttleTokens: `${base}:throttle-tokens`,
|
|
1922
|
+
throttleUpdated: `${base}:throttle-updated`,
|
|
1923
|
+
debounce: `${base}:debounce`,
|
|
1924
|
+
debounceExpiry: `${base}:debounce-expiry`,
|
|
1925
|
+
config: `${base}:config`,
|
|
1926
|
+
events: `${base}:events`,
|
|
1927
|
+
all: `${base}:all`,
|
|
1928
|
+
scheduleMeta: `${base}:schedule:`,
|
|
1929
|
+
schedules: `${base}:schedules`,
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1932
|
+
function normalizeRetry(retry) {
|
|
1933
|
+
if (retry === undefined) {
|
|
1934
|
+
return { retries: 0, backoff: undefined };
|
|
1935
|
+
}
|
|
1936
|
+
if (typeof retry === "number") {
|
|
1937
|
+
nonNegativeInteger("retry", retry);
|
|
1938
|
+
return { retries: retry, backoff: undefined };
|
|
1939
|
+
}
|
|
1940
|
+
nonNegativeInteger("retry.retries", retry.retries);
|
|
1941
|
+
if (typeof retry.backoff === "number" && retry.backoff < 0) {
|
|
1942
|
+
throw new RangeError("retry.backoff must not be negative");
|
|
1943
|
+
}
|
|
1944
|
+
return { retries: retry.retries, backoff: retry.backoff };
|
|
1945
|
+
}
|
|
1946
|
+
async function retryDelay(backoff, attempt) {
|
|
1947
|
+
if (backoff === undefined) {
|
|
1948
|
+
return 0;
|
|
1949
|
+
}
|
|
1950
|
+
if (typeof backoff === "number") {
|
|
1951
|
+
return backoff;
|
|
1952
|
+
}
|
|
1953
|
+
const base = backoff.type === "exponential"
|
|
1954
|
+
? backoff.delay * 2 ** Math.max(0, attempt - 1)
|
|
1955
|
+
: backoff.delay;
|
|
1956
|
+
const jitter = Math.min(1, Math.max(0, backoff.jitter ?? 0));
|
|
1957
|
+
return Math.max(0, base * (1 - Math.random() * jitter));
|
|
1958
|
+
}
|
|
1959
|
+
function toSnapshot(record) {
|
|
1960
|
+
return {
|
|
1961
|
+
id: record.id,
|
|
1962
|
+
name: record.name,
|
|
1963
|
+
input: record.input,
|
|
1964
|
+
status: record.status,
|
|
1965
|
+
priority: record.priority,
|
|
1966
|
+
attempt: record.attempt,
|
|
1967
|
+
retries: record.retry.retries,
|
|
1968
|
+
createdAt: record.createdAt,
|
|
1969
|
+
runAt: record.runAt,
|
|
1970
|
+
expiresAt: record.expiresAt,
|
|
1971
|
+
startedAt: record.startedAt,
|
|
1972
|
+
finishedAt: record.finishedAt,
|
|
1973
|
+
progress: record.progress,
|
|
1974
|
+
output: record.output,
|
|
1975
|
+
error: record.error,
|
|
1976
|
+
logs: [...record.logs],
|
|
1977
|
+
};
|
|
1978
|
+
}
|
|
1979
|
+
function snapshotFromFields(values) {
|
|
1980
|
+
const text = (index) => values[index] === null || values[index] === undefined
|
|
1981
|
+
? ""
|
|
1982
|
+
: String(values[index]);
|
|
1983
|
+
return {
|
|
1984
|
+
id: text(0),
|
|
1985
|
+
name: text(1),
|
|
1986
|
+
input: decode(text(2)),
|
|
1987
|
+
status: text(3),
|
|
1988
|
+
priority: Number(text(4)),
|
|
1989
|
+
attempt: Number(text(5)),
|
|
1990
|
+
retries: Number(text(6)),
|
|
1991
|
+
createdAt: Number(text(7)),
|
|
1992
|
+
runAt: Number(text(8)),
|
|
1993
|
+
expiresAt: optionalNumber(text(9)),
|
|
1994
|
+
startedAt: optionalNumber(text(10)),
|
|
1995
|
+
finishedAt: optionalNumber(text(11)),
|
|
1996
|
+
progress: text(12) ? decode(text(12)) : undefined,
|
|
1997
|
+
output: text(13) ? decode(text(13)) : undefined,
|
|
1998
|
+
error: text(14)
|
|
1999
|
+
? JSON.parse(text(14))
|
|
2000
|
+
: undefined,
|
|
2001
|
+
logs: text(15)
|
|
2002
|
+
? decode(text(15))
|
|
2003
|
+
: [],
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
function applySnapshot(record, value) {
|
|
2007
|
+
record.id = value.id;
|
|
2008
|
+
record.name = value.name;
|
|
2009
|
+
record.input = value.input;
|
|
2010
|
+
record.status = value.status;
|
|
2011
|
+
record.priority = value.priority;
|
|
2012
|
+
record.attempt = value.attempt;
|
|
2013
|
+
record.createdAt = value.createdAt;
|
|
2014
|
+
record.runAt = value.runAt;
|
|
2015
|
+
record.expiresAt = value.expiresAt;
|
|
2016
|
+
record.startedAt = value.startedAt;
|
|
2017
|
+
record.finishedAt = value.finishedAt;
|
|
2018
|
+
record.progress = value.progress;
|
|
2019
|
+
record.output = value.output;
|
|
2020
|
+
record.error = value.error;
|
|
2021
|
+
record.logs = [...(value.logs ?? [])];
|
|
2022
|
+
}
|
|
2023
|
+
function fallbackSnapshot(id) {
|
|
2024
|
+
const now = Date.now();
|
|
2025
|
+
return {
|
|
2026
|
+
id,
|
|
2027
|
+
name: "unknown",
|
|
2028
|
+
input: undefined,
|
|
2029
|
+
status: "cancelled",
|
|
2030
|
+
priority: 0,
|
|
2031
|
+
attempt: 0,
|
|
2032
|
+
retries: 0,
|
|
2033
|
+
createdAt: now,
|
|
2034
|
+
runAt: now,
|
|
2035
|
+
finishedAt: now,
|
|
2036
|
+
};
|
|
2037
|
+
}
|
|
2038
|
+
function runAt(delay, now) {
|
|
2039
|
+
if (delay instanceof Date) {
|
|
2040
|
+
const value = delay.getTime();
|
|
2041
|
+
if (!Number.isFinite(value)) {
|
|
2042
|
+
throw new RangeError("delay date must be valid");
|
|
2043
|
+
}
|
|
2044
|
+
return Math.max(now, value);
|
|
2045
|
+
}
|
|
2046
|
+
if (delay === undefined) {
|
|
2047
|
+
return now;
|
|
2048
|
+
}
|
|
2049
|
+
if (!Number.isFinite(delay) || delay < 0) {
|
|
2050
|
+
throw new RangeError("delay must be a non-negative finite number");
|
|
2051
|
+
}
|
|
2052
|
+
return now + delay;
|
|
2053
|
+
}
|
|
2054
|
+
function createId(queue, name, now, sequence) {
|
|
2055
|
+
return `${queue}:${name}:${now.toString(36)}:${sequence.toString(36)}:${randomToken()}`;
|
|
2056
|
+
}
|
|
2057
|
+
function randomToken() {
|
|
2058
|
+
const uuid = globalThis.crypto?.randomUUID?.();
|
|
2059
|
+
if (uuid) {
|
|
2060
|
+
return uuid;
|
|
2061
|
+
}
|
|
2062
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
2063
|
+
}
|
|
2064
|
+
function serializeError(error) {
|
|
2065
|
+
return {
|
|
2066
|
+
name: error.name,
|
|
2067
|
+
message: error.message,
|
|
2068
|
+
stack: error.stack,
|
|
2069
|
+
};
|
|
2070
|
+
}
|
|
2071
|
+
function toError(value) {
|
|
2072
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
2073
|
+
}
|
|
2074
|
+
function optionalNumber(value) {
|
|
2075
|
+
return value ? Number(value) : undefined;
|
|
2076
|
+
}
|
|
2077
|
+
function firstStreamEntryId(value) {
|
|
2078
|
+
if (!Array.isArray(value)) {
|
|
2079
|
+
return undefined;
|
|
2080
|
+
}
|
|
2081
|
+
if (typeof value[0] === "string" &&
|
|
2082
|
+
/^\d+-\d+$/.test(value[0])) {
|
|
2083
|
+
return value[0];
|
|
2084
|
+
}
|
|
2085
|
+
for (const entry of value) {
|
|
2086
|
+
const id = firstStreamEntryId(entry);
|
|
2087
|
+
if (id) {
|
|
2088
|
+
return id;
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
return undefined;
|
|
2092
|
+
}
|
|
2093
|
+
function streamEntries(value) {
|
|
2094
|
+
const entries = [];
|
|
2095
|
+
visitStreamValue(value, entries);
|
|
2096
|
+
return entries;
|
|
2097
|
+
}
|
|
2098
|
+
function visitStreamValue(value, entries) {
|
|
2099
|
+
if (!Array.isArray(value)) {
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
if (typeof value[0] === "string" &&
|
|
2103
|
+
/^\d+-\d+$/.test(value[0]) &&
|
|
2104
|
+
Array.isArray(value[1])) {
|
|
2105
|
+
const fields = new Map();
|
|
2106
|
+
const rawFields = value[1];
|
|
2107
|
+
for (let index = 0; index < rawFields.length; index += 2) {
|
|
2108
|
+
const key = rawFields[index];
|
|
2109
|
+
const fieldValue = rawFields[index + 1];
|
|
2110
|
+
if (key !== undefined && fieldValue !== undefined) {
|
|
2111
|
+
fields.set(String(key), String(fieldValue));
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
entries.push({ id: value[0], fields });
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
for (const entry of value) {
|
|
2118
|
+
visitStreamValue(entry, entries);
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
function snapshotForEvent(type, snapshot, timestamp) {
|
|
2122
|
+
if (type === "added") {
|
|
2123
|
+
return {
|
|
2124
|
+
...snapshot,
|
|
2125
|
+
status: snapshot.runAt > timestamp ? "scheduled" : "queued",
|
|
2126
|
+
};
|
|
2127
|
+
}
|
|
2128
|
+
if (type === "started") {
|
|
2129
|
+
return { ...snapshot, status: "running", startedAt: timestamp };
|
|
2130
|
+
}
|
|
2131
|
+
if (type === "retry" || type === "recovered") {
|
|
2132
|
+
return {
|
|
2133
|
+
...snapshot,
|
|
2134
|
+
status: snapshot.runAt > timestamp ? "scheduled" : "queued",
|
|
2135
|
+
};
|
|
2136
|
+
}
|
|
2137
|
+
if (type === "succeeded" ||
|
|
2138
|
+
type === "failed" ||
|
|
2139
|
+
type === "cancelled" ||
|
|
2140
|
+
type === "expired") {
|
|
2141
|
+
return { ...snapshot, status: type, finishedAt: timestamp };
|
|
2142
|
+
}
|
|
2143
|
+
return snapshot;
|
|
2144
|
+
}
|
|
2145
|
+
function errorFromSerialized(value) {
|
|
2146
|
+
const error = new Error(value?.message ?? "Job attempt failed");
|
|
2147
|
+
error.name = value?.name ?? "Error";
|
|
2148
|
+
if (value?.stack) {
|
|
2149
|
+
error.stack = value.stack;
|
|
2150
|
+
}
|
|
2151
|
+
return error;
|
|
2152
|
+
}
|
|
2153
|
+
function sleep(milliseconds) {
|
|
2154
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
2155
|
+
}
|
|
2156
|
+
function positive(name, value) {
|
|
2157
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
2158
|
+
throw new RangeError(`${name} must be a positive finite number`);
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
function optionalPositive(name, value) {
|
|
2162
|
+
if (value !== undefined) {
|
|
2163
|
+
positive(name, value);
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
function positiveInteger(name, value) {
|
|
2167
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
2168
|
+
throw new RangeError(`${name} must be a positive integer`);
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
function positiveIntegerOrInfinity(name, value) {
|
|
2172
|
+
if (value !== Number.POSITIVE_INFINITY) {
|
|
2173
|
+
positiveInteger(name, value);
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
function nonNegativeInteger(name, value) {
|
|
2177
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
2178
|
+
throw new RangeError(`${name} must be a non-negative integer`);
|
|
2179
|
+
}
|
|
2180
|
+
}
|