@lunora/scheduler 1.0.0-alpha.11 → 1.0.0-alpha.111
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 +4 -3
- package/dist/index.d.mts +704 -102
- package/dist/index.d.ts +704 -102
- package/dist/index.mjs +1 -8
- package/dist/packem_shared/CRON_SCHEDULE_KINDS-xyFk800s.mjs +1 -0
- package/dist/packem_shared/MAX_RETRY_ATTEMPTS-Q3JXDtb1.mjs +1 -0
- package/dist/packem_shared/assertScheduleDelay-Cz5e-UVl.mjs +1 -0
- package/dist/packem_shared/assertScheduleInstant-CMN9XCFm.mjs +1 -0
- package/dist/packem_shared/assertValidCronExpression-DnBtukpq.mjs +1 -0
- package/dist/packem_shared/base64-junLSktH.mjs +1 -0
- package/dist/packem_shared/createCronTrigger-DSuDZtqj.mjs +1 -0
- package/dist/packem_shared/createQueueConsumer-qxMgxXfE.mjs +4 -0
- package/dist/packem_shared/createScheduler-hNO7z8dj.mjs +1 -0
- package/dist/packem_shared/createSchedulerHost-BO1Wbcvr.mjs +1 -0
- package/dist/packem_shared/createWorkpool-Bi5eQkLO.mjs +1 -0
- package/dist/packem_shared/do-client-XQayNStI.mjs +1 -0
- package/dist/packem_shared/isWorkflowReference-CT3tdefh.mjs +1 -0
- package/dist/packem_shared/resolveScheduleId-DuLCFI_l.mjs +1 -0
- package/dist/packem_shared/wire-codec-DtqZBjRy.mjs +1 -0
- package/package.json +4 -3
- package/dist/packem_shared/CRON_SCHEDULE_KINDS-C2FflEnq.mjs +0 -140
- package/dist/packem_shared/SchedulerDO-DeJHI379.mjs +0 -705
- package/dist/packem_shared/assertValidCronExpression-B9m75qU0.mjs +0 -24
- package/dist/packem_shared/createCronTrigger-Dh4VLxD9.mjs +0 -28
- package/dist/packem_shared/createQueueConsumer-Cy-Mp-El.mjs +0 -61
- package/dist/packem_shared/createScheduler-Df9JUjQb.mjs +0 -52
- package/dist/packem_shared/createWorkpool-Ce0kNM2p.mjs +0 -37
- package/dist/packem_shared/do-client-CMJtLoHO.mjs +0 -42
- package/dist/packem_shared/isWorkflowReference-C9mQkMXt.mjs +0 -3
package/dist/index.d.mts
CHANGED
|
@@ -1,24 +1,50 @@
|
|
|
1
|
+
import { SchedulerHost } from '@lunora/platform';
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* direct dependency to keep this package usable from the codegen pipeline
|
|
5
|
-
* itself.
|
|
3
|
+
* The generated function-reference type, shared by every package that needs to
|
|
4
|
+
* infer a call's args or return from `api.<file>.<fn>`.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* This lives in `shared/` rather than in one package because the consumers span
|
|
7
|
+
* a dependency boundary they must not cross: `@lunora/scheduler` and
|
|
8
|
+
* `@lunora/workflow` accept a reference in `runAfter`/`runAt`/`step.run*` but
|
|
9
|
+
* cannot depend on `@lunora/client`, which is a browser package. Each of them
|
|
10
|
+
* previously hand-copied this declaration, and both copies silently rotted when
|
|
11
|
+
* the phantom carrier was renamed — the conditional matched an OPTIONAL property
|
|
12
|
+
* that no longer existed, so `ArgsOf<F>` quietly resolved to `unknown` and every
|
|
13
|
+
* `step.run(ref, args)` in the repo lost its arg checking without a single error.
|
|
14
|
+
*
|
|
15
|
+
* `shared/` is the repo's answer to exactly that shape: bundler-inlined,
|
|
16
|
+
* zero-dependency source imported by relative path, so it creates no runtime
|
|
17
|
+
* edge between the packages that inline it. Being ONE declaration, there is
|
|
18
|
+
* nothing to keep in lockstep and no drift test to write.
|
|
9
19
|
*/
|
|
10
|
-
|
|
20
|
+
/** The registered function kinds a {@link FunctionReference} can describe. `stream` is a query that yields multiple frames over the WS. */
|
|
21
|
+
type FunctionKind = "action" | "mutation" | "query" | "stream";
|
|
22
|
+
/**
|
|
23
|
+
* Opaque reference to a registered function emitted by `@lunora/codegen`.
|
|
24
|
+
*
|
|
25
|
+
* At runtime it carries the `<file>:<function>` identifier in `__lunoraRef`.
|
|
26
|
+
* Generated declarations decorate this with phantom type parameters so callers
|
|
27
|
+
* can infer args / return values per call site.
|
|
28
|
+
*/
|
|
29
|
+
interface FunctionReference<Kind extends FunctionKind = FunctionKind, Args = unknown, Return = unknown> {
|
|
30
|
+
/**
|
|
31
|
+
* Phantom marker carrying the `Kind`/`Args`/`Return` type parameters for
|
|
32
|
+
* inference. Never present at runtime; declared as a covariant (output)
|
|
33
|
+
* position so a concrete reference stays assignable to a widened one.
|
|
34
|
+
*/
|
|
35
|
+
readonly __lunoraPhantom?: {
|
|
36
|
+
args: Args;
|
|
37
|
+
kind: Kind;
|
|
38
|
+
returns: Return;
|
|
39
|
+
};
|
|
11
40
|
readonly __lunoraRef: string;
|
|
12
|
-
/** Marker phantom type — discriminates queries / mutations / actions. */
|
|
13
|
-
readonly _kind?: "query" | "mutation" | "action";
|
|
14
41
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} ? A : Record<string, unknown>;
|
|
42
|
+
/** Extract the args type from a {@link FunctionReference}. */
|
|
43
|
+
type ArgsOf<F> = F extends FunctionReference<infer _K, infer A, infer _R> ? A : never;
|
|
18
44
|
/**
|
|
19
45
|
* Typed reference to a Lunora durable workflow — either the generated
|
|
20
|
-
* `workflows
|
|
21
|
-
*
|
|
46
|
+
* `workflows.<name>` reference object (`_generated/api.ts`, which carries the
|
|
47
|
+
* workflow's class name + export name) or, structurally, a `defineWorkflow()`
|
|
22
48
|
* result imported directly. Both are matched by the `isLunoraWorkflow` brand and
|
|
23
49
|
* carry the workflow's `params` in the phantom `__params`, so a `cronJobs()`
|
|
24
50
|
* registration infers them.
|
|
@@ -35,25 +61,36 @@ type ArgsOf<F extends FunctionReference> = F extends {
|
|
|
35
61
|
interface WorkflowReference<Params = Record<string, unknown>> {
|
|
36
62
|
/** Phantom carrier for the workflow's `params` type — drives `cronJobs()` arg inference. Never read at runtime. */
|
|
37
63
|
readonly __params?: Params;
|
|
38
|
-
/** The
|
|
39
|
-
readonly
|
|
64
|
+
/** The workflow's export key — its generated class name, reached as `ctx.exports.<className>` (present on a generated ref). */
|
|
65
|
+
readonly className?: string;
|
|
40
66
|
readonly isLunoraWorkflow: true;
|
|
41
67
|
/** The workflow's export/stable name (present on a generated ref; a `defineWorkflow({ name })` override otherwise). */
|
|
42
68
|
readonly name?: string;
|
|
43
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* A function reference a scheduler or workpool may target.
|
|
72
|
+
*
|
|
73
|
+
* `stream` is excluded deliberately, and the exclusion is load-bearing rather
|
|
74
|
+
* than tidiness: a scheduled job is dispatched as an ordinary `/rpc` call, and
|
|
75
|
+
* the function runner cannot execute a stream function (see
|
|
76
|
+
* `create-worker.ts`'s registry note). Accepting one compiles a job that is
|
|
77
|
+
* guaranteed to fail when its alarm fires, long after the call site that
|
|
78
|
+
* scheduled it.
|
|
79
|
+
*/
|
|
80
|
+
type SchedulableReference<Args = unknown, Return = unknown> = FunctionReference<Exclude<FunctionKind, "stream">, Args, Return>;
|
|
44
81
|
/** A cron job's target: either a one-shot function dispatch or a durable workflow start. */
|
|
45
|
-
type CronTarget =
|
|
82
|
+
type CronTarget = SchedulableReference | WorkflowReference;
|
|
46
83
|
/** The arguments a cron's target accepts: a workflow's inferred `params`, else an open record (function args aren't inferred). */
|
|
47
84
|
type CronTargetArgs<T extends CronTarget> = T extends WorkflowReference<infer Params> ? Params : Record<string, unknown>;
|
|
48
85
|
/**
|
|
49
86
|
* The arguments a one-shot schedule target ({@link Scheduler.runAfter} /
|
|
50
|
-
* {@link Scheduler.runAt}) accepts. Unlike {@link CronTargetArgs} it
|
|
51
|
-
* {@link FunctionReference}'s
|
|
52
|
-
* {@link WorkflowReference}'s
|
|
53
|
-
*
|
|
54
|
-
* `params`.
|
|
87
|
+
* {@link Scheduler.runAt}) accepts. Unlike {@link CronTargetArgs} it resolves a
|
|
88
|
+
* {@link FunctionReference}'s `args` through {@link ArgsOf} as well as a
|
|
89
|
+
* {@link WorkflowReference}'s `params`, so scheduling a generated function
|
|
90
|
+
* reference is arg-checked against that function's validator while scheduling a
|
|
91
|
+
* workflow/agent is checked against its `params`.
|
|
55
92
|
*/
|
|
56
|
-
type ScheduleTargetArgs<T extends CronTarget> = T extends WorkflowReference<infer Params> ? Params : T extends
|
|
93
|
+
type ScheduleTargetArgs<T extends CronTarget> = T extends WorkflowReference<infer Params> ? Params : T extends SchedulableReference ? ArgsOf<T> : Record<string, unknown>;
|
|
57
94
|
/** Narrow a {@link CronTarget} to a {@link WorkflowReference} by its runtime brand. */
|
|
58
95
|
declare const isWorkflowReference: (target: unknown) => target is WorkflowReference;
|
|
59
96
|
/**
|
|
@@ -74,12 +111,46 @@ interface RetryPolicy {
|
|
|
74
111
|
backoff?: "exponential" | "linear";
|
|
75
112
|
/** Base delay in milliseconds for the first retry. Default `30_000`. */
|
|
76
113
|
baseMs?: number;
|
|
77
|
-
/**
|
|
114
|
+
/**
|
|
115
|
+
* Maximum number of **retries** after the initial dispatch. Default `5`, so
|
|
116
|
+
* a job that keeps failing is dispatched 6 times in total before it is
|
|
117
|
+
* dead-lettered (the park happens once `attempts > maxAttempts`).
|
|
118
|
+
*/
|
|
78
119
|
maxAttempts?: number;
|
|
79
120
|
/** Optional ceiling clamping the computed backoff delay. */
|
|
80
121
|
maxMs?: number;
|
|
81
122
|
}
|
|
82
123
|
interface RunOptions {
|
|
124
|
+
/**
|
|
125
|
+
* Job id to store the record under, instead of one the SchedulerDO mints.
|
|
126
|
+
*
|
|
127
|
+
* Exists for `@lunora/server`'s deferred-schedule facade: inside a mutation a
|
|
128
|
+
* `runAfter`/`runAt` is buffered until the transaction commits, but the
|
|
129
|
+
* handler is handed the id synchronously, so the id has to be decided before
|
|
130
|
+
* the call is made. Callers that are not deferring should leave it unset and
|
|
131
|
+
* take the minted id from the return value. Anything that is not a plain
|
|
132
|
+
* `[A-Za-z0-9_-]` id of at most 64 characters, or that LEADS with `-`, is
|
|
133
|
+
* REFUSED (`400 INVALID_SCHEDULE_ID`) rather than replaced: the id is handed
|
|
134
|
+
* to `WorkflowBinding.create({ id })` verbatim for a workflow target, and the
|
|
135
|
+
* engine's instance-id grammar (`^[a-zA-Z0-9_][a-zA-Z0-9-_]*$`) refuses that
|
|
136
|
+
* first character. Minting over it would mean two calls naming the same bad
|
|
137
|
+
* id ran the job twice instead of the second answering `409`.
|
|
138
|
+
*
|
|
139
|
+
* **Not an idempotency key.** An id that is already scheduled is REFUSED
|
|
140
|
+
* (`409 DUPLICATE_SCHEDULE_ID`), not replaced or de-duplicated: the time
|
|
141
|
+
* index is keyed by time as well as id, so an overwrite would fire the new
|
|
142
|
+
* job at the old job's instant and drop the slot it was actually scheduled
|
|
143
|
+
* for. Cancel the existing job first if you mean to reschedule it. The id
|
|
144
|
+
* is free again once the job has fired or been cancelled.
|
|
145
|
+
*/
|
|
146
|
+
id?: string;
|
|
147
|
+
/**
|
|
148
|
+
* Cap for the {@link RunOptions.pool} this job joins, applied when the pool
|
|
149
|
+
* is first created and refreshed on every enqueue that carries one. Ignored
|
|
150
|
+
* without `pool`. A pool created by a `runAfter`/`runAt` that omits it caps
|
|
151
|
+
* at 1 — {@link Workpool} is the usual way to set it.
|
|
152
|
+
*/
|
|
153
|
+
maxConcurrency?: number;
|
|
83
154
|
/**
|
|
84
155
|
* Logical workpool this job belongs to. When set, the SchedulerDO gates the
|
|
85
156
|
* job behind the pool's `maxConcurrency` (see {@link WorkpoolOptions}).
|
|
@@ -128,7 +199,7 @@ interface ScheduleRecord {
|
|
|
128
199
|
scheduledFor: number;
|
|
129
200
|
shardKey?: string;
|
|
130
201
|
/**
|
|
131
|
-
* The
|
|
202
|
+
* The workflow/agent export key (its generated class name) to start a fresh durable instance
|
|
132
203
|
* of on fire (the {@link ScheduleRecord.args} become its `params`). Set
|
|
133
204
|
* instead of {@link ScheduleRecord.functionPath} when the job targets a
|
|
134
205
|
* workflow/agent {@link WorkflowReference}. The runtime — not the DO — owns
|
|
@@ -140,6 +211,19 @@ interface Scheduler {
|
|
|
140
211
|
cancel: (id: string) => Promise<{
|
|
141
212
|
cancelled: boolean;
|
|
142
213
|
}>;
|
|
214
|
+
/**
|
|
215
|
+
* Jobs that exhausted their retry budget and were parked under `dead:`
|
|
216
|
+
* (the DO's `/dead` view). Deliberately absent from {@link Scheduler.list} —
|
|
217
|
+
* the park deletes the `id:` header — so this is the only view of a job
|
|
218
|
+
* that failed permanently rather than being silently dropped.
|
|
219
|
+
*/
|
|
220
|
+
dead: () => Promise<ScheduleRecord[]>;
|
|
221
|
+
/**
|
|
222
|
+
* Resurrect a parked job with a fresh attempt budget (the DO's
|
|
223
|
+
* `POST /dead/retry`). `false` when the id is not parked; a racing double
|
|
224
|
+
* recover is a no-op rather than an error.
|
|
225
|
+
*/
|
|
226
|
+
deadRetry: (id: string) => Promise<boolean>;
|
|
143
227
|
/** Resolve a single pending job by id, or `null` when absent (derived from {@link Scheduler.list}). */
|
|
144
228
|
get: (id: string) => Promise<ScheduleRecord | null>;
|
|
145
229
|
/** All pending scheduled jobs (the DO's `/list` view). */
|
|
@@ -147,20 +231,28 @@ interface Scheduler {
|
|
|
147
231
|
/**
|
|
148
232
|
* Schedule `target` to run once, `delayMs` from now. `target` is a function
|
|
149
233
|
* {@link FunctionReference} (dispatched as a one-shot) or a durable
|
|
150
|
-
* {@link WorkflowReference} — the generated `workflows
|
|
151
|
-
* `agents
|
|
234
|
+
* {@link WorkflowReference} — the generated `workflows.<name>` /
|
|
235
|
+
* `agents.<name>` ref — which starts a fresh instance on fire (args become
|
|
152
236
|
* its `params`). {@link ScheduleTargetArgs} infers the accepted args from
|
|
153
237
|
* whichever target was passed.
|
|
238
|
+
*
|
|
239
|
+
* **Resolves the job id, a bare string** — the same value `cancel`/`get`
|
|
240
|
+
* take, and the same value the `ctx.scheduler` surface promises. This object
|
|
241
|
+
* IS `ctx.scheduler` on the shard side (codegen installs it behind
|
|
242
|
+
* `SchedulerLike`, whose `runAfter`/`runAt` are declared `Promise<string>`),
|
|
243
|
+
* so resolving a `{ id, scheduledFor }` record here handed mutations an
|
|
244
|
+
* object where every other gate — `@lunora/server`'s `Scheduler`,
|
|
245
|
+
* `@lunora/shard-engine`'s `SchedulerLike`, `@lunora/runtime`'s httpAction
|
|
246
|
+
* ctx, and the docs — said string. Nothing caught it, because the install is
|
|
247
|
+
* a cast: apps wrote the object into a string column and `cancel(id)`
|
|
248
|
+
* answered `{ cancelled: false }` with no error anywhere.
|
|
249
|
+
*
|
|
250
|
+
* The fire instant is not lost: `runAt` was handed it, and a caller that
|
|
251
|
+
* needs it back reads `scheduledFor` off {@link Scheduler.get}.
|
|
154
252
|
*/
|
|
155
|
-
runAfter: <T extends CronTarget>(delayMs: number, target: T, args: ScheduleTargetArgs<T>, options?: RunOptions) => Promise<
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}>;
|
|
159
|
-
/** Like {@link Scheduler.runAfter} but fires at an absolute `date`/timestamp. */
|
|
160
|
-
runAt: <T extends CronTarget>(date: Date | number, target: T, args: ScheduleTargetArgs<T>, options?: RunOptions) => Promise<{
|
|
161
|
-
id: string;
|
|
162
|
-
scheduledFor: number;
|
|
163
|
-
}>;
|
|
253
|
+
runAfter: <T extends CronTarget>(delayMs: number, target: T, args: ScheduleTargetArgs<T>, options?: RunOptions) => Promise<string>;
|
|
254
|
+
/** Like {@link Scheduler.runAfter} but fires at an absolute `date`/timestamp. Resolves the job id. */
|
|
255
|
+
runAt: <T extends CronTarget>(date: Date | number, target: T, args: ScheduleTargetArgs<T>, options?: RunOptions) => Promise<string>;
|
|
164
256
|
}
|
|
165
257
|
/**
|
|
166
258
|
* Cloudflare Durable Object data-residency jurisdiction. Widening union —
|
|
@@ -194,13 +286,14 @@ interface LunoraSchedulerOptions {
|
|
|
194
286
|
* un-pinned global namespace.
|
|
195
287
|
*/
|
|
196
288
|
jurisdiction?: DurableObjectJurisdiction;
|
|
197
|
-
/** Binding to the `SchedulerDO` durable object namespace. */
|
|
198
|
-
namespace: DurableObjectNamespaceLike;
|
|
199
289
|
/**
|
|
200
|
-
*
|
|
201
|
-
*
|
|
290
|
+
* Binding to the `SchedulerDO` durable object namespace.
|
|
291
|
+
*
|
|
292
|
+
* The origin the DO dispatches back to is NOT passed here: it reads
|
|
293
|
+
* `env.LUNORA_ORIGIN_URL` off its own binding at fire time, because a
|
|
294
|
+
* caller-supplied dispatch target would be an SSRF vector.
|
|
202
295
|
*/
|
|
203
|
-
|
|
296
|
+
namespace: DurableObjectNamespaceLike;
|
|
204
297
|
}
|
|
205
298
|
/** Per-enqueue options for a {@link Workpool}. Extends {@link RunOptions} minus the implicit `pool` (the pool sets that). */
|
|
206
299
|
interface EnqueueOptions {
|
|
@@ -213,7 +306,7 @@ interface EnqueueOptions {
|
|
|
213
306
|
}
|
|
214
307
|
/**
|
|
215
308
|
* Options for `createWorkpool`. Mirrors {@link LunoraSchedulerOptions}
|
|
216
|
-
* (same `namespace` / `
|
|
309
|
+
* (same `namespace` / `instanceName`) plus the bounded-concurrency
|
|
217
310
|
* controls. A workpool is a NAMED logical pool inside the existing SchedulerDO —
|
|
218
311
|
* it needs no extra Durable Object or wrangler binding beyond the SchedulerDO
|
|
219
312
|
* the scheduler already uses.
|
|
@@ -227,7 +320,7 @@ interface WorkpoolOptions extends LunoraSchedulerOptions {
|
|
|
227
320
|
maxConcurrency: number;
|
|
228
321
|
/**
|
|
229
322
|
* Pool name — the concurrency counter is keyed by this inside the
|
|
230
|
-
* SchedulerDO storage (`pool
|
|
323
|
+
* SchedulerDO storage (`pool:<name>`). Default `default`.
|
|
231
324
|
*/
|
|
232
325
|
name?: string;
|
|
233
326
|
}
|
|
@@ -247,11 +340,11 @@ interface Workpool {
|
|
|
247
340
|
* and the time it was scheduled for (it may not run immediately if the pool
|
|
248
341
|
* is at capacity).
|
|
249
342
|
*/
|
|
250
|
-
enqueue: <F extends
|
|
343
|
+
enqueue: <F extends SchedulableReference>(function_: F, args: ArgsOf<F>, options?: EnqueueOptions) => Promise<{
|
|
251
344
|
id: string;
|
|
252
345
|
scheduledFor: number;
|
|
253
346
|
}>;
|
|
254
|
-
/** The pool's name (the `pool
|
|
347
|
+
/** The pool's name (the `pool:<name>` storage key suffix). */
|
|
255
348
|
readonly name: string;
|
|
256
349
|
/** Inspect the pool's current state — `inFlight` slots used and the configured `maxConcurrency`. */
|
|
257
350
|
status: () => Promise<{
|
|
@@ -302,6 +395,13 @@ interface MessageBatchLike<Body = unknown> {
|
|
|
302
395
|
}
|
|
303
396
|
/** The wire payload Lunora puts on the queue: a function dispatch. */
|
|
304
397
|
interface QueueJob {
|
|
398
|
+
/**
|
|
399
|
+
* The call's arguments in WIRE form (`shared/wire-codec`), so a `bigint`,
|
|
400
|
+
* `Date` or bytes survives the queue's own JSON serialisation. The producers
|
|
401
|
+
* encode; the shard's dispatch loop is the single decoder. A custom
|
|
402
|
+
* {@link QueueDispatch} must forward this untouched — decoding it here and
|
|
403
|
+
* letting the shard decode again flattens a `Date` to `{}`.
|
|
404
|
+
*/
|
|
305
405
|
args?: Record<string, unknown>;
|
|
306
406
|
functionPath: string;
|
|
307
407
|
/** Routing hint forwarded to the Worker so the call lands on the right shard. */
|
|
@@ -327,7 +427,7 @@ interface QueueWorkpoolOptions {
|
|
|
327
427
|
*/
|
|
328
428
|
interface QueueWorkpool {
|
|
329
429
|
/** Enqueue a single `fn(args)` dispatch. */
|
|
330
|
-
enqueue: <F extends
|
|
430
|
+
enqueue: <F extends SchedulableReference>(function_: F, args: ArgsOf<F>, options?: QueueEnqueueOptions) => Promise<void>;
|
|
331
431
|
/** Enqueue many dispatches in one `sendBatch`. Each job names its function `ref`. */
|
|
332
432
|
enqueueBatch: (jobs: ReadonlyArray<{
|
|
333
433
|
args?: Record<string, unknown>;
|
|
@@ -335,12 +435,40 @@ interface QueueWorkpool {
|
|
|
335
435
|
shardKey?: string;
|
|
336
436
|
}>, options?: QueueSendOptionsLike) => Promise<void>;
|
|
337
437
|
}
|
|
338
|
-
/**
|
|
339
|
-
|
|
438
|
+
/**
|
|
439
|
+
* Dispatches a single {@link QueueJob} — the consumer's per-message worker.
|
|
440
|
+
* `messageId` is the queue message's native id, threaded through so the
|
|
441
|
+
* dispatcher can attribute a failure to the exact message that caused it.
|
|
442
|
+
*/
|
|
443
|
+
type QueueDispatch = (job: QueueJob, messageId?: string) => Promise<void>;
|
|
340
444
|
/** Options for `createQueueConsumer`. */
|
|
341
445
|
interface QueueConsumerOptions {
|
|
342
446
|
/** How each job is executed; e.g. the `httpDispatcher`. */
|
|
343
447
|
dispatch: QueueDispatch;
|
|
448
|
+
/**
|
|
449
|
+
* The consumer's `max_retries` as deployed in `wrangler.jsonc`, which a
|
|
450
|
+
* consumer cannot read at runtime. Used only to recognise a message's last
|
|
451
|
+
* delivery, where a declined dispatch is re-enqueued through `requeue` (or
|
|
452
|
+
* logged as the drop or dead-letter it becomes without one). Defaults to
|
|
453
|
+
* Cloudflare's default of 3.
|
|
454
|
+
*/
|
|
455
|
+
maxRetries?: number;
|
|
456
|
+
/**
|
|
457
|
+
* Re-enqueue a job whose dispatch is declined on its last delivery as a
|
|
458
|
+
* copy delayed past the in-flight claim, with a fresh retry budget, instead
|
|
459
|
+
* of letting it be dead-lettered or dropped while its call is still
|
|
460
|
+
* running. Without it that loss is only logged.
|
|
461
|
+
*
|
|
462
|
+
* `queue` is the producer binding of the queue this consumer reads (the
|
|
463
|
+
* one handed to `createQueueWorkpool`). `secret` keys the MAC the copy
|
|
464
|
+
* carries — pass the admin token (`LUNORA_ADMIN_TOKEN`). The copy
|
|
465
|
+
* dispatches under the replaced message's id so the shard serves the
|
|
466
|
+
* declined call's result, and without a valid MAC no body can claim an id.
|
|
467
|
+
*/
|
|
468
|
+
requeue?: {
|
|
469
|
+
queue: QueueLike<QueueJob>;
|
|
470
|
+
secret: string;
|
|
471
|
+
};
|
|
344
472
|
}
|
|
345
473
|
/** Options for the `httpDispatcher` — the default HTTP dispatcher. */
|
|
346
474
|
interface HttpDispatcherOptions {
|
|
@@ -350,6 +478,13 @@ interface HttpDispatcherOptions {
|
|
|
350
478
|
fetchImpl?: typeof fetch;
|
|
351
479
|
/** Origin where the Worker is mounted (the `/_lunora/scheduler/dispatch` endpoint). */
|
|
352
480
|
originUrl: string;
|
|
481
|
+
/**
|
|
482
|
+
* Abort a job's dispatch after this many ms; the abort is retryable, so the
|
|
483
|
+
* consumer retries the message. Defaults to 5 minutes — raise it for a
|
|
484
|
+
* workpool running jobs that legitimately run longer, lower it to fail a
|
|
485
|
+
* stuck origin faster.
|
|
486
|
+
*/
|
|
487
|
+
timeoutMs?: number;
|
|
353
488
|
}
|
|
354
489
|
/**
|
|
355
490
|
* Client-side scheduler — forwards `runAfter` / `runAt` / `cancel` calls to a
|
|
@@ -360,9 +495,9 @@ declare const createScheduler: (options: LunoraSchedulerOptions) => Scheduler;
|
|
|
360
495
|
/**
|
|
361
496
|
* Bounded-concurrency action queue — the Lunora equivalent of
|
|
362
497
|
* `@convex-dev/workpool`. Mirrors `createScheduler`'s `namespace` /
|
|
363
|
-
* `
|
|
498
|
+
* `instanceName` options and is built on the SAME `SchedulerDO`:
|
|
364
499
|
* a workpool is just a NAMED logical pool inside that DO (concurrency counter
|
|
365
|
-
* keyed by {@link WorkpoolOptions.name} under the `pool
|
|
500
|
+
* keyed by {@link WorkpoolOptions.name} under the `pool:<name>` storage key).
|
|
366
501
|
* It needs no extra Durable Object or wrangler binding beyond the SchedulerDO
|
|
367
502
|
* the scheduler already uses.
|
|
368
503
|
*
|
|
@@ -370,8 +505,24 @@ declare const createScheduler: (options: LunoraSchedulerOptions) => Scheduler;
|
|
|
370
505
|
* `maxConcurrency` of the pool's jobs at once and queues the rest durably,
|
|
371
506
|
* draining them as the runtime reports completions (`POST /complete`).
|
|
372
507
|
*
|
|
508
|
+
* The ceiling the platform adds: one alarm drain keeps at most SIX dispatches in
|
|
509
|
+
* flight, because a Durable Object may have only six connections simultaneously
|
|
510
|
+
* waiting for response headers. A `maxConcurrency` above six is therefore not
|
|
511
|
+
* wrong, just not reachable within a single drain — the surplus drains on the
|
|
512
|
+
* following alarms. The same invocation is bounded by the 15-minute alarm wall
|
|
513
|
+
* clock, which covers the WHOLE drain rather than one job; work that does not
|
|
514
|
+
* fit is deferred to the next alarm, never dropped.
|
|
515
|
+
*
|
|
516
|
+
* For jobs that are individually long — an LLM call, an export, a payment
|
|
517
|
+
* round-trip — prefer `createQueueWorkpool`: the dispatch this DO awaits
|
|
518
|
+
* only returns once the function has finished running, so a long job occupies
|
|
519
|
+
* one of those six slots and one slice of that 15-minute budget for its whole
|
|
520
|
+
* duration. A Queues consumer gets a fresh Worker invocation per batch, with its
|
|
521
|
+
* own connection budget and its own clock, and its `max_concurrency` is not
|
|
522
|
+
* capped at six. You give up the hard cap, per-job cancel, and per-job status.
|
|
523
|
+
*
|
|
373
524
|
* ```ts
|
|
374
|
-
* const pool = createWorkpool({ namespace: env.SCHEDULER,
|
|
525
|
+
* const pool = createWorkpool({ namespace: env.SCHEDULER, maxConcurrency: 5 });
|
|
375
526
|
* await pool.enqueue(internal.stripe.sync, { invoiceId }, { retry: { maxAttempts: 3 } });
|
|
376
527
|
* ```
|
|
377
528
|
*
|
|
@@ -415,8 +566,18 @@ interface CronTriggerSnippet {
|
|
|
415
566
|
declare const createCronTrigger: (options: CronTriggerOptions) => CronTriggerSnippet;
|
|
416
567
|
/** Sub-day recurrence. Exactly one unit must be provided. */
|
|
417
568
|
interface IntervalSchedule {
|
|
569
|
+
/** 1–23, and must divide 24 (an interval repeats *within* a day). */
|
|
418
570
|
hours?: number;
|
|
571
|
+
/** 1–59, and must divide 60. */
|
|
419
572
|
minutes?: number;
|
|
573
|
+
/**
|
|
574
|
+
* Accepted by the type, **rejected at definition time**: Cloudflare Cron
|
|
575
|
+
* Triggers have a one-minute floor, so the 6-field expression this compiles
|
|
576
|
+
* to would survive codegen and land in the committed `wrangler.jsonc`, then
|
|
577
|
+
* fail at `wrangler deploy` naming neither the job nor the file. Declared so
|
|
578
|
+
* the rejection can name the job instead. Use `ctx.scheduler.runAfter`/
|
|
579
|
+
* `runAt` for sub-minute recurrence, or `{ minutes: 1 }`.
|
|
580
|
+
*/
|
|
420
581
|
seconds?: number;
|
|
421
582
|
}
|
|
422
583
|
/** Daily recurrence at a fixed UTC wall-clock time. */
|
|
@@ -426,6 +587,19 @@ interface DailySchedule {
|
|
|
426
587
|
/** 0–59. */
|
|
427
588
|
minuteUTC: number;
|
|
428
589
|
}
|
|
590
|
+
/**
|
|
591
|
+
* Hourly recurrence at a fixed minute past the hour.
|
|
592
|
+
*
|
|
593
|
+
* `crons.interval({ hours: 1 })` compiles to the same expression, but the
|
|
594
|
+
* asymmetry of having `daily`/`weekly`/`monthly` and no `hourly` is its own
|
|
595
|
+
* papercut — and unlike the interval form this one lets
|
|
596
|
+
* the caller place the job off the hour boundary, which is how you stop a
|
|
597
|
+
* dozen hourly jobs from stampeding at `:00`.
|
|
598
|
+
*/
|
|
599
|
+
interface HourlySchedule {
|
|
600
|
+
/** 0–59. */
|
|
601
|
+
minuteUTC: number;
|
|
602
|
+
}
|
|
429
603
|
/** Weekly recurrence at a fixed UTC time on a given weekday. */
|
|
430
604
|
interface WeeklySchedule extends DailySchedule {
|
|
431
605
|
/** Long weekday name, case-insensitive (e.g. `"monday"`). */
|
|
@@ -457,22 +631,24 @@ interface CronJob {
|
|
|
457
631
|
* Set when the job targets a durable workflow rather than a function: the
|
|
458
632
|
* workflow's stable name (`defineWorkflow({ name })`) when one was declared,
|
|
459
633
|
* otherwise `""`. `@lunora/codegen` statically resolves the concrete
|
|
460
|
-
* `lunora/workflows.ts` export + its
|
|
634
|
+
* `lunora/workflows.ts` export + its class name for the emitted
|
|
461
635
|
* dispatch map, so this authoring-time value is informational only.
|
|
462
636
|
*/
|
|
463
637
|
workflow?: string;
|
|
464
638
|
}
|
|
465
639
|
/** The ergonomic builder methods, excluding the raw `.cron` escape hatch. */
|
|
466
|
-
type CronScheduleKind = "daily" | "interval" | "monthly" | "weekly";
|
|
640
|
+
type CronScheduleKind = "daily" | "hourly" | "interval" | "monthly" | "weekly";
|
|
467
641
|
/** The ergonomic schedule kinds as a runtime set (codegen reads this to detect cron builder methods). */
|
|
468
642
|
declare const CRON_SCHEDULE_KINDS: ReadonlySet<CronScheduleKind>;
|
|
469
643
|
/**
|
|
470
644
|
* Compile one of the ergonomic schedule forms into a standard cron expression.
|
|
471
645
|
* Exposed as a pure function so `@lunora/codegen` can reuse the exact same
|
|
472
646
|
* compilation when it statically lifts a `crons.{kind}(...)` call out of the
|
|
473
|
-
* AST — codegen imports this directly (no duplicated mirror).
|
|
647
|
+
* AST — codegen imports this directly (no duplicated mirror). `jobName` is
|
|
648
|
+
* optional (see {@link compileInterval}) so codegen's existing 2-arg call site
|
|
649
|
+
* keeps working unchanged.
|
|
474
650
|
*/
|
|
475
|
-
declare const compileCronSchedule: (kind: CronScheduleKind, schedule: DailySchedule | IntervalSchedule | MonthlySchedule | WeeklySchedule) => string;
|
|
651
|
+
declare const compileCronSchedule: (kind: CronScheduleKind, schedule: DailySchedule | HourlySchedule | IntervalSchedule | MonthlySchedule | WeeklySchedule, jobName?: string) => string;
|
|
476
652
|
/**
|
|
477
653
|
* Builder returned by {@link cronJobs}. Each method registers one recurring
|
|
478
654
|
* job; the compiled expression is validated immediately so authoring mistakes
|
|
@@ -482,18 +658,20 @@ interface CronJobsBuilder {
|
|
|
482
658
|
/**
|
|
483
659
|
* Raw cron expression escape hatch (5- or 6-field, full cron-parser grammar).
|
|
484
660
|
* The target may be a function (`internal.file.fn`) or a durable workflow
|
|
485
|
-
* (`workflows
|
|
661
|
+
* (`workflows.<name>`); a workflow's `args` are inferred from its `params`.
|
|
486
662
|
*/
|
|
487
663
|
cron: <T extends CronTarget>(name: string, cronExpr: string, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
|
|
488
|
-
/** Daily at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows
|
|
664
|
+
/** Daily at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows.<name>`). */
|
|
489
665
|
daily: <T extends CronTarget>(name: string, schedule: DailySchedule, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
|
|
490
|
-
/**
|
|
666
|
+
/** Hourly at `minuteUTC` past the hour. The target may be a function or a durable workflow (`workflows.<name>`). */
|
|
667
|
+
hourly: <T extends CronTarget>(name: string, schedule: HourlySchedule, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
|
|
668
|
+
/** Every `{ minutes | hours }` — `{ seconds }` throws (Cron Triggers have a one-minute floor). The target may be a function or a durable workflow (`workflows.<name>`). */
|
|
491
669
|
interval: <T extends CronTarget>(name: string, schedule: IntervalSchedule, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
|
|
492
670
|
/** Snapshot of the registered jobs, in declaration order. */
|
|
493
671
|
jobs: () => ReadonlyArray<CronJob>;
|
|
494
|
-
/** Monthly on `day` at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows
|
|
672
|
+
/** Monthly on `day` at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows.<name>`). */
|
|
495
673
|
monthly: <T extends CronTarget>(name: string, schedule: MonthlySchedule, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
|
|
496
|
-
/** Weekly on `dayOfWeek` at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows
|
|
674
|
+
/** Weekly on `dayOfWeek` at `hourUTC:minuteUTC` (UTC). The target may be a function or a durable workflow (`workflows.<name>`). */
|
|
497
675
|
weekly: <T extends CronTarget>(name: string, schedule: WeeklySchedule, target: T, args?: CronTargetArgs<T>) => CronJobsBuilder;
|
|
498
676
|
}
|
|
499
677
|
/**
|
|
@@ -519,12 +697,49 @@ declare const createQueueWorkpool: (options: QueueWorkpoolOptions) => QueueWorkp
|
|
|
519
697
|
*/
|
|
520
698
|
declare const createQueueConsumer: (options: QueueConsumerOptions) => ((batch: MessageBatchLike) => Promise<void>);
|
|
521
699
|
/**
|
|
522
|
-
* Default {@link QueueDispatch}:
|
|
700
|
+
* Default {@link QueueDispatch}: dispatch each job to the Worker's
|
|
523
701
|
* `/_lunora/scheduler/dispatch` endpoint (the same path SchedulerDO dispatches
|
|
524
|
-
* through)
|
|
525
|
-
*
|
|
702
|
+
* through) via `@lunora/dispatch`'s `createDispatchRunner` — which bounds each
|
|
703
|
+
* job with {@link HttpDispatcherOptions.timeoutMs} (default
|
|
704
|
+
* {@link DEFAULT_JOB_TIMEOUT_MS}), so a hung origin no longer holds the whole
|
|
705
|
+
* `queue()` invocation open, and threads the queue message id through for
|
|
706
|
+
* failure attribution. Any dispatch failure throws so the consumer retries the
|
|
707
|
+
* message — including a 2xx carrying a non-empty non-JSON body, which is an
|
|
708
|
+
* intermediary's page rather than a function's return value and therefore no
|
|
709
|
+
* evidence the job ran. An empty 2xx is a normal success (a `void` function).
|
|
710
|
+
*
|
|
711
|
+
* `job.args` is forwarded VERBATIM: {@link encodeJobArgs} already put it in wire
|
|
712
|
+
* form at the producer, and the shard's dispatch loop is the single decoder.
|
|
713
|
+
* Encoding again here would leave the handler a tagged array.
|
|
526
714
|
*/
|
|
527
715
|
declare const httpDispatcher: (options: HttpDispatcherOptions) => QueueDispatch;
|
|
716
|
+
/**
|
|
717
|
+
* The id a new record is stored under: the caller's, or a freshly minted one when
|
|
718
|
+
* they did not name it.
|
|
719
|
+
*
|
|
720
|
+
* A caller id that is not a safe key segment is REFUSED, not replaced.
|
|
721
|
+
* `RunOptions.id` is not an idempotency key — an id already scheduled answers
|
|
722
|
+
* `409 DUPLICATE_SCHEDULE_ID` — so quietly swapping an invalid one for a random
|
|
723
|
+
* id made `runAt(ts, ref, args, { id: "-daily-2026-09-06" })` mint a fresh id on
|
|
724
|
+
* every call and run the job once per call, where naming it was the caller's way
|
|
725
|
+
* of saying "at most once".
|
|
726
|
+
*
|
|
727
|
+
* Minting swaps a leading `-` for `_` rather than re-rolling: 1 in 64 minted ids
|
|
728
|
+
* led with one and was refused by the workflow engine on dispatch. `_` is in the
|
|
729
|
+
* engine's leading class, the swap is a single pass, and 96 random bits stay 96
|
|
730
|
+
* random bits everywhere but that first character.
|
|
731
|
+
*
|
|
732
|
+
* Exported because two surfaces have to agree on it. The SchedulerDO applies it
|
|
733
|
+
* when the record is written (turning the refusal into a `400`);
|
|
734
|
+
* `@lunora/server`'s deferred-schedule facade applies it when the call is
|
|
735
|
+
* BUFFERED, because it answers the handler with the id synchronously — long
|
|
736
|
+
* before the DO sees the request. Restating the rule in the facade is how the two
|
|
737
|
+
* drift: an id the facade accepted and the DO replaced leaves the handler holding
|
|
738
|
+
* an id no job was ever stored under, so its later `cancel` silently misses.
|
|
739
|
+
* @param requested the caller's `RunOptions.id`, if any
|
|
740
|
+
* @throws LunoraError `INVALID_SCHEDULE_ID` when `requested` is supplied and is not a safe key segment
|
|
741
|
+
*/
|
|
742
|
+
declare const resolveScheduleId: (requested: unknown) => string;
|
|
528
743
|
/**
|
|
529
744
|
* Minimal projection of `DurableObjectState` for the SchedulerDO. Declared
|
|
530
745
|
* structurally so unit tests can pass a fake state without booting the
|
|
@@ -537,6 +752,15 @@ interface SchedulerDOState {
|
|
|
537
752
|
acceptWebSocket?: (ws: WebSocket) => void;
|
|
538
753
|
/** Every accepted server WebSocket (workers `state.getWebSockets`). */
|
|
539
754
|
getWebSockets?: () => WebSocket[];
|
|
755
|
+
/**
|
|
756
|
+
* Register a constant ping/pong auto-response so the runtime answers a
|
|
757
|
+
* known keepalive frame on a hibernated socket WITHOUT waking this DO (no
|
|
758
|
+
* billable request, no dispatch). Optional: absent in the unit harness and
|
|
759
|
+
* older runtimes, present on the real `DurableObjectState`. Mirrors
|
|
760
|
+
* `@lunora/do`'s `ShardDOState.setWebSocketAutoResponse` — see
|
|
761
|
+
* {@link SchedulerDO.armWebSocketKeepalive}.
|
|
762
|
+
*/
|
|
763
|
+
setWebSocketAutoResponse?: (pair: WebSocketRequestResponsePair) => void;
|
|
540
764
|
storage: {
|
|
541
765
|
delete: (key: string | string[]) => Promise<number | boolean>;
|
|
542
766
|
deleteAlarm: () => Promise<void> | void;
|
|
@@ -546,6 +770,7 @@ interface SchedulerDOState {
|
|
|
546
770
|
end?: string;
|
|
547
771
|
limit?: number;
|
|
548
772
|
prefix?: string;
|
|
773
|
+
startAfter?: string;
|
|
549
774
|
}) => Promise<Map<string, T>>;
|
|
550
775
|
put: <T = unknown>(entries: Record<string, T> | string, value?: T) => Promise<void>;
|
|
551
776
|
setAlarm: (scheduledTime: number | Date) => Promise<void> | void;
|
|
@@ -556,13 +781,13 @@ interface SchedulerEnv {
|
|
|
556
781
|
/**
|
|
557
782
|
* Fallback bearer token attached to the dispatch when
|
|
558
783
|
* {@link SchedulerEnv.LUNORA_SCHEDULER_SECRET} is not configured. Sent as
|
|
559
|
-
* `authorization: Bearer
|
|
784
|
+
* `authorization: Bearer <token>`.
|
|
560
785
|
*/
|
|
561
786
|
LUNORA_ADMIN_TOKEN?: string;
|
|
562
787
|
/**
|
|
563
788
|
* Base URL where the Worker is mounted. SchedulerDO uses this at dispatch
|
|
564
789
|
* time to call back into the Worker. Read at fire time (NOT taken from the
|
|
565
|
-
* request body
|
|
790
|
+
* request body, which carries no dispatch target at all) to prevent SSRF.
|
|
566
791
|
*/
|
|
567
792
|
LUNORA_ORIGIN_URL?: string;
|
|
568
793
|
/**
|
|
@@ -573,6 +798,21 @@ interface SchedulerEnv {
|
|
|
573
798
|
*/
|
|
574
799
|
LUNORA_SCHEDULER_SECRET?: string;
|
|
575
800
|
}
|
|
801
|
+
/**
|
|
802
|
+
* Retries allowed after the original attempt before {@link SchedulerDO.recordRetry}
|
|
803
|
+
* parks a record in the dead-letter (`dead:`) prefix. Overridable per job via
|
|
804
|
+
* {@link RetryPolicy.maxAttempts}. Exported so test doubles of the scheduler
|
|
805
|
+
* (`@lunora/testing`'s fake scheduler) model the same budget instead of
|
|
806
|
+
* duplicating the number.
|
|
807
|
+
*/
|
|
808
|
+
declare const MAX_RETRY_ATTEMPTS = 5;
|
|
809
|
+
/**
|
|
810
|
+
* Backoff before the first retry, in milliseconds; doubles on each subsequent
|
|
811
|
+
* retry under the default `"exponential"` backoff (`baseMs * 2 ** (attempts - 1)`).
|
|
812
|
+
* Overridable per job via {@link RetryPolicy.baseMs}. Exported alongside
|
|
813
|
+
* {@link MAX_RETRY_ATTEMPTS} for the same reason.
|
|
814
|
+
*/
|
|
815
|
+
declare const RETRY_BASE_DELAY_MS = 3e4;
|
|
576
816
|
/**
|
|
577
817
|
* One pool's live backlog, as surfaced by `GET /status`. `inFlight`/
|
|
578
818
|
* `maxConcurrency` mirror the durable {@link PoolState} semaphore; `queued`
|
|
@@ -583,7 +823,7 @@ interface SchedulerPoolStatus {
|
|
|
583
823
|
inFlight: number;
|
|
584
824
|
/** The pool's concurrency cap. */
|
|
585
825
|
maxConcurrency: number;
|
|
586
|
-
/** The logical workpool name (the `pool
|
|
826
|
+
/** The logical workpool name (the `pool:<name>` suffix). */
|
|
587
827
|
name: string;
|
|
588
828
|
/** Pending jobs routed to this pool but not yet dispatched. */
|
|
589
829
|
queued: number;
|
|
@@ -598,13 +838,13 @@ interface SchedulerStatus {
|
|
|
598
838
|
backlog: number;
|
|
599
839
|
/** Sum of every pool's `inFlight` count — the total held concurrency slots. */
|
|
600
840
|
inFlight: number;
|
|
601
|
-
/** Per-pool backlog breakdown, one entry per `pool
|
|
841
|
+
/** Per-pool backlog breakdown, one entry per `pool:<name>` record. */
|
|
602
842
|
pools: SchedulerPoolStatus[];
|
|
603
843
|
}
|
|
604
844
|
/**
|
|
605
845
|
* Durable Object that stores pending scheduled invocations sorted by their
|
|
606
846
|
* `scheduledFor` time and fires them via HTTP on alarm. Storage layout:
|
|
607
|
-
* `id
|
|
847
|
+
* `id:<id>` maps to {@link ScheduleRecord}; `t:<paddedTime>:<id>` maps to the
|
|
608
848
|
* id (used as a sorted index).
|
|
609
849
|
*
|
|
610
850
|
* On every mutation the DO recomputes the earliest pending task and updates
|
|
@@ -651,15 +891,57 @@ declare class SchedulerDO {
|
|
|
651
891
|
/**
|
|
652
892
|
* Normalize the mutually-exclusive dispatch target off an untrusted body: a
|
|
653
893
|
* one-shot function path (`functionPath`) or a durable workflow/agent
|
|
654
|
-
* instance (`workflow`, a
|
|
894
|
+
* instance (`workflow`, a workflow/agent export key). Returns `undefined`
|
|
655
895
|
* when neither is present so the caller can reject the schedule.
|
|
656
896
|
*/
|
|
657
897
|
private static resolveScheduleTarget;
|
|
658
898
|
protected readonly state: SchedulerDOState;
|
|
659
899
|
protected readonly env: SchedulerEnv;
|
|
900
|
+
/**
|
|
901
|
+
* Whether {@link SchedulerDO.reindexOrphanedRecords} has already run in THIS
|
|
902
|
+
* instance. Once is enough: an orphan can only be minted by an eviction, and
|
|
903
|
+
* an eviction ends the instance that minted it.
|
|
904
|
+
*/
|
|
905
|
+
private reindexed;
|
|
906
|
+
/**
|
|
907
|
+
* Tail of the serialized `pool:<name>` critical section — see
|
|
908
|
+
* the pool lock (`withPoolLock`). Per-instance, which is the right scope: the pool rows
|
|
909
|
+
* live in this Durable Object's own storage and only this instance writes
|
|
910
|
+
* them.
|
|
911
|
+
*/
|
|
912
|
+
private poolLock;
|
|
913
|
+
/**
|
|
914
|
+
* Record id → the `t:` index key its in-flight claim currently holds, for
|
|
915
|
+
* every dispatch this instance has open. See `drainRecordGuarded()`.
|
|
916
|
+
*
|
|
917
|
+
* A lease moves a record's index entry without rewriting its `scheduledFor`,
|
|
918
|
+
* so while a claim is held the live key is NOT the one derivable from the
|
|
919
|
+
* record. Anything that has to remove such a record — `removeRecord()`, on
|
|
920
|
+
* the `/cancel` path — would otherwise delete a key that no longer exists
|
|
921
|
+
* and strand the real one.
|
|
922
|
+
*
|
|
923
|
+
* In-memory and per-instance on purpose: it answers "is THIS instance
|
|
924
|
+
* dispatching that record right now", which is exactly when the divergence
|
|
925
|
+
* can be observed by another request. A lease left behind by an instance that
|
|
926
|
+
* died has no live dispatch to protect and is reconciled from storage
|
|
927
|
+
* instead — `alarm()` drops it as a dangling entry once the header is gone.
|
|
928
|
+
*/
|
|
929
|
+
private readonly activeLeases;
|
|
930
|
+
/**
|
|
931
|
+
* Ids `/cancel` removed while this instance held their dispatch lease.
|
|
932
|
+
*
|
|
933
|
+
* The attempt is already in flight when such a cancel lands, and whatever it
|
|
934
|
+
* settles to must not write the job back: `recordRetry()` and
|
|
935
|
+
* `requeuePooled()` re-create the `id:` header and a `t:` entry, so a
|
|
936
|
+
* failed attempt would resurrect a job `/cancel` answered `{ cancelled: true }`
|
|
937
|
+
* for, and it would fire again. Both check this set synchronously right
|
|
938
|
+
* before their single write, so no `/cancel` can land between the check and
|
|
939
|
+
* the write. Cleared with the lease in `drainRecordGuarded()`.
|
|
940
|
+
*/
|
|
941
|
+
private readonly cancelledLeases;
|
|
660
942
|
constructor(state: SchedulerDOState, env: SchedulerEnv);
|
|
661
943
|
fetch(request: Request): Promise<Response>;
|
|
662
|
-
/** Called by the Workers runtime when the alarm previously set by `
|
|
944
|
+
/** Called by the Workers runtime when the alarm previously set by `rescheduleAlarm()` fires. */
|
|
663
945
|
alarm(): Promise<void>;
|
|
664
946
|
/**
|
|
665
947
|
* Internal dispatch hook; overridden in unit tests to capture the outgoing
|
|
@@ -672,31 +954,119 @@ declare class SchedulerDO {
|
|
|
672
954
|
* drop. After {@link MAX_RETRY_ATTEMPTS} the record is parked under a
|
|
673
955
|
* `dead:` key for inspection — never silently deleted.
|
|
674
956
|
*
|
|
957
|
+
* One non-2xx is told apart: a `409` carrying `DISPATCH_IN_PROGRESS` returns
|
|
958
|
+
* `"in-progress"`. The shard is saying the first attempt of this very record
|
|
959
|
+
* is still running, so {@link recordRetry} re-arms it without charging an
|
|
960
|
+
* attempt — see there.
|
|
961
|
+
*
|
|
675
962
|
* The dispatch target is taken from `env.LUNORA_ORIGIN_URL` (NOT from the
|
|
676
|
-
* stored record)
|
|
677
|
-
*
|
|
963
|
+
* stored record) so a schedule request can never name where the DO calls
|
|
964
|
+
* back — that would be SSRF. If that env var is missing at fire time (a deploy/binding
|
|
678
965
|
* regression — schedule time already enforced its presence) we return
|
|
679
966
|
* `false` so the record is retried rather than silently dropped.
|
|
680
967
|
*/
|
|
681
|
-
protected dispatch(record: ScheduleRecord): Promise<boolean>;
|
|
968
|
+
protected dispatch(record: ScheduleRecord): Promise<boolean | "in-progress">;
|
|
969
|
+
/**
|
|
970
|
+
* Register the hibernation-safe ping/pong keepalive. The runtime answers a
|
|
971
|
+
* {@link WS_KEEPALIVE_PING} text frame with {@link WS_KEEPALIVE_PONG}
|
|
972
|
+
* WITHOUT waking this Durable Object, keeping an idle `/ws` subscription
|
|
973
|
+
* alive across hibernation with no billable wakeup and no dispatch. Without
|
|
974
|
+
* this, a client's heartbeat ping goes unanswered and its watchdog force-
|
|
975
|
+
* closes the socket every ~90s, defeating hibernation (each unanswered ping
|
|
976
|
+
* wakes the DO to reconnect) — mirrors `@lunora/do`'s
|
|
977
|
+
* `ShardDO.armWebSocketKeepalive`. The auto-response is per-instance, so
|
|
978
|
+
* this re-runs on every construction (including a post-hibernation wake).
|
|
979
|
+
* Guarded: the API and the `WebSocketRequestResponsePair` global are absent
|
|
980
|
+
* in the unit harness and on older runtimes, where it degrades to a no-op.
|
|
981
|
+
*/
|
|
982
|
+
private armWebSocketKeepalive;
|
|
983
|
+
/**
|
|
984
|
+
* Drain the due slice with up to {@link MAX_CONCURRENT_DISPATCHES} records in
|
|
985
|
+
* flight at once, so a slow job delays only its own lane instead of every
|
|
986
|
+
* other due job in the app (see the constant for why the drain used to
|
|
987
|
+
* serialise whole jobs, not just their kicks).
|
|
988
|
+
*
|
|
989
|
+
* Lanes pull from the head of the slice, so records still ENTER dispatch in
|
|
990
|
+
* the slice's order — the same `t:<paddedTime>:<id>` order the sequential
|
|
991
|
+
* drain used. What is no longer implied is that they FINISH in that order,
|
|
992
|
+
* which was never a guarantee worth relying on anyway: two jobs due at the
|
|
993
|
+
* same instant already ran in storage-key (id) order rather than arrival
|
|
994
|
+
* order, a saturated pooled job is pushed {@link POOL_BACKPRESSURE_DELAY_MS}
|
|
995
|
+
* into the future ahead of its queue-mates, and a failed job re-enters at the
|
|
996
|
+
* end of a backoff. Nothing in the public surface documents an ordering
|
|
997
|
+
* guarantee; jobs that must be ordered must chain themselves.
|
|
998
|
+
*
|
|
999
|
+
* {@link drainRecordGuarded} swallows every throw, so no lane can reject and
|
|
1000
|
+
* abandon its siblings.
|
|
1001
|
+
*/
|
|
1002
|
+
private drainDue;
|
|
1003
|
+
/**
|
|
1004
|
+
* Run `critical` with exclusive access to the `pool:<name>` rows.
|
|
1005
|
+
*
|
|
1006
|
+
* Every pool mutation is a read-modify-write (`loadPool` → mutate →
|
|
1007
|
+
* `savePool`), and the two halves are separated by an `await`. That was safe
|
|
1008
|
+
* while the drain ran one record at a time — the Durable Object input gate
|
|
1009
|
+
* keeps a foreign event (a concurrent `/complete`) out while a storage
|
|
1010
|
+
* operation is in flight, and nothing else in this instance could interleave.
|
|
1011
|
+
* Concurrent lanes break exactly that assumption: lane A and lane B can both
|
|
1012
|
+
* issue their `get` before either `put` lands, both observe `inFlight: 0`,
|
|
1013
|
+
* and the second `put` then erases the first lane's reservation — the pool
|
|
1014
|
+
* oversubscribes and one holder's id is lost, so its slot is never released.
|
|
1015
|
+
*
|
|
1016
|
+
* The lock is a promise chain rather than anything cleverer because the
|
|
1017
|
+
* critical section is two local storage ops with no I/O in it. It is held
|
|
1018
|
+
* across NO outbound fetch: `dispatch()` runs outside it, which is the whole
|
|
1019
|
+
* point of draining concurrently.
|
|
1020
|
+
*
|
|
1021
|
+
* The chain is rebuilt from a swallowed copy so one rejecting section can
|
|
1022
|
+
* never wedge every later one.
|
|
1023
|
+
*/
|
|
1024
|
+
private withPoolLock;
|
|
682
1025
|
/**
|
|
683
1026
|
* Claim + drain one due record with per-record fault isolation, so a storage
|
|
684
1027
|
* throw can never abort the whole alarm pass (which would skip the remaining
|
|
685
1028
|
* due records and the `rescheduleAlarm()` that re-arms the clock).
|
|
686
1029
|
*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
*
|
|
1030
|
+
* The claim is a LEASE, not a deletion. The record's `t:` entry is moved
|
|
1031
|
+
* from its due time to `now + DISPATCH_LEASE_MS` before
|
|
1032
|
+
* {@link SchedulerDO.dispatch} is called, so this alarm pass (and the next)
|
|
1033
|
+
* will not pick it up again, while the record is never left WITHOUT an index
|
|
1034
|
+
* entry. That distinction is the whole point: deleting the entry outright
|
|
1035
|
+
* made an instance evicted mid-dispatch leave an `id:` header with no index,
|
|
1036
|
+
* which {@link SchedulerDO.reindexOrphanedRecords} re-armed and the
|
|
1037
|
+
* successor fired AGAIN on sight — concurrently with an attempt that could
|
|
1038
|
+
* still be running at the origin. A leased record is not an orphan, so the
|
|
1039
|
+
* successor leaves it alone until the lease lapses; see
|
|
1040
|
+
* {@link DISPATCH_LEASE_MS} for why that horizon is fifteen minutes and what
|
|
1041
|
+
* it does and does not bound.
|
|
1042
|
+
*
|
|
1043
|
+
* The lease is released as soon as this instance knows the attempt settled —
|
|
1044
|
+
* dispatched, re-armed for retry, backpressured, or dead-lettered — so the
|
|
1045
|
+
* horizon only ever governs the one case nobody is left to report: a lost
|
|
1046
|
+
* instance.
|
|
690
1047
|
*
|
|
691
1048
|
* A throw reaching here always means the job was NOT dispatched:
|
|
692
1049
|
* {@link drainRecord} swallows its own post-dispatch cleanup errors and
|
|
693
1050
|
* returns instead of throwing once a kick succeeds, so every escaping throw
|
|
694
|
-
* comes from the pre-dispatch or failed-dispatch paths.
|
|
695
|
-
*
|
|
696
|
-
* (at-least-once)
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
1051
|
+
* comes from the pre-dispatch or failed-dispatch paths. Nothing is in flight,
|
|
1052
|
+
* so the lease is dropped and the due-time claim re-asserted — keeping the
|
|
1053
|
+
* job re-fireable on the very next alarm (at-least-once) rather than letting
|
|
1054
|
+
* a transient storage blip cost it a whole lease.
|
|
1055
|
+
*
|
|
1056
|
+
* With one exception, checked first: a record that already has a durable
|
|
1057
|
+
* `dead:` row is TERMINAL, and re-claiming it would re-dispatch a job the
|
|
1058
|
+
* dead-letter says is finished. See the comment on that branch.
|
|
1059
|
+
*
|
|
1060
|
+
* `claimKey` is the index key `alarm()` SELECTED this record under, passed
|
|
1061
|
+
* down rather than recomputed. That is load-bearing, not tidiness: a lease
|
|
1062
|
+
* moves the record's index entry and deliberately does NOT rewrite its
|
|
1063
|
+
* `scheduledFor` (that field is the job's real due time, which `/list`,
|
|
1064
|
+
* `/get`, `/dead` and the studio all show, and which `parkDead` preserves).
|
|
1065
|
+
* So from the moment a lease is taken the live key and the key derivable
|
|
1066
|
+
* from the record disagree — and a claim that recomputed it would delete a
|
|
1067
|
+
* key that no longer exists while adding a second one, leaving the record
|
|
1068
|
+
* indexed twice and dispatched twice. That is the same double-run the lease
|
|
1069
|
+
* exists to close, re-entering through the expiry path.
|
|
700
1070
|
*/
|
|
701
1071
|
private drainRecordGuarded;
|
|
702
1072
|
/**
|
|
@@ -727,12 +1097,15 @@ declare class SchedulerDO {
|
|
|
727
1097
|
* always return `true` without touching any pool state.
|
|
728
1098
|
*
|
|
729
1099
|
* The pool row is read FRESH from storage on every call — never cached
|
|
730
|
-
* across the drain
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
1100
|
+
* across the drain — and the read-modify-write runs under
|
|
1101
|
+
* the pool lock (`withPoolLock`). Both halves are load-bearing. Freshness is what
|
|
1102
|
+
* keeps a concurrent `/complete` landing during a dispatch from being
|
|
1103
|
+
* clobbered by a stale in-memory copy (which would leak a slot permanently: a
|
|
1104
|
+
* POOL SLOT has no expiry, unlike the dispatch claim's). The lock is what
|
|
1105
|
+
* keeps two drain lanes from both reading the same pre-reservation row and
|
|
1106
|
+
* both believing a slot was free — without it the pool oversubscribes past
|
|
1107
|
+
* `maxConcurrency` and one holder's id is dropped from `inFlightIds`, so its
|
|
1108
|
+
* slot is never released.
|
|
736
1109
|
*/
|
|
737
1110
|
private reservePoolSlot;
|
|
738
1111
|
/**
|
|
@@ -744,13 +1117,33 @@ declare class SchedulerDO {
|
|
|
744
1117
|
*/
|
|
745
1118
|
private handleWebSocketUpgrade;
|
|
746
1119
|
/**
|
|
747
|
-
* Re-list the jobs
|
|
748
|
-
* any change (schedule / cancel /
|
|
749
|
-
* immediately. A no-op when the
|
|
1120
|
+
* Re-list the jobs (bounded — see {@link listPage}) and push them to
|
|
1121
|
+
* every connected subscriber. Called after any change (schedule / cancel /
|
|
1122
|
+
* alarm-fire) so live studios reflect it immediately. A no-op when the
|
|
1123
|
+
* runtime doesn't support hibernated sockets.
|
|
750
1124
|
*/
|
|
751
1125
|
private broadcastChange;
|
|
752
|
-
/**
|
|
753
|
-
|
|
1126
|
+
/**
|
|
1127
|
+
* One bounded page of the rows under `prefix`, in key order, plus the
|
|
1128
|
+
* `cursor` a caller resumes from (the last key of the page) when `truncated`.
|
|
1129
|
+
* Lists `limit + 1` and slices back down so both facts are known without a
|
|
1130
|
+
* second round-trip.
|
|
1131
|
+
*
|
|
1132
|
+
* Shared by `/list` (pending headers) and `/dead` (dead-letter records) so
|
|
1133
|
+
* NEITHER can materialize an unbounded set into one JSON response: nothing
|
|
1134
|
+
* prunes `dead:`, so a workpool with a broken origin parks thousands of rows
|
|
1135
|
+
* and the studio's only view of them — and only way to requeue them — would
|
|
1136
|
+
* fail exactly when it is needed.
|
|
1137
|
+
*/
|
|
1138
|
+
private listPage;
|
|
1139
|
+
/**
|
|
1140
|
+
* Page through every row under `prefix` exactly once with bounded per-page
|
|
1141
|
+
* memory (a `limit`+`startAfter` cursor loop), invoking `visit` for each.
|
|
1142
|
+
* Unlike {@link listPage}, which intentionally truncates for the studio's
|
|
1143
|
+
* live view, `/status` and `/pool` need EXACT counts — this walks the full
|
|
1144
|
+
* set, but never materializes more than one page at a time.
|
|
1145
|
+
*/
|
|
1146
|
+
private forEachPage;
|
|
754
1147
|
/**
|
|
755
1148
|
* HMAC-SHA-256 sign the dispatch body with `env.LUNORA_SCHEDULER_SECRET`,
|
|
756
1149
|
* returning a base64url signature, or `undefined` when no secret is
|
|
@@ -763,9 +1156,31 @@ declare class SchedulerDO {
|
|
|
763
1156
|
* The retry budget/backoff comes from the record's {@link RetryPolicy}
|
|
764
1157
|
* (falling back to the DO defaults); on exhaustion the record is parked
|
|
765
1158
|
* under a `dead:` key for manual inspection.
|
|
1159
|
+
*
|
|
1160
|
+
* **A `DISPATCH_IN_PROGRESS` decline (`inProgress`) is not charged.** It
|
|
1161
|
+
* means the record's first attempt is still running on the shard, not that
|
|
1162
|
+
* anything failed; charging it would spend the budget while that attempt is
|
|
1163
|
+
* doing the work, and if it then failed the job would dead-letter having
|
|
1164
|
+
* run once. It still re-arms at the backoff of the NEXT charged attempt, at
|
|
1165
|
+
* least {@link RETRY_BASE_DELAY_MS} apart — nothing else rate-limits an
|
|
1166
|
+
* uncharged retry, and polling a running attempt faster gains nothing.
|
|
1167
|
+
*
|
|
1168
|
+
* It is bounded, not forever, only because the shard's claim is: a claim
|
|
1169
|
+
* older than the shard's fifteen-minute ceiling is treated as stale and the
|
|
1170
|
+
* next delivery runs (and is charged as usual if it fails). A handler that
|
|
1171
|
+
* hangs therefore costs at most that long in declines before the ordinary
|
|
1172
|
+
* budget applies again. Without that ceiling, not charging would let one hung
|
|
1173
|
+
* handler keep its record retrying and never dead-letter.
|
|
766
1174
|
*/
|
|
767
1175
|
private recordRetry;
|
|
768
|
-
/**
|
|
1176
|
+
/**
|
|
1177
|
+
* Terminal park into the dead-letter (`dead:`) prefix, with `reason` naming
|
|
1178
|
+
* why in the emitted warning. Shared by the two ways a retry ends for good:
|
|
1179
|
+
* an exhausted attempt budget, and a backoff that ran past the largest
|
|
1180
|
+
* schedulable time (see {@link isIndexableTime}).
|
|
1181
|
+
*/
|
|
1182
|
+
private parkDead;
|
|
1183
|
+
/** Read the durable `pool:<name>` row, defaulting to a fresh `inFlight: 0` pool. */
|
|
769
1184
|
private loadPool;
|
|
770
1185
|
private savePool;
|
|
771
1186
|
/**
|
|
@@ -787,7 +1202,7 @@ declare class SchedulerDO {
|
|
|
787
1202
|
private handlePoolStatus;
|
|
788
1203
|
/**
|
|
789
1204
|
* `GET /status` — the app-level backlog signal that powers the studio's
|
|
790
|
-
* SLO view. Enumerates every durable `pool
|
|
1205
|
+
* SLO view. Enumerates every durable `pool:<name>` row for its `inFlight`/
|
|
791
1206
|
* `maxConcurrency` semaphore, counts the pending (not-yet-dispatched) jobs
|
|
792
1207
|
* routed to each pool with the same single-pass scan {@link handlePoolStatus}
|
|
793
1208
|
* uses, and rolls those up into app-wide `backlog` (sum of `queued`) and
|
|
@@ -796,19 +1211,64 @@ declare class SchedulerDO {
|
|
|
796
1211
|
* Pools that have rows but no queued jobs still appear (with `queued: 0`) so
|
|
797
1212
|
* a saturated-but-idle pool stays visible; a pool that only ever existed as
|
|
798
1213
|
* queued jobs without a persisted row is unreachable here (the schedule path
|
|
799
|
-
* always writes a `pool
|
|
800
|
-
* scan over `pool
|
|
1214
|
+
* always writes a `pool:<name>` row before the job's header), so a single
|
|
1215
|
+
* scan over `pool:` plus a cursor loop over `id:` is sufficient.
|
|
801
1216
|
*/
|
|
802
1217
|
private handleStatus;
|
|
1218
|
+
/**
|
|
1219
|
+
* Persist (or refresh) a pool's concurrency cap, so the alarm-time gate has
|
|
1220
|
+
* a durable `maxConcurrency` even after the enqueuing client is gone.
|
|
1221
|
+
*/
|
|
1222
|
+
private persistPoolCap;
|
|
1223
|
+
/**
|
|
1224
|
+
* The `409` a caller-supplied id earns when something durable already holds
|
|
1225
|
+
* it, or `undefined` when the id is free.
|
|
1226
|
+
*
|
|
1227
|
+
* A pending header is the obvious half: `put` on `id:<id>` overwrites, but
|
|
1228
|
+
* the `t:` index is keyed by TIME as well as id, so the OLD entry survives.
|
|
1229
|
+
* The drain then dispatches the NEW record at the OLD time and deletes the
|
|
1230
|
+
* entry it should have fired at — the job runs early and never runs again.
|
|
1231
|
+
* Refused rather than made a replace: `RunOptions.id` exists so a deferred
|
|
1232
|
+
* schedule can name its own job, and silently retiming someone else's is the
|
|
1233
|
+
* worse failure.
|
|
1234
|
+
*
|
|
1235
|
+
* The `dead:` row holds the id too, and for a worse reason. A dead record
|
|
1236
|
+
* keeps NO `id:` header, so a pending-only check leaves the id apparently
|
|
1237
|
+
* free — and a later `/dead/retry` writes the revived corpse straight over
|
|
1238
|
+
* the new job's header and adds a SECOND time index under the same id. The
|
|
1239
|
+
* new job is gone and the dead one fires in its place. Recovering a dead job
|
|
1240
|
+
* is an operator action taken minutes or days after the schedule, so nothing
|
|
1241
|
+
* at schedule time would ever have surfaced the collision.
|
|
1242
|
+
*/
|
|
1243
|
+
private idConflict;
|
|
1244
|
+
/**
|
|
1245
|
+
* The id the record is stored under, or the `Response` refusing it.
|
|
1246
|
+
*
|
|
1247
|
+
* A caller id that is not a safe key segment is refused with a `400` rather
|
|
1248
|
+
* than minted over: `RunOptions.id` is not an idempotency key, so swapping an
|
|
1249
|
+
* invalid one for a random id made two calls naming it schedule two jobs
|
|
1250
|
+
* where the second should have answered `409`.
|
|
1251
|
+
*
|
|
1252
|
+
* Only an id the CALLER chose can collide — a minted one is 96 random bits —
|
|
1253
|
+
* so {@link idConflict} costs two `get`s on the deferred path and nothing on
|
|
1254
|
+
* the ordinary one.
|
|
1255
|
+
*/
|
|
1256
|
+
private resolveId;
|
|
803
1257
|
private handleSchedule;
|
|
804
1258
|
private handleCancel;
|
|
1259
|
+
/**
|
|
1260
|
+
* `GET /list[?cursor=]` — one bounded page of pending jobs. `truncated` says
|
|
1261
|
+
* whether more rows follow and `cursor` is what a caller passes back to get
|
|
1262
|
+
* them (`createScheduler.list()` walks every page; the studio shows one).
|
|
1263
|
+
*/
|
|
805
1264
|
private handleList;
|
|
806
1265
|
/**
|
|
807
1266
|
* `GET /dead` — list the dead-letter records: jobs that exhausted their
|
|
808
|
-
* retry budget ({@link recordRetry}) and were parked under `dead
|
|
1267
|
+
* retry budget ({@link recordRetry}) and were parked under `dead:<id>`
|
|
809
1268
|
* instead of being silently dropped. These never appear in `/list` (their
|
|
810
1269
|
* `id:` header is deleted on park), so this is the ONLY way the studio can
|
|
811
|
-
* surface — and recover — a permanently-failed job.
|
|
1270
|
+
* surface — and recover — a permanently-failed job. Bounded and cursored
|
|
1271
|
+
* like `/list`: nothing prunes `dead:`, so this set grows without limit.
|
|
812
1272
|
*/
|
|
813
1273
|
private handleDeadList;
|
|
814
1274
|
/**
|
|
@@ -826,7 +1286,7 @@ declare class SchedulerDO {
|
|
|
826
1286
|
*/
|
|
827
1287
|
private handleDeadCancel;
|
|
828
1288
|
/**
|
|
829
|
-
* Resolve a single pending job by id via a direct `id
|
|
1289
|
+
* Resolve a single pending job by id via a direct `id:<id>` storage read —
|
|
830
1290
|
* O(1), versus scanning the whole `/list` view. Responds `{ record }` on a
|
|
831
1291
|
* hit and `{}` on a miss (an absent `record` field — JSON has no `undefined`
|
|
832
1292
|
* — which the client reads back as `null`).
|
|
@@ -840,14 +1300,156 @@ declare class SchedulerDO {
|
|
|
840
1300
|
* full `t:` rescan is unnecessary unless the new job is the new earliest.
|
|
841
1301
|
*/
|
|
842
1302
|
private armAlarmIfEarlier;
|
|
1303
|
+
/**
|
|
1304
|
+
* Re-index every pending job whose time-index entry is gone, and fire it now.
|
|
1305
|
+
*
|
|
1306
|
+
* A record with an `id:` header but no `t:` entry is invisible to every
|
|
1307
|
+
* clock in this class: {@link SchedulerDO.rescheduleAlarm} derives the alarm
|
|
1308
|
+
* from `t:` alone, and `alarm()`'s inline reconciliation only handles the
|
|
1309
|
+
* INVERSE orphan (a `t:` entry whose header is gone). Left alone such a job
|
|
1310
|
+
* sits in `/list` and `/status.backlog` forever — never fires, never reaches
|
|
1311
|
+
* `/dead`.
|
|
1312
|
+
*
|
|
1313
|
+
* **This is no longer how a lost dispatch looks.**
|
|
1314
|
+
* {@link SchedulerDO.drainRecordGuarded} used to claim a record by deleting
|
|
1315
|
+
* its `t:` entry outright, so an instance evicted mid-dispatch minted an
|
|
1316
|
+
* orphan on every claimed record and this method re-fired each of them ON
|
|
1317
|
+
* SIGHT — concurrently with attempts that could still have been running at
|
|
1318
|
+
* the origin. The claim is a lease now (see {@link DISPATCH_LEASE_MS}): a
|
|
1319
|
+
* claimed record keeps a `t:` entry at the lease horizon, so it is not an
|
|
1320
|
+
* orphan, is not recovered here, and is re-fired by the ordinary alarm drain
|
|
1321
|
+
* only once the lease lapses.
|
|
1322
|
+
*
|
|
1323
|
+
* What still reaches this method is the residue of a storage failure — a
|
|
1324
|
+
* claim released with no lease written, or a pre-lease record written by an
|
|
1325
|
+
* older build — where nothing was dispatched and firing immediately is
|
|
1326
|
+
* exactly right.
|
|
1327
|
+
*
|
|
1328
|
+
* The dispatch is deduplicated by the record id, which the receiver spends
|
|
1329
|
+
* as `x-lunora-mutation-id` for a function target and as the workflow
|
|
1330
|
+
* INSTANCE id for a `workflow` target. A mutation's dedup read runs inside
|
|
1331
|
+
* the shard's single-writer gate, so a mutation is exactly-once even under a
|
|
1332
|
+
* genuinely concurrent re-fire; a workflow re-attaches to the running
|
|
1333
|
+
* instance rather than starting a second one. An ACTION takes no gate —
|
|
1334
|
+
* gating one would let any caller freeze a whole shard for the length of an
|
|
1335
|
+
* action's outbound I/O — and its dedup row is written only after the
|
|
1336
|
+
* handler returns, so the cache alone could not tell "already ran" from
|
|
1337
|
+
* "still running". The shard closes that itself (#803): it claims the dedup
|
|
1338
|
+
* key before entering the handler and answers a second delivery of a live
|
|
1339
|
+
* key `409 DISPATCH_IN_PROGRESS`, which is a non-2xx, so the record is
|
|
1340
|
+
* re-armed rather than cleared.
|
|
1341
|
+
*
|
|
1342
|
+
* Two bounded walks (all `t:` values, then all `id:` headers) rather than a
|
|
1343
|
+
* per-header `get`, so the cost is one pass over each prefix.
|
|
1344
|
+
*/
|
|
1345
|
+
private reindexOrphanedRecords;
|
|
1346
|
+
/**
|
|
1347
|
+
* The time component of the earliest pending `t:` entry, or `undefined` when
|
|
1348
|
+
* there is no pending entry at all. Reads exactly one row — the index's
|
|
1349
|
+
* lexical order is its numeric order. A present-but-unreadable key yields
|
|
1350
|
+
* `NaN`, which callers must distinguish from "nothing pending": the two
|
|
1351
|
+
* answers mean opposite things for the alarm.
|
|
1352
|
+
*/
|
|
1353
|
+
private earliestPendingTime;
|
|
843
1354
|
private rescheduleAlarm;
|
|
844
1355
|
}
|
|
1356
|
+
/** What the Cloudflare scheduler host needs from the Worker's environment. */
|
|
1357
|
+
interface SchedulerHostOptions {
|
|
1358
|
+
/**
|
|
1359
|
+
* Named scheduler instance — one `SchedulerDO` per name, useful for tenant
|
|
1360
|
+
* isolation. Defaults to `"default"`.
|
|
1361
|
+
*/
|
|
1362
|
+
instanceName?: string;
|
|
1363
|
+
/**
|
|
1364
|
+
* Data-residency jurisdiction for the `SchedulerDO`. Pass the same value as
|
|
1365
|
+
* the worker's own jurisdiction so scheduled state co-resides with app data.
|
|
1366
|
+
*/
|
|
1367
|
+
jurisdiction?: "eu" | "fedramp" | "us";
|
|
1368
|
+
/**
|
|
1369
|
+
* The `SchedulerDO` namespace binding.
|
|
1370
|
+
*
|
|
1371
|
+
* The origin the DO dispatches back to is not configured here — it reads
|
|
1372
|
+
* `env.LUNORA_ORIGIN_URL` off its own binding at fire time, so a wrong value
|
|
1373
|
+
* there (or none) is what makes jobs fire into nothing.
|
|
1374
|
+
*/
|
|
1375
|
+
namespace: Parameters<typeof createScheduler>[0]["namespace"];
|
|
1376
|
+
}
|
|
1377
|
+
/**
|
|
1378
|
+
* Build the Cloudflare {@link SchedulerHost}.
|
|
1379
|
+
*
|
|
1380
|
+
* The returned host has no `cron` member — see the module docstring.
|
|
1381
|
+
*/
|
|
1382
|
+
declare const createSchedulerHost: (options: SchedulerHostOptions) => SchedulerHost;
|
|
845
1383
|
/** Standard cron expression (5- or 6-field) or a supported `@macro`, per `cron-parser`. */
|
|
846
1384
|
declare const isValidCronExpression: (schedule: string) => boolean;
|
|
1385
|
+
/**
|
|
1386
|
+
* A 6-field (seconds-leading) cron expression is legal generic cron grammar
|
|
1387
|
+
* but not a Cloudflare Cron Trigger — the platform only understands the
|
|
1388
|
+
* 5-field, minute-granularity form and rejects the rest at `wrangler deploy`
|
|
1389
|
+
* with a message naming neither the job nor the file. Warn here instead of
|
|
1390
|
+
* staying silent, without throwing: unlike the ergonomic `.interval()` form,
|
|
1391
|
+
* the raw `.cron()` escape hatch is meant to accept cron grammar this module
|
|
1392
|
+
* doesn't otherwise second-guess.
|
|
1393
|
+
*
|
|
1394
|
+
* Exported (not module-private) because it is the ONE place this advisory is
|
|
1395
|
+
* written: the runtime `cronJobs()` builder reaches it via
|
|
1396
|
+
* {@link assertValidCronExpression}, and `@lunora/codegen`'s static
|
|
1397
|
+
* `discover/crons.ts` calls it directly after its own `isValidCronExpression`
|
|
1398
|
+
* check — so a hand-authored 6-field `.cron()` warns whether it's discovered
|
|
1399
|
+
* from source at build time or registered at runtime, with one shared message.
|
|
1400
|
+
*/
|
|
1401
|
+
declare const warnIfSecondsLeading: (schedule: string, context: string) => void;
|
|
847
1402
|
/**
|
|
848
1403
|
* Assert a raw cron expression is well-formed, throwing the same shaped error
|
|
849
1404
|
* both cron surfaces use. The `context` prefix lets callers name the offending
|
|
850
|
-
* job (`cron job "send digest"`) vs. the bare trigger.
|
|
1405
|
+
* job (`cron job "send digest"`) vs. the bare trigger. Well-formed but
|
|
1406
|
+
* Cloudflare-incompatible (6-field) expressions pass but log a warning — see
|
|
1407
|
+
* {@link warnIfSecondsLeading}.
|
|
851
1408
|
*/
|
|
852
1409
|
declare const assertValidCronExpression: (schedule: string, context?: string) => void;
|
|
853
|
-
|
|
1410
|
+
/**
|
|
1411
|
+
* Reject a `delayMs` a scheduler cannot act on, before it reaches the
|
|
1412
|
+
* SchedulerDO.
|
|
1413
|
+
*
|
|
1414
|
+
* A `NaN`/`Infinity` delay serializes to `null` through JSON and lands as a
|
|
1415
|
+
* malformed `scheduledFor`; a negative one schedules into the past. Both are the
|
|
1416
|
+
* caller's argument, so the answer has to name the argument — which is why the
|
|
1417
|
+
* code is `INVALID_INPUT` (400) and not `INTERNAL`: `toErrorBody` replaces an
|
|
1418
|
+
* internal-coded message with "Internal error", redacting the one sentence that
|
|
1419
|
+
* says what to fix.
|
|
1420
|
+
*
|
|
1421
|
+
* Exported because four surfaces enforce it — `createScheduler().runAfter`,
|
|
1422
|
+
* `createWorkpool().enqueue`, `@lunora/server`'s deferred-schedule facade (which
|
|
1423
|
+
* must reject BEFORE the transaction commits, not at flush time) and
|
|
1424
|
+
* `@lunora/testing`'s fake scheduler. They used to restate it and threw three
|
|
1425
|
+
* different codes between them, so a test written against the harness caught one
|
|
1426
|
+
* code while production threw another.
|
|
1427
|
+
* @param delayMs the delay to validate
|
|
1428
|
+
* @param surface what to name in the message — the call the delay was passed to (e.g. `"ctx.scheduler.runAfter"`)
|
|
1429
|
+
* @param argument the caller's argument to name in the message; `runAt` converts its absolute `date` to a delay and passes that name through so the answer points at what was actually written
|
|
1430
|
+
*/
|
|
1431
|
+
declare const assertScheduleDelay: (delayMs: number, surface: string, argument?: string) => void;
|
|
1432
|
+
/**
|
|
1433
|
+
* Reject a `runAt` instant a scheduler cannot act on — {@link assertScheduleDelay}'s
|
|
1434
|
+
* bound, restated for the absolute form by converting the instant to the delay it
|
|
1435
|
+
* implies.
|
|
1436
|
+
*
|
|
1437
|
+
* `runAfter` has refused a `NaN`/`Infinity` argument since the guard was written;
|
|
1438
|
+
* `runAt` took the same value through a different door and let it reach the DO,
|
|
1439
|
+
* where it serializes to `null` through JSON and lands as a `scheduledFor` no
|
|
1440
|
+
* alarm can ever fire. `new Date("2026-13-01")`, `runAt(row.dueAt + delay)` on a
|
|
1441
|
+
* row whose `dueAt` is absent — both arrive here as a number that is not one.
|
|
1442
|
+
*
|
|
1443
|
+
* An instant already in the PAST is not refused. It is an overdue job
|
|
1444
|
+
* (`runAt(row.dueAt)` on a row that came due while the request was in flight),
|
|
1445
|
+
* and `runAfter` itself reaches `runAt` a fraction of a millisecond after
|
|
1446
|
+
* capturing its own clock reading — so a strict sign check would fail the
|
|
1447
|
+
* documented `runAfter(0, …)` call at random. The delay is therefore clamped at
|
|
1448
|
+
* zero while it is still a finite number, which leaves the half that matters:
|
|
1449
|
+
* a value that is not a number at all.
|
|
1450
|
+
* @param timestampMs the absolute instant (epoch ms) the caller passed
|
|
1451
|
+
* @param nowMs the clock to measure it against — the wall clock in production, the harness's virtual clock in a test
|
|
1452
|
+
* @param surface what to name in the message — the call the instant was passed to (e.g. `"ctx.scheduler.runAt"`)
|
|
1453
|
+
*/
|
|
1454
|
+
declare const assertScheduleInstant: (timestampMs: number, nowMs: number, surface: string) => void;
|
|
1455
|
+
export { type ArgsOf, CRON_SCHEDULE_KINDS, type CronJob, type CronJobsBuilder, type CronScheduleKind, type CronTarget, type CronTriggerOptions, type CronTriggerSnippet, type DailySchedule, type DurableObjectIdLike, type DurableObjectJurisdiction, type DurableObjectNamespaceLike, type DurableObjectStubLike, type EnqueueOptions, type FunctionKind, type FunctionReference, type HourlySchedule, type HttpDispatcherOptions, type IntervalSchedule, type LunoraSchedulerOptions, MAX_RETRY_ATTEMPTS, type MessageBatchLike, type MonthlySchedule, type QueueConsumerOptions, type QueueDispatch, type QueueEnqueueOptions, type QueueJob, type QueueLike, type QueueMessageLike, type QueueSendOptionsLike, type QueueSendRequestLike, type QueueWorkpool, type QueueWorkpoolOptions, RETRY_BASE_DELAY_MS, type RetryPolicy, type RunOptions, type ScheduleRecord, type Scheduler, SchedulerDO, type SchedulerDOState, type SchedulerEnv, type SchedulerHostOptions, type SchedulerPoolStatus, type SchedulerStatus, type WeeklySchedule, type WorkflowReference, type Workpool, type WorkpoolOptions, assertScheduleDelay, assertScheduleInstant, assertValidCronExpression, compileCronSchedule, createCronTrigger, createQueueConsumer, createQueueWorkpool, createScheduler, createSchedulerHost, createWorkpool, cronJobs, httpDispatcher, isValidCronExpression, isWorkflowReference, resolveScheduleId, warnIfSecondsLeading };
|