@chidchanun/bcp 0.2.9 → 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.
@@ -4,6 +4,33 @@ import {
4
4
  } from "node:crypto";
5
5
  function createMemoryJobQueueAdapter() {
6
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
+ };
7
34
  return {
8
35
  async enqueue(job) {
9
36
  if (jobs.has(job.id)) {
@@ -16,7 +43,13 @@ function createMemoryJobQueueAdapter() {
16
43
  cloneJob(job)
17
44
  );
18
45
  },
19
- async reserve(now) {
46
+ async reserve(now, options) {
47
+ recoverStale(
48
+ now,
49
+ {
50
+ limit: 100
51
+ }
52
+ );
20
53
  const candidate = Array.from(
21
54
  jobs.values()
22
55
  ).filter(
@@ -31,25 +64,43 @@ function createMemoryJobQueueAdapter() {
31
64
  candidate.attempts += 1;
32
65
  candidate.startedAt = now;
33
66
  candidate.error = void 0;
34
- return cloneJob(
35
- candidate
36
- );
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);
37
79
  },
38
- async complete(id, completedAt) {
80
+ async complete(id, completedAt, ownerId) {
39
81
  const job = jobs.get(id);
40
- if (!job || job.state === "cancelled") {
82
+ if (!job || job.state === "cancelled" || !leaseOwnerMatches(
83
+ job,
84
+ ownerId
85
+ )) {
41
86
  return;
42
87
  }
43
88
  job.state = "succeeded";
44
89
  job.completedAt = completedAt;
45
90
  job.error = void 0;
91
+ clearLease(job);
92
+ deadLetters.delete(id);
46
93
  },
47
94
  async fail(id, options) {
48
95
  const job = jobs.get(id);
49
- if (!job || job.state === "cancelled") {
96
+ if (!job || job.state === "cancelled" || !leaseOwnerMatches(
97
+ job,
98
+ options.ownerId
99
+ )) {
50
100
  return;
51
101
  }
52
102
  job.error = options.error;
103
+ clearLease(job);
53
104
  if (options.retryAt !== void 0 && job.attempts < job.maxAttempts) {
54
105
  job.state = "queued";
55
106
  job.availableAt = options.retryAt;
@@ -58,6 +109,14 @@ function createMemoryJobQueueAdapter() {
58
109
  }
59
110
  job.state = "failed";
60
111
  job.completedAt = options.failedAt;
112
+ deadLetters.set(
113
+ id,
114
+ {
115
+ ...cloneJob(job),
116
+ state: "failed",
117
+ deadLetteredAt: options.failedAt
118
+ }
119
+ );
61
120
  },
62
121
  async cancel(id, cancelledAt) {
63
122
  const job = jobs.get(id);
@@ -66,6 +125,7 @@ function createMemoryJobQueueAdapter() {
66
125
  }
67
126
  job.state = "cancelled";
68
127
  job.completedAt = cancelledAt;
128
+ clearLease(job);
69
129
  return true;
70
130
  },
71
131
  async get(id) {
@@ -79,8 +139,91 @@ function createMemoryJobQueueAdapter() {
79
139
  (left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id)
80
140
  );
81
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
+ },
82
224
  clear() {
83
225
  jobs.clear();
226
+ deadLetters.clear();
84
227
  }
85
228
  };
86
229
  }
@@ -147,9 +290,7 @@ function createJobQueue(options = {}) {
147
290
  );
148
291
  const job = {
149
292
  id,
150
- name: normalizeJobName(
151
- name
152
- ),
293
+ name: normalizeJobName(name),
153
294
  payload,
154
295
  state: "queued",
155
296
  attempts: 0,
@@ -157,9 +298,7 @@ function createJobQueue(options = {}) {
157
298
  createdAt,
158
299
  availableAt: createdAt + delayMs
159
300
  };
160
- await adapter.enqueue(
161
- job
162
- );
301
+ await adapter.enqueue(job);
163
302
  return cloneJob(job);
164
303
  },
165
304
  get(id) {
@@ -176,58 +315,103 @@ function createJobQueue(options = {}) {
176
315
  now()
177
316
  );
178
317
  },
179
- async processNext(signal = new AbortController().signal) {
318
+ async processNext(signal = new AbortController().signal, processOptions = {}) {
180
319
  if (signal.aborted) {
181
320
  return false;
182
321
  }
183
- const job = await adapter.reserve(
184
- now()
322
+ const visibilityTimeoutMs = normalizePositiveInteger(
323
+ processOptions.visibilityTimeoutMs ?? 3e4,
324
+ "visibilityTimeoutMs"
185
325
  );
186
- if (!job) {
187
- return false;
188
- }
189
- const handler = handlers.get(
190
- job.name
326
+ const heartbeatIntervalMs = normalizePositiveInteger(
327
+ processOptions.heartbeatIntervalMs ?? Math.max(
328
+ 1,
329
+ Math.floor(
330
+ visibilityTimeoutMs / 3
331
+ )
332
+ ),
333
+ "heartbeatIntervalMs"
191
334
  );
192
- if (!handler) {
193
- await adapter.fail(
194
- job.id,
335
+ const ownerId = normalizeWorkerId(
336
+ processOptions.ownerId ?? `process-${randomUUID()}`
337
+ );
338
+ if (adapter.recoverStale) {
339
+ await adapter.recoverStale(
340
+ now(),
195
341
  {
196
- error: `No handler registered for job "${job.name}".`,
197
- failedAt: now()
342
+ limit: normalizePositiveInteger(
343
+ processOptions.recoveryLimit ?? 100,
344
+ "recoveryLimit"
345
+ )
198
346
  }
199
347
  );
200
- return true;
201
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);
202
369
  try {
203
- await handler({
204
- job: cloneJob(job),
205
- payload: job.payload,
206
- signal
207
- });
208
- await adapter.complete(
209
- job.id,
210
- now()
211
- );
212
- } catch (error) {
213
- const failedAt = now();
214
- const shouldRetry = job.attempts < job.maxAttempts;
215
- const retryAt = shouldRetry ? failedAt + resolveRetryDelay(
216
- retryDelay,
217
- job.attempts
218
- ) : void 0;
219
- await adapter.fail(
220
- job.id,
221
- {
222
- error: formatJobError(
223
- error
224
- ),
225
- failedAt,
226
- retryAt
227
- }
228
- );
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();
229
414
  }
230
- return true;
231
415
  },
232
416
  startWorker(workerOptions = {}) {
233
417
  const worker = createWorker(
@@ -240,6 +424,51 @@ function createJobQueue(options = {}) {
240
424
  workers.add(worker);
241
425
  return worker;
242
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
+ },
243
472
  async close() {
244
473
  await Promise.all(
245
474
  Array.from(
@@ -263,6 +492,26 @@ function createWorker(queue, options, onStop) {
263
492
  options.pollIntervalMs ?? 250,
264
493
  "pollIntervalMs"
265
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
+ );
266
515
  const controller = new AbortController();
267
516
  let running = true;
268
517
  let stopPromise = null;
@@ -270,16 +519,24 @@ function createWorker(queue, options, onStop) {
270
519
  {
271
520
  length: concurrency
272
521
  },
273
- () => runWorkerLoop(
522
+ (_, index) => runWorkerLoop(
274
523
  queue,
275
524
  controller.signal,
276
- pollIntervalMs
525
+ pollIntervalMs,
526
+ {
527
+ ownerId: `${workerId}:${index + 1}`,
528
+ visibilityTimeoutMs,
529
+ heartbeatIntervalMs,
530
+ recoveryLimit
531
+ },
532
+ options.onError
277
533
  )
278
534
  );
279
535
  return {
280
536
  get running() {
281
537
  return running;
282
538
  },
539
+ workerId,
283
540
  stop() {
284
541
  if (stopPromise) {
285
542
  return stopPromise;
@@ -297,11 +554,24 @@ function createWorker(queue, options, onStop) {
297
554
  }
298
555
  };
299
556
  }
300
- async function runWorkerLoop(queue, signal, pollIntervalMs) {
557
+ async function runWorkerLoop(queue, signal, pollIntervalMs, options, onError) {
301
558
  while (!signal.aborted) {
302
- const processed = await queue.processNext(
303
- signal
304
- );
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
+ }
305
575
  if (!processed && !signal.aborted) {
306
576
  await sleep(
307
577
  pollIntervalMs,
@@ -310,8 +580,50 @@ async function runWorkerLoop(queue, signal, pollIntervalMs) {
310
580
  }
311
581
  }
312
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
+ }
313
625
  function sleep(durationMs, signal) {
314
- if (durationMs === 0 || signal.aborted) {
626
+ if (signal.aborted) {
315
627
  return Promise.resolve();
316
628
  }
317
629
  return new Promise(
@@ -339,6 +651,21 @@ function sleep(durationMs, signal) {
339
651
  }
340
652
  );
341
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
+ }
342
669
  function resolveRetryDelay(value, attempt) {
343
670
  const delay = typeof value === "function" ? value(attempt) : value;
344
671
  return normalizeNonNegativeNumber(
@@ -374,6 +701,15 @@ function normalizeJobId(value) {
374
701
  }
375
702
  return id;
376
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
+ }
377
713
  function normalizePositiveInteger(value, field) {
378
714
  if (!Number.isInteger(value) || value <= 0) {
379
715
  throw new TypeError(
@@ -404,11 +740,27 @@ function formatJobError(error) {
404
740
  return String(error);
405
741
  }
406
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
+ }
407
754
  function cloneJob(job) {
408
755
  return {
409
756
  ...job
410
757
  };
411
758
  }
759
+ function cloneDeadLetter(job) {
760
+ return {
761
+ ...job
762
+ };
763
+ }
412
764
 
413
765
  // packages/server/src/job-scheduler.ts
414
766
  import {
@@ -1030,11 +1382,908 @@ function cloneSchedule(schedule) {
1030
1382
  }
1031
1383
  };
1032
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
+ `;
1033
2280
  export {
1034
2281
  createJobQueue,
1035
2282
  createJobScheduler,
1036
2283
  createMemoryJobQueueAdapter,
1037
2284
  createMemoryJobScheduleStore,
2285
+ createRedisJobQueueAdapter,
2286
+ createRedisJobScheduleStore,
1038
2287
  nextCronTime,
1039
2288
  nextScheduleTime
1040
2289
  };