@chidchanun/bcp 0.2.9 → 0.2.11

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.
@@ -23,6 +23,17 @@ export interface JobRecord<
23
23
  startedAt?: number;
24
24
  completedAt?: number;
25
25
  error?: string;
26
+ leaseOwner?: string;
27
+ leaseUntil?: number;
28
+ heartbeatAt?: number;
29
+ recoveredAt?: number;
30
+ }
31
+
32
+ export interface DeadLetterJobRecord<
33
+ TPayload = unknown
34
+ > extends JobRecord<TPayload> {
35
+ state: "failed";
36
+ deadLetteredAt: number;
26
37
  }
27
38
 
28
39
  export interface EnqueueJobOptions {
@@ -31,18 +42,59 @@ export interface EnqueueJobOptions {
31
42
  maxAttempts?: number;
32
43
  }
33
44
 
45
+ export interface ReserveJobOptions {
46
+ ownerId: string;
47
+ visibilityTimeoutMs: number;
48
+ }
49
+
50
+ export interface HeartbeatJobOptions {
51
+ ownerId: string;
52
+ heartbeatAt: number;
53
+ visibilityTimeoutMs: number;
54
+ }
55
+
56
+ export interface RecoverStaleJobsOptions {
57
+ limit?: number;
58
+ }
59
+
60
+ export interface RequeueDeadLetterOptions {
61
+ delayMs?: number;
62
+ resetAttempts?: boolean;
63
+ }
64
+
65
+ export interface CleanupJobsOptions {
66
+ before: number;
67
+ states?: Array<
68
+ "succeeded" |
69
+ "failed" |
70
+ "cancelled"
71
+ >;
72
+ }
73
+
74
+ export interface JobQueueStats {
75
+ total: number;
76
+ queued: number;
77
+ running: number;
78
+ succeeded: number;
79
+ failed: number;
80
+ cancelled: number;
81
+ deadLetters: number;
82
+ }
83
+
34
84
  export interface JobQueueAdapter {
35
85
  enqueue<TPayload = unknown>(
36
86
  job: JobRecord<TPayload>
37
87
  ): Promise<void>;
38
88
 
39
89
  reserve(
40
- now: number
90
+ now: number,
91
+ options?: ReserveJobOptions
41
92
  ): Promise<JobRecord | null>;
42
93
 
43
94
  complete(
44
95
  id: string,
45
- completedAt: number
96
+ completedAt: number,
97
+ ownerId?: string
46
98
  ): Promise<void>;
47
99
 
48
100
  fail(
@@ -51,6 +103,7 @@ export interface JobQueueAdapter {
51
103
  error: string;
52
104
  failedAt: number;
53
105
  retryAt?: number;
106
+ ownerId?: string;
54
107
  }
55
108
  ): Promise<void>;
56
109
 
@@ -65,6 +118,30 @@ export interface JobQueueAdapter {
65
118
 
66
119
  list(): Promise<JobRecord[]>;
67
120
 
121
+ heartbeat?(
122
+ id: string,
123
+ options: HeartbeatJobOptions
124
+ ): Promise<boolean>;
125
+
126
+ recoverStale?(
127
+ now: number,
128
+ options?: RecoverStaleJobsOptions
129
+ ): Promise<number>;
130
+
131
+ listDeadLetters?(): Promise<DeadLetterJobRecord[]>;
132
+
133
+ requeueDeadLetter?(
134
+ id: string,
135
+ now: number,
136
+ options?: RequeueDeadLetterOptions
137
+ ): Promise<boolean>;
138
+
139
+ cleanup?(
140
+ options: CleanupJobsOptions
141
+ ): Promise<number>;
142
+
143
+ stats?(): Promise<JobQueueStats>;
144
+
68
145
  close?(): Promise<void>;
69
146
  }
70
147
 
@@ -99,13 +176,26 @@ export interface JobQueueOptions {
99
176
  idFactory?: () => string;
100
177
  }
101
178
 
102
- export interface StartJobWorkerOptions {
179
+ export interface ProcessNextJobOptions {
180
+ ownerId?: string;
181
+ visibilityTimeoutMs?: number;
182
+ heartbeatIntervalMs?: number;
183
+ recoveryLimit?: number;
184
+ }
185
+
186
+ export interface StartJobWorkerOptions
187
+ extends ProcessNextJobOptions {
103
188
  concurrency?: number;
104
189
  pollIntervalMs?: number;
190
+ workerId?: string;
191
+ onError?: (
192
+ error: unknown
193
+ ) => void | Promise<void>;
105
194
  }
106
195
 
107
196
  export interface JobWorker {
108
197
  readonly running: boolean;
198
+ readonly workerId?: string;
109
199
  stop(): Promise<void>;
110
200
  }
111
201
 
@@ -132,13 +222,31 @@ export interface BackgroundJobQueue {
132
222
  cancel(id: string): Promise<boolean>;
133
223
 
134
224
  processNext(
135
- signal?: AbortSignal
225
+ signal?: AbortSignal,
226
+ options?: ProcessNextJobOptions
136
227
  ): Promise<boolean>;
137
228
 
138
229
  startWorker(
139
230
  options?: StartJobWorkerOptions
140
231
  ): JobWorker;
141
232
 
233
+ recoverStale(
234
+ options?: RecoverStaleJobsOptions
235
+ ): Promise<number>;
236
+
237
+ deadLetters(): Promise<DeadLetterJobRecord[]>;
238
+
239
+ requeueDeadLetter(
240
+ id: string,
241
+ options?: RequeueDeadLetterOptions
242
+ ): Promise<boolean>;
243
+
244
+ cleanup(
245
+ options: CleanupJobsOptions
246
+ ): Promise<number>;
247
+
248
+ stats(): Promise<JobQueueStats>;
249
+
142
250
  close(): Promise<void>;
143
251
  }
144
252
 
@@ -146,6 +254,54 @@ export function createMemoryJobQueueAdapter():
146
254
  MemoryJobQueueAdapter {
147
255
  const jobs =
148
256
  new Map<string, JobRecord>();
257
+ const deadLetters =
258
+ new Map<
259
+ string,
260
+ DeadLetterJobRecord
261
+ >();
262
+
263
+ const recoverStale = (
264
+ now: number,
265
+ options: RecoverStaleJobsOptions = {}
266
+ ): number => {
267
+ const limit =
268
+ normalizePositiveInteger(
269
+ options.limit ?? 100,
270
+ "recovery limit"
271
+ );
272
+ const stale =
273
+ Array.from(
274
+ jobs.values()
275
+ )
276
+ .filter(
277
+ job =>
278
+ job.state === "running" &&
279
+ job.leaseUntil !== undefined &&
280
+ job.leaseUntil <= now
281
+ )
282
+ .sort(
283
+ (left, right) =>
284
+ (left.leaseUntil ?? 0) -
285
+ (right.leaseUntil ?? 0) ||
286
+ left.id.localeCompare(
287
+ right.id
288
+ )
289
+ )
290
+ .slice(
291
+ 0,
292
+ limit
293
+ );
294
+
295
+ for (const job of stale) {
296
+ job.state = "queued";
297
+ job.availableAt = now;
298
+ job.startedAt = undefined;
299
+ clearLease(job);
300
+ job.recoveredAt = now;
301
+ }
302
+
303
+ return stale.length;
304
+ };
149
305
 
150
306
  return {
151
307
  async enqueue(job) {
@@ -161,7 +317,17 @@ MemoryJobQueueAdapter {
161
317
  );
162
318
  },
163
319
 
164
- async reserve(now) {
320
+ async reserve(
321
+ now,
322
+ options
323
+ ) {
324
+ recoverStale(
325
+ now,
326
+ {
327
+ limit: 100,
328
+ }
329
+ );
330
+
165
331
  const candidate =
166
332
  Array.from(
167
333
  jobs.values()
@@ -182,39 +348,53 @@ MemoryJobQueueAdapter {
182
348
  return null;
183
349
  }
184
350
 
185
- candidate.state =
186
- "running";
351
+ candidate.state = "running";
187
352
  candidate.attempts += 1;
188
- candidate.startedAt =
189
- now;
190
- candidate.error =
191
- undefined;
353
+ candidate.startedAt = now;
354
+ candidate.error = undefined;
355
+ candidate.recoveredAt = undefined;
356
+
357
+ if (options) {
358
+ candidate.leaseOwner =
359
+ normalizeWorkerId(
360
+ options.ownerId
361
+ );
362
+ candidate.leaseUntil =
363
+ now +
364
+ normalizePositiveInteger(
365
+ options.visibilityTimeoutMs,
366
+ "visibilityTimeoutMs"
367
+ );
368
+ candidate.heartbeatAt = now;
369
+ }
192
370
 
193
- return cloneJob(
194
- candidate
195
- );
371
+ return cloneJob(candidate);
196
372
  },
197
373
 
198
374
  async complete(
199
375
  id,
200
- completedAt
376
+ completedAt,
377
+ ownerId
201
378
  ) {
202
379
  const job =
203
380
  jobs.get(id);
204
381
 
205
382
  if (
206
383
  !job ||
207
- job.state === "cancelled"
384
+ job.state === "cancelled" ||
385
+ !leaseOwnerMatches(
386
+ job,
387
+ ownerId
388
+ )
208
389
  ) {
209
390
  return;
210
391
  }
211
392
 
212
- job.state =
213
- "succeeded";
214
- job.completedAt =
215
- completedAt;
216
- job.error =
217
- undefined;
393
+ job.state = "succeeded";
394
+ job.completedAt = completedAt;
395
+ job.error = undefined;
396
+ clearLease(job);
397
+ deadLetters.delete(id);
218
398
  },
219
399
 
220
400
  async fail(
@@ -226,31 +406,41 @@ MemoryJobQueueAdapter {
226
406
 
227
407
  if (
228
408
  !job ||
229
- job.state === "cancelled"
409
+ job.state === "cancelled" ||
410
+ !leaseOwnerMatches(
411
+ job,
412
+ options.ownerId
413
+ )
230
414
  ) {
231
415
  return;
232
416
  }
233
417
 
234
- job.error =
235
- options.error;
418
+ job.error = options.error;
419
+ clearLease(job);
236
420
 
237
421
  if (
238
422
  options.retryAt !== undefined &&
239
423
  job.attempts < job.maxAttempts
240
424
  ) {
241
- job.state =
242
- "queued";
425
+ job.state = "queued";
243
426
  job.availableAt =
244
427
  options.retryAt;
245
- job.startedAt =
246
- undefined;
428
+ job.startedAt = undefined;
247
429
  return;
248
430
  }
249
431
 
250
- job.state =
251
- "failed";
432
+ job.state = "failed";
252
433
  job.completedAt =
253
434
  options.failedAt;
435
+ deadLetters.set(
436
+ id,
437
+ {
438
+ ...cloneJob(job),
439
+ state: "failed",
440
+ deadLetteredAt:
441
+ options.failedAt,
442
+ }
443
+ );
254
444
  },
255
445
 
256
446
  async cancel(
@@ -269,10 +459,9 @@ MemoryJobQueueAdapter {
269
459
  return false;
270
460
  }
271
461
 
272
- job.state =
273
- "cancelled";
274
- job.completedAt =
275
- cancelledAt;
462
+ job.state = "cancelled";
463
+ job.completedAt = cancelledAt;
464
+ clearLease(job);
276
465
  return true;
277
466
  },
278
467
 
@@ -297,8 +486,153 @@ MemoryJobQueueAdapter {
297
486
  );
298
487
  },
299
488
 
489
+ async heartbeat(
490
+ id,
491
+ options
492
+ ) {
493
+ const job =
494
+ jobs.get(id);
495
+
496
+ if (
497
+ !job ||
498
+ job.state !== "running" ||
499
+ job.leaseOwner !==
500
+ options.ownerId
501
+ ) {
502
+ return false;
503
+ }
504
+
505
+ const visibilityTimeoutMs =
506
+ normalizePositiveInteger(
507
+ options.visibilityTimeoutMs,
508
+ "visibilityTimeoutMs"
509
+ );
510
+
511
+ job.heartbeatAt =
512
+ options.heartbeatAt;
513
+ job.leaseUntil =
514
+ options.heartbeatAt +
515
+ visibilityTimeoutMs;
516
+ return true;
517
+ },
518
+
519
+ async recoverStale(
520
+ now,
521
+ options
522
+ ) {
523
+ return recoverStale(
524
+ now,
525
+ options
526
+ );
527
+ },
528
+
529
+ async listDeadLetters() {
530
+ return Array.from(
531
+ deadLetters.values()
532
+ )
533
+ .map(cloneDeadLetter)
534
+ .sort(
535
+ (left, right) =>
536
+ left.deadLetteredAt -
537
+ right.deadLetteredAt ||
538
+ left.id.localeCompare(
539
+ right.id
540
+ )
541
+ );
542
+ },
543
+
544
+ async requeueDeadLetter(
545
+ id,
546
+ now,
547
+ options = {}
548
+ ) {
549
+ const job =
550
+ jobs.get(id);
551
+
552
+ if (
553
+ !job ||
554
+ job.state !== "failed" ||
555
+ !deadLetters.has(id)
556
+ ) {
557
+ return false;
558
+ }
559
+
560
+ const delayMs =
561
+ normalizeNonNegativeNumber(
562
+ options.delayMs ?? 0,
563
+ "delayMs"
564
+ );
565
+
566
+ job.state = "queued";
567
+ job.availableAt =
568
+ now + delayMs;
569
+ job.startedAt = undefined;
570
+ job.completedAt = undefined;
571
+ job.error = undefined;
572
+ job.recoveredAt = undefined;
573
+ clearLease(job);
574
+
575
+ if (
576
+ options.resetAttempts !==
577
+ false
578
+ ) {
579
+ job.attempts = 0;
580
+ }
581
+
582
+ deadLetters.delete(id);
583
+ return true;
584
+ },
585
+
586
+ async cleanup(options) {
587
+ const states =
588
+ new Set(
589
+ options.states ?? [
590
+ "succeeded",
591
+ "failed",
592
+ "cancelled",
593
+ ]
594
+ );
595
+ let removed = 0;
596
+
597
+ for (
598
+ const [
599
+ id,
600
+ job,
601
+ ] of jobs
602
+ ) {
603
+ if (
604
+ !states.has(
605
+ job.state as
606
+ "succeeded" |
607
+ "failed" |
608
+ "cancelled"
609
+ ) ||
610
+ job.completedAt === undefined ||
611
+ job.completedAt >= options.before
612
+ ) {
613
+ continue;
614
+ }
615
+
616
+ jobs.delete(id);
617
+ deadLetters.delete(id);
618
+ removed += 1;
619
+ }
620
+
621
+ return removed;
622
+ },
623
+
624
+ async stats() {
625
+ return calculateJobStats(
626
+ Array.from(
627
+ jobs.values()
628
+ ),
629
+ deadLetters.size
630
+ );
631
+ },
632
+
300
633
  clear() {
301
634
  jobs.clear();
635
+ deadLetters.clear();
302
636
  },
303
637
  };
304
638
  }
@@ -314,11 +648,9 @@ export function createJobQueue(
314
648
  const workers =
315
649
  new Set<InternalJobWorker>();
316
650
  const now =
317
- options.now ??
318
- Date.now;
651
+ options.now ?? Date.now;
319
652
  const idFactory =
320
- options.idFactory ??
321
- randomUUID;
653
+ options.idFactory ?? randomUUID;
322
654
  const defaultMaxAttempts =
323
655
  normalizePositiveInteger(
324
656
  options.defaultMaxAttempts ?? 3,
@@ -389,8 +721,7 @@ export function createJobQueue(
389
721
  payload,
390
722
  enqueueOptions = {}
391
723
  ) {
392
- const createdAt =
393
- now();
724
+ const createdAt = now();
394
725
  const delayMs =
395
726
  normalizeNonNegativeNumber(
396
727
  enqueueOptions.delayMs ?? 0,
@@ -411,23 +742,17 @@ export function createJobQueue(
411
742
  JobRecord<typeof payload> = {
412
743
  id,
413
744
  name:
414
- normalizeJobName(
415
- name
416
- ),
745
+ normalizeJobName(name),
417
746
  payload,
418
747
  state: "queued",
419
748
  attempts: 0,
420
749
  maxAttempts,
421
750
  createdAt,
422
751
  availableAt:
423
- createdAt +
424
- delayMs,
752
+ createdAt + delayMs,
425
753
  };
426
754
 
427
- await adapter.enqueue(
428
- job
429
- );
430
-
755
+ await adapter.enqueue(job);
431
756
  return cloneJob(job);
432
757
  },
433
758
 
@@ -451,81 +776,141 @@ export function createJobQueue(
451
776
  async processNext(
452
777
  signal =
453
778
  new AbortController()
454
- .signal
779
+ .signal,
780
+ processOptions = {}
455
781
  ) {
456
782
  if (signal.aborted) {
457
783
  return false;
458
784
  }
459
785
 
786
+ const visibilityTimeoutMs =
787
+ normalizePositiveInteger(
788
+ processOptions
789
+ .visibilityTimeoutMs ??
790
+ 30_000,
791
+ "visibilityTimeoutMs"
792
+ );
793
+ const heartbeatIntervalMs =
794
+ normalizePositiveInteger(
795
+ processOptions
796
+ .heartbeatIntervalMs ??
797
+ Math.max(
798
+ 1,
799
+ Math.floor(
800
+ visibilityTimeoutMs /
801
+ 3
802
+ )
803
+ ),
804
+ "heartbeatIntervalMs"
805
+ );
806
+ const ownerId =
807
+ normalizeWorkerId(
808
+ processOptions.ownerId ??
809
+ `process-${randomUUID()}`
810
+ );
811
+
812
+ if (adapter.recoverStale) {
813
+ await adapter.recoverStale(
814
+ now(),
815
+ {
816
+ limit:
817
+ normalizePositiveInteger(
818
+ processOptions
819
+ .recoveryLimit ??
820
+ 100,
821
+ "recoveryLimit"
822
+ ),
823
+ }
824
+ );
825
+ }
826
+
460
827
  const job =
461
828
  await adapter.reserve(
462
- now()
829
+ now(),
830
+ {
831
+ ownerId,
832
+ visibilityTimeoutMs,
833
+ }
463
834
  );
464
835
 
465
836
  if (!job) {
466
837
  return false;
467
838
  }
468
839
 
469
- const handler =
470
- handlers.get(
471
- job.name
472
- );
473
-
474
- if (!handler) {
475
- await adapter.fail(
840
+ const stopHeartbeat =
841
+ startJobHeartbeat(
842
+ adapter,
476
843
  job.id,
477
- {
478
- error:
479
- `No handler registered for job "${job.name}".`,
480
- failedAt:
481
- now(),
482
- }
844
+ ownerId,
845
+ visibilityTimeoutMs,
846
+ heartbeatIntervalMs,
847
+ now,
848
+ signal
483
849
  );
484
- return true;
485
- }
850
+ const handler =
851
+ handlers.get(job.name);
486
852
 
487
853
  try {
488
- await handler({
489
- job:
490
- cloneJob(job),
491
- payload:
492
- job.payload,
493
- signal,
494
- });
854
+ if (!handler) {
855
+ await adapter.fail(
856
+ job.id,
857
+ {
858
+ error:
859
+ `No handler registered for job "${job.name}".`,
860
+ failedAt:
861
+ now(),
862
+ ownerId,
863
+ }
864
+ );
865
+ return true;
866
+ }
495
867
 
496
- await adapter.complete(
497
- job.id,
498
- now()
499
- );
500
- } catch (error) {
501
- const failedAt =
502
- now();
503
- const shouldRetry =
504
- job.attempts <
505
- job.maxAttempts;
506
- const retryAt =
507
- shouldRetry
508
- ? failedAt +
509
- resolveRetryDelay(
510
- retryDelay,
511
- job.attempts
512
- )
513
- : undefined;
514
-
515
- await adapter.fail(
516
- job.id,
517
- {
518
- error:
519
- formatJobError(
520
- error
521
- ),
522
- failedAt,
523
- retryAt,
524
- }
525
- );
526
- }
868
+ try {
869
+ await handler({
870
+ job:
871
+ cloneJob(job),
872
+ payload:
873
+ job.payload,
874
+ signal,
875
+ });
876
+
877
+ await adapter.complete(
878
+ job.id,
879
+ now(),
880
+ ownerId
881
+ );
882
+ } catch (error) {
883
+ const failedAt = now();
884
+ const shouldRetry =
885
+ job.attempts <
886
+ job.maxAttempts;
887
+ const retryAt =
888
+ shouldRetry
889
+ ? failedAt +
890
+ resolveRetryDelay(
891
+ retryDelay,
892
+ job.attempts
893
+ )
894
+ : undefined;
895
+
896
+ await adapter.fail(
897
+ job.id,
898
+ {
899
+ error:
900
+ formatJobError(
901
+ error
902
+ ),
903
+ failedAt,
904
+ retryAt,
905
+ ownerId,
906
+ }
907
+ );
908
+ }
527
909
 
528
- return true;
910
+ return true;
911
+ } finally {
912
+ stopHeartbeat();
913
+ }
529
914
  },
530
915
 
531
916
  startWorker(
@@ -545,6 +930,76 @@ export function createJobQueue(
545
930
  return worker;
546
931
  },
547
932
 
933
+ async recoverStale(
934
+ recoverOptions = {}
935
+ ) {
936
+ if (!adapter.recoverStale) {
937
+ return 0;
938
+ }
939
+
940
+ return adapter.recoverStale(
941
+ now(),
942
+ recoverOptions
943
+ );
944
+ },
945
+
946
+ async deadLetters() {
947
+ return adapter.listDeadLetters
948
+ ? adapter.listDeadLetters()
949
+ : [];
950
+ },
951
+
952
+ async requeueDeadLetter(
953
+ id,
954
+ requeueOptions = {}
955
+ ) {
956
+ if (!adapter.requeueDeadLetter) {
957
+ return false;
958
+ }
959
+
960
+ return adapter.requeueDeadLetter(
961
+ normalizeJobId(id),
962
+ now(),
963
+ requeueOptions
964
+ );
965
+ },
966
+
967
+ async cleanup(cleanupOptions) {
968
+ if (!adapter.cleanup) {
969
+ return 0;
970
+ }
971
+
972
+ const before =
973
+ normalizeNonNegativeNumber(
974
+ cleanupOptions.before,
975
+ "cleanup before"
976
+ );
977
+
978
+ return adapter.cleanup({
979
+ ...cleanupOptions,
980
+ before,
981
+ });
982
+ },
983
+
984
+ async stats() {
985
+ if (adapter.stats) {
986
+ return adapter.stats();
987
+ }
988
+
989
+ const jobs =
990
+ await adapter.list();
991
+
992
+ return calculateJobStats(
993
+ jobs,
994
+ adapter.listDeadLetters
995
+ ? (
996
+ await adapter
997
+ .listDeadLetters()
998
+ ).length
999
+ : 0
1000
+ );
1001
+ },
1002
+
548
1003
  async close() {
549
1004
  await Promise.all(
550
1005
  Array.from(
@@ -581,10 +1036,37 @@ function createWorker(
581
1036
  options.pollIntervalMs ?? 250,
582
1037
  "pollIntervalMs"
583
1038
  );
1039
+ const visibilityTimeoutMs =
1040
+ normalizePositiveInteger(
1041
+ options.visibilityTimeoutMs ??
1042
+ 30_000,
1043
+ "visibilityTimeoutMs"
1044
+ );
1045
+ const heartbeatIntervalMs =
1046
+ normalizePositiveInteger(
1047
+ options.heartbeatIntervalMs ??
1048
+ Math.max(
1049
+ 1,
1050
+ Math.floor(
1051
+ visibilityTimeoutMs /
1052
+ 3
1053
+ )
1054
+ ),
1055
+ "heartbeatIntervalMs"
1056
+ );
1057
+ const recoveryLimit =
1058
+ normalizePositiveInteger(
1059
+ options.recoveryLimit ?? 100,
1060
+ "recoveryLimit"
1061
+ );
1062
+ const workerId =
1063
+ normalizeWorkerId(
1064
+ options.workerId ??
1065
+ `worker-${randomUUID()}`
1066
+ );
584
1067
  const controller =
585
1068
  new AbortController();
586
- let running =
587
- true;
1069
+ let running = true;
588
1070
  let stopPromise:
589
1071
  Promise<void> | null =
590
1072
  null;
@@ -595,11 +1077,19 @@ function createWorker(
595
1077
  length:
596
1078
  concurrency,
597
1079
  },
598
- () =>
1080
+ (_, index) =>
599
1081
  runWorkerLoop(
600
1082
  queue,
601
1083
  controller.signal,
602
- pollIntervalMs
1084
+ pollIntervalMs,
1085
+ {
1086
+ ownerId:
1087
+ `${workerId}:${index + 1}`,
1088
+ visibilityTimeoutMs,
1089
+ heartbeatIntervalMs,
1090
+ recoveryLimit,
1091
+ },
1092
+ options.onError
603
1093
  )
604
1094
  );
605
1095
 
@@ -608,13 +1098,14 @@ function createWorker(
608
1098
  return running;
609
1099
  },
610
1100
 
1101
+ workerId,
1102
+
611
1103
  stop() {
612
1104
  if (stopPromise) {
613
1105
  return stopPromise;
614
1106
  }
615
1107
 
616
- running =
617
- false;
1108
+ running = false;
618
1109
  controller.abort();
619
1110
  stopPromise =
620
1111
  Promise.allSettled(
@@ -634,13 +1125,32 @@ function createWorker(
634
1125
  async function runWorkerLoop(
635
1126
  queue: BackgroundJobQueue,
636
1127
  signal: AbortSignal,
637
- pollIntervalMs: number
1128
+ pollIntervalMs: number,
1129
+ options: ProcessNextJobOptions,
1130
+ onError?: (
1131
+ error: unknown
1132
+ ) => void | Promise<void>
638
1133
  ): Promise<void> {
639
1134
  while (!signal.aborted) {
640
- const processed =
641
- await queue.processNext(
642
- signal
643
- );
1135
+ let processed = false;
1136
+
1137
+ try {
1138
+ processed =
1139
+ await queue.processNext(
1140
+ signal,
1141
+ options
1142
+ );
1143
+ } catch (error) {
1144
+ if (signal.aborted) {
1145
+ break;
1146
+ }
1147
+
1148
+ if (onError) {
1149
+ await onError(error);
1150
+ } else {
1151
+ throw error;
1152
+ }
1153
+ }
644
1154
 
645
1155
  if (
646
1156
  !processed &&
@@ -654,14 +1164,82 @@ async function runWorkerLoop(
654
1164
  }
655
1165
  }
656
1166
 
1167
+ function startJobHeartbeat(
1168
+ adapter: JobQueueAdapter,
1169
+ jobId: string,
1170
+ ownerId: string,
1171
+ visibilityTimeoutMs: number,
1172
+ heartbeatIntervalMs: number,
1173
+ now: () => number,
1174
+ signal: AbortSignal
1175
+ ): () => void {
1176
+ if (!adapter.heartbeat) {
1177
+ return () => undefined;
1178
+ }
1179
+
1180
+ let stopped = false;
1181
+ let timeout:
1182
+ ReturnType<typeof setTimeout> |
1183
+ undefined;
1184
+
1185
+ const schedule = () => {
1186
+ if (
1187
+ stopped ||
1188
+ signal.aborted
1189
+ ) {
1190
+ return;
1191
+ }
1192
+
1193
+ timeout =
1194
+ setTimeout(
1195
+ async () => {
1196
+ if (
1197
+ stopped ||
1198
+ signal.aborted
1199
+ ) {
1200
+ return;
1201
+ }
1202
+
1203
+ try {
1204
+ const active =
1205
+ await adapter
1206
+ .heartbeat?.(
1207
+ jobId,
1208
+ {
1209
+ ownerId,
1210
+ heartbeatAt:
1211
+ now(),
1212
+ visibilityTimeoutMs,
1213
+ }
1214
+ );
1215
+
1216
+ if (active !== false) {
1217
+ schedule();
1218
+ }
1219
+ } catch {
1220
+ schedule();
1221
+ }
1222
+ },
1223
+ heartbeatIntervalMs
1224
+ );
1225
+ };
1226
+
1227
+ schedule();
1228
+
1229
+ return () => {
1230
+ stopped = true;
1231
+
1232
+ if (timeout) {
1233
+ clearTimeout(timeout);
1234
+ }
1235
+ };
1236
+ }
1237
+
657
1238
  function sleep(
658
1239
  durationMs: number,
659
1240
  signal: AbortSignal
660
1241
  ): Promise<void> {
661
- if (
662
- durationMs === 0 ||
663
- signal.aborted
664
- ) {
1242
+ if (signal.aborted) {
665
1243
  return Promise.resolve();
666
1244
  }
667
1245
 
@@ -672,10 +1250,8 @@ function sleep(
672
1250
  finish,
673
1251
  durationMs
674
1252
  );
675
-
676
1253
  const onAbort =
677
- () =>
678
- finish();
1254
+ () => finish();
679
1255
 
680
1256
  signal.addEventListener(
681
1257
  "abort",
@@ -697,6 +1273,28 @@ function sleep(
697
1273
  );
698
1274
  }
699
1275
 
1276
+ function calculateJobStats(
1277
+ jobs: JobRecord[],
1278
+ deadLetters: number
1279
+ ): JobQueueStats {
1280
+ const stats:
1281
+ JobQueueStats = {
1282
+ total: jobs.length,
1283
+ queued: 0,
1284
+ running: 0,
1285
+ succeeded: 0,
1286
+ failed: 0,
1287
+ cancelled: 0,
1288
+ deadLetters,
1289
+ };
1290
+
1291
+ for (const job of jobs) {
1292
+ stats[job.state] += 1;
1293
+ }
1294
+
1295
+ return stats;
1296
+ }
1297
+
700
1298
  function resolveRetryDelay(
701
1299
  value: JobRetryDelay,
702
1300
  attempt: number
@@ -716,8 +1314,7 @@ function normalizeJobName(
716
1314
  value: string
717
1315
  ): string {
718
1316
  const name =
719
- String(value)
720
- .trim();
1317
+ String(value).trim();
721
1318
 
722
1319
  if (!name) {
723
1320
  throw new TypeError(
@@ -738,8 +1335,7 @@ function normalizeJobId(
738
1335
  value: string
739
1336
  ): string {
740
1337
  const id =
741
- String(value)
742
- .trim();
1338
+ String(value).trim();
743
1339
 
744
1340
  if (!id) {
745
1341
  throw new TypeError(
@@ -756,6 +1352,21 @@ function normalizeJobId(
756
1352
  return id;
757
1353
  }
758
1354
 
1355
+ function normalizeWorkerId(
1356
+ value: string
1357
+ ): string {
1358
+ const id =
1359
+ String(value).trim();
1360
+
1361
+ if (!id) {
1362
+ throw new TypeError(
1363
+ "BCP Jobs: worker id must be a non-empty string."
1364
+ );
1365
+ }
1366
+
1367
+ return id;
1368
+ }
1369
+
759
1370
  function normalizePositiveInteger(
760
1371
  value: number,
761
1372
  field: string
@@ -811,6 +1422,25 @@ function formatJobError(
811
1422
  }
812
1423
  }
813
1424
 
1425
+ function leaseOwnerMatches(
1426
+ job: JobRecord,
1427
+ ownerId?: string
1428
+ ): boolean {
1429
+ if (ownerId === undefined) {
1430
+ return true;
1431
+ }
1432
+
1433
+ return job.leaseOwner === ownerId;
1434
+ }
1435
+
1436
+ function clearLease(
1437
+ job: JobRecord
1438
+ ): void {
1439
+ job.leaseOwner = undefined;
1440
+ job.leaseUntil = undefined;
1441
+ job.heartbeatAt = undefined;
1442
+ }
1443
+
814
1444
  function cloneJob<
815
1445
  TPayload
816
1446
  >(
@@ -820,3 +1450,13 @@ function cloneJob<
820
1450
  ...job,
821
1451
  };
822
1452
  }
1453
+
1454
+ function cloneDeadLetter<
1455
+ TPayload
1456
+ >(
1457
+ job: DeadLetterJobRecord<TPayload>
1458
+ ): DeadLetterJobRecord<TPayload> {
1459
+ return {
1460
+ ...job,
1461
+ };
1462
+ }