@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.
@@ -0,0 +1,1226 @@
1
+ import type {
2
+ CleanupJobsOptions,
3
+ HeartbeatJobOptions,
4
+ JobQueueAdapter,
5
+ JobQueueStats,
6
+ JobRecord,
7
+ RecoverStaleJobsOptions,
8
+ RequeueDeadLetterOptions,
9
+ } from "./jobs.js";
10
+ import type {
11
+ JobScheduleRecord,
12
+ JobScheduleStore,
13
+ } from "./job-scheduler.js";
14
+
15
+ export interface RedisCommandClient {
16
+ sendCommand(
17
+ command: string[]
18
+ ): Promise<unknown>;
19
+ }
20
+
21
+ export interface RedisJobsAdapterOptions {
22
+ client: RedisCommandClient;
23
+ namespace?: string;
24
+ close?: () => Promise<void>;
25
+ }
26
+
27
+ export interface RedisJobQueueAdapter
28
+ extends JobQueueAdapter {
29
+ readonly namespace: string;
30
+ }
31
+
32
+ export interface RedisJobScheduleStore
33
+ extends JobScheduleStore {
34
+ readonly namespace: string;
35
+ }
36
+
37
+ export function createRedisJobQueueAdapter(
38
+ options: RedisJobsAdapterOptions
39
+ ): RedisJobQueueAdapter {
40
+ const client =
41
+ requireRedisClient(
42
+ options.client
43
+ );
44
+ const namespace =
45
+ normalizeNamespace(
46
+ options.namespace
47
+ );
48
+ const keys =
49
+ createQueueKeys(
50
+ namespace
51
+ );
52
+
53
+ return {
54
+ namespace,
55
+
56
+ async enqueue(job) {
57
+ const result =
58
+ await evalRedis(
59
+ client,
60
+ ENQUEUE_SCRIPT,
61
+ [
62
+ jobKey(
63
+ keys.jobPrefix,
64
+ job.id
65
+ ),
66
+ keys.all,
67
+ keys.available,
68
+ ],
69
+ [
70
+ job.id,
71
+ JSON.stringify(job),
72
+ String(
73
+ job.availableAt
74
+ ),
75
+ ]
76
+ );
77
+
78
+ if (Number(result) !== 1) {
79
+ throw new Error(
80
+ `BCP Jobs Redis: job id "${job.id}" already exists.`
81
+ );
82
+ }
83
+ },
84
+
85
+ async reserve(
86
+ now,
87
+ reserveOptions
88
+ ) {
89
+ const options =
90
+ reserveOptions ?? {
91
+ ownerId:
92
+ "legacy-worker",
93
+ visibilityTimeoutMs:
94
+ 30_000,
95
+ };
96
+ const raw =
97
+ await evalRedis(
98
+ client,
99
+ RESERVE_SCRIPT,
100
+ [
101
+ keys.available,
102
+ keys.running,
103
+ ],
104
+ [
105
+ keys.jobPrefix,
106
+ String(now),
107
+ options.ownerId,
108
+ String(
109
+ normalizePositiveInteger(
110
+ options.visibilityTimeoutMs,
111
+ "visibilityTimeoutMs"
112
+ )
113
+ ),
114
+ ]
115
+ );
116
+
117
+ return parseJsonReply<
118
+ JobRecord
119
+ >(raw);
120
+ },
121
+
122
+ async complete(
123
+ id,
124
+ completedAt,
125
+ ownerId
126
+ ) {
127
+ await evalRedis(
128
+ client,
129
+ COMPLETE_SCRIPT,
130
+ [
131
+ jobKey(
132
+ keys.jobPrefix,
133
+ id
134
+ ),
135
+ keys.available,
136
+ keys.running,
137
+ keys.terminal,
138
+ keys.deadLetters,
139
+ ],
140
+ [
141
+ id,
142
+ String(completedAt),
143
+ ownerId ?? "",
144
+ ]
145
+ );
146
+ },
147
+
148
+ async fail(
149
+ id,
150
+ failOptions
151
+ ) {
152
+ await evalRedis(
153
+ client,
154
+ FAIL_SCRIPT,
155
+ [
156
+ jobKey(
157
+ keys.jobPrefix,
158
+ id
159
+ ),
160
+ keys.available,
161
+ keys.running,
162
+ keys.terminal,
163
+ keys.deadLetters,
164
+ ],
165
+ [
166
+ id,
167
+ failOptions.error,
168
+ String(
169
+ failOptions.failedAt
170
+ ),
171
+ failOptions.retryAt ===
172
+ undefined
173
+ ? ""
174
+ : String(
175
+ failOptions.retryAt
176
+ ),
177
+ failOptions.ownerId ??
178
+ "",
179
+ ]
180
+ );
181
+ },
182
+
183
+ async cancel(
184
+ id,
185
+ cancelledAt
186
+ ) {
187
+ const result =
188
+ await evalRedis(
189
+ client,
190
+ CANCEL_SCRIPT,
191
+ [
192
+ jobKey(
193
+ keys.jobPrefix,
194
+ id
195
+ ),
196
+ keys.available,
197
+ keys.running,
198
+ keys.terminal,
199
+ ],
200
+ [
201
+ id,
202
+ String(cancelledAt),
203
+ ]
204
+ );
205
+
206
+ return Number(result) === 1;
207
+ },
208
+
209
+ async get(id) {
210
+ const raw =
211
+ await client.sendCommand([
212
+ "GET",
213
+ jobKey(
214
+ keys.jobPrefix,
215
+ id
216
+ ),
217
+ ]);
218
+
219
+ return parseJsonReply<
220
+ JobRecord<any>
221
+ >(raw);
222
+ },
223
+
224
+ async list() {
225
+ return readJobsByIds(
226
+ client,
227
+ keys.jobPrefix,
228
+ await readStringList(
229
+ client,
230
+ [
231
+ "SMEMBERS",
232
+ keys.all,
233
+ ]
234
+ )
235
+ );
236
+ },
237
+
238
+ async heartbeat(
239
+ id,
240
+ heartbeatOptions
241
+ ) {
242
+ const result =
243
+ await evalRedis(
244
+ client,
245
+ HEARTBEAT_SCRIPT,
246
+ [
247
+ jobKey(
248
+ keys.jobPrefix,
249
+ id
250
+ ),
251
+ keys.running,
252
+ ],
253
+ [
254
+ id,
255
+ heartbeatOptions.ownerId,
256
+ String(
257
+ heartbeatOptions
258
+ .heartbeatAt
259
+ ),
260
+ String(
261
+ normalizePositiveInteger(
262
+ heartbeatOptions
263
+ .visibilityTimeoutMs,
264
+ "visibilityTimeoutMs"
265
+ )
266
+ ),
267
+ ]
268
+ );
269
+
270
+ return Number(result) === 1;
271
+ },
272
+
273
+ async recoverStale(
274
+ now,
275
+ recoverOptions = {}
276
+ ) {
277
+ const result =
278
+ await evalRedis(
279
+ client,
280
+ RECOVER_STALE_SCRIPT,
281
+ [
282
+ keys.running,
283
+ keys.available,
284
+ ],
285
+ [
286
+ keys.jobPrefix,
287
+ String(now),
288
+ String(
289
+ normalizePositiveInteger(
290
+ recoverOptions.limit ??
291
+ 100,
292
+ "recovery limit"
293
+ )
294
+ ),
295
+ ]
296
+ );
297
+
298
+ return Number(result) || 0;
299
+ },
300
+
301
+ async listDeadLetters() {
302
+ const ids =
303
+ await readStringList(
304
+ client,
305
+ [
306
+ "ZRANGE",
307
+ keys.deadLetters,
308
+ "0",
309
+ "-1",
310
+ ]
311
+ );
312
+ const jobs =
313
+ await readJobsByIds(
314
+ client,
315
+ keys.jobPrefix,
316
+ ids
317
+ );
318
+
319
+ return jobs
320
+ .filter(
321
+ (
322
+ job
323
+ ): job is JobRecord & {
324
+ state: "failed";
325
+ completedAt: number;
326
+ } =>
327
+ job.state ===
328
+ "failed" &&
329
+ job.completedAt !==
330
+ undefined
331
+ )
332
+ .map(
333
+ job => ({
334
+ ...job,
335
+ state:
336
+ "failed" as const,
337
+ deadLetteredAt:
338
+ job.completedAt,
339
+ })
340
+ );
341
+ },
342
+
343
+ async requeueDeadLetter(
344
+ id,
345
+ now,
346
+ requeueOptions = {}
347
+ ) {
348
+ const result =
349
+ await evalRedis(
350
+ client,
351
+ REQUEUE_DEAD_LETTER_SCRIPT,
352
+ [
353
+ jobKey(
354
+ keys.jobPrefix,
355
+ id
356
+ ),
357
+ keys.available,
358
+ keys.running,
359
+ keys.terminal,
360
+ keys.deadLetters,
361
+ ],
362
+ [
363
+ id,
364
+ String(now),
365
+ String(
366
+ normalizeNonNegativeNumber(
367
+ requeueOptions.delayMs ??
368
+ 0,
369
+ "delayMs"
370
+ )
371
+ ),
372
+ requeueOptions
373
+ .resetAttempts ===
374
+ false
375
+ ? "0"
376
+ : "1",
377
+ ]
378
+ );
379
+
380
+ return Number(result) === 1;
381
+ },
382
+
383
+ async cleanup(cleanupOptions) {
384
+ const states =
385
+ cleanupOptions.states ?? [
386
+ "succeeded",
387
+ "failed",
388
+ "cancelled",
389
+ ];
390
+ const result =
391
+ await evalRedis(
392
+ client,
393
+ CLEANUP_SCRIPT,
394
+ [
395
+ keys.terminal,
396
+ keys.all,
397
+ keys.available,
398
+ keys.running,
399
+ keys.deadLetters,
400
+ ],
401
+ [
402
+ keys.jobPrefix,
403
+ String(
404
+ cleanupOptions.before
405
+ ),
406
+ JSON.stringify(states),
407
+ ]
408
+ );
409
+
410
+ return Number(result) || 0;
411
+ },
412
+
413
+ async stats() {
414
+ const jobs =
415
+ await readJobsByIds(
416
+ client,
417
+ keys.jobPrefix,
418
+ await readStringList(
419
+ client,
420
+ [
421
+ "SMEMBERS",
422
+ keys.all,
423
+ ]
424
+ )
425
+ );
426
+ const deadLetters =
427
+ Number(
428
+ await client
429
+ .sendCommand([
430
+ "ZCARD",
431
+ keys.deadLetters,
432
+ ])
433
+ ) || 0;
434
+
435
+ return calculateStats(
436
+ jobs,
437
+ deadLetters
438
+ );
439
+ },
440
+
441
+ async close() {
442
+ if (options.close) {
443
+ await options.close();
444
+ }
445
+ },
446
+ };
447
+ }
448
+
449
+ export function createRedisJobScheduleStore(
450
+ options: RedisJobsAdapterOptions
451
+ ): RedisJobScheduleStore {
452
+ const client =
453
+ requireRedisClient(
454
+ options.client
455
+ );
456
+ const namespace =
457
+ normalizeNamespace(
458
+ options.namespace
459
+ );
460
+ const keys =
461
+ createScheduleKeys(
462
+ namespace
463
+ );
464
+
465
+ return {
466
+ namespace,
467
+
468
+ async upsert(schedule) {
469
+ await evalRedis(
470
+ client,
471
+ UPSERT_SCHEDULE_SCRIPT,
472
+ [
473
+ scheduleKey(
474
+ keys.schedulePrefix,
475
+ schedule.id
476
+ ),
477
+ keys.all,
478
+ keys.due,
479
+ ],
480
+ [
481
+ schedule.id,
482
+ JSON.stringify(schedule),
483
+ String(
484
+ schedule.nextRunAt
485
+ ),
486
+ ]
487
+ );
488
+ },
489
+
490
+ async get(id) {
491
+ return parseJsonReply<
492
+ JobScheduleRecord<any>
493
+ >(
494
+ await client
495
+ .sendCommand([
496
+ "GET",
497
+ scheduleKey(
498
+ keys.schedulePrefix,
499
+ id
500
+ ),
501
+ ])
502
+ );
503
+ },
504
+
505
+ async list() {
506
+ const ids =
507
+ await readStringList(
508
+ client,
509
+ [
510
+ "SMEMBERS",
511
+ keys.all,
512
+ ]
513
+ );
514
+ const schedules =
515
+ await readSchedulesByIds(
516
+ client,
517
+ keys.schedulePrefix,
518
+ ids
519
+ );
520
+
521
+ return schedules.sort(
522
+ (left, right) =>
523
+ left.nextRunAt -
524
+ right.nextRunAt ||
525
+ left.id.localeCompare(
526
+ right.id
527
+ )
528
+ );
529
+ },
530
+
531
+ async remove(id) {
532
+ const result =
533
+ await evalRedis(
534
+ client,
535
+ REMOVE_SCHEDULE_SCRIPT,
536
+ [
537
+ scheduleKey(
538
+ keys.schedulePrefix,
539
+ id
540
+ ),
541
+ keys.all,
542
+ keys.due,
543
+ ],
544
+ [
545
+ id,
546
+ ]
547
+ );
548
+
549
+ return Number(result) === 1;
550
+ },
551
+
552
+ async acquireDue(
553
+ now,
554
+ acquireOptions
555
+ ) {
556
+ const raw =
557
+ await evalRedis(
558
+ client,
559
+ ACQUIRE_DUE_SCHEDULES_SCRIPT,
560
+ [
561
+ keys.due,
562
+ ],
563
+ [
564
+ keys.schedulePrefix,
565
+ String(now),
566
+ acquireOptions.ownerId,
567
+ String(
568
+ normalizePositiveInteger(
569
+ acquireOptions.leaseMs,
570
+ "schedule leaseMs"
571
+ )
572
+ ),
573
+ String(
574
+ normalizePositiveInteger(
575
+ acquireOptions.limit,
576
+ "schedule limit"
577
+ )
578
+ ),
579
+ ]
580
+ );
581
+
582
+ if (!Array.isArray(raw)) {
583
+ return [];
584
+ }
585
+
586
+ return raw
587
+ .map(
588
+ value =>
589
+ parseJsonReply<
590
+ JobScheduleRecord
591
+ >(value)
592
+ )
593
+ .filter(
594
+ (
595
+ value
596
+ ): value is JobScheduleRecord =>
597
+ value !== null
598
+ );
599
+ },
600
+
601
+ async complete(
602
+ id,
603
+ completeOptions
604
+ ) {
605
+ await evalRedis(
606
+ client,
607
+ COMPLETE_SCHEDULE_SCRIPT,
608
+ [
609
+ scheduleKey(
610
+ keys.schedulePrefix,
611
+ id
612
+ ),
613
+ keys.due,
614
+ ],
615
+ [
616
+ id,
617
+ completeOptions.ownerId,
618
+ String(
619
+ completeOptions
620
+ .lastRunAt
621
+ ),
622
+ String(
623
+ completeOptions
624
+ .nextRunAt
625
+ ),
626
+ String(
627
+ completeOptions
628
+ .updatedAt
629
+ ),
630
+ ]
631
+ );
632
+ },
633
+
634
+ async release(
635
+ id,
636
+ ownerId
637
+ ) {
638
+ await evalRedis(
639
+ client,
640
+ RELEASE_SCHEDULE_SCRIPT,
641
+ [
642
+ scheduleKey(
643
+ keys.schedulePrefix,
644
+ id
645
+ ),
646
+ ],
647
+ [
648
+ ownerId,
649
+ ]
650
+ );
651
+ },
652
+
653
+ async close() {
654
+ if (options.close) {
655
+ await options.close();
656
+ }
657
+ },
658
+ };
659
+ }
660
+
661
+ function createQueueKeys(
662
+ namespace: string
663
+ ) {
664
+ return {
665
+ all:
666
+ `${namespace}:jobs:all`,
667
+ available:
668
+ `${namespace}:jobs:available`,
669
+ running:
670
+ `${namespace}:jobs:running`,
671
+ terminal:
672
+ `${namespace}:jobs:terminal`,
673
+ deadLetters:
674
+ `${namespace}:jobs:dlq`,
675
+ jobPrefix:
676
+ `${namespace}:job:`,
677
+ };
678
+ }
679
+
680
+ function createScheduleKeys(
681
+ namespace: string
682
+ ) {
683
+ return {
684
+ all:
685
+ `${namespace}:schedules:all`,
686
+ due:
687
+ `${namespace}:schedules:due`,
688
+ schedulePrefix:
689
+ `${namespace}:schedule:`,
690
+ };
691
+ }
692
+
693
+ function jobKey(
694
+ prefix: string,
695
+ id: string
696
+ ): string {
697
+ return `${prefix}${id}`;
698
+ }
699
+
700
+ function scheduleKey(
701
+ prefix: string,
702
+ id: string
703
+ ): string {
704
+ return `${prefix}${id}`;
705
+ }
706
+
707
+ async function evalRedis(
708
+ client: RedisCommandClient,
709
+ script: string,
710
+ keys: string[],
711
+ args: string[]
712
+ ): Promise<unknown> {
713
+ return client.sendCommand([
714
+ "EVAL",
715
+ script,
716
+ String(keys.length),
717
+ ...keys,
718
+ ...args,
719
+ ]);
720
+ }
721
+
722
+ async function readStringList(
723
+ client: RedisCommandClient,
724
+ command: string[]
725
+ ): Promise<string[]> {
726
+ const result =
727
+ await client.sendCommand(
728
+ command
729
+ );
730
+
731
+ if (!Array.isArray(result)) {
732
+ return [];
733
+ }
734
+
735
+ return result
736
+ .map(
737
+ value =>
738
+ redisString(value)
739
+ )
740
+ .filter(
741
+ (
742
+ value
743
+ ): value is string =>
744
+ value !== null
745
+ );
746
+ }
747
+
748
+ async function readJobsByIds(
749
+ client: RedisCommandClient,
750
+ prefix: string,
751
+ ids: string[]
752
+ ): Promise<JobRecord[]> {
753
+ if (ids.length === 0) {
754
+ return [];
755
+ }
756
+
757
+ const raw =
758
+ await client.sendCommand([
759
+ "MGET",
760
+ ...ids.map(
761
+ id =>
762
+ jobKey(
763
+ prefix,
764
+ id
765
+ )
766
+ ),
767
+ ]);
768
+
769
+ if (!Array.isArray(raw)) {
770
+ return [];
771
+ }
772
+
773
+ return raw
774
+ .map(
775
+ value =>
776
+ parseJsonReply<
777
+ JobRecord
778
+ >(value)
779
+ )
780
+ .filter(
781
+ (
782
+ value
783
+ ): value is JobRecord =>
784
+ value !== null
785
+ )
786
+ .sort(
787
+ (left, right) =>
788
+ left.createdAt -
789
+ right.createdAt ||
790
+ left.id.localeCompare(
791
+ right.id
792
+ )
793
+ );
794
+ }
795
+
796
+ async function readSchedulesByIds(
797
+ client: RedisCommandClient,
798
+ prefix: string,
799
+ ids: string[]
800
+ ): Promise<JobScheduleRecord[]> {
801
+ if (ids.length === 0) {
802
+ return [];
803
+ }
804
+
805
+ const raw =
806
+ await client.sendCommand([
807
+ "MGET",
808
+ ...ids.map(
809
+ id =>
810
+ scheduleKey(
811
+ prefix,
812
+ id
813
+ )
814
+ ),
815
+ ]);
816
+
817
+ if (!Array.isArray(raw)) {
818
+ return [];
819
+ }
820
+
821
+ return raw
822
+ .map(
823
+ value =>
824
+ parseJsonReply<
825
+ JobScheduleRecord
826
+ >(value)
827
+ )
828
+ .filter(
829
+ (
830
+ value
831
+ ): value is JobScheduleRecord =>
832
+ value !== null
833
+ );
834
+ }
835
+
836
+ function parseJsonReply<T>(
837
+ value: unknown
838
+ ): T | null {
839
+ const text =
840
+ redisString(value);
841
+
842
+ if (text === null) {
843
+ return null;
844
+ }
845
+
846
+ return JSON.parse(text) as T;
847
+ }
848
+
849
+ function redisString(
850
+ value: unknown
851
+ ): string | null {
852
+ if (typeof value === "string") {
853
+ return value;
854
+ }
855
+
856
+ if (
857
+ value instanceof Uint8Array
858
+ ) {
859
+ return Buffer.from(
860
+ value
861
+ ).toString("utf8");
862
+ }
863
+
864
+ if (
865
+ typeof value === "number"
866
+ ) {
867
+ return String(value);
868
+ }
869
+
870
+ return null;
871
+ }
872
+
873
+ function calculateStats(
874
+ jobs: JobRecord[],
875
+ deadLetters: number
876
+ ): JobQueueStats {
877
+ const stats:
878
+ JobQueueStats = {
879
+ total: jobs.length,
880
+ queued: 0,
881
+ running: 0,
882
+ succeeded: 0,
883
+ failed: 0,
884
+ cancelled: 0,
885
+ deadLetters,
886
+ };
887
+
888
+ for (const job of jobs) {
889
+ stats[job.state] += 1;
890
+ }
891
+
892
+ return stats;
893
+ }
894
+
895
+ function requireRedisClient(
896
+ client: RedisCommandClient
897
+ ): RedisCommandClient {
898
+ if (
899
+ !client ||
900
+ typeof client.sendCommand !==
901
+ "function"
902
+ ) {
903
+ throw new TypeError(
904
+ "BCP Jobs Redis: client.sendCommand(command) is required."
905
+ );
906
+ }
907
+
908
+ return client;
909
+ }
910
+
911
+ function normalizeNamespace(
912
+ value = "bcp:{jobs}"
913
+ ): string {
914
+ const namespace =
915
+ String(value).trim();
916
+
917
+ if (!namespace) {
918
+ throw new TypeError(
919
+ "BCP Jobs Redis: namespace must be non-empty."
920
+ );
921
+ }
922
+
923
+ return namespace.replace(
924
+ /:+$/,
925
+ ""
926
+ );
927
+ }
928
+
929
+ function normalizePositiveInteger(
930
+ value: number,
931
+ field: string
932
+ ): number {
933
+ if (
934
+ !Number.isInteger(value) ||
935
+ value <= 0
936
+ ) {
937
+ throw new TypeError(
938
+ `BCP Jobs Redis: ${field} must be a positive integer.`
939
+ );
940
+ }
941
+
942
+ return value;
943
+ }
944
+
945
+ function normalizeNonNegativeNumber(
946
+ value: number,
947
+ field: string
948
+ ): number {
949
+ if (
950
+ !Number.isFinite(value) ||
951
+ value < 0
952
+ ) {
953
+ throw new TypeError(
954
+ `BCP Jobs Redis: ${field} must be a non-negative finite number.`
955
+ );
956
+ }
957
+
958
+ return Math.floor(value);
959
+ }
960
+
961
+ const ENQUEUE_SCRIPT = `
962
+ if redis.call('EXISTS', KEYS[1]) == 1 then
963
+ return 0
964
+ end
965
+ redis.call('SET', KEYS[1], ARGV[2])
966
+ redis.call('SADD', KEYS[2], ARGV[1])
967
+ redis.call('ZADD', KEYS[3], ARGV[3], ARGV[1])
968
+ return 1
969
+ `;
970
+
971
+ const RESERVE_SCRIPT = `
972
+ local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[2], 'LIMIT', 0, 20)
973
+ for _, id in ipairs(ids) do
974
+ local key = ARGV[1] .. id
975
+ local raw = redis.call('GET', key)
976
+ if not raw then
977
+ redis.call('ZREM', KEYS[1], id)
978
+ else
979
+ local job = cjson.decode(raw)
980
+ if job.state == 'queued' then
981
+ job.state = 'running'
982
+ job.attempts = (job.attempts or 0) + 1
983
+ job.startedAt = tonumber(ARGV[2])
984
+ job.error = nil
985
+ job.recoveredAt = nil
986
+ job.leaseOwner = ARGV[3]
987
+ job.leaseUntil = tonumber(ARGV[2]) + tonumber(ARGV[4])
988
+ job.heartbeatAt = tonumber(ARGV[2])
989
+ local encoded = cjson.encode(job)
990
+ redis.call('SET', key, encoded)
991
+ redis.call('ZREM', KEYS[1], id)
992
+ redis.call('ZADD', KEYS[2], job.leaseUntil, id)
993
+ return encoded
994
+ end
995
+ redis.call('ZREM', KEYS[1], id)
996
+ end
997
+ end
998
+ return nil
999
+ `;
1000
+
1001
+ const COMPLETE_SCRIPT = `
1002
+ local raw = redis.call('GET', KEYS[1])
1003
+ if not raw then return 0 end
1004
+ local job = cjson.decode(raw)
1005
+ if job.state == 'cancelled' then return 0 end
1006
+ if ARGV[3] ~= '' and job.leaseOwner ~= ARGV[3] then return 0 end
1007
+ job.state = 'succeeded'
1008
+ job.completedAt = tonumber(ARGV[2])
1009
+ job.error = nil
1010
+ job.leaseOwner = nil
1011
+ job.leaseUntil = nil
1012
+ job.heartbeatAt = nil
1013
+ redis.call('SET', KEYS[1], cjson.encode(job))
1014
+ redis.call('ZREM', KEYS[2], ARGV[1])
1015
+ redis.call('ZREM', KEYS[3], ARGV[1])
1016
+ redis.call('ZADD', KEYS[4], ARGV[2], ARGV[1])
1017
+ redis.call('ZREM', KEYS[5], ARGV[1])
1018
+ return 1
1019
+ `;
1020
+
1021
+ const FAIL_SCRIPT = `
1022
+ local raw = redis.call('GET', KEYS[1])
1023
+ if not raw then return 0 end
1024
+ local job = cjson.decode(raw)
1025
+ if job.state == 'cancelled' then return 0 end
1026
+ if ARGV[5] ~= '' and job.leaseOwner ~= ARGV[5] then return 0 end
1027
+ job.error = ARGV[2]
1028
+ job.leaseOwner = nil
1029
+ job.leaseUntil = nil
1030
+ job.heartbeatAt = nil
1031
+ local retryAt = nil
1032
+ if ARGV[4] ~= '' then retryAt = tonumber(ARGV[4]) end
1033
+ if retryAt and (job.attempts or 0) < (job.maxAttempts or 1) then
1034
+ job.state = 'queued'
1035
+ job.availableAt = retryAt
1036
+ job.startedAt = nil
1037
+ redis.call('SET', KEYS[1], cjson.encode(job))
1038
+ redis.call('ZREM', KEYS[3], ARGV[1])
1039
+ redis.call('ZADD', KEYS[2], retryAt, ARGV[1])
1040
+ return 2
1041
+ end
1042
+ job.state = 'failed'
1043
+ job.completedAt = tonumber(ARGV[3])
1044
+ redis.call('SET', KEYS[1], cjson.encode(job))
1045
+ redis.call('ZREM', KEYS[2], ARGV[1])
1046
+ redis.call('ZREM', KEYS[3], ARGV[1])
1047
+ redis.call('ZADD', KEYS[4], ARGV[3], ARGV[1])
1048
+ redis.call('ZADD', KEYS[5], ARGV[3], ARGV[1])
1049
+ return 1
1050
+ `;
1051
+
1052
+ const CANCEL_SCRIPT = `
1053
+ local raw = redis.call('GET', KEYS[1])
1054
+ if not raw then return 0 end
1055
+ local job = cjson.decode(raw)
1056
+ if job.state == 'succeeded' or job.state == 'failed' or job.state == 'cancelled' then
1057
+ return 0
1058
+ end
1059
+ job.state = 'cancelled'
1060
+ job.completedAt = tonumber(ARGV[2])
1061
+ job.leaseOwner = nil
1062
+ job.leaseUntil = nil
1063
+ job.heartbeatAt = nil
1064
+ redis.call('SET', KEYS[1], cjson.encode(job))
1065
+ redis.call('ZREM', KEYS[2], ARGV[1])
1066
+ redis.call('ZREM', KEYS[3], ARGV[1])
1067
+ redis.call('ZADD', KEYS[4], ARGV[2], ARGV[1])
1068
+ return 1
1069
+ `;
1070
+
1071
+ const HEARTBEAT_SCRIPT = `
1072
+ local raw = redis.call('GET', KEYS[1])
1073
+ if not raw then return 0 end
1074
+ local job = cjson.decode(raw)
1075
+ if job.state ~= 'running' or job.leaseOwner ~= ARGV[2] then
1076
+ return 0
1077
+ end
1078
+ job.heartbeatAt = tonumber(ARGV[3])
1079
+ job.leaseUntil = tonumber(ARGV[3]) + tonumber(ARGV[4])
1080
+ redis.call('SET', KEYS[1], cjson.encode(job))
1081
+ redis.call('ZADD', KEYS[2], job.leaseUntil, ARGV[1])
1082
+ return 1
1083
+ `;
1084
+
1085
+ const RECOVER_STALE_SCRIPT = `
1086
+ local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[2], 'LIMIT', 0, tonumber(ARGV[3]))
1087
+ local recovered = 0
1088
+ for _, id in ipairs(ids) do
1089
+ local key = ARGV[1] .. id
1090
+ local raw = redis.call('GET', key)
1091
+ if not raw then
1092
+ redis.call('ZREM', KEYS[1], id)
1093
+ else
1094
+ local job = cjson.decode(raw)
1095
+ if job.state == 'running' and job.leaseUntil and tonumber(job.leaseUntil) <= tonumber(ARGV[2]) then
1096
+ job.state = 'queued'
1097
+ job.availableAt = tonumber(ARGV[2])
1098
+ job.startedAt = nil
1099
+ job.leaseOwner = nil
1100
+ job.leaseUntil = nil
1101
+ job.heartbeatAt = nil
1102
+ job.recoveredAt = tonumber(ARGV[2])
1103
+ redis.call('SET', key, cjson.encode(job))
1104
+ redis.call('ZREM', KEYS[1], id)
1105
+ redis.call('ZADD', KEYS[2], ARGV[2], id)
1106
+ recovered = recovered + 1
1107
+ end
1108
+ end
1109
+ end
1110
+ return recovered
1111
+ `;
1112
+
1113
+ const REQUEUE_DEAD_LETTER_SCRIPT = `
1114
+ local raw = redis.call('GET', KEYS[1])
1115
+ if not raw then return 0 end
1116
+ if not redis.call('ZSCORE', KEYS[5], ARGV[1]) then return 0 end
1117
+ local job = cjson.decode(raw)
1118
+ if job.state ~= 'failed' then return 0 end
1119
+ job.state = 'queued'
1120
+ job.availableAt = tonumber(ARGV[2]) + tonumber(ARGV[3])
1121
+ job.startedAt = nil
1122
+ job.completedAt = nil
1123
+ job.error = nil
1124
+ job.leaseOwner = nil
1125
+ job.leaseUntil = nil
1126
+ job.heartbeatAt = nil
1127
+ job.recoveredAt = nil
1128
+ if ARGV[4] == '1' then job.attempts = 0 end
1129
+ redis.call('SET', KEYS[1], cjson.encode(job))
1130
+ redis.call('ZADD', KEYS[2], job.availableAt, ARGV[1])
1131
+ redis.call('ZREM', KEYS[3], ARGV[1])
1132
+ redis.call('ZREM', KEYS[4], ARGV[1])
1133
+ redis.call('ZREM', KEYS[5], ARGV[1])
1134
+ return 1
1135
+ `;
1136
+
1137
+ const CLEANUP_SCRIPT = `
1138
+ local allowed = {}
1139
+ for _, state in ipairs(cjson.decode(ARGV[3])) do allowed[state] = true end
1140
+ local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[2], 'LIMIT', 0, 1000)
1141
+ local removed = 0
1142
+ for _, id in ipairs(ids) do
1143
+ local key = ARGV[1] .. id
1144
+ local raw = redis.call('GET', key)
1145
+ if not raw then
1146
+ redis.call('ZREM', KEYS[1], id)
1147
+ redis.call('SREM', KEYS[2], id)
1148
+ else
1149
+ local job = cjson.decode(raw)
1150
+ if allowed[job.state] then
1151
+ redis.call('DEL', key)
1152
+ redis.call('SREM', KEYS[2], id)
1153
+ redis.call('ZREM', KEYS[1], id)
1154
+ redis.call('ZREM', KEYS[3], id)
1155
+ redis.call('ZREM', KEYS[4], id)
1156
+ redis.call('ZREM', KEYS[5], id)
1157
+ removed = removed + 1
1158
+ end
1159
+ end
1160
+ end
1161
+ return removed
1162
+ `;
1163
+
1164
+ const UPSERT_SCHEDULE_SCRIPT = `
1165
+ redis.call('SET', KEYS[1], ARGV[2])
1166
+ redis.call('SADD', KEYS[2], ARGV[1])
1167
+ redis.call('ZADD', KEYS[3], ARGV[3], ARGV[1])
1168
+ return 1
1169
+ `;
1170
+
1171
+ const REMOVE_SCHEDULE_SCRIPT = `
1172
+ local existed = redis.call('DEL', KEYS[1])
1173
+ redis.call('SREM', KEYS[2], ARGV[1])
1174
+ redis.call('ZREM', KEYS[3], ARGV[1])
1175
+ return existed
1176
+ `;
1177
+
1178
+ const ACQUIRE_DUE_SCHEDULES_SCRIPT = `
1179
+ local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[2], 'LIMIT', 0, tonumber(ARGV[5]) * 4)
1180
+ local result = {}
1181
+ for _, id in ipairs(ids) do
1182
+ if #result >= tonumber(ARGV[5]) then break end
1183
+ local key = ARGV[1] .. id
1184
+ local raw = redis.call('GET', key)
1185
+ if not raw then
1186
+ redis.call('ZREM', KEYS[1], id)
1187
+ else
1188
+ local schedule = cjson.decode(raw)
1189
+ local leaseUntil = schedule.leaseUntil
1190
+ if schedule.nextRunAt <= tonumber(ARGV[2]) and (not leaseUntil or tonumber(leaseUntil) <= tonumber(ARGV[2])) then
1191
+ schedule.leaseOwner = ARGV[3]
1192
+ schedule.leaseUntil = tonumber(ARGV[2]) + tonumber(ARGV[4])
1193
+ local encoded = cjson.encode(schedule)
1194
+ redis.call('SET', key, encoded)
1195
+ table.insert(result, encoded)
1196
+ end
1197
+ end
1198
+ end
1199
+ return result
1200
+ `;
1201
+
1202
+ const COMPLETE_SCHEDULE_SCRIPT = `
1203
+ local raw = redis.call('GET', KEYS[1])
1204
+ if not raw then return 0 end
1205
+ local schedule = cjson.decode(raw)
1206
+ if schedule.leaseOwner ~= ARGV[2] then return 0 end
1207
+ schedule.lastRunAt = tonumber(ARGV[3])
1208
+ schedule.nextRunAt = tonumber(ARGV[4])
1209
+ schedule.updatedAt = tonumber(ARGV[5])
1210
+ schedule.leaseOwner = nil
1211
+ schedule.leaseUntil = nil
1212
+ redis.call('SET', KEYS[1], cjson.encode(schedule))
1213
+ redis.call('ZADD', KEYS[2], ARGV[4], ARGV[1])
1214
+ return 1
1215
+ `;
1216
+
1217
+ const RELEASE_SCHEDULE_SCRIPT = `
1218
+ local raw = redis.call('GET', KEYS[1])
1219
+ if not raw then return 0 end
1220
+ local schedule = cjson.decode(raw)
1221
+ if schedule.leaseOwner ~= ARGV[1] then return 0 end
1222
+ schedule.leaseOwner = nil
1223
+ schedule.leaseUntil = nil
1224
+ redis.call('SET', KEYS[1], cjson.encode(schedule))
1225
+ return 1
1226
+ `;