@chidchanun/bcp 0.2.8 → 0.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +164 -163
- package/docs/README.md +48 -139
- package/docs/api-manifest.json +4 -2
- package/docs/api-reference.md +92 -242
- package/docs/docs-web-manifest.json +7 -3
- package/docs/durable-jobs.md +359 -0
- package/docs/job-scheduling.md +357 -0
- package/docs/platform-manifest.json +23 -4
- package/docs/releases/0.2.10.md +148 -0
- package/docs/releases/0.2.9.md +162 -0
- package/package.json +2 -2
- package/packages/client/src/jobs.mjs +2289 -0
- package/packages/client/src/jobs.ts +34 -0
- package/packages/server/src/job-scheduler.ts +1144 -0
- package/packages/server/src/jobs-redis.ts +1226 -0
- package/packages/server/src/jobs.ts +768 -128
|
@@ -0,0 +1,1144 @@
|
|
|
1
|
+
import {
|
|
2
|
+
randomUUID,
|
|
3
|
+
} from "node:crypto";
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
BackgroundJobQueue,
|
|
7
|
+
} from "./jobs.js";
|
|
8
|
+
|
|
9
|
+
export type JobSchedule =
|
|
10
|
+
| {
|
|
11
|
+
kind: "interval";
|
|
12
|
+
everyMs: number;
|
|
13
|
+
}
|
|
14
|
+
| {
|
|
15
|
+
kind: "cron";
|
|
16
|
+
expression: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface JobScheduleRecord<
|
|
20
|
+
TPayload = unknown
|
|
21
|
+
> {
|
|
22
|
+
id: string;
|
|
23
|
+
jobName: string;
|
|
24
|
+
payload: TPayload;
|
|
25
|
+
schedule: JobSchedule;
|
|
26
|
+
createdAt: number;
|
|
27
|
+
updatedAt: number;
|
|
28
|
+
nextRunAt: number;
|
|
29
|
+
lastRunAt?: number;
|
|
30
|
+
maxAttempts?: number;
|
|
31
|
+
leaseOwner?: string;
|
|
32
|
+
leaseUntil?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ScheduleJobOptions {
|
|
36
|
+
id?: string;
|
|
37
|
+
everyMs?: number;
|
|
38
|
+
cron?: string;
|
|
39
|
+
startAt?: number | Date;
|
|
40
|
+
maxAttempts?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface JobScheduleStore {
|
|
44
|
+
upsert<TPayload = unknown>(
|
|
45
|
+
schedule: JobScheduleRecord<TPayload>
|
|
46
|
+
): Promise<void>;
|
|
47
|
+
|
|
48
|
+
get<TPayload = unknown>(
|
|
49
|
+
id: string
|
|
50
|
+
): Promise<JobScheduleRecord<TPayload> | null>;
|
|
51
|
+
|
|
52
|
+
list(): Promise<JobScheduleRecord[]>;
|
|
53
|
+
|
|
54
|
+
remove(id: string): Promise<boolean>;
|
|
55
|
+
|
|
56
|
+
acquireDue(
|
|
57
|
+
now: number,
|
|
58
|
+
options: {
|
|
59
|
+
ownerId: string;
|
|
60
|
+
leaseMs: number;
|
|
61
|
+
limit: number;
|
|
62
|
+
}
|
|
63
|
+
): Promise<JobScheduleRecord[]>;
|
|
64
|
+
|
|
65
|
+
complete(
|
|
66
|
+
id: string,
|
|
67
|
+
options: {
|
|
68
|
+
ownerId: string;
|
|
69
|
+
lastRunAt: number;
|
|
70
|
+
nextRunAt: number;
|
|
71
|
+
updatedAt: number;
|
|
72
|
+
}
|
|
73
|
+
): Promise<void>;
|
|
74
|
+
|
|
75
|
+
release(
|
|
76
|
+
id: string,
|
|
77
|
+
ownerId: string
|
|
78
|
+
): Promise<void>;
|
|
79
|
+
|
|
80
|
+
close?(): Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface MemoryJobScheduleStore
|
|
84
|
+
extends JobScheduleStore {
|
|
85
|
+
clear(): void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface JobSchedulerOptions {
|
|
89
|
+
queue: BackgroundJobQueue;
|
|
90
|
+
store?: JobScheduleStore;
|
|
91
|
+
now?: () => number;
|
|
92
|
+
idFactory?: () => string;
|
|
93
|
+
ownerId?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface RunDueSchedulesOptions {
|
|
97
|
+
limit?: number;
|
|
98
|
+
leaseMs?: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface StartJobSchedulerOptions
|
|
102
|
+
extends RunDueSchedulesOptions {
|
|
103
|
+
pollIntervalMs?: number;
|
|
104
|
+
onError?: (
|
|
105
|
+
error: unknown
|
|
106
|
+
) => void | Promise<void>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface JobSchedulerRunner {
|
|
110
|
+
readonly running: boolean;
|
|
111
|
+
stop(): Promise<void>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface JobScheduler {
|
|
115
|
+
readonly queue: BackgroundJobQueue;
|
|
116
|
+
readonly store: JobScheduleStore;
|
|
117
|
+
readonly ownerId: string;
|
|
118
|
+
|
|
119
|
+
schedule<TPayload = unknown>(
|
|
120
|
+
jobName: string,
|
|
121
|
+
payload: TPayload,
|
|
122
|
+
options: ScheduleJobOptions
|
|
123
|
+
): Promise<JobScheduleRecord<TPayload>>;
|
|
124
|
+
|
|
125
|
+
get<TPayload = unknown>(
|
|
126
|
+
id: string
|
|
127
|
+
): Promise<JobScheduleRecord<TPayload> | null>;
|
|
128
|
+
|
|
129
|
+
list(): Promise<JobScheduleRecord[]>;
|
|
130
|
+
|
|
131
|
+
remove(id: string): Promise<boolean>;
|
|
132
|
+
|
|
133
|
+
runDue(
|
|
134
|
+
options?: RunDueSchedulesOptions
|
|
135
|
+
): Promise<number>;
|
|
136
|
+
|
|
137
|
+
start(
|
|
138
|
+
options?: StartJobSchedulerOptions
|
|
139
|
+
): JobSchedulerRunner;
|
|
140
|
+
|
|
141
|
+
close(): Promise<void>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function createMemoryJobScheduleStore():
|
|
145
|
+
MemoryJobScheduleStore {
|
|
146
|
+
const schedules =
|
|
147
|
+
new Map<
|
|
148
|
+
string,
|
|
149
|
+
JobScheduleRecord
|
|
150
|
+
>();
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
async upsert(schedule) {
|
|
154
|
+
schedules.set(
|
|
155
|
+
schedule.id,
|
|
156
|
+
cloneSchedule(
|
|
157
|
+
schedule
|
|
158
|
+
)
|
|
159
|
+
);
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
async get(id) {
|
|
163
|
+
const schedule =
|
|
164
|
+
schedules.get(id);
|
|
165
|
+
|
|
166
|
+
return schedule
|
|
167
|
+
? cloneSchedule(
|
|
168
|
+
schedule
|
|
169
|
+
) as JobScheduleRecord<any>
|
|
170
|
+
: null;
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
async list() {
|
|
174
|
+
return Array.from(
|
|
175
|
+
schedules.values()
|
|
176
|
+
)
|
|
177
|
+
.map(
|
|
178
|
+
cloneSchedule
|
|
179
|
+
)
|
|
180
|
+
.sort(
|
|
181
|
+
(left, right) =>
|
|
182
|
+
left.nextRunAt -
|
|
183
|
+
right.nextRunAt ||
|
|
184
|
+
left.id.localeCompare(
|
|
185
|
+
right.id
|
|
186
|
+
)
|
|
187
|
+
);
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
async remove(id) {
|
|
191
|
+
return schedules.delete(id);
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
async acquireDue(
|
|
195
|
+
now,
|
|
196
|
+
options
|
|
197
|
+
) {
|
|
198
|
+
const due =
|
|
199
|
+
Array.from(
|
|
200
|
+
schedules.values()
|
|
201
|
+
)
|
|
202
|
+
.filter(
|
|
203
|
+
schedule =>
|
|
204
|
+
schedule.nextRunAt <= now &&
|
|
205
|
+
(
|
|
206
|
+
schedule.leaseUntil === undefined ||
|
|
207
|
+
schedule.leaseUntil <= now
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
.sort(
|
|
211
|
+
(left, right) =>
|
|
212
|
+
left.nextRunAt -
|
|
213
|
+
right.nextRunAt ||
|
|
214
|
+
left.id.localeCompare(
|
|
215
|
+
right.id
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
.slice(
|
|
219
|
+
0,
|
|
220
|
+
options.limit
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
for (const schedule of due) {
|
|
224
|
+
schedule.leaseOwner =
|
|
225
|
+
options.ownerId;
|
|
226
|
+
schedule.leaseUntil =
|
|
227
|
+
now + options.leaseMs;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return due.map(
|
|
231
|
+
cloneSchedule
|
|
232
|
+
);
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
async complete(
|
|
236
|
+
id,
|
|
237
|
+
options
|
|
238
|
+
) {
|
|
239
|
+
const schedule =
|
|
240
|
+
schedules.get(id);
|
|
241
|
+
|
|
242
|
+
if (
|
|
243
|
+
!schedule ||
|
|
244
|
+
schedule.leaseOwner !==
|
|
245
|
+
options.ownerId
|
|
246
|
+
) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
schedule.lastRunAt =
|
|
251
|
+
options.lastRunAt;
|
|
252
|
+
schedule.nextRunAt =
|
|
253
|
+
options.nextRunAt;
|
|
254
|
+
schedule.updatedAt =
|
|
255
|
+
options.updatedAt;
|
|
256
|
+
schedule.leaseOwner =
|
|
257
|
+
undefined;
|
|
258
|
+
schedule.leaseUntil =
|
|
259
|
+
undefined;
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
async release(
|
|
263
|
+
id,
|
|
264
|
+
ownerId
|
|
265
|
+
) {
|
|
266
|
+
const schedule =
|
|
267
|
+
schedules.get(id);
|
|
268
|
+
|
|
269
|
+
if (
|
|
270
|
+
!schedule ||
|
|
271
|
+
schedule.leaseOwner !==
|
|
272
|
+
ownerId
|
|
273
|
+
) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
schedule.leaseOwner =
|
|
278
|
+
undefined;
|
|
279
|
+
schedule.leaseUntil =
|
|
280
|
+
undefined;
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
clear() {
|
|
284
|
+
schedules.clear();
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function createJobScheduler(
|
|
290
|
+
options: JobSchedulerOptions
|
|
291
|
+
): JobScheduler {
|
|
292
|
+
if (
|
|
293
|
+
!options ||
|
|
294
|
+
!options.queue
|
|
295
|
+
) {
|
|
296
|
+
throw new TypeError(
|
|
297
|
+
"BCP Jobs: createJobScheduler requires a queue."
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const queue =
|
|
302
|
+
options.queue;
|
|
303
|
+
const store =
|
|
304
|
+
options.store ??
|
|
305
|
+
createMemoryJobScheduleStore();
|
|
306
|
+
const now =
|
|
307
|
+
options.now ??
|
|
308
|
+
Date.now;
|
|
309
|
+
const idFactory =
|
|
310
|
+
options.idFactory ??
|
|
311
|
+
randomUUID;
|
|
312
|
+
const ownerId =
|
|
313
|
+
normalizeIdentifier(
|
|
314
|
+
options.ownerId ??
|
|
315
|
+
`scheduler-${randomUUID()}`,
|
|
316
|
+
"scheduler owner id"
|
|
317
|
+
);
|
|
318
|
+
const runners =
|
|
319
|
+
new Set<InternalSchedulerRunner>();
|
|
320
|
+
|
|
321
|
+
const scheduler:
|
|
322
|
+
JobScheduler = {
|
|
323
|
+
queue,
|
|
324
|
+
store,
|
|
325
|
+
ownerId,
|
|
326
|
+
|
|
327
|
+
async schedule(
|
|
328
|
+
jobName,
|
|
329
|
+
payload,
|
|
330
|
+
scheduleOptions
|
|
331
|
+
) {
|
|
332
|
+
const currentTime =
|
|
333
|
+
normalizeTimestamp(
|
|
334
|
+
now(),
|
|
335
|
+
"current time"
|
|
336
|
+
);
|
|
337
|
+
const schedule =
|
|
338
|
+
normalizeSchedule(
|
|
339
|
+
scheduleOptions
|
|
340
|
+
);
|
|
341
|
+
const id =
|
|
342
|
+
normalizeIdentifier(
|
|
343
|
+
scheduleOptions.id ??
|
|
344
|
+
idFactory(),
|
|
345
|
+
"schedule id"
|
|
346
|
+
);
|
|
347
|
+
const startAt =
|
|
348
|
+
scheduleOptions.startAt ===
|
|
349
|
+
undefined
|
|
350
|
+
? undefined
|
|
351
|
+
: normalizeTimestamp(
|
|
352
|
+
scheduleOptions.startAt instanceof Date
|
|
353
|
+
? scheduleOptions.startAt.getTime()
|
|
354
|
+
: scheduleOptions.startAt,
|
|
355
|
+
"startAt"
|
|
356
|
+
);
|
|
357
|
+
const maxAttempts =
|
|
358
|
+
scheduleOptions.maxAttempts ===
|
|
359
|
+
undefined
|
|
360
|
+
? undefined
|
|
361
|
+
: normalizePositiveInteger(
|
|
362
|
+
scheduleOptions.maxAttempts,
|
|
363
|
+
"maxAttempts"
|
|
364
|
+
);
|
|
365
|
+
const nextRunAt =
|
|
366
|
+
startAt ??
|
|
367
|
+
nextScheduleTime(
|
|
368
|
+
schedule,
|
|
369
|
+
currentTime
|
|
370
|
+
);
|
|
371
|
+
const record:
|
|
372
|
+
JobScheduleRecord<
|
|
373
|
+
typeof payload
|
|
374
|
+
> = {
|
|
375
|
+
id,
|
|
376
|
+
jobName:
|
|
377
|
+
normalizeIdentifier(
|
|
378
|
+
jobName,
|
|
379
|
+
"job name"
|
|
380
|
+
),
|
|
381
|
+
payload,
|
|
382
|
+
schedule,
|
|
383
|
+
createdAt:
|
|
384
|
+
currentTime,
|
|
385
|
+
updatedAt:
|
|
386
|
+
currentTime,
|
|
387
|
+
nextRunAt,
|
|
388
|
+
maxAttempts,
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
await store.upsert(
|
|
392
|
+
record
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
return cloneSchedule(
|
|
396
|
+
record
|
|
397
|
+
);
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
get(id) {
|
|
401
|
+
return store.get(
|
|
402
|
+
normalizeIdentifier(
|
|
403
|
+
id,
|
|
404
|
+
"schedule id"
|
|
405
|
+
)
|
|
406
|
+
);
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
list() {
|
|
410
|
+
return store.list();
|
|
411
|
+
},
|
|
412
|
+
|
|
413
|
+
remove(id) {
|
|
414
|
+
return store.remove(
|
|
415
|
+
normalizeIdentifier(
|
|
416
|
+
id,
|
|
417
|
+
"schedule id"
|
|
418
|
+
)
|
|
419
|
+
);
|
|
420
|
+
},
|
|
421
|
+
|
|
422
|
+
async runDue(
|
|
423
|
+
runOptions = {}
|
|
424
|
+
) {
|
|
425
|
+
const currentTime =
|
|
426
|
+
normalizeTimestamp(
|
|
427
|
+
now(),
|
|
428
|
+
"current time"
|
|
429
|
+
);
|
|
430
|
+
const limit =
|
|
431
|
+
normalizePositiveInteger(
|
|
432
|
+
runOptions.limit ?? 100,
|
|
433
|
+
"limit"
|
|
434
|
+
);
|
|
435
|
+
const leaseMs =
|
|
436
|
+
normalizePositiveInteger(
|
|
437
|
+
runOptions.leaseMs ??
|
|
438
|
+
30_000,
|
|
439
|
+
"leaseMs"
|
|
440
|
+
);
|
|
441
|
+
const schedules =
|
|
442
|
+
await store.acquireDue(
|
|
443
|
+
currentTime,
|
|
444
|
+
{
|
|
445
|
+
ownerId,
|
|
446
|
+
leaseMs,
|
|
447
|
+
limit,
|
|
448
|
+
}
|
|
449
|
+
);
|
|
450
|
+
let enqueued = 0;
|
|
451
|
+
|
|
452
|
+
for (
|
|
453
|
+
const scheduleRecord
|
|
454
|
+
of schedules
|
|
455
|
+
) {
|
|
456
|
+
const scheduledFor =
|
|
457
|
+
scheduleRecord.nextRunAt;
|
|
458
|
+
const runId =
|
|
459
|
+
createScheduledRunId(
|
|
460
|
+
scheduleRecord.id,
|
|
461
|
+
scheduledFor
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
await queue.enqueue(
|
|
466
|
+
scheduleRecord.jobName,
|
|
467
|
+
scheduleRecord.payload,
|
|
468
|
+
{
|
|
469
|
+
id: runId,
|
|
470
|
+
maxAttempts:
|
|
471
|
+
scheduleRecord.maxAttempts,
|
|
472
|
+
}
|
|
473
|
+
);
|
|
474
|
+
enqueued += 1;
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if (
|
|
477
|
+
!isDuplicateJobIdError(
|
|
478
|
+
error
|
|
479
|
+
)
|
|
480
|
+
) {
|
|
481
|
+
await store.release(
|
|
482
|
+
scheduleRecord.id,
|
|
483
|
+
ownerId
|
|
484
|
+
);
|
|
485
|
+
throw error;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const nextRunAt =
|
|
490
|
+
nextScheduleTime(
|
|
491
|
+
scheduleRecord.schedule,
|
|
492
|
+
scheduledFor
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
await store.complete(
|
|
496
|
+
scheduleRecord.id,
|
|
497
|
+
{
|
|
498
|
+
ownerId,
|
|
499
|
+
lastRunAt:
|
|
500
|
+
scheduledFor,
|
|
501
|
+
nextRunAt,
|
|
502
|
+
updatedAt:
|
|
503
|
+
currentTime,
|
|
504
|
+
}
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return enqueued;
|
|
509
|
+
},
|
|
510
|
+
|
|
511
|
+
start(
|
|
512
|
+
startOptions = {}
|
|
513
|
+
) {
|
|
514
|
+
const runner =
|
|
515
|
+
createSchedulerRunner(
|
|
516
|
+
scheduler,
|
|
517
|
+
startOptions,
|
|
518
|
+
() =>
|
|
519
|
+
runners.delete(
|
|
520
|
+
runner
|
|
521
|
+
)
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
runners.add(runner);
|
|
525
|
+
return runner;
|
|
526
|
+
},
|
|
527
|
+
|
|
528
|
+
async close() {
|
|
529
|
+
await Promise.all(
|
|
530
|
+
Array.from(
|
|
531
|
+
runners,
|
|
532
|
+
runner =>
|
|
533
|
+
runner.stop()
|
|
534
|
+
)
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
if (store.close) {
|
|
538
|
+
await store.close();
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
return scheduler;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function nextScheduleTime(
|
|
547
|
+
schedule: JobSchedule,
|
|
548
|
+
after: number
|
|
549
|
+
): number {
|
|
550
|
+
const timestamp =
|
|
551
|
+
normalizeTimestamp(
|
|
552
|
+
after,
|
|
553
|
+
"schedule reference time"
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
if (schedule.kind === "interval") {
|
|
557
|
+
return timestamp +
|
|
558
|
+
normalizePositiveInteger(
|
|
559
|
+
schedule.everyMs,
|
|
560
|
+
"everyMs"
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return nextCronTime(
|
|
565
|
+
schedule.expression,
|
|
566
|
+
timestamp
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export function nextCronTime(
|
|
571
|
+
expression: string,
|
|
572
|
+
after: number
|
|
573
|
+
): number {
|
|
574
|
+
const cron =
|
|
575
|
+
parseCronExpression(
|
|
576
|
+
expression
|
|
577
|
+
);
|
|
578
|
+
const afterTimestamp =
|
|
579
|
+
normalizeTimestamp(
|
|
580
|
+
after,
|
|
581
|
+
"cron reference time"
|
|
582
|
+
);
|
|
583
|
+
const minute =
|
|
584
|
+
60_000;
|
|
585
|
+
let candidate =
|
|
586
|
+
Math.floor(
|
|
587
|
+
afterTimestamp /
|
|
588
|
+
minute
|
|
589
|
+
) * minute + minute;
|
|
590
|
+
const maxIterations =
|
|
591
|
+
366 * 24 * 60 * 8;
|
|
592
|
+
|
|
593
|
+
for (
|
|
594
|
+
let iteration = 0;
|
|
595
|
+
iteration < maxIterations;
|
|
596
|
+
iteration += 1
|
|
597
|
+
) {
|
|
598
|
+
const date =
|
|
599
|
+
new Date(candidate);
|
|
600
|
+
|
|
601
|
+
if (
|
|
602
|
+
cron.month.has(
|
|
603
|
+
date.getUTCMonth() + 1
|
|
604
|
+
) &&
|
|
605
|
+
cron.hour.has(
|
|
606
|
+
date.getUTCHours()
|
|
607
|
+
) &&
|
|
608
|
+
cron.minute.has(
|
|
609
|
+
date.getUTCMinutes()
|
|
610
|
+
) &&
|
|
611
|
+
matchesCronDay(
|
|
612
|
+
cron,
|
|
613
|
+
date
|
|
614
|
+
)
|
|
615
|
+
) {
|
|
616
|
+
return candidate;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
candidate += minute;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
throw new RangeError(
|
|
623
|
+
`BCP Jobs: cron expression "${expression}" did not produce a run time within 8 years.`
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
interface InternalSchedulerRunner
|
|
628
|
+
extends JobSchedulerRunner {}
|
|
629
|
+
|
|
630
|
+
function createSchedulerRunner(
|
|
631
|
+
scheduler: JobScheduler,
|
|
632
|
+
options: StartJobSchedulerOptions,
|
|
633
|
+
onStop: () => void
|
|
634
|
+
): InternalSchedulerRunner {
|
|
635
|
+
const pollIntervalMs =
|
|
636
|
+
normalizePositiveInteger(
|
|
637
|
+
options.pollIntervalMs ??
|
|
638
|
+
1_000,
|
|
639
|
+
"pollIntervalMs"
|
|
640
|
+
);
|
|
641
|
+
const runOptions:
|
|
642
|
+
RunDueSchedulesOptions = {
|
|
643
|
+
limit:
|
|
644
|
+
options.limit,
|
|
645
|
+
leaseMs:
|
|
646
|
+
options.leaseMs,
|
|
647
|
+
};
|
|
648
|
+
const controller =
|
|
649
|
+
new AbortController();
|
|
650
|
+
let running = true;
|
|
651
|
+
let stopPromise:
|
|
652
|
+
Promise<void> | null =
|
|
653
|
+
null;
|
|
654
|
+
|
|
655
|
+
const loop =
|
|
656
|
+
runSchedulerLoop(
|
|
657
|
+
scheduler,
|
|
658
|
+
controller.signal,
|
|
659
|
+
pollIntervalMs,
|
|
660
|
+
runOptions,
|
|
661
|
+
options.onError
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
return {
|
|
665
|
+
get running() {
|
|
666
|
+
return running;
|
|
667
|
+
},
|
|
668
|
+
|
|
669
|
+
stop() {
|
|
670
|
+
if (stopPromise) {
|
|
671
|
+
return stopPromise;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
running = false;
|
|
675
|
+
controller.abort();
|
|
676
|
+
stopPromise =
|
|
677
|
+
loop.finally(
|
|
678
|
+
onStop
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
return stopPromise;
|
|
682
|
+
},
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async function runSchedulerLoop(
|
|
687
|
+
scheduler: JobScheduler,
|
|
688
|
+
signal: AbortSignal,
|
|
689
|
+
pollIntervalMs: number,
|
|
690
|
+
runOptions: RunDueSchedulesOptions,
|
|
691
|
+
onError:
|
|
692
|
+
StartJobSchedulerOptions["onError"]
|
|
693
|
+
): Promise<void> {
|
|
694
|
+
while (!signal.aborted) {
|
|
695
|
+
try {
|
|
696
|
+
await scheduler.runDue(
|
|
697
|
+
runOptions
|
|
698
|
+
);
|
|
699
|
+
} catch (error) {
|
|
700
|
+
if (onError) {
|
|
701
|
+
await onError(error);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (!signal.aborted) {
|
|
706
|
+
await sleep(
|
|
707
|
+
pollIntervalMs,
|
|
708
|
+
signal
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function normalizeSchedule(
|
|
715
|
+
options: ScheduleJobOptions
|
|
716
|
+
): JobSchedule {
|
|
717
|
+
if (!options) {
|
|
718
|
+
throw new TypeError(
|
|
719
|
+
"BCP Jobs: schedule options are required."
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const hasInterval =
|
|
724
|
+
options.everyMs !== undefined;
|
|
725
|
+
const hasCron =
|
|
726
|
+
options.cron !== undefined;
|
|
727
|
+
|
|
728
|
+
if (
|
|
729
|
+
hasInterval === hasCron
|
|
730
|
+
) {
|
|
731
|
+
throw new TypeError(
|
|
732
|
+
"BCP Jobs: schedule requires exactly one of everyMs or cron."
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (hasInterval) {
|
|
737
|
+
return {
|
|
738
|
+
kind: "interval",
|
|
739
|
+
everyMs:
|
|
740
|
+
normalizePositiveInteger(
|
|
741
|
+
options.everyMs as number,
|
|
742
|
+
"everyMs"
|
|
743
|
+
),
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const expression =
|
|
748
|
+
String(options.cron)
|
|
749
|
+
.trim();
|
|
750
|
+
|
|
751
|
+
parseCronExpression(
|
|
752
|
+
expression
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
return {
|
|
756
|
+
kind: "cron",
|
|
757
|
+
expression,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
interface ParsedCronExpression {
|
|
762
|
+
minute: Set<number>;
|
|
763
|
+
hour: Set<number>;
|
|
764
|
+
dayOfMonth: Set<number>;
|
|
765
|
+
month: Set<number>;
|
|
766
|
+
dayOfWeek: Set<number>;
|
|
767
|
+
dayOfMonthWildcard: boolean;
|
|
768
|
+
dayOfWeekWildcard: boolean;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function parseCronExpression(
|
|
772
|
+
expression: string
|
|
773
|
+
): ParsedCronExpression {
|
|
774
|
+
const parts =
|
|
775
|
+
String(expression)
|
|
776
|
+
.trim()
|
|
777
|
+
.split(/\s+/);
|
|
778
|
+
|
|
779
|
+
if (parts.length !== 5) {
|
|
780
|
+
throw new TypeError(
|
|
781
|
+
"BCP Jobs: cron must contain 5 fields: minute hour day-of-month month day-of-week."
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return {
|
|
786
|
+
minute:
|
|
787
|
+
parseCronField(
|
|
788
|
+
parts[0],
|
|
789
|
+
0,
|
|
790
|
+
59,
|
|
791
|
+
"minute"
|
|
792
|
+
).values,
|
|
793
|
+
hour:
|
|
794
|
+
parseCronField(
|
|
795
|
+
parts[1],
|
|
796
|
+
0,
|
|
797
|
+
23,
|
|
798
|
+
"hour"
|
|
799
|
+
).values,
|
|
800
|
+
dayOfMonth:
|
|
801
|
+
parseCronField(
|
|
802
|
+
parts[2],
|
|
803
|
+
1,
|
|
804
|
+
31,
|
|
805
|
+
"day-of-month"
|
|
806
|
+
).values,
|
|
807
|
+
month:
|
|
808
|
+
parseCronField(
|
|
809
|
+
parts[3],
|
|
810
|
+
1,
|
|
811
|
+
12,
|
|
812
|
+
"month"
|
|
813
|
+
).values,
|
|
814
|
+
dayOfWeek:
|
|
815
|
+
parseCronField(
|
|
816
|
+
parts[4],
|
|
817
|
+
0,
|
|
818
|
+
7,
|
|
819
|
+
"day-of-week",
|
|
820
|
+
true
|
|
821
|
+
).values,
|
|
822
|
+
dayOfMonthWildcard:
|
|
823
|
+
parts[2] === "*",
|
|
824
|
+
dayOfWeekWildcard:
|
|
825
|
+
parts[4] === "*",
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function parseCronField(
|
|
830
|
+
source: string,
|
|
831
|
+
minimum: number,
|
|
832
|
+
maximum: number,
|
|
833
|
+
field: string,
|
|
834
|
+
normalizeSunday = false
|
|
835
|
+
): {
|
|
836
|
+
values: Set<number>;
|
|
837
|
+
} {
|
|
838
|
+
const values =
|
|
839
|
+
new Set<number>();
|
|
840
|
+
|
|
841
|
+
for (
|
|
842
|
+
const segment
|
|
843
|
+
of source.split(",")
|
|
844
|
+
) {
|
|
845
|
+
const [base, stepSource] =
|
|
846
|
+
segment.split("/");
|
|
847
|
+
|
|
848
|
+
if (
|
|
849
|
+
segment.split("/").length > 2
|
|
850
|
+
) {
|
|
851
|
+
throwCronField(
|
|
852
|
+
field,
|
|
853
|
+
source
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const step =
|
|
858
|
+
stepSource === undefined
|
|
859
|
+
? 1
|
|
860
|
+
: parseCronInteger(
|
|
861
|
+
stepSource,
|
|
862
|
+
1,
|
|
863
|
+
maximum - minimum + 1,
|
|
864
|
+
field,
|
|
865
|
+
source
|
|
866
|
+
);
|
|
867
|
+
let rangeStart:
|
|
868
|
+
number;
|
|
869
|
+
let rangeEnd:
|
|
870
|
+
number;
|
|
871
|
+
|
|
872
|
+
if (base === "*") {
|
|
873
|
+
rangeStart = minimum;
|
|
874
|
+
rangeEnd = maximum;
|
|
875
|
+
} else if (
|
|
876
|
+
base.includes("-")
|
|
877
|
+
) {
|
|
878
|
+
const bounds =
|
|
879
|
+
base.split("-");
|
|
880
|
+
|
|
881
|
+
if (bounds.length !== 2) {
|
|
882
|
+
throwCronField(
|
|
883
|
+
field,
|
|
884
|
+
source
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
rangeStart =
|
|
889
|
+
parseCronInteger(
|
|
890
|
+
bounds[0],
|
|
891
|
+
minimum,
|
|
892
|
+
maximum,
|
|
893
|
+
field,
|
|
894
|
+
source
|
|
895
|
+
);
|
|
896
|
+
rangeEnd =
|
|
897
|
+
parseCronInteger(
|
|
898
|
+
bounds[1],
|
|
899
|
+
minimum,
|
|
900
|
+
maximum,
|
|
901
|
+
field,
|
|
902
|
+
source
|
|
903
|
+
);
|
|
904
|
+
|
|
905
|
+
if (
|
|
906
|
+
rangeEnd < rangeStart
|
|
907
|
+
) {
|
|
908
|
+
throwCronField(
|
|
909
|
+
field,
|
|
910
|
+
source
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
} else {
|
|
914
|
+
rangeStart =
|
|
915
|
+
parseCronInteger(
|
|
916
|
+
base,
|
|
917
|
+
minimum,
|
|
918
|
+
maximum,
|
|
919
|
+
field,
|
|
920
|
+
source
|
|
921
|
+
);
|
|
922
|
+
rangeEnd =
|
|
923
|
+
rangeStart;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
for (
|
|
927
|
+
let value = rangeStart;
|
|
928
|
+
value <= rangeEnd;
|
|
929
|
+
value += step
|
|
930
|
+
) {
|
|
931
|
+
values.add(
|
|
932
|
+
normalizeSunday &&
|
|
933
|
+
value === 7
|
|
934
|
+
? 0
|
|
935
|
+
: value
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (values.size === 0) {
|
|
941
|
+
throwCronField(
|
|
942
|
+
field,
|
|
943
|
+
source
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
return {
|
|
948
|
+
values,
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function parseCronInteger(
|
|
953
|
+
source: string,
|
|
954
|
+
minimum: number,
|
|
955
|
+
maximum: number,
|
|
956
|
+
field: string,
|
|
957
|
+
fullSource: string
|
|
958
|
+
): number {
|
|
959
|
+
if (!/^\d+$/.test(source)) {
|
|
960
|
+
throwCronField(
|
|
961
|
+
field,
|
|
962
|
+
fullSource
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const value =
|
|
967
|
+
Number(source);
|
|
968
|
+
|
|
969
|
+
if (
|
|
970
|
+
!Number.isInteger(value) ||
|
|
971
|
+
value < minimum ||
|
|
972
|
+
value > maximum
|
|
973
|
+
) {
|
|
974
|
+
throwCronField(
|
|
975
|
+
field,
|
|
976
|
+
fullSource
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return value;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function throwCronField(
|
|
984
|
+
field: string,
|
|
985
|
+
source: string
|
|
986
|
+
): never {
|
|
987
|
+
throw new TypeError(
|
|
988
|
+
`BCP Jobs: invalid cron ${field} field "${source}".`
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function matchesCronDay(
|
|
993
|
+
cron: ParsedCronExpression,
|
|
994
|
+
date: Date
|
|
995
|
+
): boolean {
|
|
996
|
+
const dayOfMonthMatches =
|
|
997
|
+
cron.dayOfMonth.has(
|
|
998
|
+
date.getUTCDate()
|
|
999
|
+
);
|
|
1000
|
+
const dayOfWeekMatches =
|
|
1001
|
+
cron.dayOfWeek.has(
|
|
1002
|
+
date.getUTCDay()
|
|
1003
|
+
);
|
|
1004
|
+
|
|
1005
|
+
if (
|
|
1006
|
+
cron.dayOfMonthWildcard &&
|
|
1007
|
+
cron.dayOfWeekWildcard
|
|
1008
|
+
) {
|
|
1009
|
+
return true;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
if (cron.dayOfMonthWildcard) {
|
|
1013
|
+
return dayOfWeekMatches;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (cron.dayOfWeekWildcard) {
|
|
1017
|
+
return dayOfMonthMatches;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
return dayOfMonthMatches ||
|
|
1021
|
+
dayOfWeekMatches;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function createScheduledRunId(
|
|
1025
|
+
scheduleId: string,
|
|
1026
|
+
scheduledFor: number
|
|
1027
|
+
): string {
|
|
1028
|
+
return `schedule:${scheduleId}:${scheduledFor}`;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function isDuplicateJobIdError(
|
|
1032
|
+
error: unknown
|
|
1033
|
+
): boolean {
|
|
1034
|
+
return error instanceof Error &&
|
|
1035
|
+
/job id .* already exists/i.test(
|
|
1036
|
+
error.message
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function normalizeIdentifier(
|
|
1041
|
+
value: string,
|
|
1042
|
+
field: string
|
|
1043
|
+
): string {
|
|
1044
|
+
const normalized =
|
|
1045
|
+
String(value)
|
|
1046
|
+
.trim();
|
|
1047
|
+
|
|
1048
|
+
if (!normalized) {
|
|
1049
|
+
throw new TypeError(
|
|
1050
|
+
`BCP Jobs: ${field} must be a non-empty string.`
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (normalized.length > 200) {
|
|
1055
|
+
throw new TypeError(
|
|
1056
|
+
`BCP Jobs: ${field} must not exceed 200 characters.`
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
return normalized;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function normalizeTimestamp(
|
|
1064
|
+
value: number,
|
|
1065
|
+
field: string
|
|
1066
|
+
): number {
|
|
1067
|
+
if (
|
|
1068
|
+
!Number.isFinite(value) ||
|
|
1069
|
+
value < 0
|
|
1070
|
+
) {
|
|
1071
|
+
throw new TypeError(
|
|
1072
|
+
`BCP Jobs: ${field} must be a non-negative finite timestamp.`
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
return Math.floor(value);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function normalizePositiveInteger(
|
|
1080
|
+
value: number,
|
|
1081
|
+
field: string
|
|
1082
|
+
): number {
|
|
1083
|
+
if (
|
|
1084
|
+
!Number.isInteger(value) ||
|
|
1085
|
+
value <= 0
|
|
1086
|
+
) {
|
|
1087
|
+
throw new TypeError(
|
|
1088
|
+
`BCP Jobs: ${field} must be a positive integer.`
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
return value;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function sleep(
|
|
1096
|
+
durationMs: number,
|
|
1097
|
+
signal: AbortSignal
|
|
1098
|
+
): Promise<void> {
|
|
1099
|
+
if (signal.aborted) {
|
|
1100
|
+
return Promise.resolve();
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
return new Promise(
|
|
1104
|
+
resolve => {
|
|
1105
|
+
const timeout =
|
|
1106
|
+
setTimeout(
|
|
1107
|
+
finish,
|
|
1108
|
+
durationMs
|
|
1109
|
+
);
|
|
1110
|
+
const onAbort =
|
|
1111
|
+
() => finish();
|
|
1112
|
+
|
|
1113
|
+
signal.addEventListener(
|
|
1114
|
+
"abort",
|
|
1115
|
+
onAbort,
|
|
1116
|
+
{
|
|
1117
|
+
once: true,
|
|
1118
|
+
}
|
|
1119
|
+
);
|
|
1120
|
+
|
|
1121
|
+
function finish() {
|
|
1122
|
+
clearTimeout(timeout);
|
|
1123
|
+
signal.removeEventListener(
|
|
1124
|
+
"abort",
|
|
1125
|
+
onAbort
|
|
1126
|
+
);
|
|
1127
|
+
resolve();
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function cloneSchedule<
|
|
1134
|
+
TPayload
|
|
1135
|
+
>(
|
|
1136
|
+
schedule: JobScheduleRecord<TPayload>
|
|
1137
|
+
): JobScheduleRecord<TPayload> {
|
|
1138
|
+
return {
|
|
1139
|
+
...schedule,
|
|
1140
|
+
schedule: {
|
|
1141
|
+
...schedule.schedule,
|
|
1142
|
+
},
|
|
1143
|
+
};
|
|
1144
|
+
}
|