@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.
- package/README.md +196 -129
- package/docs/README.md +49 -43
- package/docs/api-manifest.json +15 -2
- package/docs/api-reference.md +113 -8
- package/docs/docs-web-manifest.json +7 -3
- package/docs/durable-jobs.md +359 -0
- package/docs/platform-manifest.json +26 -4
- package/docs/releases/0.2.10.md +148 -0
- package/docs/releases/0.2.11.md +180 -0
- package/docs/workflow-orchestration.md +374 -0
- package/package.json +6 -1
- package/packages/bundler/src/client-boundary.ts +1 -0
- package/packages/client/src/jobs.mjs +1310 -61
- package/packages/client/src/jobs.ts +17 -0
- package/packages/client/src/workflow.mjs +601 -0
- package/packages/client/src/workflow.ts +23 -0
- package/packages/server/src/jobs-redis.ts +1226 -0
- package/packages/server/src/jobs.ts +768 -128
- package/packages/server/src/workflow.ts +887 -0
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
190
|
-
candidate.
|
|
191
|
-
|
|
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
|
-
|
|
214
|
-
job.
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
job
|
|
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
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
);
|
|
473
|
-
|
|
474
|
-
if (!handler) {
|
|
475
|
-
await adapter.fail(
|
|
840
|
+
const stopHeartbeat =
|
|
841
|
+
startJobHeartbeat(
|
|
842
|
+
adapter,
|
|
476
843
|
job.id,
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
}
|
|
844
|
+
ownerId,
|
|
845
|
+
visibilityTimeoutMs,
|
|
846
|
+
heartbeatIntervalMs,
|
|
847
|
+
now,
|
|
848
|
+
signal
|
|
483
849
|
);
|
|
484
|
-
|
|
485
|
-
|
|
850
|
+
const handler =
|
|
851
|
+
handlers.get(job.name);
|
|
486
852
|
|
|
487
853
|
try {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
+
}
|