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