@lunora/scheduler 1.0.0-alpha.1 → 1.0.0-alpha.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/LICENSE.md +6 -0
- package/__assets__/package-og.svg +1 -1
- package/dist/index.d.mts +429 -361
- package/dist/index.d.ts +429 -361
- package/dist/index.mjs +7 -7
- package/dist/packem_shared/{compileCronSchedule-BaLlXJiN.mjs → CRON_SCHEDULE_KINDS-C2FflEnq.mjs} +28 -11
- package/dist/packem_shared/{SchedulerDO-BNzXNnS4.mjs → SchedulerDO-DeJHI379.mjs} +80 -53
- package/dist/packem_shared/{assertValidCronExpression-BLfrDgmK.mjs → assertValidCronExpression-B9m75qU0.mjs} +5 -1
- package/dist/packem_shared/{createCronTrigger-Cq9IBcWQ.mjs → createCronTrigger-Dh4VLxD9.mjs} +3 -2
- package/dist/packem_shared/{createQueueConsumer-DWahNPfz.mjs → createQueueConsumer-Cy-Mp-El.mjs} +5 -3
- package/dist/packem_shared/createScheduler-Df9JUjQb.mjs +52 -0
- package/dist/packem_shared/createWorkpool-Ce0kNM2p.mjs +37 -0
- package/dist/packem_shared/do-client-CMJtLoHO.mjs +42 -0
- package/package.json +4 -3
- package/dist/packem_shared/createScheduler-KCso4_at.mjs +0 -63
- package/dist/packem_shared/createWorkpool-D5cWPQmH.mjs +0 -56
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export { default as createScheduler } from './packem_shared/createScheduler-
|
|
2
|
-
export { default as createWorkpool } from './packem_shared/createWorkpool-
|
|
3
|
-
export { createCronTrigger } from './packem_shared/createCronTrigger-
|
|
4
|
-
export { CRON_SCHEDULE_KINDS, compileCronSchedule, cronJobs } from './packem_shared/
|
|
5
|
-
export { createQueueConsumer, createQueueWorkpool, httpDispatcher } from './packem_shared/createQueueConsumer-
|
|
6
|
-
export { SchedulerDO } from './packem_shared/SchedulerDO-
|
|
1
|
+
export { default as createScheduler } from './packem_shared/createScheduler-Df9JUjQb.mjs';
|
|
2
|
+
export { default as createWorkpool } from './packem_shared/createWorkpool-Ce0kNM2p.mjs';
|
|
3
|
+
export { createCronTrigger } from './packem_shared/createCronTrigger-Dh4VLxD9.mjs';
|
|
4
|
+
export { CRON_SCHEDULE_KINDS, compileCronSchedule, cronJobs } from './packem_shared/CRON_SCHEDULE_KINDS-C2FflEnq.mjs';
|
|
5
|
+
export { createQueueConsumer, createQueueWorkpool, httpDispatcher } from './packem_shared/createQueueConsumer-Cy-Mp-El.mjs';
|
|
6
|
+
export { SchedulerDO } from './packem_shared/SchedulerDO-DeJHI379.mjs';
|
|
7
7
|
export { isWorkflowReference } from './packem_shared/isWorkflowReference-C9mQkMXt.mjs';
|
|
8
|
-
export { assertValidCronExpression, isValidCronExpression } from './packem_shared/assertValidCronExpression-
|
|
8
|
+
export { assertValidCronExpression, isValidCronExpression } from './packem_shared/assertValidCronExpression-B9m75qU0.mjs';
|
package/dist/packem_shared/{compileCronSchedule-BaLlXJiN.mjs → CRON_SCHEDULE_KINDS-C2FflEnq.mjs}
RENAMED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { LunoraError } from '@lunora/errors';
|
|
1
2
|
import { isWorkflowReference } from './isWorkflowReference-C9mQkMXt.mjs';
|
|
2
|
-
import { assertValidCronExpression } from './assertValidCronExpression-
|
|
3
|
+
import { assertValidCronExpression } from './assertValidCronExpression-B9m75qU0.mjs';
|
|
3
4
|
|
|
4
5
|
const WEEKDAY_INDEX = {
|
|
5
6
|
friday: 5,
|
|
@@ -12,24 +13,37 @@ const WEEKDAY_INDEX = {
|
|
|
12
13
|
};
|
|
13
14
|
const field = (value, label, min, max) => {
|
|
14
15
|
if (!Number.isInteger(value) || value < min || value > max) {
|
|
15
|
-
throw new
|
|
16
|
+
throw new LunoraError(
|
|
17
|
+
"INTERNAL",
|
|
18
|
+
`@lunora/scheduler: cronJobs ${label} must be an integer in [${min.toFixed(0)}, ${max.toFixed(0)}], got ${String(value)}`
|
|
19
|
+
);
|
|
16
20
|
}
|
|
17
21
|
return value.toFixed(0);
|
|
18
22
|
};
|
|
23
|
+
const stepField = (value, label, period) => {
|
|
24
|
+
const rendered = field(value, label, 1, period - 1);
|
|
25
|
+
if (period % value !== 0) {
|
|
26
|
+
throw new LunoraError(
|
|
27
|
+
"INTERNAL",
|
|
28
|
+
`@lunora/scheduler: ${label} must evenly divide ${period.toFixed(0)} for a fixed "every ${value.toFixed(0)}" interval — cron "*/${value.toFixed(0)}" means "at values divisible by ${value.toFixed(0)}", which wraps unevenly; pick a divisor of ${period.toFixed(0)}`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return rendered;
|
|
32
|
+
};
|
|
19
33
|
const compileInterval = (schedule) => {
|
|
20
34
|
const units = ["seconds", "minutes", "hours"].filter((unit2) => schedule[unit2] !== void 0);
|
|
21
35
|
if (units.length !== 1) {
|
|
22
|
-
throw new
|
|
36
|
+
throw new LunoraError("INTERNAL", `@lunora/scheduler: interval schedule must specify exactly one of { seconds, minutes, hours }`);
|
|
23
37
|
}
|
|
24
38
|
const unit = units[0];
|
|
25
39
|
const value = schedule[unit];
|
|
26
40
|
if (unit === "seconds") {
|
|
27
|
-
return `*/${
|
|
41
|
+
return `*/${stepField(value, "interval.seconds", 60)} * * * * *`;
|
|
28
42
|
}
|
|
29
43
|
if (unit === "minutes") {
|
|
30
|
-
return `*/${
|
|
44
|
+
return `*/${stepField(value, "interval.minutes", 60)} * * * *`;
|
|
31
45
|
}
|
|
32
|
-
return `0 */${
|
|
46
|
+
return `0 */${stepField(value, "interval.hours", 24)} * * *`;
|
|
33
47
|
};
|
|
34
48
|
const compileDaily = (schedule) => {
|
|
35
49
|
const minute = field(schedule.minuteUTC, "daily.minuteUTC", 0, 59);
|
|
@@ -39,7 +53,7 @@ const compileDaily = (schedule) => {
|
|
|
39
53
|
const compileWeekly = (schedule) => {
|
|
40
54
|
const index = WEEKDAY_INDEX[schedule.dayOfWeek];
|
|
41
55
|
if (index === void 0) {
|
|
42
|
-
throw new
|
|
56
|
+
throw new LunoraError("INTERNAL", `@lunora/scheduler: weekly schedule has invalid dayOfWeek "${schedule.dayOfWeek}"`);
|
|
43
57
|
}
|
|
44
58
|
const minute = field(schedule.minuteUTC, "weekly.minuteUTC", 0, 59);
|
|
45
59
|
const hour = field(schedule.hourUTC, "weekly.hourUTC", 0, 23);
|
|
@@ -67,7 +81,7 @@ const compileCronSchedule = (kind, schedule) => {
|
|
|
67
81
|
return compileWeekly(schedule);
|
|
68
82
|
}
|
|
69
83
|
default: {
|
|
70
|
-
throw new
|
|
84
|
+
throw new LunoraError("INTERNAL", `@lunora/scheduler: unknown cron schedule kind "${String(kind)}"`);
|
|
71
85
|
}
|
|
72
86
|
}
|
|
73
87
|
};
|
|
@@ -76,10 +90,10 @@ const cronJobs = () => {
|
|
|
76
90
|
const seen = /* @__PURE__ */ new Set();
|
|
77
91
|
const register = (name, cron, target, args) => {
|
|
78
92
|
if (typeof name !== "string" || name.trim() === "") {
|
|
79
|
-
throw new
|
|
93
|
+
throw new LunoraError("INTERNAL", `@lunora/scheduler: cron job name must be a non-empty string`);
|
|
80
94
|
}
|
|
81
95
|
if (seen.has(name)) {
|
|
82
|
-
throw new
|
|
96
|
+
throw new LunoraError("INTERNAL", `@lunora/scheduler: duplicate cron job name "${name}" — names must be unique within one cronJobs()`);
|
|
83
97
|
}
|
|
84
98
|
if (isWorkflowReference(target)) {
|
|
85
99
|
assertValidCronExpression(cron, `cron expression for job "${name}"`);
|
|
@@ -88,7 +102,10 @@ const cronJobs = () => {
|
|
|
88
102
|
return;
|
|
89
103
|
}
|
|
90
104
|
if (!target || typeof target.__lunoraRef !== "string") {
|
|
91
|
-
throw new
|
|
105
|
+
throw new LunoraError(
|
|
106
|
+
"INTERNAL",
|
|
107
|
+
`@lunora/scheduler: cron job "${name}" requires a function reference (e.g. internal.email.digest) or a workflow reference`
|
|
108
|
+
);
|
|
92
109
|
}
|
|
93
110
|
assertValidCronExpression(cron, `cron expression for job "${name}"`);
|
|
94
111
|
seen.add(name);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const jsonResponse = (body, status = 200, headers) => Response.json(body, { headers: { "content-type": "application/json", ...headers }, status });
|
|
2
|
+
|
|
1
3
|
const HEADER_PREFIX = "id:";
|
|
2
4
|
const RETRY_PREFIX = "retry:";
|
|
3
5
|
const DEAD_PREFIX = "dead:";
|
|
@@ -5,7 +7,7 @@ const POOL_PREFIX = "pool:";
|
|
|
5
7
|
const MAX_RETRY_ATTEMPTS = 5;
|
|
6
8
|
const RETRY_BASE_DELAY_MS = 3e4;
|
|
7
9
|
const POOL_BACKPRESSURE_DELAY_MS = 1e3;
|
|
8
|
-
const MAX_SCHEDULED_FOR_MS =
|
|
10
|
+
const MAX_SCHEDULED_FOR_MS = 999999999999999;
|
|
9
11
|
const TIME_PAD = 15;
|
|
10
12
|
const padTime = (n) => String(n).padStart(TIME_PAD, "0");
|
|
11
13
|
const generateId = () => {
|
|
@@ -21,7 +23,7 @@ class SchedulerDO {
|
|
|
21
23
|
return `t:${padTime(scheduledFor)}:${id}`;
|
|
22
24
|
}
|
|
23
25
|
static json(body, status = 200) {
|
|
24
|
-
return
|
|
26
|
+
return jsonResponse(body, status);
|
|
25
27
|
}
|
|
26
28
|
static error(status, code, message) {
|
|
27
29
|
return SchedulerDO.json({ error: { code, message } }, status);
|
|
@@ -100,6 +102,20 @@ class SchedulerDO {
|
|
|
100
102
|
const next = pool.inFlightIds.slice(0, Math.max(0, pool.inFlightIds.length - 1));
|
|
101
103
|
return { ...pool, inFlight: next.length, inFlightIds: next };
|
|
102
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Normalize the mutually-exclusive dispatch target off an untrusted body: a
|
|
107
|
+
* one-shot function path (`functionPath`) or a durable workflow/agent
|
|
108
|
+
* instance (`workflow`, a `WORKFLOW_*`/`AGENT_*` binding). Returns `undefined`
|
|
109
|
+
* when neither is present so the caller can reject the schedule.
|
|
110
|
+
*/
|
|
111
|
+
static resolveScheduleTarget(body) {
|
|
112
|
+
const functionPath = typeof body?.functionPath === "string" && body.functionPath.length > 0 ? body.functionPath : void 0;
|
|
113
|
+
const workflow = typeof body?.workflow === "string" && body.workflow.length > 0 ? body.workflow : void 0;
|
|
114
|
+
if (functionPath === void 0 && workflow === void 0) {
|
|
115
|
+
return void 0;
|
|
116
|
+
}
|
|
117
|
+
return { functionPath, workflow };
|
|
118
|
+
}
|
|
103
119
|
state;
|
|
104
120
|
env;
|
|
105
121
|
constructor(state, env) {
|
|
@@ -143,13 +159,7 @@ class SchedulerDO {
|
|
|
143
159
|
return this.handleSchedule(request);
|
|
144
160
|
}
|
|
145
161
|
}
|
|
146
|
-
return
|
|
147
|
-
{ error: { code: "NOT_FOUND" } },
|
|
148
|
-
{
|
|
149
|
-
headers: { "content-type": "application/json" },
|
|
150
|
-
status: 404
|
|
151
|
-
}
|
|
152
|
-
);
|
|
162
|
+
return jsonResponse({ error: { code: "NOT_FOUND" } }, 404);
|
|
153
163
|
}
|
|
154
164
|
/** Called by the Workers runtime when the alarm previously set by `_rescheduleAlarm()` fires. */
|
|
155
165
|
async alarm() {
|
|
@@ -166,13 +176,14 @@ class SchedulerDO {
|
|
|
166
176
|
const record = await this.state.storage.get(`${HEADER_PREFIX}${recordId}`);
|
|
167
177
|
if (record) {
|
|
168
178
|
due.push(record);
|
|
179
|
+
} else {
|
|
180
|
+
await this.state.storage.delete(indexKey);
|
|
169
181
|
}
|
|
170
182
|
}
|
|
171
183
|
}
|
|
172
|
-
const pools = /* @__PURE__ */ new Map();
|
|
173
184
|
try {
|
|
174
185
|
for (const record of due) {
|
|
175
|
-
await this.drainRecordGuarded(record
|
|
186
|
+
await this.drainRecordGuarded(record);
|
|
176
187
|
}
|
|
177
188
|
} finally {
|
|
178
189
|
await this.rescheduleAlarm();
|
|
@@ -214,7 +225,12 @@ class SchedulerDO {
|
|
|
214
225
|
// so the pool's concurrency slot is released — see handleComplete().
|
|
215
226
|
pool: record.pool,
|
|
216
227
|
scheduledFor: record.scheduledFor,
|
|
217
|
-
shardKey: record.shardKey
|
|
228
|
+
shardKey: record.shardKey,
|
|
229
|
+
// Present instead of `functionPath` for a workflow/agent target; the
|
|
230
|
+
// runtime starts a fresh instance of this binding. `undefined` when
|
|
231
|
+
// absent, so JSON.stringify drops it and the payload is unchanged for
|
|
232
|
+
// ordinary function dispatches.
|
|
233
|
+
workflow: record.workflow
|
|
218
234
|
});
|
|
219
235
|
try {
|
|
220
236
|
const headers = { "content-type": "application/json" };
|
|
@@ -241,27 +257,26 @@ class SchedulerDO {
|
|
|
241
257
|
*
|
|
242
258
|
* Claims the job by deleting its time-index entry BEFORE dispatch (an alarm
|
|
243
259
|
* re-fire then won't pick it up again), runs {@link drainRecord}, and on a
|
|
244
|
-
* thrown storage op
|
|
260
|
+
* thrown storage op re-asserts the claim so the job stays re-fireable.
|
|
245
261
|
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
262
|
+
* A throw reaching here always means the job was NOT dispatched:
|
|
263
|
+
* {@link drainRecord} swallows its own post-dispatch cleanup errors and
|
|
264
|
+
* returns instead of throwing once a kick succeeds, so every escaping throw
|
|
265
|
+
* comes from the pre-dispatch or failed-dispatch paths. We therefore always
|
|
266
|
+
* re-assert the time-index claim so a later alarm re-attempts it
|
|
267
|
+
* (at-least-once): the claim delete may have removed it and
|
|
268
|
+
* recordRetry()/requeuePooled() may not have re-armed it before throwing, and
|
|
269
|
+
* re-inserting the same key is idempotent, so a surviving claim is simply
|
|
270
|
+
* rewritten to its prior value.
|
|
253
271
|
*/
|
|
254
|
-
async drainRecordGuarded(record
|
|
255
|
-
let dispatched = false;
|
|
272
|
+
async drainRecordGuarded(record) {
|
|
256
273
|
try {
|
|
257
274
|
await this.state.storage.delete(SchedulerDO.indexKey(record.scheduledFor, record.id));
|
|
258
|
-
|
|
275
|
+
await this.drainRecord(record);
|
|
259
276
|
} catch {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
} catch {
|
|
264
|
-
}
|
|
277
|
+
try {
|
|
278
|
+
await this.state.storage.put(SchedulerDO.indexKey(record.scheduledFor, record.id), record.id);
|
|
279
|
+
} catch {
|
|
265
280
|
}
|
|
266
281
|
}
|
|
267
282
|
}
|
|
@@ -272,30 +287,29 @@ class SchedulerDO {
|
|
|
272
287
|
* free slot is reserved durably before dispatch and released immediately if
|
|
273
288
|
* the kick fails (success holds it until the runtime reports completion).
|
|
274
289
|
* Success clears the `id:`/`retry:` rows; failure routes to
|
|
275
|
-
* {@link recordRetry}.
|
|
276
|
-
*
|
|
290
|
+
* {@link recordRetry}. Pool state is read FRESH from storage per record (see
|
|
291
|
+
* {@link reservePoolSlot}) and never held across the dispatch() await, so a
|
|
292
|
+
* concurrent /complete landing mid-dispatch can't be clobbered.
|
|
293
|
+
* Once a kick succeeds, post-dispatch cleanup (clearing the `id:`/`retry:`
|
|
294
|
+
* rows) is swallowed rather than allowed to throw, so a successful dispatch
|
|
295
|
+
* NEVER propagates an error to {@link drainRecordGuarded}: every throw that
|
|
296
|
+
* escapes comes from the pre-dispatch or failed-dispatch paths, where the job
|
|
297
|
+
* is still re-fireable and the guard safely re-claims the time index.
|
|
277
298
|
* @returns `true` only when the record was successfully dispatched (a 2xx
|
|
278
|
-
* kick)
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
* an already-kicked job would break idempotency. A `false` return (pool
|
|
282
|
-
* backpressure or a failed dispatch) means the job is still re-fireable —
|
|
283
|
-
* either already re-armed here, or, if a throw escapes, re-claimed by the
|
|
284
|
-
* guard's catch.
|
|
299
|
+
* kick); `false` on pool backpressure or a failed dispatch (the job is still
|
|
300
|
+
* re-fireable — already re-armed here). The value is informational (the guard
|
|
301
|
+
* branches on throw/no-throw, not on this boolean).
|
|
285
302
|
*/
|
|
286
|
-
async drainRecord(record
|
|
287
|
-
const reserved = await this.reservePoolSlot(record
|
|
303
|
+
async drainRecord(record) {
|
|
304
|
+
const reserved = await this.reservePoolSlot(record);
|
|
288
305
|
if (!reserved) {
|
|
289
306
|
return false;
|
|
290
307
|
}
|
|
291
308
|
const ok = await this.dispatch(record);
|
|
292
309
|
if (!ok && record.pool !== void 0) {
|
|
293
|
-
const pool =
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
pools.set(record.pool, released);
|
|
297
|
-
await this.savePool(record.pool, released);
|
|
298
|
-
}
|
|
310
|
+
const pool = await this.loadPool(record.pool);
|
|
311
|
+
const released = SchedulerDO.releaseSlot(pool, record.id);
|
|
312
|
+
await this.savePool(record.pool, released);
|
|
299
313
|
}
|
|
300
314
|
if (ok) {
|
|
301
315
|
try {
|
|
@@ -312,13 +326,20 @@ class SchedulerDO {
|
|
|
312
326
|
* job via {@link requeuePooled}) when the pool is at `maxConcurrency`;
|
|
313
327
|
* otherwise reserves a slot durably and returns `true`. Non-pooled records
|
|
314
328
|
* always return `true` without touching any pool state.
|
|
329
|
+
*
|
|
330
|
+
* The pool row is read FRESH from storage on every call — never cached
|
|
331
|
+
* across the drain. Each reservation durably `savePool()`s before the next
|
|
332
|
+
* record runs, so a same-pass reservation is still visible to the next
|
|
333
|
+
* record's fresh read (the budget carries forward); and because dispatch()
|
|
334
|
+
* awaits an outbound fetch between records, a concurrent /complete that
|
|
335
|
+
* decrements the row mid-drain IS reflected here instead of being clobbered
|
|
336
|
+
* by a stale in-memory copy (which would leak a slot permanently).
|
|
315
337
|
*/
|
|
316
|
-
async reservePoolSlot(record
|
|
338
|
+
async reservePoolSlot(record) {
|
|
317
339
|
if (record.pool === void 0) {
|
|
318
340
|
return true;
|
|
319
341
|
}
|
|
320
|
-
const pool =
|
|
321
|
-
pools.set(record.pool, pool);
|
|
342
|
+
const pool = await this.loadPool(record.pool);
|
|
322
343
|
if (pool.inFlight >= pool.maxConcurrency) {
|
|
323
344
|
await this.requeuePooled(record);
|
|
324
345
|
return false;
|
|
@@ -406,6 +427,9 @@ class SchedulerDO {
|
|
|
406
427
|
if (attempts > maxAttempts) {
|
|
407
428
|
await this.state.storage.put(`${DEAD_PREFIX}${record.id}`, { ...record, attempts });
|
|
408
429
|
await this.state.storage.delete([`${RETRY_PREFIX}${record.id}`, `${HEADER_PREFIX}${record.id}`]);
|
|
430
|
+
console.warn(
|
|
431
|
+
`@lunora/scheduler: job "${record.id}" (${record.functionPath ?? record.workflow ?? "unknown"}) parked in dead-letter after ${String(attempts)} attempts`
|
|
432
|
+
);
|
|
409
433
|
return;
|
|
410
434
|
}
|
|
411
435
|
const rawDelay = backoff === "linear" ? baseMs * attempts : baseMs * 2 ** (attempts - 1);
|
|
@@ -521,11 +545,13 @@ class SchedulerDO {
|
|
|
521
545
|
}
|
|
522
546
|
async handleSchedule(request) {
|
|
523
547
|
const body = await request.json().catch(() => void 0);
|
|
524
|
-
|
|
525
|
-
|
|
548
|
+
const target = SchedulerDO.resolveScheduleTarget(body);
|
|
549
|
+
if (!body || target === void 0) {
|
|
550
|
+
return SchedulerDO.error(400, "INVALID_INPUT", "functionPath or workflow is required");
|
|
526
551
|
}
|
|
552
|
+
const { functionPath, workflow } = target;
|
|
527
553
|
if (typeof body.scheduledFor !== "number" || !Number.isInteger(body.scheduledFor) || body.scheduledFor <= 0 || body.scheduledFor > MAX_SCHEDULED_FOR_MS) {
|
|
528
|
-
return SchedulerDO.error(400, "INVALID_INPUT", "scheduledFor must be a positive integer epoch-millisecond number no greater than
|
|
554
|
+
return SchedulerDO.error(400, "INVALID_INPUT", "scheduledFor must be a positive integer epoch-millisecond number no greater than 999999999999999");
|
|
529
555
|
}
|
|
530
556
|
if (typeof this.env.LUNORA_ORIGIN_URL !== "string" || this.env.LUNORA_ORIGIN_URL.length === 0) {
|
|
531
557
|
return SchedulerDO.error(500, "ORIGIN_NOT_CONFIGURED", "LUNORA_ORIGIN_URL env binding must be set on the SchedulerDO");
|
|
@@ -540,13 +566,14 @@ class SchedulerDO {
|
|
|
540
566
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- parsed wire data can omit args
|
|
541
567
|
args: body.args ?? {},
|
|
542
568
|
enqueuedAt: Date.now(),
|
|
543
|
-
functionPath: body.functionPath,
|
|
544
569
|
id,
|
|
570
|
+
...functionPath === void 0 ? {} : { functionPath },
|
|
545
571
|
...instanceName === void 0 ? {} : { instanceName },
|
|
546
572
|
...pool === void 0 ? {} : { pool },
|
|
547
573
|
...retry === void 0 ? {} : { retry },
|
|
548
574
|
scheduledFor: body.scheduledFor,
|
|
549
|
-
shardKey: body.shardKey
|
|
575
|
+
shardKey: body.shardKey,
|
|
576
|
+
...workflow === void 0 ? {} : { workflow }
|
|
550
577
|
};
|
|
551
578
|
if (pool !== void 0) {
|
|
552
579
|
const current = await this.loadPool(pool, body.maxConcurrency);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LunoraError } from '@lunora/errors';
|
|
1
2
|
import { CronExpressionParser } from 'cron-parser';
|
|
2
3
|
|
|
3
4
|
const isValidCronExpression = (schedule) => {
|
|
@@ -13,7 +14,10 @@ const isValidCronExpression = (schedule) => {
|
|
|
13
14
|
};
|
|
14
15
|
const assertValidCronExpression = (schedule, context = "cron expression") => {
|
|
15
16
|
if (!isValidCronExpression(schedule)) {
|
|
16
|
-
throw new
|
|
17
|
+
throw new LunoraError(
|
|
18
|
+
"INTERNAL",
|
|
19
|
+
`@lunora/scheduler: invalid ${context} "${schedule}" — expected a standard 5- or 6-field cron expression (e.g. "0 * * * *")`
|
|
20
|
+
);
|
|
17
21
|
}
|
|
18
22
|
};
|
|
19
23
|
|
package/dist/packem_shared/{createCronTrigger-Cq9IBcWQ.mjs → createCronTrigger-Dh4VLxD9.mjs}
RENAMED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { LunoraError } from '@lunora/errors';
|
|
2
|
+
import { assertValidCronExpression } from './assertValidCronExpression-B9m75qU0.mjs';
|
|
2
3
|
|
|
3
4
|
const createCronTrigger = (options) => {
|
|
4
5
|
if (!options.schedule || !options.fn) {
|
|
5
|
-
throw new
|
|
6
|
+
throw new LunoraError("INTERNAL", "@lunora/scheduler: createCronTrigger() requires `schedule` and `fn`");
|
|
6
7
|
}
|
|
7
8
|
assertValidCronExpression(options.schedule);
|
|
8
9
|
const snippet = JSON.stringify(
|
package/dist/packem_shared/{createQueueConsumer-DWahNPfz.mjs → createQueueConsumer-Cy-Mp-El.mjs}
RENAMED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { LunoraError } from '@lunora/errors';
|
|
2
|
+
|
|
1
3
|
const trimTrailingSlashes = (value) => {
|
|
2
4
|
let end = value.length;
|
|
3
5
|
while (end > 0 && value[end - 1] === "/") {
|
|
@@ -7,7 +9,7 @@ const trimTrailingSlashes = (value) => {
|
|
|
7
9
|
};
|
|
8
10
|
const createQueueWorkpool = (options) => {
|
|
9
11
|
if (!options.queue) {
|
|
10
|
-
throw new
|
|
12
|
+
throw new LunoraError("INTERNAL", "@lunora/scheduler: `queue` (a Cloudflare Queue binding) is required");
|
|
11
13
|
}
|
|
12
14
|
const enqueue = async (function_, args, enqueueOptions = {}) => {
|
|
13
15
|
const job = { args, functionPath: function_.__lunoraRef, shardKey: enqueueOptions.shardKey };
|
|
@@ -28,7 +30,7 @@ const createQueueConsumer = (options) => async (batch) => {
|
|
|
28
30
|
batch.messages.map(async (message) => {
|
|
29
31
|
try {
|
|
30
32
|
if (!isQueueJob(message.body)) {
|
|
31
|
-
throw new
|
|
33
|
+
throw new LunoraError("INTERNAL", "@lunora/scheduler: queue message body is not a QueueJob (missing functionPath)");
|
|
32
34
|
}
|
|
33
35
|
await options.dispatch(message.body);
|
|
34
36
|
message.ack();
|
|
@@ -51,7 +53,7 @@ const httpDispatcher = (options) => {
|
|
|
51
53
|
method: "POST"
|
|
52
54
|
});
|
|
53
55
|
if (!response.ok) {
|
|
54
|
-
throw new
|
|
56
|
+
throw new LunoraError("INTERNAL", `@lunora/scheduler: queue dispatch failed (${response.status.toString()}): ${await response.text()}`);
|
|
55
57
|
}
|
|
56
58
|
};
|
|
57
59
|
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { LunoraError } from '@lunora/errors';
|
|
2
|
+
import { c as callDO, g as getDO } from './do-client-CMJtLoHO.mjs';
|
|
3
|
+
import { isWorkflowReference } from './isWorkflowReference-C9mQkMXt.mjs';
|
|
4
|
+
|
|
5
|
+
const createScheduler = (options) => {
|
|
6
|
+
if (!options.namespace) {
|
|
7
|
+
throw new LunoraError("INTERNAL", "@lunora/scheduler: `namespace` (SchedulerDO binding) is required");
|
|
8
|
+
}
|
|
9
|
+
if (!options.originUrl) {
|
|
10
|
+
throw new LunoraError("INTERNAL", "@lunora/scheduler: `originUrl` is required so the DO can dispatch back to the Worker");
|
|
11
|
+
}
|
|
12
|
+
const runAt = async (date, target, args, options_ = {}) => {
|
|
13
|
+
const scheduledFor = date instanceof Date ? date.getTime() : date;
|
|
14
|
+
const base = {
|
|
15
|
+
args,
|
|
16
|
+
originUrl: options.originUrl,
|
|
17
|
+
pool: options_.pool,
|
|
18
|
+
retry: options_.retry,
|
|
19
|
+
scheduledFor,
|
|
20
|
+
shardKey: options_.shardKey
|
|
21
|
+
};
|
|
22
|
+
if (isWorkflowReference(target)) {
|
|
23
|
+
if (typeof target.binding !== "string" || target.binding.length === 0) {
|
|
24
|
+
throw new LunoraError(
|
|
25
|
+
"INTERNAL",
|
|
26
|
+
"@lunora/scheduler: workflow/agent schedule target is missing its `binding` — pass the generated `workflows.<name>` / `agents.<name>` reference"
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return callDO(options, "/schedule", { ...base, workflow: target.binding });
|
|
30
|
+
}
|
|
31
|
+
const functionPath = typeof target === "string" ? target : target.__lunoraRef;
|
|
32
|
+
return callDO(options, "/schedule", { ...base, functionPath });
|
|
33
|
+
};
|
|
34
|
+
const runAfter = async (delayMs, target, args, options_ = {}) => {
|
|
35
|
+
if (!Number.isFinite(delayMs) || delayMs < 0) {
|
|
36
|
+
throw new LunoraError("INTERNAL", "@lunora/scheduler: `delayMs` must be a non-negative finite number");
|
|
37
|
+
}
|
|
38
|
+
return runAt(Date.now() + delayMs, target, args, options_);
|
|
39
|
+
};
|
|
40
|
+
const cancel = async (id) => callDO(options, "/cancel", { id });
|
|
41
|
+
const list = async () => {
|
|
42
|
+
const body = await getDO(options, "/list");
|
|
43
|
+
return Array.isArray(body.records) ? body.records : [];
|
|
44
|
+
};
|
|
45
|
+
const get = async (id) => {
|
|
46
|
+
const body = await getDO(options, `/get?id=${encodeURIComponent(id)}`);
|
|
47
|
+
return body.record ?? null;
|
|
48
|
+
};
|
|
49
|
+
return { cancel, get, list, runAfter, runAt };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export { createScheduler as default };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { LunoraError } from '@lunora/errors';
|
|
2
|
+
import { g as getDO, c as callDO } from './do-client-CMJtLoHO.mjs';
|
|
3
|
+
|
|
4
|
+
const createWorkpool = (options) => {
|
|
5
|
+
if (!options.namespace) {
|
|
6
|
+
throw new LunoraError("INTERNAL", "@lunora/scheduler: `namespace` (SchedulerDO binding) is required");
|
|
7
|
+
}
|
|
8
|
+
if (!options.originUrl) {
|
|
9
|
+
throw new LunoraError("INTERNAL", "@lunora/scheduler: `originUrl` is required so the DO can dispatch back to the Worker");
|
|
10
|
+
}
|
|
11
|
+
if (!Number.isInteger(options.maxConcurrency) || options.maxConcurrency <= 0) {
|
|
12
|
+
throw new LunoraError("INTERNAL", "@lunora/scheduler: `maxConcurrency` must be a positive integer");
|
|
13
|
+
}
|
|
14
|
+
const name = typeof options.name === "string" && options.name.length > 0 ? options.name : "default";
|
|
15
|
+
const enqueue = async (function_, args, options_ = {}) => {
|
|
16
|
+
const delayMs = options_.delayMs ?? 0;
|
|
17
|
+
if (!Number.isFinite(delayMs) || delayMs < 0) {
|
|
18
|
+
throw new LunoraError("INTERNAL", "@lunora/scheduler: `delayMs` must be a non-negative finite number");
|
|
19
|
+
}
|
|
20
|
+
return callDO(options, "/schedule", {
|
|
21
|
+
args,
|
|
22
|
+
functionPath: function_.__lunoraRef,
|
|
23
|
+
instanceName: options.instanceName ?? "default",
|
|
24
|
+
maxConcurrency: options.maxConcurrency,
|
|
25
|
+
originUrl: options.originUrl,
|
|
26
|
+
pool: name,
|
|
27
|
+
retry: options_.retry,
|
|
28
|
+
scheduledFor: Date.now() + delayMs,
|
|
29
|
+
shardKey: options_.shardKey
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
const cancel = async (id) => callDO(options, "/cancel", { id });
|
|
33
|
+
const status = async () => getDO(options, `/pool?name=${encodeURIComponent(name)}`);
|
|
34
|
+
return { cancel, enqueue, name, status };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export { createWorkpool as default };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { LunoraError } from '@lunora/errors';
|
|
2
|
+
|
|
3
|
+
const applyJurisdiction = (namespace, jurisdiction) => {
|
|
4
|
+
if (jurisdiction === void 0) {
|
|
5
|
+
return namespace;
|
|
6
|
+
}
|
|
7
|
+
if (typeof namespace.jurisdiction !== "function") {
|
|
8
|
+
throw new TypeError(
|
|
9
|
+
`@lunora/scheduler: Durable Object namespace does not support jurisdiction("${jurisdiction}") — update @cloudflare/workers-types or remove the jurisdiction option`
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
return namespace.jurisdiction(jurisdiction);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const schedulerStub = (options) => {
|
|
16
|
+
const namespace = applyJurisdiction(options.namespace, options.jurisdiction);
|
|
17
|
+
return namespace.get(namespace.idFromName(options.instanceName ?? "default"));
|
|
18
|
+
};
|
|
19
|
+
const callDO = async (options, path, body) => {
|
|
20
|
+
const stub = schedulerStub(options);
|
|
21
|
+
const response = await stub.fetch(`https://scheduler.internal${path}`, {
|
|
22
|
+
body: JSON.stringify(body),
|
|
23
|
+
headers: { "content-type": "application/json" },
|
|
24
|
+
method: "POST"
|
|
25
|
+
});
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
const text = await response.text();
|
|
28
|
+
throw new LunoraError("INTERNAL", `@lunora/scheduler: SchedulerDO ${path} failed (${String(response.status)}): ${text}`);
|
|
29
|
+
}
|
|
30
|
+
return await response.json();
|
|
31
|
+
};
|
|
32
|
+
const getDO = async (options, path) => {
|
|
33
|
+
const stub = schedulerStub(options);
|
|
34
|
+
const response = await stub.fetch(`https://scheduler.internal${path}`, { method: "GET" });
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
const text = await response.text();
|
|
37
|
+
throw new LunoraError("INTERNAL", `@lunora/scheduler: SchedulerDO ${path} failed (${String(response.status)}): ${text}`);
|
|
38
|
+
}
|
|
39
|
+
return await response.json();
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export { callDO as c, getDO as g };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunora/scheduler",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.11",
|
|
4
4
|
"description": "Scheduling for Lunora: runAfter / runAt and Cron Triggers via SchedulerDO",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"directory": "packages/scheduler"
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
|
-
"dist",
|
|
28
|
+
"./dist",
|
|
29
29
|
"__assets__",
|
|
30
30
|
"README.md",
|
|
31
31
|
"LICENSE.md"
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
"access": "public"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"
|
|
49
|
+
"@lunora/errors": "1.0.0-alpha.6",
|
|
50
|
+
"cron-parser": "5.6.2"
|
|
50
51
|
},
|
|
51
52
|
"engines": {
|
|
52
53
|
"node": "^22.15.0 || >=24.11.0"
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
const callDO = async (options, path, body) => {
|
|
2
|
-
const stub = options.namespace.get(options.namespace.idFromName(options.instanceName ?? "default"));
|
|
3
|
-
const response = await stub.fetch(`https://scheduler.internal${path}`, {
|
|
4
|
-
body: JSON.stringify(body),
|
|
5
|
-
headers: { "content-type": "application/json" },
|
|
6
|
-
method: "POST"
|
|
7
|
-
});
|
|
8
|
-
if (!response.ok) {
|
|
9
|
-
const text = await response.text();
|
|
10
|
-
throw new Error(`@lunora/scheduler: SchedulerDO ${path} failed (${String(response.status)}): ${text}`);
|
|
11
|
-
}
|
|
12
|
-
return await response.json();
|
|
13
|
-
};
|
|
14
|
-
const getDO = async (options, path) => {
|
|
15
|
-
const stub = options.namespace.get(options.namespace.idFromName(options.instanceName ?? "default"));
|
|
16
|
-
const response = await stub.fetch(`https://scheduler.internal${path}`, { method: "GET" });
|
|
17
|
-
if (!response.ok) {
|
|
18
|
-
const text = await response.text();
|
|
19
|
-
throw new Error(`@lunora/scheduler: SchedulerDO ${path} failed (${String(response.status)}): ${text}`);
|
|
20
|
-
}
|
|
21
|
-
return await response.json();
|
|
22
|
-
};
|
|
23
|
-
const createScheduler = (options) => {
|
|
24
|
-
if (!options.namespace) {
|
|
25
|
-
throw new Error("@lunora/scheduler: `namespace` (SchedulerDO binding) is required");
|
|
26
|
-
}
|
|
27
|
-
if (!options.originUrl) {
|
|
28
|
-
throw new Error("@lunora/scheduler: `originUrl` is required so the DO can dispatch back to the Worker");
|
|
29
|
-
}
|
|
30
|
-
const runAt = async (date, function_, args, options_ = {}) => {
|
|
31
|
-
const scheduledFor = date instanceof Date ? date.getTime() : date;
|
|
32
|
-
return callDO(options, "/schedule", {
|
|
33
|
-
args,
|
|
34
|
-
functionPath: function_.__lunoraRef,
|
|
35
|
-
originUrl: options.originUrl,
|
|
36
|
-
// Optional workpool / retry-policy passthrough. Absent for ordinary
|
|
37
|
-
// `runAfter`/`runAt` calls, which keeps the wire payload (and the
|
|
38
|
-
// DO's behaviour) identical to before this feature.
|
|
39
|
-
pool: options_.pool,
|
|
40
|
-
retry: options_.retry,
|
|
41
|
-
scheduledFor,
|
|
42
|
-
shardKey: options_.shardKey
|
|
43
|
-
});
|
|
44
|
-
};
|
|
45
|
-
const runAfter = async (delayMs, function_, args, options_ = {}) => {
|
|
46
|
-
if (!Number.isFinite(delayMs) || delayMs < 0) {
|
|
47
|
-
throw new Error("@lunora/scheduler: `delayMs` must be a non-negative finite number");
|
|
48
|
-
}
|
|
49
|
-
return runAt(Date.now() + delayMs, function_, args, options_);
|
|
50
|
-
};
|
|
51
|
-
const cancel = async (id) => callDO(options, "/cancel", { id });
|
|
52
|
-
const list = async () => {
|
|
53
|
-
const body = await getDO(options, "/list");
|
|
54
|
-
return Array.isArray(body.records) ? body.records : [];
|
|
55
|
-
};
|
|
56
|
-
const get = async (id) => {
|
|
57
|
-
const body = await getDO(options, `/get?id=${encodeURIComponent(id)}`);
|
|
58
|
-
return body.record ?? null;
|
|
59
|
-
};
|
|
60
|
-
return { cancel, get, list, runAfter, runAt };
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
export { createScheduler as default };
|