@chidchanun/bcp 0.2.8 → 0.2.10
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 +164 -163
- package/docs/README.md +48 -139
- package/docs/api-manifest.json +4 -2
- package/docs/api-reference.md +92 -242
- package/docs/docs-web-manifest.json +7 -3
- package/docs/durable-jobs.md +359 -0
- package/docs/job-scheduling.md +357 -0
- package/docs/platform-manifest.json +23 -4
- package/docs/releases/0.2.10.md +148 -0
- package/docs/releases/0.2.9.md +162 -0
- package/package.json +2 -2
- package/packages/client/src/jobs.mjs +2289 -0
- package/packages/client/src/jobs.ts +34 -0
- package/packages/server/src/job-scheduler.ts +1144 -0
- package/packages/server/src/jobs-redis.ts +1226 -0
- package/packages/server/src/jobs.ts +768 -128
|
@@ -0,0 +1,2289 @@
|
|
|
1
|
+
// packages/server/src/jobs.ts
|
|
2
|
+
import {
|
|
3
|
+
randomUUID
|
|
4
|
+
} from "node:crypto";
|
|
5
|
+
function createMemoryJobQueueAdapter() {
|
|
6
|
+
const jobs = /* @__PURE__ */ new Map();
|
|
7
|
+
const deadLetters = /* @__PURE__ */ new Map();
|
|
8
|
+
const recoverStale = (now, options = {}) => {
|
|
9
|
+
const limit = normalizePositiveInteger(
|
|
10
|
+
options.limit ?? 100,
|
|
11
|
+
"recovery limit"
|
|
12
|
+
);
|
|
13
|
+
const stale = Array.from(
|
|
14
|
+
jobs.values()
|
|
15
|
+
).filter(
|
|
16
|
+
(job) => job.state === "running" && job.leaseUntil !== void 0 && job.leaseUntil <= now
|
|
17
|
+
).sort(
|
|
18
|
+
(left, right) => (left.leaseUntil ?? 0) - (right.leaseUntil ?? 0) || left.id.localeCompare(
|
|
19
|
+
right.id
|
|
20
|
+
)
|
|
21
|
+
).slice(
|
|
22
|
+
0,
|
|
23
|
+
limit
|
|
24
|
+
);
|
|
25
|
+
for (const job of stale) {
|
|
26
|
+
job.state = "queued";
|
|
27
|
+
job.availableAt = now;
|
|
28
|
+
job.startedAt = void 0;
|
|
29
|
+
clearLease(job);
|
|
30
|
+
job.recoveredAt = now;
|
|
31
|
+
}
|
|
32
|
+
return stale.length;
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
async enqueue(job) {
|
|
36
|
+
if (jobs.has(job.id)) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`BCP Jobs: job id "${job.id}" already exists.`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
jobs.set(
|
|
42
|
+
job.id,
|
|
43
|
+
cloneJob(job)
|
|
44
|
+
);
|
|
45
|
+
},
|
|
46
|
+
async reserve(now, options) {
|
|
47
|
+
recoverStale(
|
|
48
|
+
now,
|
|
49
|
+
{
|
|
50
|
+
limit: 100
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
const candidate = Array.from(
|
|
54
|
+
jobs.values()
|
|
55
|
+
).filter(
|
|
56
|
+
(job) => job.state === "queued" && job.availableAt <= now
|
|
57
|
+
).sort(
|
|
58
|
+
(left, right) => left.availableAt - right.availableAt || left.createdAt - right.createdAt || left.id.localeCompare(right.id)
|
|
59
|
+
)[0];
|
|
60
|
+
if (!candidate) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
candidate.state = "running";
|
|
64
|
+
candidate.attempts += 1;
|
|
65
|
+
candidate.startedAt = now;
|
|
66
|
+
candidate.error = void 0;
|
|
67
|
+
candidate.recoveredAt = void 0;
|
|
68
|
+
if (options) {
|
|
69
|
+
candidate.leaseOwner = normalizeWorkerId(
|
|
70
|
+
options.ownerId
|
|
71
|
+
);
|
|
72
|
+
candidate.leaseUntil = now + normalizePositiveInteger(
|
|
73
|
+
options.visibilityTimeoutMs,
|
|
74
|
+
"visibilityTimeoutMs"
|
|
75
|
+
);
|
|
76
|
+
candidate.heartbeatAt = now;
|
|
77
|
+
}
|
|
78
|
+
return cloneJob(candidate);
|
|
79
|
+
},
|
|
80
|
+
async complete(id, completedAt, ownerId) {
|
|
81
|
+
const job = jobs.get(id);
|
|
82
|
+
if (!job || job.state === "cancelled" || !leaseOwnerMatches(
|
|
83
|
+
job,
|
|
84
|
+
ownerId
|
|
85
|
+
)) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
job.state = "succeeded";
|
|
89
|
+
job.completedAt = completedAt;
|
|
90
|
+
job.error = void 0;
|
|
91
|
+
clearLease(job);
|
|
92
|
+
deadLetters.delete(id);
|
|
93
|
+
},
|
|
94
|
+
async fail(id, options) {
|
|
95
|
+
const job = jobs.get(id);
|
|
96
|
+
if (!job || job.state === "cancelled" || !leaseOwnerMatches(
|
|
97
|
+
job,
|
|
98
|
+
options.ownerId
|
|
99
|
+
)) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
job.error = options.error;
|
|
103
|
+
clearLease(job);
|
|
104
|
+
if (options.retryAt !== void 0 && job.attempts < job.maxAttempts) {
|
|
105
|
+
job.state = "queued";
|
|
106
|
+
job.availableAt = options.retryAt;
|
|
107
|
+
job.startedAt = void 0;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
job.state = "failed";
|
|
111
|
+
job.completedAt = options.failedAt;
|
|
112
|
+
deadLetters.set(
|
|
113
|
+
id,
|
|
114
|
+
{
|
|
115
|
+
...cloneJob(job),
|
|
116
|
+
state: "failed",
|
|
117
|
+
deadLetteredAt: options.failedAt
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
},
|
|
121
|
+
async cancel(id, cancelledAt) {
|
|
122
|
+
const job = jobs.get(id);
|
|
123
|
+
if (!job || job.state === "succeeded" || job.state === "failed" || job.state === "cancelled") {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
job.state = "cancelled";
|
|
127
|
+
job.completedAt = cancelledAt;
|
|
128
|
+
clearLease(job);
|
|
129
|
+
return true;
|
|
130
|
+
},
|
|
131
|
+
async get(id) {
|
|
132
|
+
const job = jobs.get(id);
|
|
133
|
+
return job ? cloneJob(job) : null;
|
|
134
|
+
},
|
|
135
|
+
async list() {
|
|
136
|
+
return Array.from(
|
|
137
|
+
jobs.values()
|
|
138
|
+
).map(cloneJob).sort(
|
|
139
|
+
(left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id)
|
|
140
|
+
);
|
|
141
|
+
},
|
|
142
|
+
async heartbeat(id, options) {
|
|
143
|
+
const job = jobs.get(id);
|
|
144
|
+
if (!job || job.state !== "running" || job.leaseOwner !== options.ownerId) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
const visibilityTimeoutMs = normalizePositiveInteger(
|
|
148
|
+
options.visibilityTimeoutMs,
|
|
149
|
+
"visibilityTimeoutMs"
|
|
150
|
+
);
|
|
151
|
+
job.heartbeatAt = options.heartbeatAt;
|
|
152
|
+
job.leaseUntil = options.heartbeatAt + visibilityTimeoutMs;
|
|
153
|
+
return true;
|
|
154
|
+
},
|
|
155
|
+
async recoverStale(now, options) {
|
|
156
|
+
return recoverStale(
|
|
157
|
+
now,
|
|
158
|
+
options
|
|
159
|
+
);
|
|
160
|
+
},
|
|
161
|
+
async listDeadLetters() {
|
|
162
|
+
return Array.from(
|
|
163
|
+
deadLetters.values()
|
|
164
|
+
).map(cloneDeadLetter).sort(
|
|
165
|
+
(left, right) => left.deadLetteredAt - right.deadLetteredAt || left.id.localeCompare(
|
|
166
|
+
right.id
|
|
167
|
+
)
|
|
168
|
+
);
|
|
169
|
+
},
|
|
170
|
+
async requeueDeadLetter(id, now, options = {}) {
|
|
171
|
+
const job = jobs.get(id);
|
|
172
|
+
if (!job || job.state !== "failed" || !deadLetters.has(id)) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
const delayMs = normalizeNonNegativeNumber(
|
|
176
|
+
options.delayMs ?? 0,
|
|
177
|
+
"delayMs"
|
|
178
|
+
);
|
|
179
|
+
job.state = "queued";
|
|
180
|
+
job.availableAt = now + delayMs;
|
|
181
|
+
job.startedAt = void 0;
|
|
182
|
+
job.completedAt = void 0;
|
|
183
|
+
job.error = void 0;
|
|
184
|
+
job.recoveredAt = void 0;
|
|
185
|
+
clearLease(job);
|
|
186
|
+
if (options.resetAttempts !== false) {
|
|
187
|
+
job.attempts = 0;
|
|
188
|
+
}
|
|
189
|
+
deadLetters.delete(id);
|
|
190
|
+
return true;
|
|
191
|
+
},
|
|
192
|
+
async cleanup(options) {
|
|
193
|
+
const states = new Set(
|
|
194
|
+
options.states ?? [
|
|
195
|
+
"succeeded",
|
|
196
|
+
"failed",
|
|
197
|
+
"cancelled"
|
|
198
|
+
]
|
|
199
|
+
);
|
|
200
|
+
let removed = 0;
|
|
201
|
+
for (const [
|
|
202
|
+
id,
|
|
203
|
+
job
|
|
204
|
+
] of jobs) {
|
|
205
|
+
if (!states.has(
|
|
206
|
+
job.state
|
|
207
|
+
) || job.completedAt === void 0 || job.completedAt >= options.before) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
jobs.delete(id);
|
|
211
|
+
deadLetters.delete(id);
|
|
212
|
+
removed += 1;
|
|
213
|
+
}
|
|
214
|
+
return removed;
|
|
215
|
+
},
|
|
216
|
+
async stats() {
|
|
217
|
+
return calculateJobStats(
|
|
218
|
+
Array.from(
|
|
219
|
+
jobs.values()
|
|
220
|
+
),
|
|
221
|
+
deadLetters.size
|
|
222
|
+
);
|
|
223
|
+
},
|
|
224
|
+
clear() {
|
|
225
|
+
jobs.clear();
|
|
226
|
+
deadLetters.clear();
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function createJobQueue(options = {}) {
|
|
231
|
+
const adapter = options.adapter ?? createMemoryJobQueueAdapter();
|
|
232
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
233
|
+
const workers = /* @__PURE__ */ new Set();
|
|
234
|
+
const now = options.now ?? Date.now;
|
|
235
|
+
const idFactory = options.idFactory ?? randomUUID;
|
|
236
|
+
const defaultMaxAttempts = normalizePositiveInteger(
|
|
237
|
+
options.defaultMaxAttempts ?? 3,
|
|
238
|
+
"defaultMaxAttempts"
|
|
239
|
+
);
|
|
240
|
+
const retryDelay = options.retryDelayMs ?? ((attempt) => Math.min(
|
|
241
|
+
3e4,
|
|
242
|
+
1e3 * 2 ** Math.max(
|
|
243
|
+
0,
|
|
244
|
+
attempt - 1
|
|
245
|
+
)
|
|
246
|
+
));
|
|
247
|
+
const queue = {
|
|
248
|
+
adapter,
|
|
249
|
+
register(name, handler) {
|
|
250
|
+
const normalizedName = normalizeJobName(name);
|
|
251
|
+
if (typeof handler !== "function") {
|
|
252
|
+
throw new TypeError(
|
|
253
|
+
"BCP Jobs: handler must be a function."
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
if (handlers.has(
|
|
257
|
+
normalizedName
|
|
258
|
+
)) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`BCP Jobs: handler "${normalizedName}" is already registered.`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
const registeredHandler = handler;
|
|
264
|
+
handlers.set(
|
|
265
|
+
normalizedName,
|
|
266
|
+
registeredHandler
|
|
267
|
+
);
|
|
268
|
+
return () => {
|
|
269
|
+
if (handlers.get(
|
|
270
|
+
normalizedName
|
|
271
|
+
) === registeredHandler) {
|
|
272
|
+
handlers.delete(
|
|
273
|
+
normalizedName
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
},
|
|
278
|
+
async enqueue(name, payload, enqueueOptions = {}) {
|
|
279
|
+
const createdAt = now();
|
|
280
|
+
const delayMs = normalizeNonNegativeNumber(
|
|
281
|
+
enqueueOptions.delayMs ?? 0,
|
|
282
|
+
"delayMs"
|
|
283
|
+
);
|
|
284
|
+
const maxAttempts = normalizePositiveInteger(
|
|
285
|
+
enqueueOptions.maxAttempts ?? defaultMaxAttempts,
|
|
286
|
+
"maxAttempts"
|
|
287
|
+
);
|
|
288
|
+
const id = normalizeJobId(
|
|
289
|
+
enqueueOptions.id ?? idFactory()
|
|
290
|
+
);
|
|
291
|
+
const job = {
|
|
292
|
+
id,
|
|
293
|
+
name: normalizeJobName(name),
|
|
294
|
+
payload,
|
|
295
|
+
state: "queued",
|
|
296
|
+
attempts: 0,
|
|
297
|
+
maxAttempts,
|
|
298
|
+
createdAt,
|
|
299
|
+
availableAt: createdAt + delayMs
|
|
300
|
+
};
|
|
301
|
+
await adapter.enqueue(job);
|
|
302
|
+
return cloneJob(job);
|
|
303
|
+
},
|
|
304
|
+
get(id) {
|
|
305
|
+
return adapter.get(
|
|
306
|
+
normalizeJobId(id)
|
|
307
|
+
);
|
|
308
|
+
},
|
|
309
|
+
list() {
|
|
310
|
+
return adapter.list();
|
|
311
|
+
},
|
|
312
|
+
cancel(id) {
|
|
313
|
+
return adapter.cancel(
|
|
314
|
+
normalizeJobId(id),
|
|
315
|
+
now()
|
|
316
|
+
);
|
|
317
|
+
},
|
|
318
|
+
async processNext(signal = new AbortController().signal, processOptions = {}) {
|
|
319
|
+
if (signal.aborted) {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
const visibilityTimeoutMs = normalizePositiveInteger(
|
|
323
|
+
processOptions.visibilityTimeoutMs ?? 3e4,
|
|
324
|
+
"visibilityTimeoutMs"
|
|
325
|
+
);
|
|
326
|
+
const heartbeatIntervalMs = normalizePositiveInteger(
|
|
327
|
+
processOptions.heartbeatIntervalMs ?? Math.max(
|
|
328
|
+
1,
|
|
329
|
+
Math.floor(
|
|
330
|
+
visibilityTimeoutMs / 3
|
|
331
|
+
)
|
|
332
|
+
),
|
|
333
|
+
"heartbeatIntervalMs"
|
|
334
|
+
);
|
|
335
|
+
const ownerId = normalizeWorkerId(
|
|
336
|
+
processOptions.ownerId ?? `process-${randomUUID()}`
|
|
337
|
+
);
|
|
338
|
+
if (adapter.recoverStale) {
|
|
339
|
+
await adapter.recoverStale(
|
|
340
|
+
now(),
|
|
341
|
+
{
|
|
342
|
+
limit: normalizePositiveInteger(
|
|
343
|
+
processOptions.recoveryLimit ?? 100,
|
|
344
|
+
"recoveryLimit"
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
const job = await adapter.reserve(
|
|
350
|
+
now(),
|
|
351
|
+
{
|
|
352
|
+
ownerId,
|
|
353
|
+
visibilityTimeoutMs
|
|
354
|
+
}
|
|
355
|
+
);
|
|
356
|
+
if (!job) {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
const stopHeartbeat = startJobHeartbeat(
|
|
360
|
+
adapter,
|
|
361
|
+
job.id,
|
|
362
|
+
ownerId,
|
|
363
|
+
visibilityTimeoutMs,
|
|
364
|
+
heartbeatIntervalMs,
|
|
365
|
+
now,
|
|
366
|
+
signal
|
|
367
|
+
);
|
|
368
|
+
const handler = handlers.get(job.name);
|
|
369
|
+
try {
|
|
370
|
+
if (!handler) {
|
|
371
|
+
await adapter.fail(
|
|
372
|
+
job.id,
|
|
373
|
+
{
|
|
374
|
+
error: `No handler registered for job "${job.name}".`,
|
|
375
|
+
failedAt: now(),
|
|
376
|
+
ownerId
|
|
377
|
+
}
|
|
378
|
+
);
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
await handler({
|
|
383
|
+
job: cloneJob(job),
|
|
384
|
+
payload: job.payload,
|
|
385
|
+
signal
|
|
386
|
+
});
|
|
387
|
+
await adapter.complete(
|
|
388
|
+
job.id,
|
|
389
|
+
now(),
|
|
390
|
+
ownerId
|
|
391
|
+
);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
const failedAt = now();
|
|
394
|
+
const shouldRetry = job.attempts < job.maxAttempts;
|
|
395
|
+
const retryAt = shouldRetry ? failedAt + resolveRetryDelay(
|
|
396
|
+
retryDelay,
|
|
397
|
+
job.attempts
|
|
398
|
+
) : void 0;
|
|
399
|
+
await adapter.fail(
|
|
400
|
+
job.id,
|
|
401
|
+
{
|
|
402
|
+
error: formatJobError(
|
|
403
|
+
error
|
|
404
|
+
),
|
|
405
|
+
failedAt,
|
|
406
|
+
retryAt,
|
|
407
|
+
ownerId
|
|
408
|
+
}
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
return true;
|
|
412
|
+
} finally {
|
|
413
|
+
stopHeartbeat();
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
startWorker(workerOptions = {}) {
|
|
417
|
+
const worker = createWorker(
|
|
418
|
+
queue,
|
|
419
|
+
workerOptions,
|
|
420
|
+
() => workers.delete(
|
|
421
|
+
worker
|
|
422
|
+
)
|
|
423
|
+
);
|
|
424
|
+
workers.add(worker);
|
|
425
|
+
return worker;
|
|
426
|
+
},
|
|
427
|
+
async recoverStale(recoverOptions = {}) {
|
|
428
|
+
if (!adapter.recoverStale) {
|
|
429
|
+
return 0;
|
|
430
|
+
}
|
|
431
|
+
return adapter.recoverStale(
|
|
432
|
+
now(),
|
|
433
|
+
recoverOptions
|
|
434
|
+
);
|
|
435
|
+
},
|
|
436
|
+
async deadLetters() {
|
|
437
|
+
return adapter.listDeadLetters ? adapter.listDeadLetters() : [];
|
|
438
|
+
},
|
|
439
|
+
async requeueDeadLetter(id, requeueOptions = {}) {
|
|
440
|
+
if (!adapter.requeueDeadLetter) {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
return adapter.requeueDeadLetter(
|
|
444
|
+
normalizeJobId(id),
|
|
445
|
+
now(),
|
|
446
|
+
requeueOptions
|
|
447
|
+
);
|
|
448
|
+
},
|
|
449
|
+
async cleanup(cleanupOptions) {
|
|
450
|
+
if (!adapter.cleanup) {
|
|
451
|
+
return 0;
|
|
452
|
+
}
|
|
453
|
+
const before = normalizeNonNegativeNumber(
|
|
454
|
+
cleanupOptions.before,
|
|
455
|
+
"cleanup before"
|
|
456
|
+
);
|
|
457
|
+
return adapter.cleanup({
|
|
458
|
+
...cleanupOptions,
|
|
459
|
+
before
|
|
460
|
+
});
|
|
461
|
+
},
|
|
462
|
+
async stats() {
|
|
463
|
+
if (adapter.stats) {
|
|
464
|
+
return adapter.stats();
|
|
465
|
+
}
|
|
466
|
+
const jobs = await adapter.list();
|
|
467
|
+
return calculateJobStats(
|
|
468
|
+
jobs,
|
|
469
|
+
adapter.listDeadLetters ? (await adapter.listDeadLetters()).length : 0
|
|
470
|
+
);
|
|
471
|
+
},
|
|
472
|
+
async close() {
|
|
473
|
+
await Promise.all(
|
|
474
|
+
Array.from(
|
|
475
|
+
workers,
|
|
476
|
+
(worker) => worker.stop()
|
|
477
|
+
)
|
|
478
|
+
);
|
|
479
|
+
if (adapter.close) {
|
|
480
|
+
await adapter.close();
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
return queue;
|
|
485
|
+
}
|
|
486
|
+
function createWorker(queue, options, onStop) {
|
|
487
|
+
const concurrency = normalizePositiveInteger(
|
|
488
|
+
options.concurrency ?? 1,
|
|
489
|
+
"concurrency"
|
|
490
|
+
);
|
|
491
|
+
const pollIntervalMs = normalizeNonNegativeNumber(
|
|
492
|
+
options.pollIntervalMs ?? 250,
|
|
493
|
+
"pollIntervalMs"
|
|
494
|
+
);
|
|
495
|
+
const visibilityTimeoutMs = normalizePositiveInteger(
|
|
496
|
+
options.visibilityTimeoutMs ?? 3e4,
|
|
497
|
+
"visibilityTimeoutMs"
|
|
498
|
+
);
|
|
499
|
+
const heartbeatIntervalMs = normalizePositiveInteger(
|
|
500
|
+
options.heartbeatIntervalMs ?? Math.max(
|
|
501
|
+
1,
|
|
502
|
+
Math.floor(
|
|
503
|
+
visibilityTimeoutMs / 3
|
|
504
|
+
)
|
|
505
|
+
),
|
|
506
|
+
"heartbeatIntervalMs"
|
|
507
|
+
);
|
|
508
|
+
const recoveryLimit = normalizePositiveInteger(
|
|
509
|
+
options.recoveryLimit ?? 100,
|
|
510
|
+
"recoveryLimit"
|
|
511
|
+
);
|
|
512
|
+
const workerId = normalizeWorkerId(
|
|
513
|
+
options.workerId ?? `worker-${randomUUID()}`
|
|
514
|
+
);
|
|
515
|
+
const controller = new AbortController();
|
|
516
|
+
let running = true;
|
|
517
|
+
let stopPromise = null;
|
|
518
|
+
const loops = Array.from(
|
|
519
|
+
{
|
|
520
|
+
length: concurrency
|
|
521
|
+
},
|
|
522
|
+
(_, index) => runWorkerLoop(
|
|
523
|
+
queue,
|
|
524
|
+
controller.signal,
|
|
525
|
+
pollIntervalMs,
|
|
526
|
+
{
|
|
527
|
+
ownerId: `${workerId}:${index + 1}`,
|
|
528
|
+
visibilityTimeoutMs,
|
|
529
|
+
heartbeatIntervalMs,
|
|
530
|
+
recoveryLimit
|
|
531
|
+
},
|
|
532
|
+
options.onError
|
|
533
|
+
)
|
|
534
|
+
);
|
|
535
|
+
return {
|
|
536
|
+
get running() {
|
|
537
|
+
return running;
|
|
538
|
+
},
|
|
539
|
+
workerId,
|
|
540
|
+
stop() {
|
|
541
|
+
if (stopPromise) {
|
|
542
|
+
return stopPromise;
|
|
543
|
+
}
|
|
544
|
+
running = false;
|
|
545
|
+
controller.abort();
|
|
546
|
+
stopPromise = Promise.allSettled(
|
|
547
|
+
loops
|
|
548
|
+
).then(
|
|
549
|
+
() => {
|
|
550
|
+
onStop();
|
|
551
|
+
}
|
|
552
|
+
);
|
|
553
|
+
return stopPromise;
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
async function runWorkerLoop(queue, signal, pollIntervalMs, options, onError) {
|
|
558
|
+
while (!signal.aborted) {
|
|
559
|
+
let processed = false;
|
|
560
|
+
try {
|
|
561
|
+
processed = await queue.processNext(
|
|
562
|
+
signal,
|
|
563
|
+
options
|
|
564
|
+
);
|
|
565
|
+
} catch (error) {
|
|
566
|
+
if (signal.aborted) {
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
if (onError) {
|
|
570
|
+
await onError(error);
|
|
571
|
+
} else {
|
|
572
|
+
throw error;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (!processed && !signal.aborted) {
|
|
576
|
+
await sleep(
|
|
577
|
+
pollIntervalMs,
|
|
578
|
+
signal
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
function startJobHeartbeat(adapter, jobId, ownerId, visibilityTimeoutMs, heartbeatIntervalMs, now, signal) {
|
|
584
|
+
if (!adapter.heartbeat) {
|
|
585
|
+
return () => void 0;
|
|
586
|
+
}
|
|
587
|
+
let stopped = false;
|
|
588
|
+
let timeout;
|
|
589
|
+
const schedule = () => {
|
|
590
|
+
if (stopped || signal.aborted) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
timeout = setTimeout(
|
|
594
|
+
async () => {
|
|
595
|
+
if (stopped || signal.aborted) {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
try {
|
|
599
|
+
const active = await adapter.heartbeat?.(
|
|
600
|
+
jobId,
|
|
601
|
+
{
|
|
602
|
+
ownerId,
|
|
603
|
+
heartbeatAt: now(),
|
|
604
|
+
visibilityTimeoutMs
|
|
605
|
+
}
|
|
606
|
+
);
|
|
607
|
+
if (active !== false) {
|
|
608
|
+
schedule();
|
|
609
|
+
}
|
|
610
|
+
} catch {
|
|
611
|
+
schedule();
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
heartbeatIntervalMs
|
|
615
|
+
);
|
|
616
|
+
};
|
|
617
|
+
schedule();
|
|
618
|
+
return () => {
|
|
619
|
+
stopped = true;
|
|
620
|
+
if (timeout) {
|
|
621
|
+
clearTimeout(timeout);
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
function sleep(durationMs, signal) {
|
|
626
|
+
if (signal.aborted) {
|
|
627
|
+
return Promise.resolve();
|
|
628
|
+
}
|
|
629
|
+
return new Promise(
|
|
630
|
+
(resolve) => {
|
|
631
|
+
const timeout = setTimeout(
|
|
632
|
+
finish,
|
|
633
|
+
durationMs
|
|
634
|
+
);
|
|
635
|
+
const onAbort = () => finish();
|
|
636
|
+
signal.addEventListener(
|
|
637
|
+
"abort",
|
|
638
|
+
onAbort,
|
|
639
|
+
{
|
|
640
|
+
once: true
|
|
641
|
+
}
|
|
642
|
+
);
|
|
643
|
+
function finish() {
|
|
644
|
+
clearTimeout(timeout);
|
|
645
|
+
signal.removeEventListener(
|
|
646
|
+
"abort",
|
|
647
|
+
onAbort
|
|
648
|
+
);
|
|
649
|
+
resolve();
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
function calculateJobStats(jobs, deadLetters) {
|
|
655
|
+
const stats = {
|
|
656
|
+
total: jobs.length,
|
|
657
|
+
queued: 0,
|
|
658
|
+
running: 0,
|
|
659
|
+
succeeded: 0,
|
|
660
|
+
failed: 0,
|
|
661
|
+
cancelled: 0,
|
|
662
|
+
deadLetters
|
|
663
|
+
};
|
|
664
|
+
for (const job of jobs) {
|
|
665
|
+
stats[job.state] += 1;
|
|
666
|
+
}
|
|
667
|
+
return stats;
|
|
668
|
+
}
|
|
669
|
+
function resolveRetryDelay(value, attempt) {
|
|
670
|
+
const delay = typeof value === "function" ? value(attempt) : value;
|
|
671
|
+
return normalizeNonNegativeNumber(
|
|
672
|
+
delay,
|
|
673
|
+
"retryDelayMs"
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
function normalizeJobName(value) {
|
|
677
|
+
const name = String(value).trim();
|
|
678
|
+
if (!name) {
|
|
679
|
+
throw new TypeError(
|
|
680
|
+
"BCP Jobs: job name must be a non-empty string."
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
if (name.length > 200) {
|
|
684
|
+
throw new TypeError(
|
|
685
|
+
"BCP Jobs: job name must not exceed 200 characters."
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
return name;
|
|
689
|
+
}
|
|
690
|
+
function normalizeJobId(value) {
|
|
691
|
+
const id = String(value).trim();
|
|
692
|
+
if (!id) {
|
|
693
|
+
throw new TypeError(
|
|
694
|
+
"BCP Jobs: job id must be a non-empty string."
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
if (id.length > 200) {
|
|
698
|
+
throw new TypeError(
|
|
699
|
+
"BCP Jobs: job id must not exceed 200 characters."
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
return id;
|
|
703
|
+
}
|
|
704
|
+
function normalizeWorkerId(value) {
|
|
705
|
+
const id = String(value).trim();
|
|
706
|
+
if (!id) {
|
|
707
|
+
throw new TypeError(
|
|
708
|
+
"BCP Jobs: worker id must be a non-empty string."
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
return id;
|
|
712
|
+
}
|
|
713
|
+
function normalizePositiveInteger(value, field) {
|
|
714
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
715
|
+
throw new TypeError(
|
|
716
|
+
`BCP Jobs: ${field} must be a positive integer.`
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
return value;
|
|
720
|
+
}
|
|
721
|
+
function normalizeNonNegativeNumber(value, field) {
|
|
722
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
723
|
+
throw new TypeError(
|
|
724
|
+
`BCP Jobs: ${field} must be a non-negative finite number.`
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
return Math.floor(value);
|
|
728
|
+
}
|
|
729
|
+
function formatJobError(error) {
|
|
730
|
+
if (error instanceof Error) {
|
|
731
|
+
return error.message || error.name;
|
|
732
|
+
}
|
|
733
|
+
if (typeof error === "string") {
|
|
734
|
+
return error;
|
|
735
|
+
}
|
|
736
|
+
try {
|
|
737
|
+
const serialized = JSON.stringify(error);
|
|
738
|
+
return serialized ?? String(error);
|
|
739
|
+
} catch {
|
|
740
|
+
return String(error);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
function leaseOwnerMatches(job, ownerId) {
|
|
744
|
+
if (ownerId === void 0) {
|
|
745
|
+
return true;
|
|
746
|
+
}
|
|
747
|
+
return job.leaseOwner === ownerId;
|
|
748
|
+
}
|
|
749
|
+
function clearLease(job) {
|
|
750
|
+
job.leaseOwner = void 0;
|
|
751
|
+
job.leaseUntil = void 0;
|
|
752
|
+
job.heartbeatAt = void 0;
|
|
753
|
+
}
|
|
754
|
+
function cloneJob(job) {
|
|
755
|
+
return {
|
|
756
|
+
...job
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
function cloneDeadLetter(job) {
|
|
760
|
+
return {
|
|
761
|
+
...job
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// packages/server/src/job-scheduler.ts
|
|
766
|
+
import {
|
|
767
|
+
randomUUID as randomUUID2
|
|
768
|
+
} from "node:crypto";
|
|
769
|
+
function createMemoryJobScheduleStore() {
|
|
770
|
+
const schedules = /* @__PURE__ */ new Map();
|
|
771
|
+
return {
|
|
772
|
+
async upsert(schedule) {
|
|
773
|
+
schedules.set(
|
|
774
|
+
schedule.id,
|
|
775
|
+
cloneSchedule(
|
|
776
|
+
schedule
|
|
777
|
+
)
|
|
778
|
+
);
|
|
779
|
+
},
|
|
780
|
+
async get(id) {
|
|
781
|
+
const schedule = schedules.get(id);
|
|
782
|
+
return schedule ? cloneSchedule(
|
|
783
|
+
schedule
|
|
784
|
+
) : null;
|
|
785
|
+
},
|
|
786
|
+
async list() {
|
|
787
|
+
return Array.from(
|
|
788
|
+
schedules.values()
|
|
789
|
+
).map(
|
|
790
|
+
cloneSchedule
|
|
791
|
+
).sort(
|
|
792
|
+
(left, right) => left.nextRunAt - right.nextRunAt || left.id.localeCompare(
|
|
793
|
+
right.id
|
|
794
|
+
)
|
|
795
|
+
);
|
|
796
|
+
},
|
|
797
|
+
async remove(id) {
|
|
798
|
+
return schedules.delete(id);
|
|
799
|
+
},
|
|
800
|
+
async acquireDue(now, options) {
|
|
801
|
+
const due = Array.from(
|
|
802
|
+
schedules.values()
|
|
803
|
+
).filter(
|
|
804
|
+
(schedule) => schedule.nextRunAt <= now && (schedule.leaseUntil === void 0 || schedule.leaseUntil <= now)
|
|
805
|
+
).sort(
|
|
806
|
+
(left, right) => left.nextRunAt - right.nextRunAt || left.id.localeCompare(
|
|
807
|
+
right.id
|
|
808
|
+
)
|
|
809
|
+
).slice(
|
|
810
|
+
0,
|
|
811
|
+
options.limit
|
|
812
|
+
);
|
|
813
|
+
for (const schedule of due) {
|
|
814
|
+
schedule.leaseOwner = options.ownerId;
|
|
815
|
+
schedule.leaseUntil = now + options.leaseMs;
|
|
816
|
+
}
|
|
817
|
+
return due.map(
|
|
818
|
+
cloneSchedule
|
|
819
|
+
);
|
|
820
|
+
},
|
|
821
|
+
async complete(id, options) {
|
|
822
|
+
const schedule = schedules.get(id);
|
|
823
|
+
if (!schedule || schedule.leaseOwner !== options.ownerId) {
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
schedule.lastRunAt = options.lastRunAt;
|
|
827
|
+
schedule.nextRunAt = options.nextRunAt;
|
|
828
|
+
schedule.updatedAt = options.updatedAt;
|
|
829
|
+
schedule.leaseOwner = void 0;
|
|
830
|
+
schedule.leaseUntil = void 0;
|
|
831
|
+
},
|
|
832
|
+
async release(id, ownerId) {
|
|
833
|
+
const schedule = schedules.get(id);
|
|
834
|
+
if (!schedule || schedule.leaseOwner !== ownerId) {
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
schedule.leaseOwner = void 0;
|
|
838
|
+
schedule.leaseUntil = void 0;
|
|
839
|
+
},
|
|
840
|
+
clear() {
|
|
841
|
+
schedules.clear();
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
function createJobScheduler(options) {
|
|
846
|
+
if (!options || !options.queue) {
|
|
847
|
+
throw new TypeError(
|
|
848
|
+
"BCP Jobs: createJobScheduler requires a queue."
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
const queue = options.queue;
|
|
852
|
+
const store = options.store ?? createMemoryJobScheduleStore();
|
|
853
|
+
const now = options.now ?? Date.now;
|
|
854
|
+
const idFactory = options.idFactory ?? randomUUID2;
|
|
855
|
+
const ownerId = normalizeIdentifier(
|
|
856
|
+
options.ownerId ?? `scheduler-${randomUUID2()}`,
|
|
857
|
+
"scheduler owner id"
|
|
858
|
+
);
|
|
859
|
+
const runners = /* @__PURE__ */ new Set();
|
|
860
|
+
const scheduler = {
|
|
861
|
+
queue,
|
|
862
|
+
store,
|
|
863
|
+
ownerId,
|
|
864
|
+
async schedule(jobName, payload, scheduleOptions) {
|
|
865
|
+
const currentTime = normalizeTimestamp(
|
|
866
|
+
now(),
|
|
867
|
+
"current time"
|
|
868
|
+
);
|
|
869
|
+
const schedule = normalizeSchedule(
|
|
870
|
+
scheduleOptions
|
|
871
|
+
);
|
|
872
|
+
const id = normalizeIdentifier(
|
|
873
|
+
scheduleOptions.id ?? idFactory(),
|
|
874
|
+
"schedule id"
|
|
875
|
+
);
|
|
876
|
+
const startAt = scheduleOptions.startAt === void 0 ? void 0 : normalizeTimestamp(
|
|
877
|
+
scheduleOptions.startAt instanceof Date ? scheduleOptions.startAt.getTime() : scheduleOptions.startAt,
|
|
878
|
+
"startAt"
|
|
879
|
+
);
|
|
880
|
+
const maxAttempts = scheduleOptions.maxAttempts === void 0 ? void 0 : normalizePositiveInteger2(
|
|
881
|
+
scheduleOptions.maxAttempts,
|
|
882
|
+
"maxAttempts"
|
|
883
|
+
);
|
|
884
|
+
const nextRunAt = startAt ?? nextScheduleTime(
|
|
885
|
+
schedule,
|
|
886
|
+
currentTime
|
|
887
|
+
);
|
|
888
|
+
const record = {
|
|
889
|
+
id,
|
|
890
|
+
jobName: normalizeIdentifier(
|
|
891
|
+
jobName,
|
|
892
|
+
"job name"
|
|
893
|
+
),
|
|
894
|
+
payload,
|
|
895
|
+
schedule,
|
|
896
|
+
createdAt: currentTime,
|
|
897
|
+
updatedAt: currentTime,
|
|
898
|
+
nextRunAt,
|
|
899
|
+
maxAttempts
|
|
900
|
+
};
|
|
901
|
+
await store.upsert(
|
|
902
|
+
record
|
|
903
|
+
);
|
|
904
|
+
return cloneSchedule(
|
|
905
|
+
record
|
|
906
|
+
);
|
|
907
|
+
},
|
|
908
|
+
get(id) {
|
|
909
|
+
return store.get(
|
|
910
|
+
normalizeIdentifier(
|
|
911
|
+
id,
|
|
912
|
+
"schedule id"
|
|
913
|
+
)
|
|
914
|
+
);
|
|
915
|
+
},
|
|
916
|
+
list() {
|
|
917
|
+
return store.list();
|
|
918
|
+
},
|
|
919
|
+
remove(id) {
|
|
920
|
+
return store.remove(
|
|
921
|
+
normalizeIdentifier(
|
|
922
|
+
id,
|
|
923
|
+
"schedule id"
|
|
924
|
+
)
|
|
925
|
+
);
|
|
926
|
+
},
|
|
927
|
+
async runDue(runOptions = {}) {
|
|
928
|
+
const currentTime = normalizeTimestamp(
|
|
929
|
+
now(),
|
|
930
|
+
"current time"
|
|
931
|
+
);
|
|
932
|
+
const limit = normalizePositiveInteger2(
|
|
933
|
+
runOptions.limit ?? 100,
|
|
934
|
+
"limit"
|
|
935
|
+
);
|
|
936
|
+
const leaseMs = normalizePositiveInteger2(
|
|
937
|
+
runOptions.leaseMs ?? 3e4,
|
|
938
|
+
"leaseMs"
|
|
939
|
+
);
|
|
940
|
+
const schedules = await store.acquireDue(
|
|
941
|
+
currentTime,
|
|
942
|
+
{
|
|
943
|
+
ownerId,
|
|
944
|
+
leaseMs,
|
|
945
|
+
limit
|
|
946
|
+
}
|
|
947
|
+
);
|
|
948
|
+
let enqueued = 0;
|
|
949
|
+
for (const scheduleRecord of schedules) {
|
|
950
|
+
const scheduledFor = scheduleRecord.nextRunAt;
|
|
951
|
+
const runId = createScheduledRunId(
|
|
952
|
+
scheduleRecord.id,
|
|
953
|
+
scheduledFor
|
|
954
|
+
);
|
|
955
|
+
try {
|
|
956
|
+
await queue.enqueue(
|
|
957
|
+
scheduleRecord.jobName,
|
|
958
|
+
scheduleRecord.payload,
|
|
959
|
+
{
|
|
960
|
+
id: runId,
|
|
961
|
+
maxAttempts: scheduleRecord.maxAttempts
|
|
962
|
+
}
|
|
963
|
+
);
|
|
964
|
+
enqueued += 1;
|
|
965
|
+
} catch (error) {
|
|
966
|
+
if (!isDuplicateJobIdError(
|
|
967
|
+
error
|
|
968
|
+
)) {
|
|
969
|
+
await store.release(
|
|
970
|
+
scheduleRecord.id,
|
|
971
|
+
ownerId
|
|
972
|
+
);
|
|
973
|
+
throw error;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
const nextRunAt = nextScheduleTime(
|
|
977
|
+
scheduleRecord.schedule,
|
|
978
|
+
scheduledFor
|
|
979
|
+
);
|
|
980
|
+
await store.complete(
|
|
981
|
+
scheduleRecord.id,
|
|
982
|
+
{
|
|
983
|
+
ownerId,
|
|
984
|
+
lastRunAt: scheduledFor,
|
|
985
|
+
nextRunAt,
|
|
986
|
+
updatedAt: currentTime
|
|
987
|
+
}
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
return enqueued;
|
|
991
|
+
},
|
|
992
|
+
start(startOptions = {}) {
|
|
993
|
+
const runner = createSchedulerRunner(
|
|
994
|
+
scheduler,
|
|
995
|
+
startOptions,
|
|
996
|
+
() => runners.delete(
|
|
997
|
+
runner
|
|
998
|
+
)
|
|
999
|
+
);
|
|
1000
|
+
runners.add(runner);
|
|
1001
|
+
return runner;
|
|
1002
|
+
},
|
|
1003
|
+
async close() {
|
|
1004
|
+
await Promise.all(
|
|
1005
|
+
Array.from(
|
|
1006
|
+
runners,
|
|
1007
|
+
(runner) => runner.stop()
|
|
1008
|
+
)
|
|
1009
|
+
);
|
|
1010
|
+
if (store.close) {
|
|
1011
|
+
await store.close();
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
return scheduler;
|
|
1016
|
+
}
|
|
1017
|
+
function nextScheduleTime(schedule, after) {
|
|
1018
|
+
const timestamp = normalizeTimestamp(
|
|
1019
|
+
after,
|
|
1020
|
+
"schedule reference time"
|
|
1021
|
+
);
|
|
1022
|
+
if (schedule.kind === "interval") {
|
|
1023
|
+
return timestamp + normalizePositiveInteger2(
|
|
1024
|
+
schedule.everyMs,
|
|
1025
|
+
"everyMs"
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
return nextCronTime(
|
|
1029
|
+
schedule.expression,
|
|
1030
|
+
timestamp
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
function nextCronTime(expression, after) {
|
|
1034
|
+
const cron = parseCronExpression(
|
|
1035
|
+
expression
|
|
1036
|
+
);
|
|
1037
|
+
const afterTimestamp = normalizeTimestamp(
|
|
1038
|
+
after,
|
|
1039
|
+
"cron reference time"
|
|
1040
|
+
);
|
|
1041
|
+
const minute = 6e4;
|
|
1042
|
+
let candidate = Math.floor(
|
|
1043
|
+
afterTimestamp / minute
|
|
1044
|
+
) * minute + minute;
|
|
1045
|
+
const maxIterations = 366 * 24 * 60 * 8;
|
|
1046
|
+
for (let iteration = 0; iteration < maxIterations; iteration += 1) {
|
|
1047
|
+
const date = new Date(candidate);
|
|
1048
|
+
if (cron.month.has(
|
|
1049
|
+
date.getUTCMonth() + 1
|
|
1050
|
+
) && cron.hour.has(
|
|
1051
|
+
date.getUTCHours()
|
|
1052
|
+
) && cron.minute.has(
|
|
1053
|
+
date.getUTCMinutes()
|
|
1054
|
+
) && matchesCronDay(
|
|
1055
|
+
cron,
|
|
1056
|
+
date
|
|
1057
|
+
)) {
|
|
1058
|
+
return candidate;
|
|
1059
|
+
}
|
|
1060
|
+
candidate += minute;
|
|
1061
|
+
}
|
|
1062
|
+
throw new RangeError(
|
|
1063
|
+
`BCP Jobs: cron expression "${expression}" did not produce a run time within 8 years.`
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
function createSchedulerRunner(scheduler, options, onStop) {
|
|
1067
|
+
const pollIntervalMs = normalizePositiveInteger2(
|
|
1068
|
+
options.pollIntervalMs ?? 1e3,
|
|
1069
|
+
"pollIntervalMs"
|
|
1070
|
+
);
|
|
1071
|
+
const runOptions = {
|
|
1072
|
+
limit: options.limit,
|
|
1073
|
+
leaseMs: options.leaseMs
|
|
1074
|
+
};
|
|
1075
|
+
const controller = new AbortController();
|
|
1076
|
+
let running = true;
|
|
1077
|
+
let stopPromise = null;
|
|
1078
|
+
const loop = runSchedulerLoop(
|
|
1079
|
+
scheduler,
|
|
1080
|
+
controller.signal,
|
|
1081
|
+
pollIntervalMs,
|
|
1082
|
+
runOptions,
|
|
1083
|
+
options.onError
|
|
1084
|
+
);
|
|
1085
|
+
return {
|
|
1086
|
+
get running() {
|
|
1087
|
+
return running;
|
|
1088
|
+
},
|
|
1089
|
+
stop() {
|
|
1090
|
+
if (stopPromise) {
|
|
1091
|
+
return stopPromise;
|
|
1092
|
+
}
|
|
1093
|
+
running = false;
|
|
1094
|
+
controller.abort();
|
|
1095
|
+
stopPromise = loop.finally(
|
|
1096
|
+
onStop
|
|
1097
|
+
);
|
|
1098
|
+
return stopPromise;
|
|
1099
|
+
}
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
async function runSchedulerLoop(scheduler, signal, pollIntervalMs, runOptions, onError) {
|
|
1103
|
+
while (!signal.aborted) {
|
|
1104
|
+
try {
|
|
1105
|
+
await scheduler.runDue(
|
|
1106
|
+
runOptions
|
|
1107
|
+
);
|
|
1108
|
+
} catch (error) {
|
|
1109
|
+
if (onError) {
|
|
1110
|
+
await onError(error);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
if (!signal.aborted) {
|
|
1114
|
+
await sleep2(
|
|
1115
|
+
pollIntervalMs,
|
|
1116
|
+
signal
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
function normalizeSchedule(options) {
|
|
1122
|
+
if (!options) {
|
|
1123
|
+
throw new TypeError(
|
|
1124
|
+
"BCP Jobs: schedule options are required."
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
const hasInterval = options.everyMs !== void 0;
|
|
1128
|
+
const hasCron = options.cron !== void 0;
|
|
1129
|
+
if (hasInterval === hasCron) {
|
|
1130
|
+
throw new TypeError(
|
|
1131
|
+
"BCP Jobs: schedule requires exactly one of everyMs or cron."
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
if (hasInterval) {
|
|
1135
|
+
return {
|
|
1136
|
+
kind: "interval",
|
|
1137
|
+
everyMs: normalizePositiveInteger2(
|
|
1138
|
+
options.everyMs,
|
|
1139
|
+
"everyMs"
|
|
1140
|
+
)
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
const expression = String(options.cron).trim();
|
|
1144
|
+
parseCronExpression(
|
|
1145
|
+
expression
|
|
1146
|
+
);
|
|
1147
|
+
return {
|
|
1148
|
+
kind: "cron",
|
|
1149
|
+
expression
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
function parseCronExpression(expression) {
|
|
1153
|
+
const parts = String(expression).trim().split(/\s+/);
|
|
1154
|
+
if (parts.length !== 5) {
|
|
1155
|
+
throw new TypeError(
|
|
1156
|
+
"BCP Jobs: cron must contain 5 fields: minute hour day-of-month month day-of-week."
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
return {
|
|
1160
|
+
minute: parseCronField(
|
|
1161
|
+
parts[0],
|
|
1162
|
+
0,
|
|
1163
|
+
59,
|
|
1164
|
+
"minute"
|
|
1165
|
+
).values,
|
|
1166
|
+
hour: parseCronField(
|
|
1167
|
+
parts[1],
|
|
1168
|
+
0,
|
|
1169
|
+
23,
|
|
1170
|
+
"hour"
|
|
1171
|
+
).values,
|
|
1172
|
+
dayOfMonth: parseCronField(
|
|
1173
|
+
parts[2],
|
|
1174
|
+
1,
|
|
1175
|
+
31,
|
|
1176
|
+
"day-of-month"
|
|
1177
|
+
).values,
|
|
1178
|
+
month: parseCronField(
|
|
1179
|
+
parts[3],
|
|
1180
|
+
1,
|
|
1181
|
+
12,
|
|
1182
|
+
"month"
|
|
1183
|
+
).values,
|
|
1184
|
+
dayOfWeek: parseCronField(
|
|
1185
|
+
parts[4],
|
|
1186
|
+
0,
|
|
1187
|
+
7,
|
|
1188
|
+
"day-of-week",
|
|
1189
|
+
true
|
|
1190
|
+
).values,
|
|
1191
|
+
dayOfMonthWildcard: parts[2] === "*",
|
|
1192
|
+
dayOfWeekWildcard: parts[4] === "*"
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
function parseCronField(source, minimum, maximum, field, normalizeSunday = false) {
|
|
1196
|
+
const values = /* @__PURE__ */ new Set();
|
|
1197
|
+
for (const segment of source.split(",")) {
|
|
1198
|
+
const [base, stepSource] = segment.split("/");
|
|
1199
|
+
if (segment.split("/").length > 2) {
|
|
1200
|
+
throwCronField(
|
|
1201
|
+
field,
|
|
1202
|
+
source
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
const step = stepSource === void 0 ? 1 : parseCronInteger(
|
|
1206
|
+
stepSource,
|
|
1207
|
+
1,
|
|
1208
|
+
maximum - minimum + 1,
|
|
1209
|
+
field,
|
|
1210
|
+
source
|
|
1211
|
+
);
|
|
1212
|
+
let rangeStart;
|
|
1213
|
+
let rangeEnd;
|
|
1214
|
+
if (base === "*") {
|
|
1215
|
+
rangeStart = minimum;
|
|
1216
|
+
rangeEnd = maximum;
|
|
1217
|
+
} else if (base.includes("-")) {
|
|
1218
|
+
const bounds = base.split("-");
|
|
1219
|
+
if (bounds.length !== 2) {
|
|
1220
|
+
throwCronField(
|
|
1221
|
+
field,
|
|
1222
|
+
source
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
rangeStart = parseCronInteger(
|
|
1226
|
+
bounds[0],
|
|
1227
|
+
minimum,
|
|
1228
|
+
maximum,
|
|
1229
|
+
field,
|
|
1230
|
+
source
|
|
1231
|
+
);
|
|
1232
|
+
rangeEnd = parseCronInteger(
|
|
1233
|
+
bounds[1],
|
|
1234
|
+
minimum,
|
|
1235
|
+
maximum,
|
|
1236
|
+
field,
|
|
1237
|
+
source
|
|
1238
|
+
);
|
|
1239
|
+
if (rangeEnd < rangeStart) {
|
|
1240
|
+
throwCronField(
|
|
1241
|
+
field,
|
|
1242
|
+
source
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1245
|
+
} else {
|
|
1246
|
+
rangeStart = parseCronInteger(
|
|
1247
|
+
base,
|
|
1248
|
+
minimum,
|
|
1249
|
+
maximum,
|
|
1250
|
+
field,
|
|
1251
|
+
source
|
|
1252
|
+
);
|
|
1253
|
+
rangeEnd = rangeStart;
|
|
1254
|
+
}
|
|
1255
|
+
for (let value = rangeStart; value <= rangeEnd; value += step) {
|
|
1256
|
+
values.add(
|
|
1257
|
+
normalizeSunday && value === 7 ? 0 : value
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
if (values.size === 0) {
|
|
1262
|
+
throwCronField(
|
|
1263
|
+
field,
|
|
1264
|
+
source
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
return {
|
|
1268
|
+
values
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
function parseCronInteger(source, minimum, maximum, field, fullSource) {
|
|
1272
|
+
if (!/^\d+$/.test(source)) {
|
|
1273
|
+
throwCronField(
|
|
1274
|
+
field,
|
|
1275
|
+
fullSource
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
const value = Number(source);
|
|
1279
|
+
if (!Number.isInteger(value) || value < minimum || value > maximum) {
|
|
1280
|
+
throwCronField(
|
|
1281
|
+
field,
|
|
1282
|
+
fullSource
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
return value;
|
|
1286
|
+
}
|
|
1287
|
+
function throwCronField(field, source) {
|
|
1288
|
+
throw new TypeError(
|
|
1289
|
+
`BCP Jobs: invalid cron ${field} field "${source}".`
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
function matchesCronDay(cron, date) {
|
|
1293
|
+
const dayOfMonthMatches = cron.dayOfMonth.has(
|
|
1294
|
+
date.getUTCDate()
|
|
1295
|
+
);
|
|
1296
|
+
const dayOfWeekMatches = cron.dayOfWeek.has(
|
|
1297
|
+
date.getUTCDay()
|
|
1298
|
+
);
|
|
1299
|
+
if (cron.dayOfMonthWildcard && cron.dayOfWeekWildcard) {
|
|
1300
|
+
return true;
|
|
1301
|
+
}
|
|
1302
|
+
if (cron.dayOfMonthWildcard) {
|
|
1303
|
+
return dayOfWeekMatches;
|
|
1304
|
+
}
|
|
1305
|
+
if (cron.dayOfWeekWildcard) {
|
|
1306
|
+
return dayOfMonthMatches;
|
|
1307
|
+
}
|
|
1308
|
+
return dayOfMonthMatches || dayOfWeekMatches;
|
|
1309
|
+
}
|
|
1310
|
+
function createScheduledRunId(scheduleId, scheduledFor) {
|
|
1311
|
+
return `schedule:${scheduleId}:${scheduledFor}`;
|
|
1312
|
+
}
|
|
1313
|
+
function isDuplicateJobIdError(error) {
|
|
1314
|
+
return error instanceof Error && /job id .* already exists/i.test(
|
|
1315
|
+
error.message
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
function normalizeIdentifier(value, field) {
|
|
1319
|
+
const normalized = String(value).trim();
|
|
1320
|
+
if (!normalized) {
|
|
1321
|
+
throw new TypeError(
|
|
1322
|
+
`BCP Jobs: ${field} must be a non-empty string.`
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
if (normalized.length > 200) {
|
|
1326
|
+
throw new TypeError(
|
|
1327
|
+
`BCP Jobs: ${field} must not exceed 200 characters.`
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
return normalized;
|
|
1331
|
+
}
|
|
1332
|
+
function normalizeTimestamp(value, field) {
|
|
1333
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
1334
|
+
throw new TypeError(
|
|
1335
|
+
`BCP Jobs: ${field} must be a non-negative finite timestamp.`
|
|
1336
|
+
);
|
|
1337
|
+
}
|
|
1338
|
+
return Math.floor(value);
|
|
1339
|
+
}
|
|
1340
|
+
function normalizePositiveInteger2(value, field) {
|
|
1341
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
1342
|
+
throw new TypeError(
|
|
1343
|
+
`BCP Jobs: ${field} must be a positive integer.`
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
return value;
|
|
1347
|
+
}
|
|
1348
|
+
function sleep2(durationMs, signal) {
|
|
1349
|
+
if (signal.aborted) {
|
|
1350
|
+
return Promise.resolve();
|
|
1351
|
+
}
|
|
1352
|
+
return new Promise(
|
|
1353
|
+
(resolve) => {
|
|
1354
|
+
const timeout = setTimeout(
|
|
1355
|
+
finish,
|
|
1356
|
+
durationMs
|
|
1357
|
+
);
|
|
1358
|
+
const onAbort = () => finish();
|
|
1359
|
+
signal.addEventListener(
|
|
1360
|
+
"abort",
|
|
1361
|
+
onAbort,
|
|
1362
|
+
{
|
|
1363
|
+
once: true
|
|
1364
|
+
}
|
|
1365
|
+
);
|
|
1366
|
+
function finish() {
|
|
1367
|
+
clearTimeout(timeout);
|
|
1368
|
+
signal.removeEventListener(
|
|
1369
|
+
"abort",
|
|
1370
|
+
onAbort
|
|
1371
|
+
);
|
|
1372
|
+
resolve();
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
function cloneSchedule(schedule) {
|
|
1378
|
+
return {
|
|
1379
|
+
...schedule,
|
|
1380
|
+
schedule: {
|
|
1381
|
+
...schedule.schedule
|
|
1382
|
+
}
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// packages/server/src/jobs-redis.ts
|
|
1387
|
+
function createRedisJobQueueAdapter(options) {
|
|
1388
|
+
const client = requireRedisClient(
|
|
1389
|
+
options.client
|
|
1390
|
+
);
|
|
1391
|
+
const namespace = normalizeNamespace(
|
|
1392
|
+
options.namespace
|
|
1393
|
+
);
|
|
1394
|
+
const keys = createQueueKeys(
|
|
1395
|
+
namespace
|
|
1396
|
+
);
|
|
1397
|
+
return {
|
|
1398
|
+
namespace,
|
|
1399
|
+
async enqueue(job) {
|
|
1400
|
+
const result = await evalRedis(
|
|
1401
|
+
client,
|
|
1402
|
+
ENQUEUE_SCRIPT,
|
|
1403
|
+
[
|
|
1404
|
+
jobKey(
|
|
1405
|
+
keys.jobPrefix,
|
|
1406
|
+
job.id
|
|
1407
|
+
),
|
|
1408
|
+
keys.all,
|
|
1409
|
+
keys.available
|
|
1410
|
+
],
|
|
1411
|
+
[
|
|
1412
|
+
job.id,
|
|
1413
|
+
JSON.stringify(job),
|
|
1414
|
+
String(
|
|
1415
|
+
job.availableAt
|
|
1416
|
+
)
|
|
1417
|
+
]
|
|
1418
|
+
);
|
|
1419
|
+
if (Number(result) !== 1) {
|
|
1420
|
+
throw new Error(
|
|
1421
|
+
`BCP Jobs Redis: job id "${job.id}" already exists.`
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
},
|
|
1425
|
+
async reserve(now, reserveOptions) {
|
|
1426
|
+
const options2 = reserveOptions ?? {
|
|
1427
|
+
ownerId: "legacy-worker",
|
|
1428
|
+
visibilityTimeoutMs: 3e4
|
|
1429
|
+
};
|
|
1430
|
+
const raw = await evalRedis(
|
|
1431
|
+
client,
|
|
1432
|
+
RESERVE_SCRIPT,
|
|
1433
|
+
[
|
|
1434
|
+
keys.available,
|
|
1435
|
+
keys.running
|
|
1436
|
+
],
|
|
1437
|
+
[
|
|
1438
|
+
keys.jobPrefix,
|
|
1439
|
+
String(now),
|
|
1440
|
+
options2.ownerId,
|
|
1441
|
+
String(
|
|
1442
|
+
normalizePositiveInteger3(
|
|
1443
|
+
options2.visibilityTimeoutMs,
|
|
1444
|
+
"visibilityTimeoutMs"
|
|
1445
|
+
)
|
|
1446
|
+
)
|
|
1447
|
+
]
|
|
1448
|
+
);
|
|
1449
|
+
return parseJsonReply(raw);
|
|
1450
|
+
},
|
|
1451
|
+
async complete(id, completedAt, ownerId) {
|
|
1452
|
+
await evalRedis(
|
|
1453
|
+
client,
|
|
1454
|
+
COMPLETE_SCRIPT,
|
|
1455
|
+
[
|
|
1456
|
+
jobKey(
|
|
1457
|
+
keys.jobPrefix,
|
|
1458
|
+
id
|
|
1459
|
+
),
|
|
1460
|
+
keys.available,
|
|
1461
|
+
keys.running,
|
|
1462
|
+
keys.terminal,
|
|
1463
|
+
keys.deadLetters
|
|
1464
|
+
],
|
|
1465
|
+
[
|
|
1466
|
+
id,
|
|
1467
|
+
String(completedAt),
|
|
1468
|
+
ownerId ?? ""
|
|
1469
|
+
]
|
|
1470
|
+
);
|
|
1471
|
+
},
|
|
1472
|
+
async fail(id, failOptions) {
|
|
1473
|
+
await evalRedis(
|
|
1474
|
+
client,
|
|
1475
|
+
FAIL_SCRIPT,
|
|
1476
|
+
[
|
|
1477
|
+
jobKey(
|
|
1478
|
+
keys.jobPrefix,
|
|
1479
|
+
id
|
|
1480
|
+
),
|
|
1481
|
+
keys.available,
|
|
1482
|
+
keys.running,
|
|
1483
|
+
keys.terminal,
|
|
1484
|
+
keys.deadLetters
|
|
1485
|
+
],
|
|
1486
|
+
[
|
|
1487
|
+
id,
|
|
1488
|
+
failOptions.error,
|
|
1489
|
+
String(
|
|
1490
|
+
failOptions.failedAt
|
|
1491
|
+
),
|
|
1492
|
+
failOptions.retryAt === void 0 ? "" : String(
|
|
1493
|
+
failOptions.retryAt
|
|
1494
|
+
),
|
|
1495
|
+
failOptions.ownerId ?? ""
|
|
1496
|
+
]
|
|
1497
|
+
);
|
|
1498
|
+
},
|
|
1499
|
+
async cancel(id, cancelledAt) {
|
|
1500
|
+
const result = await evalRedis(
|
|
1501
|
+
client,
|
|
1502
|
+
CANCEL_SCRIPT,
|
|
1503
|
+
[
|
|
1504
|
+
jobKey(
|
|
1505
|
+
keys.jobPrefix,
|
|
1506
|
+
id
|
|
1507
|
+
),
|
|
1508
|
+
keys.available,
|
|
1509
|
+
keys.running,
|
|
1510
|
+
keys.terminal
|
|
1511
|
+
],
|
|
1512
|
+
[
|
|
1513
|
+
id,
|
|
1514
|
+
String(cancelledAt)
|
|
1515
|
+
]
|
|
1516
|
+
);
|
|
1517
|
+
return Number(result) === 1;
|
|
1518
|
+
},
|
|
1519
|
+
async get(id) {
|
|
1520
|
+
const raw = await client.sendCommand([
|
|
1521
|
+
"GET",
|
|
1522
|
+
jobKey(
|
|
1523
|
+
keys.jobPrefix,
|
|
1524
|
+
id
|
|
1525
|
+
)
|
|
1526
|
+
]);
|
|
1527
|
+
return parseJsonReply(raw);
|
|
1528
|
+
},
|
|
1529
|
+
async list() {
|
|
1530
|
+
return readJobsByIds(
|
|
1531
|
+
client,
|
|
1532
|
+
keys.jobPrefix,
|
|
1533
|
+
await readStringList(
|
|
1534
|
+
client,
|
|
1535
|
+
[
|
|
1536
|
+
"SMEMBERS",
|
|
1537
|
+
keys.all
|
|
1538
|
+
]
|
|
1539
|
+
)
|
|
1540
|
+
);
|
|
1541
|
+
},
|
|
1542
|
+
async heartbeat(id, heartbeatOptions) {
|
|
1543
|
+
const result = await evalRedis(
|
|
1544
|
+
client,
|
|
1545
|
+
HEARTBEAT_SCRIPT,
|
|
1546
|
+
[
|
|
1547
|
+
jobKey(
|
|
1548
|
+
keys.jobPrefix,
|
|
1549
|
+
id
|
|
1550
|
+
),
|
|
1551
|
+
keys.running
|
|
1552
|
+
],
|
|
1553
|
+
[
|
|
1554
|
+
id,
|
|
1555
|
+
heartbeatOptions.ownerId,
|
|
1556
|
+
String(
|
|
1557
|
+
heartbeatOptions.heartbeatAt
|
|
1558
|
+
),
|
|
1559
|
+
String(
|
|
1560
|
+
normalizePositiveInteger3(
|
|
1561
|
+
heartbeatOptions.visibilityTimeoutMs,
|
|
1562
|
+
"visibilityTimeoutMs"
|
|
1563
|
+
)
|
|
1564
|
+
)
|
|
1565
|
+
]
|
|
1566
|
+
);
|
|
1567
|
+
return Number(result) === 1;
|
|
1568
|
+
},
|
|
1569
|
+
async recoverStale(now, recoverOptions = {}) {
|
|
1570
|
+
const result = await evalRedis(
|
|
1571
|
+
client,
|
|
1572
|
+
RECOVER_STALE_SCRIPT,
|
|
1573
|
+
[
|
|
1574
|
+
keys.running,
|
|
1575
|
+
keys.available
|
|
1576
|
+
],
|
|
1577
|
+
[
|
|
1578
|
+
keys.jobPrefix,
|
|
1579
|
+
String(now),
|
|
1580
|
+
String(
|
|
1581
|
+
normalizePositiveInteger3(
|
|
1582
|
+
recoverOptions.limit ?? 100,
|
|
1583
|
+
"recovery limit"
|
|
1584
|
+
)
|
|
1585
|
+
)
|
|
1586
|
+
]
|
|
1587
|
+
);
|
|
1588
|
+
return Number(result) || 0;
|
|
1589
|
+
},
|
|
1590
|
+
async listDeadLetters() {
|
|
1591
|
+
const ids = await readStringList(
|
|
1592
|
+
client,
|
|
1593
|
+
[
|
|
1594
|
+
"ZRANGE",
|
|
1595
|
+
keys.deadLetters,
|
|
1596
|
+
"0",
|
|
1597
|
+
"-1"
|
|
1598
|
+
]
|
|
1599
|
+
);
|
|
1600
|
+
const jobs = await readJobsByIds(
|
|
1601
|
+
client,
|
|
1602
|
+
keys.jobPrefix,
|
|
1603
|
+
ids
|
|
1604
|
+
);
|
|
1605
|
+
return jobs.filter(
|
|
1606
|
+
(job) => job.state === "failed" && job.completedAt !== void 0
|
|
1607
|
+
).map(
|
|
1608
|
+
(job) => ({
|
|
1609
|
+
...job,
|
|
1610
|
+
state: "failed",
|
|
1611
|
+
deadLetteredAt: job.completedAt
|
|
1612
|
+
})
|
|
1613
|
+
);
|
|
1614
|
+
},
|
|
1615
|
+
async requeueDeadLetter(id, now, requeueOptions = {}) {
|
|
1616
|
+
const result = await evalRedis(
|
|
1617
|
+
client,
|
|
1618
|
+
REQUEUE_DEAD_LETTER_SCRIPT,
|
|
1619
|
+
[
|
|
1620
|
+
jobKey(
|
|
1621
|
+
keys.jobPrefix,
|
|
1622
|
+
id
|
|
1623
|
+
),
|
|
1624
|
+
keys.available,
|
|
1625
|
+
keys.running,
|
|
1626
|
+
keys.terminal,
|
|
1627
|
+
keys.deadLetters
|
|
1628
|
+
],
|
|
1629
|
+
[
|
|
1630
|
+
id,
|
|
1631
|
+
String(now),
|
|
1632
|
+
String(
|
|
1633
|
+
normalizeNonNegativeNumber2(
|
|
1634
|
+
requeueOptions.delayMs ?? 0,
|
|
1635
|
+
"delayMs"
|
|
1636
|
+
)
|
|
1637
|
+
),
|
|
1638
|
+
requeueOptions.resetAttempts === false ? "0" : "1"
|
|
1639
|
+
]
|
|
1640
|
+
);
|
|
1641
|
+
return Number(result) === 1;
|
|
1642
|
+
},
|
|
1643
|
+
async cleanup(cleanupOptions) {
|
|
1644
|
+
const states = cleanupOptions.states ?? [
|
|
1645
|
+
"succeeded",
|
|
1646
|
+
"failed",
|
|
1647
|
+
"cancelled"
|
|
1648
|
+
];
|
|
1649
|
+
const result = await evalRedis(
|
|
1650
|
+
client,
|
|
1651
|
+
CLEANUP_SCRIPT,
|
|
1652
|
+
[
|
|
1653
|
+
keys.terminal,
|
|
1654
|
+
keys.all,
|
|
1655
|
+
keys.available,
|
|
1656
|
+
keys.running,
|
|
1657
|
+
keys.deadLetters
|
|
1658
|
+
],
|
|
1659
|
+
[
|
|
1660
|
+
keys.jobPrefix,
|
|
1661
|
+
String(
|
|
1662
|
+
cleanupOptions.before
|
|
1663
|
+
),
|
|
1664
|
+
JSON.stringify(states)
|
|
1665
|
+
]
|
|
1666
|
+
);
|
|
1667
|
+
return Number(result) || 0;
|
|
1668
|
+
},
|
|
1669
|
+
async stats() {
|
|
1670
|
+
const jobs = await readJobsByIds(
|
|
1671
|
+
client,
|
|
1672
|
+
keys.jobPrefix,
|
|
1673
|
+
await readStringList(
|
|
1674
|
+
client,
|
|
1675
|
+
[
|
|
1676
|
+
"SMEMBERS",
|
|
1677
|
+
keys.all
|
|
1678
|
+
]
|
|
1679
|
+
)
|
|
1680
|
+
);
|
|
1681
|
+
const deadLetters = Number(
|
|
1682
|
+
await client.sendCommand([
|
|
1683
|
+
"ZCARD",
|
|
1684
|
+
keys.deadLetters
|
|
1685
|
+
])
|
|
1686
|
+
) || 0;
|
|
1687
|
+
return calculateStats(
|
|
1688
|
+
jobs,
|
|
1689
|
+
deadLetters
|
|
1690
|
+
);
|
|
1691
|
+
},
|
|
1692
|
+
async close() {
|
|
1693
|
+
if (options.close) {
|
|
1694
|
+
await options.close();
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
function createRedisJobScheduleStore(options) {
|
|
1700
|
+
const client = requireRedisClient(
|
|
1701
|
+
options.client
|
|
1702
|
+
);
|
|
1703
|
+
const namespace = normalizeNamespace(
|
|
1704
|
+
options.namespace
|
|
1705
|
+
);
|
|
1706
|
+
const keys = createScheduleKeys(
|
|
1707
|
+
namespace
|
|
1708
|
+
);
|
|
1709
|
+
return {
|
|
1710
|
+
namespace,
|
|
1711
|
+
async upsert(schedule) {
|
|
1712
|
+
await evalRedis(
|
|
1713
|
+
client,
|
|
1714
|
+
UPSERT_SCHEDULE_SCRIPT,
|
|
1715
|
+
[
|
|
1716
|
+
scheduleKey(
|
|
1717
|
+
keys.schedulePrefix,
|
|
1718
|
+
schedule.id
|
|
1719
|
+
),
|
|
1720
|
+
keys.all,
|
|
1721
|
+
keys.due
|
|
1722
|
+
],
|
|
1723
|
+
[
|
|
1724
|
+
schedule.id,
|
|
1725
|
+
JSON.stringify(schedule),
|
|
1726
|
+
String(
|
|
1727
|
+
schedule.nextRunAt
|
|
1728
|
+
)
|
|
1729
|
+
]
|
|
1730
|
+
);
|
|
1731
|
+
},
|
|
1732
|
+
async get(id) {
|
|
1733
|
+
return parseJsonReply(
|
|
1734
|
+
await client.sendCommand([
|
|
1735
|
+
"GET",
|
|
1736
|
+
scheduleKey(
|
|
1737
|
+
keys.schedulePrefix,
|
|
1738
|
+
id
|
|
1739
|
+
)
|
|
1740
|
+
])
|
|
1741
|
+
);
|
|
1742
|
+
},
|
|
1743
|
+
async list() {
|
|
1744
|
+
const ids = await readStringList(
|
|
1745
|
+
client,
|
|
1746
|
+
[
|
|
1747
|
+
"SMEMBERS",
|
|
1748
|
+
keys.all
|
|
1749
|
+
]
|
|
1750
|
+
);
|
|
1751
|
+
const schedules = await readSchedulesByIds(
|
|
1752
|
+
client,
|
|
1753
|
+
keys.schedulePrefix,
|
|
1754
|
+
ids
|
|
1755
|
+
);
|
|
1756
|
+
return schedules.sort(
|
|
1757
|
+
(left, right) => left.nextRunAt - right.nextRunAt || left.id.localeCompare(
|
|
1758
|
+
right.id
|
|
1759
|
+
)
|
|
1760
|
+
);
|
|
1761
|
+
},
|
|
1762
|
+
async remove(id) {
|
|
1763
|
+
const result = await evalRedis(
|
|
1764
|
+
client,
|
|
1765
|
+
REMOVE_SCHEDULE_SCRIPT,
|
|
1766
|
+
[
|
|
1767
|
+
scheduleKey(
|
|
1768
|
+
keys.schedulePrefix,
|
|
1769
|
+
id
|
|
1770
|
+
),
|
|
1771
|
+
keys.all,
|
|
1772
|
+
keys.due
|
|
1773
|
+
],
|
|
1774
|
+
[
|
|
1775
|
+
id
|
|
1776
|
+
]
|
|
1777
|
+
);
|
|
1778
|
+
return Number(result) === 1;
|
|
1779
|
+
},
|
|
1780
|
+
async acquireDue(now, acquireOptions) {
|
|
1781
|
+
const raw = await evalRedis(
|
|
1782
|
+
client,
|
|
1783
|
+
ACQUIRE_DUE_SCHEDULES_SCRIPT,
|
|
1784
|
+
[
|
|
1785
|
+
keys.due
|
|
1786
|
+
],
|
|
1787
|
+
[
|
|
1788
|
+
keys.schedulePrefix,
|
|
1789
|
+
String(now),
|
|
1790
|
+
acquireOptions.ownerId,
|
|
1791
|
+
String(
|
|
1792
|
+
normalizePositiveInteger3(
|
|
1793
|
+
acquireOptions.leaseMs,
|
|
1794
|
+
"schedule leaseMs"
|
|
1795
|
+
)
|
|
1796
|
+
),
|
|
1797
|
+
String(
|
|
1798
|
+
normalizePositiveInteger3(
|
|
1799
|
+
acquireOptions.limit,
|
|
1800
|
+
"schedule limit"
|
|
1801
|
+
)
|
|
1802
|
+
)
|
|
1803
|
+
]
|
|
1804
|
+
);
|
|
1805
|
+
if (!Array.isArray(raw)) {
|
|
1806
|
+
return [];
|
|
1807
|
+
}
|
|
1808
|
+
return raw.map(
|
|
1809
|
+
(value) => parseJsonReply(value)
|
|
1810
|
+
).filter(
|
|
1811
|
+
(value) => value !== null
|
|
1812
|
+
);
|
|
1813
|
+
},
|
|
1814
|
+
async complete(id, completeOptions) {
|
|
1815
|
+
await evalRedis(
|
|
1816
|
+
client,
|
|
1817
|
+
COMPLETE_SCHEDULE_SCRIPT,
|
|
1818
|
+
[
|
|
1819
|
+
scheduleKey(
|
|
1820
|
+
keys.schedulePrefix,
|
|
1821
|
+
id
|
|
1822
|
+
),
|
|
1823
|
+
keys.due
|
|
1824
|
+
],
|
|
1825
|
+
[
|
|
1826
|
+
id,
|
|
1827
|
+
completeOptions.ownerId,
|
|
1828
|
+
String(
|
|
1829
|
+
completeOptions.lastRunAt
|
|
1830
|
+
),
|
|
1831
|
+
String(
|
|
1832
|
+
completeOptions.nextRunAt
|
|
1833
|
+
),
|
|
1834
|
+
String(
|
|
1835
|
+
completeOptions.updatedAt
|
|
1836
|
+
)
|
|
1837
|
+
]
|
|
1838
|
+
);
|
|
1839
|
+
},
|
|
1840
|
+
async release(id, ownerId) {
|
|
1841
|
+
await evalRedis(
|
|
1842
|
+
client,
|
|
1843
|
+
RELEASE_SCHEDULE_SCRIPT,
|
|
1844
|
+
[
|
|
1845
|
+
scheduleKey(
|
|
1846
|
+
keys.schedulePrefix,
|
|
1847
|
+
id
|
|
1848
|
+
)
|
|
1849
|
+
],
|
|
1850
|
+
[
|
|
1851
|
+
ownerId
|
|
1852
|
+
]
|
|
1853
|
+
);
|
|
1854
|
+
},
|
|
1855
|
+
async close() {
|
|
1856
|
+
if (options.close) {
|
|
1857
|
+
await options.close();
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
function createQueueKeys(namespace) {
|
|
1863
|
+
return {
|
|
1864
|
+
all: `${namespace}:jobs:all`,
|
|
1865
|
+
available: `${namespace}:jobs:available`,
|
|
1866
|
+
running: `${namespace}:jobs:running`,
|
|
1867
|
+
terminal: `${namespace}:jobs:terminal`,
|
|
1868
|
+
deadLetters: `${namespace}:jobs:dlq`,
|
|
1869
|
+
jobPrefix: `${namespace}:job:`
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
function createScheduleKeys(namespace) {
|
|
1873
|
+
return {
|
|
1874
|
+
all: `${namespace}:schedules:all`,
|
|
1875
|
+
due: `${namespace}:schedules:due`,
|
|
1876
|
+
schedulePrefix: `${namespace}:schedule:`
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
function jobKey(prefix, id) {
|
|
1880
|
+
return `${prefix}${id}`;
|
|
1881
|
+
}
|
|
1882
|
+
function scheduleKey(prefix, id) {
|
|
1883
|
+
return `${prefix}${id}`;
|
|
1884
|
+
}
|
|
1885
|
+
async function evalRedis(client, script, keys, args) {
|
|
1886
|
+
return client.sendCommand([
|
|
1887
|
+
"EVAL",
|
|
1888
|
+
script,
|
|
1889
|
+
String(keys.length),
|
|
1890
|
+
...keys,
|
|
1891
|
+
...args
|
|
1892
|
+
]);
|
|
1893
|
+
}
|
|
1894
|
+
async function readStringList(client, command) {
|
|
1895
|
+
const result = await client.sendCommand(
|
|
1896
|
+
command
|
|
1897
|
+
);
|
|
1898
|
+
if (!Array.isArray(result)) {
|
|
1899
|
+
return [];
|
|
1900
|
+
}
|
|
1901
|
+
return result.map(
|
|
1902
|
+
(value) => redisString(value)
|
|
1903
|
+
).filter(
|
|
1904
|
+
(value) => value !== null
|
|
1905
|
+
);
|
|
1906
|
+
}
|
|
1907
|
+
async function readJobsByIds(client, prefix, ids) {
|
|
1908
|
+
if (ids.length === 0) {
|
|
1909
|
+
return [];
|
|
1910
|
+
}
|
|
1911
|
+
const raw = await client.sendCommand([
|
|
1912
|
+
"MGET",
|
|
1913
|
+
...ids.map(
|
|
1914
|
+
(id) => jobKey(
|
|
1915
|
+
prefix,
|
|
1916
|
+
id
|
|
1917
|
+
)
|
|
1918
|
+
)
|
|
1919
|
+
]);
|
|
1920
|
+
if (!Array.isArray(raw)) {
|
|
1921
|
+
return [];
|
|
1922
|
+
}
|
|
1923
|
+
return raw.map(
|
|
1924
|
+
(value) => parseJsonReply(value)
|
|
1925
|
+
).filter(
|
|
1926
|
+
(value) => value !== null
|
|
1927
|
+
).sort(
|
|
1928
|
+
(left, right) => left.createdAt - right.createdAt || left.id.localeCompare(
|
|
1929
|
+
right.id
|
|
1930
|
+
)
|
|
1931
|
+
);
|
|
1932
|
+
}
|
|
1933
|
+
async function readSchedulesByIds(client, prefix, ids) {
|
|
1934
|
+
if (ids.length === 0) {
|
|
1935
|
+
return [];
|
|
1936
|
+
}
|
|
1937
|
+
const raw = await client.sendCommand([
|
|
1938
|
+
"MGET",
|
|
1939
|
+
...ids.map(
|
|
1940
|
+
(id) => scheduleKey(
|
|
1941
|
+
prefix,
|
|
1942
|
+
id
|
|
1943
|
+
)
|
|
1944
|
+
)
|
|
1945
|
+
]);
|
|
1946
|
+
if (!Array.isArray(raw)) {
|
|
1947
|
+
return [];
|
|
1948
|
+
}
|
|
1949
|
+
return raw.map(
|
|
1950
|
+
(value) => parseJsonReply(value)
|
|
1951
|
+
).filter(
|
|
1952
|
+
(value) => value !== null
|
|
1953
|
+
);
|
|
1954
|
+
}
|
|
1955
|
+
function parseJsonReply(value) {
|
|
1956
|
+
const text = redisString(value);
|
|
1957
|
+
if (text === null) {
|
|
1958
|
+
return null;
|
|
1959
|
+
}
|
|
1960
|
+
return JSON.parse(text);
|
|
1961
|
+
}
|
|
1962
|
+
function redisString(value) {
|
|
1963
|
+
if (typeof value === "string") {
|
|
1964
|
+
return value;
|
|
1965
|
+
}
|
|
1966
|
+
if (value instanceof Uint8Array) {
|
|
1967
|
+
return Buffer.from(
|
|
1968
|
+
value
|
|
1969
|
+
).toString("utf8");
|
|
1970
|
+
}
|
|
1971
|
+
if (typeof value === "number") {
|
|
1972
|
+
return String(value);
|
|
1973
|
+
}
|
|
1974
|
+
return null;
|
|
1975
|
+
}
|
|
1976
|
+
function calculateStats(jobs, deadLetters) {
|
|
1977
|
+
const stats = {
|
|
1978
|
+
total: jobs.length,
|
|
1979
|
+
queued: 0,
|
|
1980
|
+
running: 0,
|
|
1981
|
+
succeeded: 0,
|
|
1982
|
+
failed: 0,
|
|
1983
|
+
cancelled: 0,
|
|
1984
|
+
deadLetters
|
|
1985
|
+
};
|
|
1986
|
+
for (const job of jobs) {
|
|
1987
|
+
stats[job.state] += 1;
|
|
1988
|
+
}
|
|
1989
|
+
return stats;
|
|
1990
|
+
}
|
|
1991
|
+
function requireRedisClient(client) {
|
|
1992
|
+
if (!client || typeof client.sendCommand !== "function") {
|
|
1993
|
+
throw new TypeError(
|
|
1994
|
+
"BCP Jobs Redis: client.sendCommand(command) is required."
|
|
1995
|
+
);
|
|
1996
|
+
}
|
|
1997
|
+
return client;
|
|
1998
|
+
}
|
|
1999
|
+
function normalizeNamespace(value = "bcp:{jobs}") {
|
|
2000
|
+
const namespace = String(value).trim();
|
|
2001
|
+
if (!namespace) {
|
|
2002
|
+
throw new TypeError(
|
|
2003
|
+
"BCP Jobs Redis: namespace must be non-empty."
|
|
2004
|
+
);
|
|
2005
|
+
}
|
|
2006
|
+
return namespace.replace(
|
|
2007
|
+
/:+$/,
|
|
2008
|
+
""
|
|
2009
|
+
);
|
|
2010
|
+
}
|
|
2011
|
+
function normalizePositiveInteger3(value, field) {
|
|
2012
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
2013
|
+
throw new TypeError(
|
|
2014
|
+
`BCP Jobs Redis: ${field} must be a positive integer.`
|
|
2015
|
+
);
|
|
2016
|
+
}
|
|
2017
|
+
return value;
|
|
2018
|
+
}
|
|
2019
|
+
function normalizeNonNegativeNumber2(value, field) {
|
|
2020
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
2021
|
+
throw new TypeError(
|
|
2022
|
+
`BCP Jobs Redis: ${field} must be a non-negative finite number.`
|
|
2023
|
+
);
|
|
2024
|
+
}
|
|
2025
|
+
return Math.floor(value);
|
|
2026
|
+
}
|
|
2027
|
+
var ENQUEUE_SCRIPT = `
|
|
2028
|
+
if redis.call('EXISTS', KEYS[1]) == 1 then
|
|
2029
|
+
return 0
|
|
2030
|
+
end
|
|
2031
|
+
redis.call('SET', KEYS[1], ARGV[2])
|
|
2032
|
+
redis.call('SADD', KEYS[2], ARGV[1])
|
|
2033
|
+
redis.call('ZADD', KEYS[3], ARGV[3], ARGV[1])
|
|
2034
|
+
return 1
|
|
2035
|
+
`;
|
|
2036
|
+
var RESERVE_SCRIPT = `
|
|
2037
|
+
local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[2], 'LIMIT', 0, 20)
|
|
2038
|
+
for _, id in ipairs(ids) do
|
|
2039
|
+
local key = ARGV[1] .. id
|
|
2040
|
+
local raw = redis.call('GET', key)
|
|
2041
|
+
if not raw then
|
|
2042
|
+
redis.call('ZREM', KEYS[1], id)
|
|
2043
|
+
else
|
|
2044
|
+
local job = cjson.decode(raw)
|
|
2045
|
+
if job.state == 'queued' then
|
|
2046
|
+
job.state = 'running'
|
|
2047
|
+
job.attempts = (job.attempts or 0) + 1
|
|
2048
|
+
job.startedAt = tonumber(ARGV[2])
|
|
2049
|
+
job.error = nil
|
|
2050
|
+
job.recoveredAt = nil
|
|
2051
|
+
job.leaseOwner = ARGV[3]
|
|
2052
|
+
job.leaseUntil = tonumber(ARGV[2]) + tonumber(ARGV[4])
|
|
2053
|
+
job.heartbeatAt = tonumber(ARGV[2])
|
|
2054
|
+
local encoded = cjson.encode(job)
|
|
2055
|
+
redis.call('SET', key, encoded)
|
|
2056
|
+
redis.call('ZREM', KEYS[1], id)
|
|
2057
|
+
redis.call('ZADD', KEYS[2], job.leaseUntil, id)
|
|
2058
|
+
return encoded
|
|
2059
|
+
end
|
|
2060
|
+
redis.call('ZREM', KEYS[1], id)
|
|
2061
|
+
end
|
|
2062
|
+
end
|
|
2063
|
+
return nil
|
|
2064
|
+
`;
|
|
2065
|
+
var COMPLETE_SCRIPT = `
|
|
2066
|
+
local raw = redis.call('GET', KEYS[1])
|
|
2067
|
+
if not raw then return 0 end
|
|
2068
|
+
local job = cjson.decode(raw)
|
|
2069
|
+
if job.state == 'cancelled' then return 0 end
|
|
2070
|
+
if ARGV[3] ~= '' and job.leaseOwner ~= ARGV[3] then return 0 end
|
|
2071
|
+
job.state = 'succeeded'
|
|
2072
|
+
job.completedAt = tonumber(ARGV[2])
|
|
2073
|
+
job.error = nil
|
|
2074
|
+
job.leaseOwner = nil
|
|
2075
|
+
job.leaseUntil = nil
|
|
2076
|
+
job.heartbeatAt = nil
|
|
2077
|
+
redis.call('SET', KEYS[1], cjson.encode(job))
|
|
2078
|
+
redis.call('ZREM', KEYS[2], ARGV[1])
|
|
2079
|
+
redis.call('ZREM', KEYS[3], ARGV[1])
|
|
2080
|
+
redis.call('ZADD', KEYS[4], ARGV[2], ARGV[1])
|
|
2081
|
+
redis.call('ZREM', KEYS[5], ARGV[1])
|
|
2082
|
+
return 1
|
|
2083
|
+
`;
|
|
2084
|
+
var FAIL_SCRIPT = `
|
|
2085
|
+
local raw = redis.call('GET', KEYS[1])
|
|
2086
|
+
if not raw then return 0 end
|
|
2087
|
+
local job = cjson.decode(raw)
|
|
2088
|
+
if job.state == 'cancelled' then return 0 end
|
|
2089
|
+
if ARGV[5] ~= '' and job.leaseOwner ~= ARGV[5] then return 0 end
|
|
2090
|
+
job.error = ARGV[2]
|
|
2091
|
+
job.leaseOwner = nil
|
|
2092
|
+
job.leaseUntil = nil
|
|
2093
|
+
job.heartbeatAt = nil
|
|
2094
|
+
local retryAt = nil
|
|
2095
|
+
if ARGV[4] ~= '' then retryAt = tonumber(ARGV[4]) end
|
|
2096
|
+
if retryAt and (job.attempts or 0) < (job.maxAttempts or 1) then
|
|
2097
|
+
job.state = 'queued'
|
|
2098
|
+
job.availableAt = retryAt
|
|
2099
|
+
job.startedAt = nil
|
|
2100
|
+
redis.call('SET', KEYS[1], cjson.encode(job))
|
|
2101
|
+
redis.call('ZREM', KEYS[3], ARGV[1])
|
|
2102
|
+
redis.call('ZADD', KEYS[2], retryAt, ARGV[1])
|
|
2103
|
+
return 2
|
|
2104
|
+
end
|
|
2105
|
+
job.state = 'failed'
|
|
2106
|
+
job.completedAt = tonumber(ARGV[3])
|
|
2107
|
+
redis.call('SET', KEYS[1], cjson.encode(job))
|
|
2108
|
+
redis.call('ZREM', KEYS[2], ARGV[1])
|
|
2109
|
+
redis.call('ZREM', KEYS[3], ARGV[1])
|
|
2110
|
+
redis.call('ZADD', KEYS[4], ARGV[3], ARGV[1])
|
|
2111
|
+
redis.call('ZADD', KEYS[5], ARGV[3], ARGV[1])
|
|
2112
|
+
return 1
|
|
2113
|
+
`;
|
|
2114
|
+
var CANCEL_SCRIPT = `
|
|
2115
|
+
local raw = redis.call('GET', KEYS[1])
|
|
2116
|
+
if not raw then return 0 end
|
|
2117
|
+
local job = cjson.decode(raw)
|
|
2118
|
+
if job.state == 'succeeded' or job.state == 'failed' or job.state == 'cancelled' then
|
|
2119
|
+
return 0
|
|
2120
|
+
end
|
|
2121
|
+
job.state = 'cancelled'
|
|
2122
|
+
job.completedAt = tonumber(ARGV[2])
|
|
2123
|
+
job.leaseOwner = nil
|
|
2124
|
+
job.leaseUntil = nil
|
|
2125
|
+
job.heartbeatAt = nil
|
|
2126
|
+
redis.call('SET', KEYS[1], cjson.encode(job))
|
|
2127
|
+
redis.call('ZREM', KEYS[2], ARGV[1])
|
|
2128
|
+
redis.call('ZREM', KEYS[3], ARGV[1])
|
|
2129
|
+
redis.call('ZADD', KEYS[4], ARGV[2], ARGV[1])
|
|
2130
|
+
return 1
|
|
2131
|
+
`;
|
|
2132
|
+
var HEARTBEAT_SCRIPT = `
|
|
2133
|
+
local raw = redis.call('GET', KEYS[1])
|
|
2134
|
+
if not raw then return 0 end
|
|
2135
|
+
local job = cjson.decode(raw)
|
|
2136
|
+
if job.state ~= 'running' or job.leaseOwner ~= ARGV[2] then
|
|
2137
|
+
return 0
|
|
2138
|
+
end
|
|
2139
|
+
job.heartbeatAt = tonumber(ARGV[3])
|
|
2140
|
+
job.leaseUntil = tonumber(ARGV[3]) + tonumber(ARGV[4])
|
|
2141
|
+
redis.call('SET', KEYS[1], cjson.encode(job))
|
|
2142
|
+
redis.call('ZADD', KEYS[2], job.leaseUntil, ARGV[1])
|
|
2143
|
+
return 1
|
|
2144
|
+
`;
|
|
2145
|
+
var RECOVER_STALE_SCRIPT = `
|
|
2146
|
+
local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[2], 'LIMIT', 0, tonumber(ARGV[3]))
|
|
2147
|
+
local recovered = 0
|
|
2148
|
+
for _, id in ipairs(ids) do
|
|
2149
|
+
local key = ARGV[1] .. id
|
|
2150
|
+
local raw = redis.call('GET', key)
|
|
2151
|
+
if not raw then
|
|
2152
|
+
redis.call('ZREM', KEYS[1], id)
|
|
2153
|
+
else
|
|
2154
|
+
local job = cjson.decode(raw)
|
|
2155
|
+
if job.state == 'running' and job.leaseUntil and tonumber(job.leaseUntil) <= tonumber(ARGV[2]) then
|
|
2156
|
+
job.state = 'queued'
|
|
2157
|
+
job.availableAt = tonumber(ARGV[2])
|
|
2158
|
+
job.startedAt = nil
|
|
2159
|
+
job.leaseOwner = nil
|
|
2160
|
+
job.leaseUntil = nil
|
|
2161
|
+
job.heartbeatAt = nil
|
|
2162
|
+
job.recoveredAt = tonumber(ARGV[2])
|
|
2163
|
+
redis.call('SET', key, cjson.encode(job))
|
|
2164
|
+
redis.call('ZREM', KEYS[1], id)
|
|
2165
|
+
redis.call('ZADD', KEYS[2], ARGV[2], id)
|
|
2166
|
+
recovered = recovered + 1
|
|
2167
|
+
end
|
|
2168
|
+
end
|
|
2169
|
+
end
|
|
2170
|
+
return recovered
|
|
2171
|
+
`;
|
|
2172
|
+
var REQUEUE_DEAD_LETTER_SCRIPT = `
|
|
2173
|
+
local raw = redis.call('GET', KEYS[1])
|
|
2174
|
+
if not raw then return 0 end
|
|
2175
|
+
if not redis.call('ZSCORE', KEYS[5], ARGV[1]) then return 0 end
|
|
2176
|
+
local job = cjson.decode(raw)
|
|
2177
|
+
if job.state ~= 'failed' then return 0 end
|
|
2178
|
+
job.state = 'queued'
|
|
2179
|
+
job.availableAt = tonumber(ARGV[2]) + tonumber(ARGV[3])
|
|
2180
|
+
job.startedAt = nil
|
|
2181
|
+
job.completedAt = nil
|
|
2182
|
+
job.error = nil
|
|
2183
|
+
job.leaseOwner = nil
|
|
2184
|
+
job.leaseUntil = nil
|
|
2185
|
+
job.heartbeatAt = nil
|
|
2186
|
+
job.recoveredAt = nil
|
|
2187
|
+
if ARGV[4] == '1' then job.attempts = 0 end
|
|
2188
|
+
redis.call('SET', KEYS[1], cjson.encode(job))
|
|
2189
|
+
redis.call('ZADD', KEYS[2], job.availableAt, ARGV[1])
|
|
2190
|
+
redis.call('ZREM', KEYS[3], ARGV[1])
|
|
2191
|
+
redis.call('ZREM', KEYS[4], ARGV[1])
|
|
2192
|
+
redis.call('ZREM', KEYS[5], ARGV[1])
|
|
2193
|
+
return 1
|
|
2194
|
+
`;
|
|
2195
|
+
var CLEANUP_SCRIPT = `
|
|
2196
|
+
local allowed = {}
|
|
2197
|
+
for _, state in ipairs(cjson.decode(ARGV[3])) do allowed[state] = true end
|
|
2198
|
+
local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[2], 'LIMIT', 0, 1000)
|
|
2199
|
+
local removed = 0
|
|
2200
|
+
for _, id in ipairs(ids) do
|
|
2201
|
+
local key = ARGV[1] .. id
|
|
2202
|
+
local raw = redis.call('GET', key)
|
|
2203
|
+
if not raw then
|
|
2204
|
+
redis.call('ZREM', KEYS[1], id)
|
|
2205
|
+
redis.call('SREM', KEYS[2], id)
|
|
2206
|
+
else
|
|
2207
|
+
local job = cjson.decode(raw)
|
|
2208
|
+
if allowed[job.state] then
|
|
2209
|
+
redis.call('DEL', key)
|
|
2210
|
+
redis.call('SREM', KEYS[2], id)
|
|
2211
|
+
redis.call('ZREM', KEYS[1], id)
|
|
2212
|
+
redis.call('ZREM', KEYS[3], id)
|
|
2213
|
+
redis.call('ZREM', KEYS[4], id)
|
|
2214
|
+
redis.call('ZREM', KEYS[5], id)
|
|
2215
|
+
removed = removed + 1
|
|
2216
|
+
end
|
|
2217
|
+
end
|
|
2218
|
+
end
|
|
2219
|
+
return removed
|
|
2220
|
+
`;
|
|
2221
|
+
var UPSERT_SCHEDULE_SCRIPT = `
|
|
2222
|
+
redis.call('SET', KEYS[1], ARGV[2])
|
|
2223
|
+
redis.call('SADD', KEYS[2], ARGV[1])
|
|
2224
|
+
redis.call('ZADD', KEYS[3], ARGV[3], ARGV[1])
|
|
2225
|
+
return 1
|
|
2226
|
+
`;
|
|
2227
|
+
var REMOVE_SCHEDULE_SCRIPT = `
|
|
2228
|
+
local existed = redis.call('DEL', KEYS[1])
|
|
2229
|
+
redis.call('SREM', KEYS[2], ARGV[1])
|
|
2230
|
+
redis.call('ZREM', KEYS[3], ARGV[1])
|
|
2231
|
+
return existed
|
|
2232
|
+
`;
|
|
2233
|
+
var ACQUIRE_DUE_SCHEDULES_SCRIPT = `
|
|
2234
|
+
local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[2], 'LIMIT', 0, tonumber(ARGV[5]) * 4)
|
|
2235
|
+
local result = {}
|
|
2236
|
+
for _, id in ipairs(ids) do
|
|
2237
|
+
if #result >= tonumber(ARGV[5]) then break end
|
|
2238
|
+
local key = ARGV[1] .. id
|
|
2239
|
+
local raw = redis.call('GET', key)
|
|
2240
|
+
if not raw then
|
|
2241
|
+
redis.call('ZREM', KEYS[1], id)
|
|
2242
|
+
else
|
|
2243
|
+
local schedule = cjson.decode(raw)
|
|
2244
|
+
local leaseUntil = schedule.leaseUntil
|
|
2245
|
+
if schedule.nextRunAt <= tonumber(ARGV[2]) and (not leaseUntil or tonumber(leaseUntil) <= tonumber(ARGV[2])) then
|
|
2246
|
+
schedule.leaseOwner = ARGV[3]
|
|
2247
|
+
schedule.leaseUntil = tonumber(ARGV[2]) + tonumber(ARGV[4])
|
|
2248
|
+
local encoded = cjson.encode(schedule)
|
|
2249
|
+
redis.call('SET', key, encoded)
|
|
2250
|
+
table.insert(result, encoded)
|
|
2251
|
+
end
|
|
2252
|
+
end
|
|
2253
|
+
end
|
|
2254
|
+
return result
|
|
2255
|
+
`;
|
|
2256
|
+
var COMPLETE_SCHEDULE_SCRIPT = `
|
|
2257
|
+
local raw = redis.call('GET', KEYS[1])
|
|
2258
|
+
if not raw then return 0 end
|
|
2259
|
+
local schedule = cjson.decode(raw)
|
|
2260
|
+
if schedule.leaseOwner ~= ARGV[2] then return 0 end
|
|
2261
|
+
schedule.lastRunAt = tonumber(ARGV[3])
|
|
2262
|
+
schedule.nextRunAt = tonumber(ARGV[4])
|
|
2263
|
+
schedule.updatedAt = tonumber(ARGV[5])
|
|
2264
|
+
schedule.leaseOwner = nil
|
|
2265
|
+
schedule.leaseUntil = nil
|
|
2266
|
+
redis.call('SET', KEYS[1], cjson.encode(schedule))
|
|
2267
|
+
redis.call('ZADD', KEYS[2], ARGV[4], ARGV[1])
|
|
2268
|
+
return 1
|
|
2269
|
+
`;
|
|
2270
|
+
var RELEASE_SCHEDULE_SCRIPT = `
|
|
2271
|
+
local raw = redis.call('GET', KEYS[1])
|
|
2272
|
+
if not raw then return 0 end
|
|
2273
|
+
local schedule = cjson.decode(raw)
|
|
2274
|
+
if schedule.leaseOwner ~= ARGV[1] then return 0 end
|
|
2275
|
+
schedule.leaseOwner = nil
|
|
2276
|
+
schedule.leaseUntil = nil
|
|
2277
|
+
redis.call('SET', KEYS[1], cjson.encode(schedule))
|
|
2278
|
+
return 1
|
|
2279
|
+
`;
|
|
2280
|
+
export {
|
|
2281
|
+
createJobQueue,
|
|
2282
|
+
createJobScheduler,
|
|
2283
|
+
createMemoryJobQueueAdapter,
|
|
2284
|
+
createMemoryJobScheduleStore,
|
|
2285
|
+
createRedisJobQueueAdapter,
|
|
2286
|
+
createRedisJobScheduleStore,
|
|
2287
|
+
nextCronTime,
|
|
2288
|
+
nextScheduleTime
|
|
2289
|
+
};
|