@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.
@@ -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
+ };