@chidchanun/bcp 0.2.6 → 0.2.8
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 +112 -60
- package/docs/README.md +44 -57
- package/docs/api-manifest.json +23 -1
- package/docs/api-reference.md +67 -0
- package/docs/background-jobs.md +356 -0
- package/docs/development-logging.md +44 -1
- package/docs/docs-web-manifest.json +8 -4
- package/docs/observability.md +445 -0
- package/docs/platform-manifest.json +20 -4
- package/docs/releases/0.2.7.md +205 -0
- package/docs/releases/0.2.8.md +150 -0
- package/package.json +11 -1
- package/packages/bundler/src/client-boundary.ts +2 -0
- package/packages/client/src/jobs.ts +16 -0
- package/packages/client/src/observability.ts +22 -0
- package/packages/server/src/jobs.ts +822 -0
- package/packages/server/src/observability.ts +1258 -0
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
import {
|
|
2
|
+
randomUUID,
|
|
3
|
+
} from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export type JobState =
|
|
6
|
+
| "queued"
|
|
7
|
+
| "running"
|
|
8
|
+
| "succeeded"
|
|
9
|
+
| "failed"
|
|
10
|
+
| "cancelled";
|
|
11
|
+
|
|
12
|
+
export interface JobRecord<
|
|
13
|
+
TPayload = unknown
|
|
14
|
+
> {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
payload: TPayload;
|
|
18
|
+
state: JobState;
|
|
19
|
+
attempts: number;
|
|
20
|
+
maxAttempts: number;
|
|
21
|
+
createdAt: number;
|
|
22
|
+
availableAt: number;
|
|
23
|
+
startedAt?: number;
|
|
24
|
+
completedAt?: number;
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface EnqueueJobOptions {
|
|
29
|
+
id?: string;
|
|
30
|
+
delayMs?: number;
|
|
31
|
+
maxAttempts?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface JobQueueAdapter {
|
|
35
|
+
enqueue<TPayload = unknown>(
|
|
36
|
+
job: JobRecord<TPayload>
|
|
37
|
+
): Promise<void>;
|
|
38
|
+
|
|
39
|
+
reserve(
|
|
40
|
+
now: number
|
|
41
|
+
): Promise<JobRecord | null>;
|
|
42
|
+
|
|
43
|
+
complete(
|
|
44
|
+
id: string,
|
|
45
|
+
completedAt: number
|
|
46
|
+
): Promise<void>;
|
|
47
|
+
|
|
48
|
+
fail(
|
|
49
|
+
id: string,
|
|
50
|
+
options: {
|
|
51
|
+
error: string;
|
|
52
|
+
failedAt: number;
|
|
53
|
+
retryAt?: number;
|
|
54
|
+
}
|
|
55
|
+
): Promise<void>;
|
|
56
|
+
|
|
57
|
+
cancel(
|
|
58
|
+
id: string,
|
|
59
|
+
cancelledAt: number
|
|
60
|
+
): Promise<boolean>;
|
|
61
|
+
|
|
62
|
+
get<TPayload = unknown>(
|
|
63
|
+
id: string
|
|
64
|
+
): Promise<JobRecord<TPayload> | null>;
|
|
65
|
+
|
|
66
|
+
list(): Promise<JobRecord[]>;
|
|
67
|
+
|
|
68
|
+
close?(): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface MemoryJobQueueAdapter
|
|
72
|
+
extends JobQueueAdapter {
|
|
73
|
+
clear(): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface JobHandlerContext<
|
|
77
|
+
TPayload = unknown
|
|
78
|
+
> {
|
|
79
|
+
job: Readonly<JobRecord<TPayload>>;
|
|
80
|
+
payload: TPayload;
|
|
81
|
+
signal: AbortSignal;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type JobHandler<
|
|
85
|
+
TPayload = unknown
|
|
86
|
+
> = (
|
|
87
|
+
context: JobHandlerContext<TPayload>
|
|
88
|
+
) => unknown | Promise<unknown>;
|
|
89
|
+
|
|
90
|
+
export type JobRetryDelay =
|
|
91
|
+
| number
|
|
92
|
+
| ((attempt: number) => number);
|
|
93
|
+
|
|
94
|
+
export interface JobQueueOptions {
|
|
95
|
+
adapter?: JobQueueAdapter;
|
|
96
|
+
defaultMaxAttempts?: number;
|
|
97
|
+
retryDelayMs?: JobRetryDelay;
|
|
98
|
+
now?: () => number;
|
|
99
|
+
idFactory?: () => string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface StartJobWorkerOptions {
|
|
103
|
+
concurrency?: number;
|
|
104
|
+
pollIntervalMs?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface JobWorker {
|
|
108
|
+
readonly running: boolean;
|
|
109
|
+
stop(): Promise<void>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface BackgroundJobQueue {
|
|
113
|
+
readonly adapter: JobQueueAdapter;
|
|
114
|
+
|
|
115
|
+
register<TPayload = unknown>(
|
|
116
|
+
name: string,
|
|
117
|
+
handler: JobHandler<TPayload>
|
|
118
|
+
): () => void;
|
|
119
|
+
|
|
120
|
+
enqueue<TPayload = unknown>(
|
|
121
|
+
name: string,
|
|
122
|
+
payload: TPayload,
|
|
123
|
+
options?: EnqueueJobOptions
|
|
124
|
+
): Promise<JobRecord<TPayload>>;
|
|
125
|
+
|
|
126
|
+
get<TPayload = unknown>(
|
|
127
|
+
id: string
|
|
128
|
+
): Promise<JobRecord<TPayload> | null>;
|
|
129
|
+
|
|
130
|
+
list(): Promise<JobRecord[]>;
|
|
131
|
+
|
|
132
|
+
cancel(id: string): Promise<boolean>;
|
|
133
|
+
|
|
134
|
+
processNext(
|
|
135
|
+
signal?: AbortSignal
|
|
136
|
+
): Promise<boolean>;
|
|
137
|
+
|
|
138
|
+
startWorker(
|
|
139
|
+
options?: StartJobWorkerOptions
|
|
140
|
+
): JobWorker;
|
|
141
|
+
|
|
142
|
+
close(): Promise<void>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function createMemoryJobQueueAdapter():
|
|
146
|
+
MemoryJobQueueAdapter {
|
|
147
|
+
const jobs =
|
|
148
|
+
new Map<string, JobRecord>();
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
async enqueue(job) {
|
|
152
|
+
if (jobs.has(job.id)) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`BCP Jobs: job id "${job.id}" already exists.`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
jobs.set(
|
|
159
|
+
job.id,
|
|
160
|
+
cloneJob(job)
|
|
161
|
+
);
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
async reserve(now) {
|
|
165
|
+
const candidate =
|
|
166
|
+
Array.from(
|
|
167
|
+
jobs.values()
|
|
168
|
+
)
|
|
169
|
+
.filter(
|
|
170
|
+
job =>
|
|
171
|
+
job.state === "queued" &&
|
|
172
|
+
job.availableAt <= now
|
|
173
|
+
)
|
|
174
|
+
.sort(
|
|
175
|
+
(left, right) =>
|
|
176
|
+
left.availableAt - right.availableAt ||
|
|
177
|
+
left.createdAt - right.createdAt ||
|
|
178
|
+
left.id.localeCompare(right.id)
|
|
179
|
+
)[0];
|
|
180
|
+
|
|
181
|
+
if (!candidate) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
candidate.state =
|
|
186
|
+
"running";
|
|
187
|
+
candidate.attempts += 1;
|
|
188
|
+
candidate.startedAt =
|
|
189
|
+
now;
|
|
190
|
+
candidate.error =
|
|
191
|
+
undefined;
|
|
192
|
+
|
|
193
|
+
return cloneJob(
|
|
194
|
+
candidate
|
|
195
|
+
);
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
async complete(
|
|
199
|
+
id,
|
|
200
|
+
completedAt
|
|
201
|
+
) {
|
|
202
|
+
const job =
|
|
203
|
+
jobs.get(id);
|
|
204
|
+
|
|
205
|
+
if (
|
|
206
|
+
!job ||
|
|
207
|
+
job.state === "cancelled"
|
|
208
|
+
) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
job.state =
|
|
213
|
+
"succeeded";
|
|
214
|
+
job.completedAt =
|
|
215
|
+
completedAt;
|
|
216
|
+
job.error =
|
|
217
|
+
undefined;
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
async fail(
|
|
221
|
+
id,
|
|
222
|
+
options
|
|
223
|
+
) {
|
|
224
|
+
const job =
|
|
225
|
+
jobs.get(id);
|
|
226
|
+
|
|
227
|
+
if (
|
|
228
|
+
!job ||
|
|
229
|
+
job.state === "cancelled"
|
|
230
|
+
) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
job.error =
|
|
235
|
+
options.error;
|
|
236
|
+
|
|
237
|
+
if (
|
|
238
|
+
options.retryAt !== undefined &&
|
|
239
|
+
job.attempts < job.maxAttempts
|
|
240
|
+
) {
|
|
241
|
+
job.state =
|
|
242
|
+
"queued";
|
|
243
|
+
job.availableAt =
|
|
244
|
+
options.retryAt;
|
|
245
|
+
job.startedAt =
|
|
246
|
+
undefined;
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
job.state =
|
|
251
|
+
"failed";
|
|
252
|
+
job.completedAt =
|
|
253
|
+
options.failedAt;
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
async cancel(
|
|
257
|
+
id,
|
|
258
|
+
cancelledAt
|
|
259
|
+
) {
|
|
260
|
+
const job =
|
|
261
|
+
jobs.get(id);
|
|
262
|
+
|
|
263
|
+
if (
|
|
264
|
+
!job ||
|
|
265
|
+
job.state === "succeeded" ||
|
|
266
|
+
job.state === "failed" ||
|
|
267
|
+
job.state === "cancelled"
|
|
268
|
+
) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
job.state =
|
|
273
|
+
"cancelled";
|
|
274
|
+
job.completedAt =
|
|
275
|
+
cancelledAt;
|
|
276
|
+
return true;
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
async get(id) {
|
|
280
|
+
const job =
|
|
281
|
+
jobs.get(id);
|
|
282
|
+
|
|
283
|
+
return job
|
|
284
|
+
? cloneJob(job) as JobRecord<any>
|
|
285
|
+
: null;
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
async list() {
|
|
289
|
+
return Array.from(
|
|
290
|
+
jobs.values()
|
|
291
|
+
)
|
|
292
|
+
.map(cloneJob)
|
|
293
|
+
.sort(
|
|
294
|
+
(left, right) =>
|
|
295
|
+
left.createdAt - right.createdAt ||
|
|
296
|
+
left.id.localeCompare(right.id)
|
|
297
|
+
);
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
clear() {
|
|
301
|
+
jobs.clear();
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function createJobQueue(
|
|
307
|
+
options: JobQueueOptions = {}
|
|
308
|
+
): BackgroundJobQueue {
|
|
309
|
+
const adapter =
|
|
310
|
+
options.adapter ??
|
|
311
|
+
createMemoryJobQueueAdapter();
|
|
312
|
+
const handlers =
|
|
313
|
+
new Map<string, JobHandler<any>>();
|
|
314
|
+
const workers =
|
|
315
|
+
new Set<InternalJobWorker>();
|
|
316
|
+
const now =
|
|
317
|
+
options.now ??
|
|
318
|
+
Date.now;
|
|
319
|
+
const idFactory =
|
|
320
|
+
options.idFactory ??
|
|
321
|
+
randomUUID;
|
|
322
|
+
const defaultMaxAttempts =
|
|
323
|
+
normalizePositiveInteger(
|
|
324
|
+
options.defaultMaxAttempts ?? 3,
|
|
325
|
+
"defaultMaxAttempts"
|
|
326
|
+
);
|
|
327
|
+
const retryDelay =
|
|
328
|
+
options.retryDelayMs ??
|
|
329
|
+
((attempt: number) =>
|
|
330
|
+
Math.min(
|
|
331
|
+
30_000,
|
|
332
|
+
1_000 *
|
|
333
|
+
2 ** Math.max(
|
|
334
|
+
0,
|
|
335
|
+
attempt - 1
|
|
336
|
+
)
|
|
337
|
+
));
|
|
338
|
+
|
|
339
|
+
const queue:
|
|
340
|
+
BackgroundJobQueue = {
|
|
341
|
+
adapter,
|
|
342
|
+
|
|
343
|
+
register(name, handler) {
|
|
344
|
+
const normalizedName =
|
|
345
|
+
normalizeJobName(name);
|
|
346
|
+
|
|
347
|
+
if (
|
|
348
|
+
typeof handler !==
|
|
349
|
+
"function"
|
|
350
|
+
) {
|
|
351
|
+
throw new TypeError(
|
|
352
|
+
"BCP Jobs: handler must be a function."
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (
|
|
357
|
+
handlers.has(
|
|
358
|
+
normalizedName
|
|
359
|
+
)
|
|
360
|
+
) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`BCP Jobs: handler "${normalizedName}" is already registered.`
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const registeredHandler =
|
|
367
|
+
handler as JobHandler<any>;
|
|
368
|
+
|
|
369
|
+
handlers.set(
|
|
370
|
+
normalizedName,
|
|
371
|
+
registeredHandler
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
return () => {
|
|
375
|
+
if (
|
|
376
|
+
handlers.get(
|
|
377
|
+
normalizedName
|
|
378
|
+
) === registeredHandler
|
|
379
|
+
) {
|
|
380
|
+
handlers.delete(
|
|
381
|
+
normalizedName
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
},
|
|
386
|
+
|
|
387
|
+
async enqueue(
|
|
388
|
+
name,
|
|
389
|
+
payload,
|
|
390
|
+
enqueueOptions = {}
|
|
391
|
+
) {
|
|
392
|
+
const createdAt =
|
|
393
|
+
now();
|
|
394
|
+
const delayMs =
|
|
395
|
+
normalizeNonNegativeNumber(
|
|
396
|
+
enqueueOptions.delayMs ?? 0,
|
|
397
|
+
"delayMs"
|
|
398
|
+
);
|
|
399
|
+
const maxAttempts =
|
|
400
|
+
normalizePositiveInteger(
|
|
401
|
+
enqueueOptions.maxAttempts ??
|
|
402
|
+
defaultMaxAttempts,
|
|
403
|
+
"maxAttempts"
|
|
404
|
+
);
|
|
405
|
+
const id =
|
|
406
|
+
normalizeJobId(
|
|
407
|
+
enqueueOptions.id ??
|
|
408
|
+
idFactory()
|
|
409
|
+
);
|
|
410
|
+
const job:
|
|
411
|
+
JobRecord<typeof payload> = {
|
|
412
|
+
id,
|
|
413
|
+
name:
|
|
414
|
+
normalizeJobName(
|
|
415
|
+
name
|
|
416
|
+
),
|
|
417
|
+
payload,
|
|
418
|
+
state: "queued",
|
|
419
|
+
attempts: 0,
|
|
420
|
+
maxAttempts,
|
|
421
|
+
createdAt,
|
|
422
|
+
availableAt:
|
|
423
|
+
createdAt +
|
|
424
|
+
delayMs,
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
await adapter.enqueue(
|
|
428
|
+
job
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
return cloneJob(job);
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
get(id) {
|
|
435
|
+
return adapter.get(
|
|
436
|
+
normalizeJobId(id)
|
|
437
|
+
);
|
|
438
|
+
},
|
|
439
|
+
|
|
440
|
+
list() {
|
|
441
|
+
return adapter.list();
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
cancel(id) {
|
|
445
|
+
return adapter.cancel(
|
|
446
|
+
normalizeJobId(id),
|
|
447
|
+
now()
|
|
448
|
+
);
|
|
449
|
+
},
|
|
450
|
+
|
|
451
|
+
async processNext(
|
|
452
|
+
signal =
|
|
453
|
+
new AbortController()
|
|
454
|
+
.signal
|
|
455
|
+
) {
|
|
456
|
+
if (signal.aborted) {
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const job =
|
|
461
|
+
await adapter.reserve(
|
|
462
|
+
now()
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
if (!job) {
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const handler =
|
|
470
|
+
handlers.get(
|
|
471
|
+
job.name
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
if (!handler) {
|
|
475
|
+
await adapter.fail(
|
|
476
|
+
job.id,
|
|
477
|
+
{
|
|
478
|
+
error:
|
|
479
|
+
`No handler registered for job "${job.name}".`,
|
|
480
|
+
failedAt:
|
|
481
|
+
now(),
|
|
482
|
+
}
|
|
483
|
+
);
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
try {
|
|
488
|
+
await handler({
|
|
489
|
+
job:
|
|
490
|
+
cloneJob(job),
|
|
491
|
+
payload:
|
|
492
|
+
job.payload,
|
|
493
|
+
signal,
|
|
494
|
+
});
|
|
495
|
+
|
|
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
|
+
}
|
|
527
|
+
|
|
528
|
+
return true;
|
|
529
|
+
},
|
|
530
|
+
|
|
531
|
+
startWorker(
|
|
532
|
+
workerOptions = {}
|
|
533
|
+
) {
|
|
534
|
+
const worker =
|
|
535
|
+
createWorker(
|
|
536
|
+
queue,
|
|
537
|
+
workerOptions,
|
|
538
|
+
() =>
|
|
539
|
+
workers.delete(
|
|
540
|
+
worker
|
|
541
|
+
)
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
workers.add(worker);
|
|
545
|
+
return worker;
|
|
546
|
+
},
|
|
547
|
+
|
|
548
|
+
async close() {
|
|
549
|
+
await Promise.all(
|
|
550
|
+
Array.from(
|
|
551
|
+
workers,
|
|
552
|
+
worker =>
|
|
553
|
+
worker.stop()
|
|
554
|
+
)
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
if (adapter.close) {
|
|
558
|
+
await adapter.close();
|
|
559
|
+
}
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
return queue;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
interface InternalJobWorker
|
|
567
|
+
extends JobWorker {}
|
|
568
|
+
|
|
569
|
+
function createWorker(
|
|
570
|
+
queue: BackgroundJobQueue,
|
|
571
|
+
options: StartJobWorkerOptions,
|
|
572
|
+
onStop: () => void
|
|
573
|
+
): InternalJobWorker {
|
|
574
|
+
const concurrency =
|
|
575
|
+
normalizePositiveInteger(
|
|
576
|
+
options.concurrency ?? 1,
|
|
577
|
+
"concurrency"
|
|
578
|
+
);
|
|
579
|
+
const pollIntervalMs =
|
|
580
|
+
normalizeNonNegativeNumber(
|
|
581
|
+
options.pollIntervalMs ?? 250,
|
|
582
|
+
"pollIntervalMs"
|
|
583
|
+
);
|
|
584
|
+
const controller =
|
|
585
|
+
new AbortController();
|
|
586
|
+
let running =
|
|
587
|
+
true;
|
|
588
|
+
let stopPromise:
|
|
589
|
+
Promise<void> | null =
|
|
590
|
+
null;
|
|
591
|
+
|
|
592
|
+
const loops =
|
|
593
|
+
Array.from(
|
|
594
|
+
{
|
|
595
|
+
length:
|
|
596
|
+
concurrency,
|
|
597
|
+
},
|
|
598
|
+
() =>
|
|
599
|
+
runWorkerLoop(
|
|
600
|
+
queue,
|
|
601
|
+
controller.signal,
|
|
602
|
+
pollIntervalMs
|
|
603
|
+
)
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
get running() {
|
|
608
|
+
return running;
|
|
609
|
+
},
|
|
610
|
+
|
|
611
|
+
stop() {
|
|
612
|
+
if (stopPromise) {
|
|
613
|
+
return stopPromise;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
running =
|
|
617
|
+
false;
|
|
618
|
+
controller.abort();
|
|
619
|
+
stopPromise =
|
|
620
|
+
Promise.allSettled(
|
|
621
|
+
loops
|
|
622
|
+
)
|
|
623
|
+
.then(
|
|
624
|
+
() => {
|
|
625
|
+
onStop();
|
|
626
|
+
}
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
return stopPromise;
|
|
630
|
+
},
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function runWorkerLoop(
|
|
635
|
+
queue: BackgroundJobQueue,
|
|
636
|
+
signal: AbortSignal,
|
|
637
|
+
pollIntervalMs: number
|
|
638
|
+
): Promise<void> {
|
|
639
|
+
while (!signal.aborted) {
|
|
640
|
+
const processed =
|
|
641
|
+
await queue.processNext(
|
|
642
|
+
signal
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
if (
|
|
646
|
+
!processed &&
|
|
647
|
+
!signal.aborted
|
|
648
|
+
) {
|
|
649
|
+
await sleep(
|
|
650
|
+
pollIntervalMs,
|
|
651
|
+
signal
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function sleep(
|
|
658
|
+
durationMs: number,
|
|
659
|
+
signal: AbortSignal
|
|
660
|
+
): Promise<void> {
|
|
661
|
+
if (
|
|
662
|
+
durationMs === 0 ||
|
|
663
|
+
signal.aborted
|
|
664
|
+
) {
|
|
665
|
+
return Promise.resolve();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return new Promise(
|
|
669
|
+
resolve => {
|
|
670
|
+
const timeout =
|
|
671
|
+
setTimeout(
|
|
672
|
+
finish,
|
|
673
|
+
durationMs
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
const onAbort =
|
|
677
|
+
() =>
|
|
678
|
+
finish();
|
|
679
|
+
|
|
680
|
+
signal.addEventListener(
|
|
681
|
+
"abort",
|
|
682
|
+
onAbort,
|
|
683
|
+
{
|
|
684
|
+
once: true,
|
|
685
|
+
}
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
function finish() {
|
|
689
|
+
clearTimeout(timeout);
|
|
690
|
+
signal.removeEventListener(
|
|
691
|
+
"abort",
|
|
692
|
+
onAbort
|
|
693
|
+
);
|
|
694
|
+
resolve();
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function resolveRetryDelay(
|
|
701
|
+
value: JobRetryDelay,
|
|
702
|
+
attempt: number
|
|
703
|
+
): number {
|
|
704
|
+
const delay =
|
|
705
|
+
typeof value === "function"
|
|
706
|
+
? value(attempt)
|
|
707
|
+
: value;
|
|
708
|
+
|
|
709
|
+
return normalizeNonNegativeNumber(
|
|
710
|
+
delay,
|
|
711
|
+
"retryDelayMs"
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function normalizeJobName(
|
|
716
|
+
value: string
|
|
717
|
+
): string {
|
|
718
|
+
const name =
|
|
719
|
+
String(value)
|
|
720
|
+
.trim();
|
|
721
|
+
|
|
722
|
+
if (!name) {
|
|
723
|
+
throw new TypeError(
|
|
724
|
+
"BCP Jobs: job name must be a non-empty string."
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (name.length > 200) {
|
|
729
|
+
throw new TypeError(
|
|
730
|
+
"BCP Jobs: job name must not exceed 200 characters."
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return name;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function normalizeJobId(
|
|
738
|
+
value: string
|
|
739
|
+
): string {
|
|
740
|
+
const id =
|
|
741
|
+
String(value)
|
|
742
|
+
.trim();
|
|
743
|
+
|
|
744
|
+
if (!id) {
|
|
745
|
+
throw new TypeError(
|
|
746
|
+
"BCP Jobs: job id must be a non-empty string."
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (id.length > 200) {
|
|
751
|
+
throw new TypeError(
|
|
752
|
+
"BCP Jobs: job id must not exceed 200 characters."
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
return id;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function normalizePositiveInteger(
|
|
760
|
+
value: number,
|
|
761
|
+
field: string
|
|
762
|
+
): number {
|
|
763
|
+
if (
|
|
764
|
+
!Number.isInteger(value) ||
|
|
765
|
+
value <= 0
|
|
766
|
+
) {
|
|
767
|
+
throw new TypeError(
|
|
768
|
+
`BCP Jobs: ${field} must be a positive integer.`
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return value;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function normalizeNonNegativeNumber(
|
|
776
|
+
value: number,
|
|
777
|
+
field: string
|
|
778
|
+
): number {
|
|
779
|
+
if (
|
|
780
|
+
!Number.isFinite(value) ||
|
|
781
|
+
value < 0
|
|
782
|
+
) {
|
|
783
|
+
throw new TypeError(
|
|
784
|
+
`BCP Jobs: ${field} must be a non-negative finite number.`
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
return Math.floor(value);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function formatJobError(
|
|
792
|
+
error: unknown
|
|
793
|
+
): string {
|
|
794
|
+
if (error instanceof Error) {
|
|
795
|
+
return error.message ||
|
|
796
|
+
error.name;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (typeof error === "string") {
|
|
800
|
+
return error;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
try {
|
|
804
|
+
const serialized =
|
|
805
|
+
JSON.stringify(error);
|
|
806
|
+
|
|
807
|
+
return serialized ??
|
|
808
|
+
String(error);
|
|
809
|
+
} catch {
|
|
810
|
+
return String(error);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function cloneJob<
|
|
815
|
+
TPayload
|
|
816
|
+
>(
|
|
817
|
+
job: JobRecord<TPayload>
|
|
818
|
+
): JobRecord<TPayload> {
|
|
819
|
+
return {
|
|
820
|
+
...job,
|
|
821
|
+
};
|
|
822
|
+
}
|