@lunora/scheduler 1.0.0-alpha.77 → 1.0.0-alpha.78
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/dist/index.d.mts
CHANGED
|
@@ -481,6 +481,22 @@ declare const createScheduler: (options: LunoraSchedulerOptions) => Scheduler;
|
|
|
481
481
|
* `maxConcurrency` of the pool's jobs at once and queues the rest durably,
|
|
482
482
|
* draining them as the runtime reports completions (`POST /complete`).
|
|
483
483
|
*
|
|
484
|
+
* The ceiling the platform adds: one alarm drain keeps at most SIX dispatches in
|
|
485
|
+
* flight, because a Durable Object may have only six connections simultaneously
|
|
486
|
+
* waiting for response headers. A `maxConcurrency` above six is therefore not
|
|
487
|
+
* wrong, just not reachable within a single drain — the surplus drains on the
|
|
488
|
+
* following alarms. The same invocation is bounded by the 15-minute alarm wall
|
|
489
|
+
* clock, which covers the WHOLE drain rather than one job; work that does not
|
|
490
|
+
* fit is deferred to the next alarm, never dropped.
|
|
491
|
+
*
|
|
492
|
+
* For jobs that are individually long — an LLM call, an export, a payment
|
|
493
|
+
* round-trip — prefer `createQueueWorkpool`: the dispatch this DO awaits
|
|
494
|
+
* only returns once the function has finished running, so a long job occupies
|
|
495
|
+
* one of those six slots and one slice of that 15-minute budget for its whole
|
|
496
|
+
* duration. A Queues consumer gets a fresh Worker invocation per batch, with its
|
|
497
|
+
* own connection budget and its own clock, and its `max_concurrency` is not
|
|
498
|
+
* capped at six. You give up the hard cap, per-job cancel, and per-job status.
|
|
499
|
+
*
|
|
484
500
|
* ```ts
|
|
485
501
|
* const pool = createWorkpool({ namespace: env.SCHEDULER, maxConcurrency: 5 });
|
|
486
502
|
* await pool.enqueue(internal.stripe.sync, { invoiceId }, { retry: { maxAttempts: 3 } });
|
|
@@ -863,6 +879,13 @@ declare class SchedulerDO {
|
|
|
863
879
|
* an eviction ends the instance that minted it.
|
|
864
880
|
*/
|
|
865
881
|
private reindexed;
|
|
882
|
+
/**
|
|
883
|
+
* Tail of the serialized `pool:<name>` critical section — see
|
|
884
|
+
* the pool lock (`withPoolLock`). Per-instance, which is the right scope: the pool rows
|
|
885
|
+
* live in this Durable Object's own storage and only this instance writes
|
|
886
|
+
* them.
|
|
887
|
+
*/
|
|
888
|
+
private poolLock;
|
|
866
889
|
constructor(state: SchedulerDOState, env: SchedulerEnv);
|
|
867
890
|
fetch(request: Request): Promise<Response>;
|
|
868
891
|
/** Called by the Workers runtime when the alarm previously set by `rescheduleAlarm()` fires. */
|
|
@@ -899,6 +922,48 @@ declare class SchedulerDO {
|
|
|
899
922
|
* in the unit harness and on older runtimes, where it degrades to a no-op.
|
|
900
923
|
*/
|
|
901
924
|
private armWebSocketKeepalive;
|
|
925
|
+
/**
|
|
926
|
+
* Drain the due slice with up to {@link MAX_CONCURRENT_DISPATCHES} records in
|
|
927
|
+
* flight at once, so a slow job delays only its own lane instead of every
|
|
928
|
+
* other due job in the app (see the constant for why the drain used to
|
|
929
|
+
* serialise whole jobs, not just their kicks).
|
|
930
|
+
*
|
|
931
|
+
* Lanes pull from the head of the slice, so records still ENTER dispatch in
|
|
932
|
+
* the slice's order — the same `t:<paddedTime>:<id>` order the sequential
|
|
933
|
+
* drain used. What is no longer implied is that they FINISH in that order,
|
|
934
|
+
* which was never a guarantee worth relying on anyway: two jobs due at the
|
|
935
|
+
* same instant already ran in storage-key (id) order rather than arrival
|
|
936
|
+
* order, a saturated pooled job is pushed {@link POOL_BACKPRESSURE_DELAY_MS}
|
|
937
|
+
* into the future ahead of its queue-mates, and a failed job re-enters at the
|
|
938
|
+
* end of a backoff. Nothing in the public surface documents an ordering
|
|
939
|
+
* guarantee; jobs that must be ordered must chain themselves.
|
|
940
|
+
*
|
|
941
|
+
* {@link drainRecordGuarded} swallows every throw, so no lane can reject and
|
|
942
|
+
* abandon its siblings.
|
|
943
|
+
*/
|
|
944
|
+
private drainDue;
|
|
945
|
+
/**
|
|
946
|
+
* Run `critical` with exclusive access to the `pool:<name>` rows.
|
|
947
|
+
*
|
|
948
|
+
* Every pool mutation is a read-modify-write (`loadPool` → mutate →
|
|
949
|
+
* `savePool`), and the two halves are separated by an `await`. That was safe
|
|
950
|
+
* while the drain ran one record at a time — the Durable Object input gate
|
|
951
|
+
* keeps a foreign event (a concurrent `/complete`) out while a storage
|
|
952
|
+
* operation is in flight, and nothing else in this instance could interleave.
|
|
953
|
+
* Concurrent lanes break exactly that assumption: lane A and lane B can both
|
|
954
|
+
* issue their `get` before either `put` lands, both observe `inFlight: 0`,
|
|
955
|
+
* and the second `put` then erases the first lane's reservation — the pool
|
|
956
|
+
* oversubscribes and one holder's id is lost, so its slot is never released.
|
|
957
|
+
*
|
|
958
|
+
* The lock is a promise chain rather than anything cleverer because the
|
|
959
|
+
* critical section is two local storage ops with no I/O in it. It is held
|
|
960
|
+
* across NO outbound fetch: `dispatch()` runs outside it, which is the whole
|
|
961
|
+
* point of draining concurrently.
|
|
962
|
+
*
|
|
963
|
+
* The chain is rebuilt from a swallowed copy so one rejecting section can
|
|
964
|
+
* never wedge every later one.
|
|
965
|
+
*/
|
|
966
|
+
private withPoolLock;
|
|
902
967
|
/**
|
|
903
968
|
* Claim + drain one due record with per-record fault isolation, so a storage
|
|
904
969
|
* throw can never abort the whole alarm pass (which would skip the remaining
|
|
@@ -950,12 +1015,15 @@ declare class SchedulerDO {
|
|
|
950
1015
|
* always return `true` without touching any pool state.
|
|
951
1016
|
*
|
|
952
1017
|
* The pool row is read FRESH from storage on every call — never cached
|
|
953
|
-
* across the drain
|
|
954
|
-
*
|
|
955
|
-
*
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
1018
|
+
* across the drain — and the read-modify-write runs under
|
|
1019
|
+
* the pool lock (`withPoolLock`). Both halves are load-bearing. Freshness is what
|
|
1020
|
+
* keeps a concurrent `/complete` landing during a dispatch from being
|
|
1021
|
+
* clobbered by a stale in-memory copy (which would leak a slot permanently,
|
|
1022
|
+
* there being no lease to reclaim it). The lock is what keeps two drain
|
|
1023
|
+
* lanes from both reading the same pre-reservation row and both believing a
|
|
1024
|
+
* slot was free — without it the pool oversubscribes past `maxConcurrency`
|
|
1025
|
+
* and one holder's id is dropped from `inFlightIds`, so its slot is never
|
|
1026
|
+
* released.
|
|
959
1027
|
*/
|
|
960
1028
|
private reservePoolSlot;
|
|
961
1029
|
/**
|
|
@@ -1149,10 +1217,21 @@ declare class SchedulerDO {
|
|
|
1149
1217
|
* `/dead`. The at-least-once contract `drainRecordGuarded` documents covers
|
|
1150
1218
|
* a thrown storage op, not a lost instance.
|
|
1151
1219
|
*
|
|
1152
|
-
* Re-firing is
|
|
1153
|
-
* spends as `x-lunora-mutation-id` for a
|
|
1154
|
-
* INSTANCE id for a `workflow` target
|
|
1155
|
-
*
|
|
1220
|
+
* Re-firing is deduplicated, but NOT unconditionally: the dispatch carries
|
|
1221
|
+
* the record id, which the receiver spends as `x-lunora-mutation-id` for a
|
|
1222
|
+
* function target and as the workflow INSTANCE id for a `workflow` target.
|
|
1223
|
+
* A workflow attaches to the running instance, and a mutation's dedup read
|
|
1224
|
+
* runs inside the shard's single-writer gate, so both are exactly-once.
|
|
1225
|
+
*
|
|
1226
|
+
* An ACTION is the exception, and it is the case this path most often
|
|
1227
|
+
* recovers. `@lunora/do` deliberately does NOT take the gate for a
|
|
1228
|
+
* non-mutation — gating one would let any caller freeze a whole shard for
|
|
1229
|
+
* the length of an action's outbound I/O — and the dedup row is written only
|
|
1230
|
+
* after the handler returns. So a long action that was still running when
|
|
1231
|
+
* this instance was lost has no row yet, and the re-fire runs the handler a
|
|
1232
|
+
* SECOND time, concurrently with the first. That is at-least-once, not
|
|
1233
|
+
* exactly-once, and an action with non-idempotent side effects has to carry
|
|
1234
|
+
* its own guard.
|
|
1156
1235
|
*
|
|
1157
1236
|
* Two bounded walks (all `t:` values, then all `id:` headers) rather than a
|
|
1158
1237
|
* per-header `get`, so the cost is one pass over each prefix.
|
package/dist/index.d.ts
CHANGED
|
@@ -481,6 +481,22 @@ declare const createScheduler: (options: LunoraSchedulerOptions) => Scheduler;
|
|
|
481
481
|
* `maxConcurrency` of the pool's jobs at once and queues the rest durably,
|
|
482
482
|
* draining them as the runtime reports completions (`POST /complete`).
|
|
483
483
|
*
|
|
484
|
+
* The ceiling the platform adds: one alarm drain keeps at most SIX dispatches in
|
|
485
|
+
* flight, because a Durable Object may have only six connections simultaneously
|
|
486
|
+
* waiting for response headers. A `maxConcurrency` above six is therefore not
|
|
487
|
+
* wrong, just not reachable within a single drain — the surplus drains on the
|
|
488
|
+
* following alarms. The same invocation is bounded by the 15-minute alarm wall
|
|
489
|
+
* clock, which covers the WHOLE drain rather than one job; work that does not
|
|
490
|
+
* fit is deferred to the next alarm, never dropped.
|
|
491
|
+
*
|
|
492
|
+
* For jobs that are individually long — an LLM call, an export, a payment
|
|
493
|
+
* round-trip — prefer `createQueueWorkpool`: the dispatch this DO awaits
|
|
494
|
+
* only returns once the function has finished running, so a long job occupies
|
|
495
|
+
* one of those six slots and one slice of that 15-minute budget for its whole
|
|
496
|
+
* duration. A Queues consumer gets a fresh Worker invocation per batch, with its
|
|
497
|
+
* own connection budget and its own clock, and its `max_concurrency` is not
|
|
498
|
+
* capped at six. You give up the hard cap, per-job cancel, and per-job status.
|
|
499
|
+
*
|
|
484
500
|
* ```ts
|
|
485
501
|
* const pool = createWorkpool({ namespace: env.SCHEDULER, maxConcurrency: 5 });
|
|
486
502
|
* await pool.enqueue(internal.stripe.sync, { invoiceId }, { retry: { maxAttempts: 3 } });
|
|
@@ -863,6 +879,13 @@ declare class SchedulerDO {
|
|
|
863
879
|
* an eviction ends the instance that minted it.
|
|
864
880
|
*/
|
|
865
881
|
private reindexed;
|
|
882
|
+
/**
|
|
883
|
+
* Tail of the serialized `pool:<name>` critical section — see
|
|
884
|
+
* the pool lock (`withPoolLock`). Per-instance, which is the right scope: the pool rows
|
|
885
|
+
* live in this Durable Object's own storage and only this instance writes
|
|
886
|
+
* them.
|
|
887
|
+
*/
|
|
888
|
+
private poolLock;
|
|
866
889
|
constructor(state: SchedulerDOState, env: SchedulerEnv);
|
|
867
890
|
fetch(request: Request): Promise<Response>;
|
|
868
891
|
/** Called by the Workers runtime when the alarm previously set by `rescheduleAlarm()` fires. */
|
|
@@ -899,6 +922,48 @@ declare class SchedulerDO {
|
|
|
899
922
|
* in the unit harness and on older runtimes, where it degrades to a no-op.
|
|
900
923
|
*/
|
|
901
924
|
private armWebSocketKeepalive;
|
|
925
|
+
/**
|
|
926
|
+
* Drain the due slice with up to {@link MAX_CONCURRENT_DISPATCHES} records in
|
|
927
|
+
* flight at once, so a slow job delays only its own lane instead of every
|
|
928
|
+
* other due job in the app (see the constant for why the drain used to
|
|
929
|
+
* serialise whole jobs, not just their kicks).
|
|
930
|
+
*
|
|
931
|
+
* Lanes pull from the head of the slice, so records still ENTER dispatch in
|
|
932
|
+
* the slice's order — the same `t:<paddedTime>:<id>` order the sequential
|
|
933
|
+
* drain used. What is no longer implied is that they FINISH in that order,
|
|
934
|
+
* which was never a guarantee worth relying on anyway: two jobs due at the
|
|
935
|
+
* same instant already ran in storage-key (id) order rather than arrival
|
|
936
|
+
* order, a saturated pooled job is pushed {@link POOL_BACKPRESSURE_DELAY_MS}
|
|
937
|
+
* into the future ahead of its queue-mates, and a failed job re-enters at the
|
|
938
|
+
* end of a backoff. Nothing in the public surface documents an ordering
|
|
939
|
+
* guarantee; jobs that must be ordered must chain themselves.
|
|
940
|
+
*
|
|
941
|
+
* {@link drainRecordGuarded} swallows every throw, so no lane can reject and
|
|
942
|
+
* abandon its siblings.
|
|
943
|
+
*/
|
|
944
|
+
private drainDue;
|
|
945
|
+
/**
|
|
946
|
+
* Run `critical` with exclusive access to the `pool:<name>` rows.
|
|
947
|
+
*
|
|
948
|
+
* Every pool mutation is a read-modify-write (`loadPool` → mutate →
|
|
949
|
+
* `savePool`), and the two halves are separated by an `await`. That was safe
|
|
950
|
+
* while the drain ran one record at a time — the Durable Object input gate
|
|
951
|
+
* keeps a foreign event (a concurrent `/complete`) out while a storage
|
|
952
|
+
* operation is in flight, and nothing else in this instance could interleave.
|
|
953
|
+
* Concurrent lanes break exactly that assumption: lane A and lane B can both
|
|
954
|
+
* issue their `get` before either `put` lands, both observe `inFlight: 0`,
|
|
955
|
+
* and the second `put` then erases the first lane's reservation — the pool
|
|
956
|
+
* oversubscribes and one holder's id is lost, so its slot is never released.
|
|
957
|
+
*
|
|
958
|
+
* The lock is a promise chain rather than anything cleverer because the
|
|
959
|
+
* critical section is two local storage ops with no I/O in it. It is held
|
|
960
|
+
* across NO outbound fetch: `dispatch()` runs outside it, which is the whole
|
|
961
|
+
* point of draining concurrently.
|
|
962
|
+
*
|
|
963
|
+
* The chain is rebuilt from a swallowed copy so one rejecting section can
|
|
964
|
+
* never wedge every later one.
|
|
965
|
+
*/
|
|
966
|
+
private withPoolLock;
|
|
902
967
|
/**
|
|
903
968
|
* Claim + drain one due record with per-record fault isolation, so a storage
|
|
904
969
|
* throw can never abort the whole alarm pass (which would skip the remaining
|
|
@@ -950,12 +1015,15 @@ declare class SchedulerDO {
|
|
|
950
1015
|
* always return `true` without touching any pool state.
|
|
951
1016
|
*
|
|
952
1017
|
* The pool row is read FRESH from storage on every call — never cached
|
|
953
|
-
* across the drain
|
|
954
|
-
*
|
|
955
|
-
*
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
1018
|
+
* across the drain — and the read-modify-write runs under
|
|
1019
|
+
* the pool lock (`withPoolLock`). Both halves are load-bearing. Freshness is what
|
|
1020
|
+
* keeps a concurrent `/complete` landing during a dispatch from being
|
|
1021
|
+
* clobbered by a stale in-memory copy (which would leak a slot permanently,
|
|
1022
|
+
* there being no lease to reclaim it). The lock is what keeps two drain
|
|
1023
|
+
* lanes from both reading the same pre-reservation row and both believing a
|
|
1024
|
+
* slot was free — without it the pool oversubscribes past `maxConcurrency`
|
|
1025
|
+
* and one holder's id is dropped from `inFlightIds`, so its slot is never
|
|
1026
|
+
* released.
|
|
959
1027
|
*/
|
|
960
1028
|
private reservePoolSlot;
|
|
961
1029
|
/**
|
|
@@ -1149,10 +1217,21 @@ declare class SchedulerDO {
|
|
|
1149
1217
|
* `/dead`. The at-least-once contract `drainRecordGuarded` documents covers
|
|
1150
1218
|
* a thrown storage op, not a lost instance.
|
|
1151
1219
|
*
|
|
1152
|
-
* Re-firing is
|
|
1153
|
-
* spends as `x-lunora-mutation-id` for a
|
|
1154
|
-
* INSTANCE id for a `workflow` target
|
|
1155
|
-
*
|
|
1220
|
+
* Re-firing is deduplicated, but NOT unconditionally: the dispatch carries
|
|
1221
|
+
* the record id, which the receiver spends as `x-lunora-mutation-id` for a
|
|
1222
|
+
* function target and as the workflow INSTANCE id for a `workflow` target.
|
|
1223
|
+
* A workflow attaches to the running instance, and a mutation's dedup read
|
|
1224
|
+
* runs inside the shard's single-writer gate, so both are exactly-once.
|
|
1225
|
+
*
|
|
1226
|
+
* An ACTION is the exception, and it is the case this path most often
|
|
1227
|
+
* recovers. `@lunora/do` deliberately does NOT take the gate for a
|
|
1228
|
+
* non-mutation — gating one would let any caller freeze a whole shard for
|
|
1229
|
+
* the length of an action's outbound I/O — and the dedup row is written only
|
|
1230
|
+
* after the handler returns. So a long action that was still running when
|
|
1231
|
+
* this instance was lost has no row yet, and the re-fire runs the handler a
|
|
1232
|
+
* SECOND time, concurrently with the first. That is at-least-once, not
|
|
1233
|
+
* exactly-once, and an action with non-idempotent side effects has to carry
|
|
1234
|
+
* its own guard.
|
|
1156
1235
|
*
|
|
1157
1236
|
* Two bounded walks (all `t:` values, then all `id:` headers) rather than a
|
|
1158
1237
|
* per-header `get`, so the cost is one pass over each prefix.
|
package/dist/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{default as o}from"./packem_shared/createScheduler-BW6wVD57.mjs";import{default as a}from"./packem_shared/createWorkpool-CQs6z3Y0.mjs";import{createCronTrigger as f}from"./packem_shared/createCronTrigger-DSuDZtqj.mjs";import{CRON_SCHEDULE_KINDS as p,compileCronSchedule as c,cronJobs as d}from"./packem_shared/CRON_SCHEDULE_KINDS-xyFk800s.mjs";import{createQueueConsumer as m,createQueueWorkpool as n,httpDispatcher as x}from"./packem_shared/createQueueConsumer-DWWfGYKN.mjs";import{default as i}from"./packem_shared/resolveScheduleId-DgysKSrN.mjs";import{MAX_RETRY_ATTEMPTS as E,RETRY_BASE_DELAY_MS as C,SchedulerDO as _}from"./packem_shared/MAX_RETRY_ATTEMPTS-
|
|
1
|
+
import{default as o}from"./packem_shared/createScheduler-BW6wVD57.mjs";import{default as a}from"./packem_shared/createWorkpool-CQs6z3Y0.mjs";import{createCronTrigger as f}from"./packem_shared/createCronTrigger-DSuDZtqj.mjs";import{CRON_SCHEDULE_KINDS as p,compileCronSchedule as c,cronJobs as d}from"./packem_shared/CRON_SCHEDULE_KINDS-xyFk800s.mjs";import{createQueueConsumer as m,createQueueWorkpool as n,httpDispatcher as x}from"./packem_shared/createQueueConsumer-DWWfGYKN.mjs";import{default as i}from"./packem_shared/resolveScheduleId-DgysKSrN.mjs";import{MAX_RETRY_ATTEMPTS as E,RETRY_BASE_DELAY_MS as C,SchedulerDO as _}from"./packem_shared/MAX_RETRY_ATTEMPTS-BSJ-pXvs.mjs";import{createSchedulerHost as R}from"./packem_shared/createSchedulerHost-CO6BlgVY.mjs";import{isWorkflowReference as A}from"./packem_shared/isWorkflowReference-CT3tdefh.mjs";import{assertValidCronExpression as g,isValidCronExpression as k,warnIfSecondsLeading as L}from"./packem_shared/assertValidCronExpression-DnBtukpq.mjs";import{default as W}from"./packem_shared/assertScheduleDelay-BgA4K1WB.mjs";import{default as w}from"./packem_shared/assertScheduleInstant-BzESPqyw.mjs";export{p as CRON_SCHEDULE_KINDS,E as MAX_RETRY_ATTEMPTS,C as RETRY_BASE_DELAY_MS,_ as SchedulerDO,W as assertScheduleDelay,w as assertScheduleInstant,g as assertValidCronExpression,c as compileCronSchedule,f as createCronTrigger,m as createQueueConsumer,n as createQueueWorkpool,o as createScheduler,R as createSchedulerHost,a as createWorkpool,d as cronJobs,x as httpDispatcher,k as isValidCronExpression,A as isWorkflowReference,i as resolveScheduleId,L as warnIfSecondsLeading};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{t as P}from"./base64-BFBvYeZM.mjs";import b from"./resolveScheduleId-DgysKSrN.mjs";const I=(u,t=200,e)=>{const s=new Headers({"content-type":"application/json"});for(const[a,n]of Object.entries({}))s.set(a,n);return Response.json(u,{headers:s,status:t})},F="lunora-ping",x="lunora-pong",c="id:",f="retry:",g="dead:",p="pool:",m=100,N=5,R=3e4,A=1e3,E=6,$=999999999999999,k=15,v=u=>String(u).padStart(k,"0"),w=u=>Number.isInteger(u)&&u>0&&u<=$;class i{static indexKey(t,e){return`t:${v(t)}:${e}`}static json(t,e=200){return I(t,e)}static error(t,e,s){return i.json({error:{code:e,message:s}},t)}static resolveRetry(t){const e=t.retry,s=typeof e?.maxAttempts=="number"&&Number.isInteger(e.maxAttempts)&&e.maxAttempts>0?e.maxAttempts:N,a=typeof e?.baseMs=="number"&&Number.isFinite(e.baseMs)&&e.baseMs>=0?e.baseMs:R,n=e?.backoff==="linear"?"linear":"exponential",o=typeof e?.maxMs=="number"&&Number.isFinite(e.maxMs)&&e.maxMs>=0?e.maxMs:void 0;return{backoff:n,baseMs:a,maxAttempts:s,maxMs:o}}static normalizeConcurrency(t,e){return typeof t=="number"&&Number.isInteger(t)&&t>0?t:e}static normalizeRetry(t){if(typeof t!="object"||t===null)return;const e=t,s={};return typeof e.maxAttempts=="number"&&Number.isInteger(e.maxAttempts)&&e.maxAttempts>0&&(s.maxAttempts=e.maxAttempts),typeof e.baseMs=="number"&&Number.isFinite(e.baseMs)&&e.baseMs>=0&&(s.baseMs=e.baseMs),(e.backoff==="exponential"||e.backoff==="linear")&&(s.backoff=e.backoff),typeof e.maxMs=="number"&&Number.isFinite(e.maxMs)&&e.maxMs>=0&&(s.maxMs=e.maxMs),Object.keys(s).length===0?void 0:s}static releaseSlot(t,e){if(t.inFlightIds===void 0)return{...t,inFlight:Math.max(0,t.inFlight-1)};const s=t.inFlightIds.filter(a=>a!==e);return{...t,inFlight:s.length,inFlightIds:s}}static releaseFirstSlot(t){if(t.inFlightIds===void 0)return{...t,inFlight:Math.max(0,t.inFlight-1)};const e=t.inFlightIds.slice(0,Math.max(0,t.inFlightIds.length-1));return{...t,inFlight:e.length,inFlightIds:e}}static resolveScheduleTarget(t){const e=typeof t?.functionPath=="string"&&t.functionPath.length>0?t.functionPath:void 0,s=typeof t?.workflow=="string"&&t.workflow.length>0?t.workflow:void 0;if(!(e===void 0&&s===void 0))return{functionPath:e,workflow:s}}state;env;reindexed=!1;poolLock=Promise.resolve();constructor(t,e){this.state=t,this.env=e,this.armWebSocketKeepalive()}async fetch(t){await this.reindexOrphanedRecords();const e=new URL(t.url);if(e.pathname==="/ws"&&t.headers.get("Upgrade")==="websocket")return this.handleWebSocketUpgrade();switch(`${t.method} ${e.pathname}`){case"GET /dead":return this.handleDeadList(e);case"GET /get":return this.handleGet(e);case"GET /list":return this.handleList(e);case"GET /pool":return this.handlePoolStatus(e);case"GET /status":return this.handleStatus();case"POST /cancel":return this.handleCancel(t);case"POST /complete":return this.handleComplete(t);case"POST /dead/cancel":return this.handleDeadCancel(t);case"POST /dead/retry":return this.handleDeadRetry(t);case"POST /schedule":return this.handleSchedule(t)}return I({error:{code:"NOT_FOUND"}},404)}async alarm(){await this.reindexOrphanedRecords();const t=Date.now(),e=[],s=await this.state.storage.list({end:`t:${v(t)}:~`,limit:100,prefix:"t:"});for(const[a,n]of s.entries()){const o=Number.parseInt(a.slice(2,a.indexOf(":",2)),10);if(Number.isFinite(o)&&o<=t){const r=await this.state.storage.get(`${c}${n}`);r?e.push(r):await this.state.storage.delete(a)}}try{await this.drainDue(e)}finally{await this.rescheduleAlarm()}e.length>0&&await this.broadcastChange()}async dispatch(t){const e=typeof this.env.LUNORA_ORIGIN_URL=="string"&&this.env.LUNORA_ORIGIN_URL.length>0?this.env.LUNORA_ORIGIN_URL:void 0;if(!e)return!1;const s=JSON.stringify({args:t.args,functionPath:t.functionPath,id:t.id,instanceName:t.instanceName,pool:t.pool,scheduledFor:t.scheduledFor,shardKey:t.shardKey,workflow:t.workflow});try{const a={"content-type":"application/json"},n=await this.signDispatch(s);return n!==void 0?a["x-lunora-scheduler-signature"]=n:typeof this.env.LUNORA_ADMIN_TOKEN=="string"&&this.env.LUNORA_ADMIN_TOKEN.length>0&&(a.authorization=`Bearer ${this.env.LUNORA_ADMIN_TOKEN}`),(await fetch(`${e}/_lunora/scheduler/dispatch`,{body:s,headers:a,method:"POST"})).ok}catch{return!1}}armWebSocketKeepalive(){const t=this.state.setWebSocketAutoResponse;typeof t!="function"||typeof WebSocketRequestResponsePair>"u"||t.call(this.state,new WebSocketRequestResponsePair(F,x))}async drainDue(t){const e=[...t],s=[],a=Math.min(E,e.length);for(let n=0;n<a;n+=1)s.push((async()=>{for(let o=e.shift();o!==void 0;o=e.shift())await this.drainRecordGuarded(o)})());await Promise.all(s)}async withPoolLock(t){const e=this.poolLock.then(t,t);return this.poolLock=e.then(()=>{},()=>{}),e}async drainRecordGuarded(t){try{await this.state.storage.delete(i.indexKey(t.scheduledFor,t.id)),await this.drainRecord(t)}catch{try{if(await this.state.storage.get(`${g}${t.id}`)!==void 0){await this.state.storage.delete([`${f}${t.id}`,`${c}${t.id}`]);return}await this.state.storage.put(i.indexKey(t.scheduledFor,t.id),t.id)}catch{}}}async drainRecord(t){if(!await this.reservePoolSlot(t))return!1;const s=await this.dispatch(t),a=t.pool;if(!s&&a!==void 0&&await this.withPoolLock(async()=>{const n=await this.loadPool(a);await this.savePool(a,i.releaseSlot(n,t.id))}),s){try{await this.state.storage.delete([`${c}${t.id}`,`${f}${t.id}`])}catch{}return!0}return await this.recordRetry(t),!1}async reservePoolSlot(t){const e=t.pool;if(e===void 0)return!0;const s=await this.withPoolLock(async()=>{const a=await this.loadPool(e);if(a.inFlight>=a.maxConcurrency)return!1;const n=a.inFlightIds??[];return n.includes(t.id)||n.push(t.id),a.inFlightIds=n,a.inFlight=n.length,await this.savePool(e,a),!0});return s||await this.requeuePooled(t),s}async handleWebSocketUpgrade(){if(this.state.acceptWebSocket===void 0)return i.error(501,"WS_UNSUPPORTED","WebSocket subscriptions are not supported in this runtime");const t=new WebSocketPair,e=t[0],s=t[1];this.state.acceptWebSocket(s);const a=await this.listPage(c,m);return s.send(JSON.stringify({records:a.records,truncated:a.truncated,type:"jobs"})),new Response(null,{status:101,webSocket:e})}async broadcastChange(){const t=this.state.getWebSockets?.();if(t===void 0||t.length===0)return;const{records:e,truncated:s}=await this.listPage(c,m),a=JSON.stringify({records:e,truncated:s,type:"jobs"});for(const n of t)try{n.send(a)}catch{}}async listPage(t,e,s){const a=await this.state.storage.list({limit:e+1,prefix:t,...s===void 0?{}:{startAfter:s}}),n=[...a.keys()],o=[...a.values()],r=o.length>e;return r?{cursor:n[e-1],records:o.slice(0,e),truncated:r}:{records:o,truncated:r}}async forEachPage(t,e,s=m){let a;for(;;){const n=await this.state.storage.list(a===void 0?{limit:s,prefix:t}:{limit:s,prefix:t,startAfter:a});if(n.size===0)break;for(const[r,h]of n.entries())e(h,r);if(a=[...n.keys()].at(-1),n.size<s)break}}async signDispatch(t){const e=typeof this.env.LUNORA_SCHEDULER_SECRET=="string"?this.env.LUNORA_SCHEDULER_SECRET:void 0;if(!e||e.length===0)return;const s=new TextEncoder,a=await crypto.subtle.importKey("raw",s.encode(e),{hash:"SHA-256",name:"HMAC"},!1,["sign"]),n=await crypto.subtle.sign("HMAC",a,s.encode(t));return P(new Uint8Array(n))}async recordRetry(t){const e=(t.attempts??0)+1,{backoff:s,baseMs:a,maxAttempts:n,maxMs:o}=i.resolveRetry(t),r=s==="linear"?a*e:a*2**(e-1),h=o===void 0?r:Math.min(r,o),l=Math.round(Date.now()+h);if(e>n){await this.parkDead(t,e,`after ${String(e)} attempts`);return}if(!w(l)){await this.parkDead(t,e,`at attempt ${String(e)}: the retry backoff exceeded the largest schedulable time`);return}const d={...t,attempts:e,scheduledFor:l};await this.state.storage.put(`${f}${t.id}`,d),await this.state.storage.put(`${c}${t.id}`,d),await this.state.storage.put(i.indexKey(l,t.id),t.id)}async parkDead(t,e,s){await this.state.storage.put(`${g}${t.id}`,{...t,attempts:e}),await this.state.storage.delete([`${f}${t.id}`,`${c}${t.id}`]),console.warn(`@lunora/scheduler: job "${t.id}" (${t.functionPath??t.workflow??"unknown"}) parked in dead-letter ${s}`)}async loadPool(t,e){const s=await this.state.storage.get(`${p}${t}`);return s!==void 0?Array.isArray(s.inFlightIds)?{inFlight:s.inFlightIds.length,inFlightIds:[...s.inFlightIds],maxConcurrency:s.maxConcurrency}:{inFlight:Math.max(0,s.inFlight),maxConcurrency:s.maxConcurrency}:{inFlight:0,inFlightIds:[],maxConcurrency:i.normalizeConcurrency(e,1)}}async savePool(t,e){await this.state.storage.put(`${p}${t}`,e)}async requeuePooled(t){const e=Date.now()+A,s={...t,scheduledFor:e};await this.state.storage.put(`${c}${t.id}`,s),await this.state.storage.put(i.indexKey(e,t.id),t.id)}async handleComplete(t){const e=await t.json().catch(()=>{}),s=typeof e?.pool=="string"&&e.pool.length>0?e.pool:void 0,a=typeof e?.id=="string"&&e.id.length>0?e.id:void 0;if(s===void 0)return i.error(400,"INVALID_INPUT","pool is required");const n=await this.withPoolLock(async()=>{const o=await this.loadPool(s),r=a===void 0?i.releaseFirstSlot(o):i.releaseSlot(o,a);return await this.savePool(s,r),r});return await this.armAlarmIfEarlier(Date.now()),i.json({inFlight:n.inFlight})}async handlePoolStatus(t){const e=t.searchParams.get("name");if(e===null||e.length===0)return i.error(400,"INVALID_INPUT","name is required");const s=await this.loadPool(e);let a=0;return await this.forEachPage(c,n=>{n.pool===e&&(a+=1)}),i.json({inFlight:s.inFlight,maxConcurrency:s.maxConcurrency,queued:a})}async handleStatus(){const t=new Map;await this.forEachPage(c,o=>{o.pool!==void 0&&t.set(o.pool,(t.get(o.pool)??0)+1)});const e=[];let s=0,a=0;await this.forEachPage(p,(o,r)=>{const h=r.slice(p.length),l=Math.max(0,o.inFlight),d=t.get(h)??0;e.push({inFlight:l,maxConcurrency:o.maxConcurrency,name:h,queued:d}),s+=d,a+=l});const n={backlog:s,inFlight:a,pools:e};return i.json(n)}async persistPoolCap(t,e){const s=await this.loadPool(t,e);await this.savePool(t,{inFlight:s.inFlight,...s.inFlightIds===void 0?{}:{inFlightIds:s.inFlightIds},maxConcurrency:i.normalizeConcurrency(e,s.maxConcurrency)})}async idConflict(t){if(await this.state.storage.get(`${c}${t}`)!==void 0)return i.error(409,"DUPLICATE_SCHEDULE_ID",`a job with id "${t}" is already scheduled — cancel it first, or schedule under a different id`);if(await this.state.storage.get(`${g}${t}`)!==void 0)return i.error(409,"DUPLICATE_SCHEDULE_ID",`id "${t}" is held by a dead-letter record — retry or cancel it (POST /dead/retry, POST /dead/cancel) first, or schedule under a different id`)}async resolveId(t){let e;try{e=b(t)}catch(s){return i.error(400,"INVALID_SCHEDULE_ID",s instanceof Error?s.message:"invalid `id`")}return t===void 0?e:await this.idConflict(e)??e}async handleSchedule(t){const e=await t.json().catch(()=>{}),s=i.resolveScheduleTarget(e);if(!e||s===void 0)return i.error(400,"INVALID_INPUT","functionPath or workflow is required");const{functionPath:a,workflow:n}=s;if(typeof e.scheduledFor!="number"||!w(e.scheduledFor))return i.error(400,"INVALID_INPUT","scheduledFor must be a positive integer epoch-millisecond number no greater than 999999999999999");if(typeof this.env.LUNORA_ORIGIN_URL!="string"||this.env.LUNORA_ORIGIN_URL.length===0)return i.error(500,"ORIGIN_NOT_CONFIGURED","LUNORA_ORIGIN_URL env binding must be set on the SchedulerDO");const o=typeof e.pool=="string"&&e.pool.length>0?e.pool:void 0,r=typeof e.instanceName=="string"&&e.instanceName.length>0?e.instanceName:void 0,h=i.normalizeRetry(e.retry),l=await this.resolveId(e.id);if(l instanceof Response)return l;const d=l,y={args:e.args??{},enqueuedAt:Date.now(),id:d,...a===void 0?{}:{functionPath:a},...r===void 0?{}:{instanceName:r},...o===void 0?{}:{pool:o},...h===void 0?{}:{retry:h},scheduledFor:e.scheduledFor,shardKey:e.shardKey,...n===void 0?{}:{workflow:n}};return o!==void 0&&await this.persistPoolCap(o,e.maxConcurrency),await this.state.storage.put(`${c}${d}`,y),await this.state.storage.put(i.indexKey(y.scheduledFor,d),d),await this.armAlarmIfEarlier(y.scheduledFor),await this.broadcastChange(),i.json({id:d,scheduledFor:y.scheduledFor})}async handleCancel(t){const e=await t.json().catch(()=>{});if(!e?.id)return i.error(400,"INVALID_INPUT","id is required");const s=await this.state.storage.get(`${c}${e.id}`);return s?(await this.removeRecord(s),await this.rescheduleAlarm(),await this.broadcastChange(),i.json({cancelled:!0})):i.json({cancelled:!1})}async handleList(t){const{cursor:e,records:s,truncated:a}=await this.listPage(c,m,t.searchParams.get("cursor")??void 0);return i.json({cursor:e,records:s,truncated:a})}async handleDeadList(t){const{cursor:e,records:s,truncated:a}=await this.listPage(g,m,t.searchParams.get("cursor")??void 0);return i.json({cursor:e,records:s,truncated:a})}async handleDeadRetry(t){const e=await t.json().catch(()=>{});if(typeof e?.id!="string"||e.id.length===0)return i.error(400,"INVALID_INPUT","id is required");const s=await this.state.storage.get(`${g}${e.id}`);if(s===void 0)return i.json({retried:!1});const a=Date.now(),n={...s,attempts:0,scheduledFor:a};return await this.state.storage.put(`${c}${s.id}`,n),await this.state.storage.put(i.indexKey(a,s.id),s.id),await this.state.storage.delete(`${g}${s.id}`),await this.armAlarmIfEarlier(a),await this.broadcastChange(),i.json({id:s.id,retried:!0,scheduledFor:a})}async handleDeadCancel(t){const e=await t.json().catch(()=>{});if(typeof e?.id!="string"||e.id.length===0)return i.error(400,"INVALID_INPUT","id is required");const s=await this.state.storage.delete(`${g}${e.id}`);return i.json({removed:!!s})}async handleGet(t){const e=t.searchParams.get("id");if(e===null||e.length===0)return i.error(400,"INVALID_INPUT","id is required");const s=await this.state.storage.get(`${c}${e}`);return i.json(s===void 0?{}:{record:s})}async removeRecord(t){await this.state.storage.delete([`${c}${t.id}`,i.indexKey(t.scheduledFor,t.id),`${f}${t.id}`])}async armAlarmIfEarlier(t){const e=await this.state.storage.getAlarm();(e===null||t<e)&&await this.state.storage.setAlarm(t)}async reindexOrphanedRecords(){if(this.reindexed)return;this.reindexed=!0;const t=new Set;await this.forEachPage("t:",s=>{t.add(s)});const e=[];await this.forEachPage(c,s=>{!t.has(s.id)&&w(s.scheduledFor)&&e.push(s)});for(const s of e)await this.state.storage.put(i.indexKey(s.scheduledFor,s.id),s.id),await this.armAlarmIfEarlier(s.scheduledFor)}async rescheduleAlarm(){const e=(await this.state.storage.list({limit:1,prefix:"t:"})).entries().next();if(e.done){await this.state.storage.deleteAlarm();return}const[s]=e.value,a=Number.parseInt(s.slice(2,s.indexOf(":",2)),10);Number.isFinite(a)&&await this.state.storage.setAlarm(a)}}export{E as MAX_CONCURRENT_DISPATCHES,N as MAX_RETRY_ATTEMPTS,R as RETRY_BASE_DELAY_MS,i as SchedulerDO};
|
package/package.json
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{t as b}from"./base64-BFBvYeZM.mjs";import P from"./resolveScheduleId-DgysKSrN.mjs";const I=(u,t=200,e)=>{const s=new Headers({"content-type":"application/json"});for(const[a,n]of Object.entries({}))s.set(a,n);return Response.json(u,{headers:s,status:t})},F="lunora-ping",x="lunora-pong",r="id:",f="retry:",g="dead:",y="pool:",m=100,R=5,A=3e4,N=1e3,E=999999999999999,$=15,v=u=>String(u).padStart($,"0"),w=u=>Number.isInteger(u)&&u>0&&u<=E;class i{static indexKey(t,e){return`t:${v(t)}:${e}`}static json(t,e=200){return I(t,e)}static error(t,e,s){return i.json({error:{code:e,message:s}},t)}static resolveRetry(t){const e=t.retry,s=typeof e?.maxAttempts=="number"&&Number.isInteger(e.maxAttempts)&&e.maxAttempts>0?e.maxAttempts:R,a=typeof e?.baseMs=="number"&&Number.isFinite(e.baseMs)&&e.baseMs>=0?e.baseMs:A,n=e?.backoff==="linear"?"linear":"exponential",o=typeof e?.maxMs=="number"&&Number.isFinite(e.maxMs)&&e.maxMs>=0?e.maxMs:void 0;return{backoff:n,baseMs:a,maxAttempts:s,maxMs:o}}static normalizeConcurrency(t,e){return typeof t=="number"&&Number.isInteger(t)&&t>0?t:e}static normalizeRetry(t){if(typeof t!="object"||t===null)return;const e=t,s={};return typeof e.maxAttempts=="number"&&Number.isInteger(e.maxAttempts)&&e.maxAttempts>0&&(s.maxAttempts=e.maxAttempts),typeof e.baseMs=="number"&&Number.isFinite(e.baseMs)&&e.baseMs>=0&&(s.baseMs=e.baseMs),(e.backoff==="exponential"||e.backoff==="linear")&&(s.backoff=e.backoff),typeof e.maxMs=="number"&&Number.isFinite(e.maxMs)&&e.maxMs>=0&&(s.maxMs=e.maxMs),Object.keys(s).length===0?void 0:s}static releaseSlot(t,e){if(t.inFlightIds===void 0)return{...t,inFlight:Math.max(0,t.inFlight-1)};const s=t.inFlightIds.filter(a=>a!==e);return{...t,inFlight:s.length,inFlightIds:s}}static releaseFirstSlot(t){if(t.inFlightIds===void 0)return{...t,inFlight:Math.max(0,t.inFlight-1)};const e=t.inFlightIds.slice(0,Math.max(0,t.inFlightIds.length-1));return{...t,inFlight:e.length,inFlightIds:e}}static resolveScheduleTarget(t){const e=typeof t?.functionPath=="string"&&t.functionPath.length>0?t.functionPath:void 0,s=typeof t?.workflow=="string"&&t.workflow.length>0?t.workflow:void 0;if(!(e===void 0&&s===void 0))return{functionPath:e,workflow:s}}state;env;reindexed=!1;constructor(t,e){this.state=t,this.env=e,this.armWebSocketKeepalive()}async fetch(t){await this.reindexOrphanedRecords();const e=new URL(t.url);if(e.pathname==="/ws"&&t.headers.get("Upgrade")==="websocket")return this.handleWebSocketUpgrade();switch(`${t.method} ${e.pathname}`){case"GET /dead":return this.handleDeadList(e);case"GET /get":return this.handleGet(e);case"GET /list":return this.handleList(e);case"GET /pool":return this.handlePoolStatus(e);case"GET /status":return this.handleStatus();case"POST /cancel":return this.handleCancel(t);case"POST /complete":return this.handleComplete(t);case"POST /dead/cancel":return this.handleDeadCancel(t);case"POST /dead/retry":return this.handleDeadRetry(t);case"POST /schedule":return this.handleSchedule(t)}return I({error:{code:"NOT_FOUND"}},404)}async alarm(){await this.reindexOrphanedRecords();const t=Date.now(),e=[],s=await this.state.storage.list({end:`t:${v(t)}:~`,limit:100,prefix:"t:"});for(const[a,n]of s.entries()){const o=Number.parseInt(a.slice(2,a.indexOf(":",2)),10);if(Number.isFinite(o)&&o<=t){const c=await this.state.storage.get(`${r}${n}`);c?e.push(c):await this.state.storage.delete(a)}}try{for(const a of e)await this.drainRecordGuarded(a)}finally{await this.rescheduleAlarm()}e.length>0&&await this.broadcastChange()}async dispatch(t){const e=typeof this.env.LUNORA_ORIGIN_URL=="string"&&this.env.LUNORA_ORIGIN_URL.length>0?this.env.LUNORA_ORIGIN_URL:void 0;if(!e)return!1;const s=JSON.stringify({args:t.args,functionPath:t.functionPath,id:t.id,instanceName:t.instanceName,pool:t.pool,scheduledFor:t.scheduledFor,shardKey:t.shardKey,workflow:t.workflow});try{const a={"content-type":"application/json"},n=await this.signDispatch(s);return n!==void 0?a["x-lunora-scheduler-signature"]=n:typeof this.env.LUNORA_ADMIN_TOKEN=="string"&&this.env.LUNORA_ADMIN_TOKEN.length>0&&(a.authorization=`Bearer ${this.env.LUNORA_ADMIN_TOKEN}`),(await fetch(`${e}/_lunora/scheduler/dispatch`,{body:s,headers:a,method:"POST"})).ok}catch{return!1}}armWebSocketKeepalive(){const t=this.state.setWebSocketAutoResponse;typeof t!="function"||typeof WebSocketRequestResponsePair>"u"||t.call(this.state,new WebSocketRequestResponsePair(F,x))}async drainRecordGuarded(t){try{await this.state.storage.delete(i.indexKey(t.scheduledFor,t.id)),await this.drainRecord(t)}catch{try{if(await this.state.storage.get(`${g}${t.id}`)!==void 0){await this.state.storage.delete([`${f}${t.id}`,`${r}${t.id}`]);return}await this.state.storage.put(i.indexKey(t.scheduledFor,t.id),t.id)}catch{}}}async drainRecord(t){if(!await this.reservePoolSlot(t))return!1;const s=await this.dispatch(t);if(!s&&t.pool!==void 0){const a=await this.loadPool(t.pool),n=i.releaseSlot(a,t.id);await this.savePool(t.pool,n)}if(s){try{await this.state.storage.delete([`${r}${t.id}`,`${f}${t.id}`])}catch{}return!0}return await this.recordRetry(t),!1}async reservePoolSlot(t){if(t.pool===void 0)return!0;const e=await this.loadPool(t.pool);if(e.inFlight>=e.maxConcurrency)return await this.requeuePooled(t),!1;const s=e.inFlightIds??[];return s.includes(t.id)||s.push(t.id),e.inFlightIds=s,e.inFlight=s.length,await this.savePool(t.pool,e),!0}async handleWebSocketUpgrade(){if(this.state.acceptWebSocket===void 0)return i.error(501,"WS_UNSUPPORTED","WebSocket subscriptions are not supported in this runtime");const t=new WebSocketPair,e=t[0],s=t[1];this.state.acceptWebSocket(s);const a=await this.listPage(r,m);return s.send(JSON.stringify({records:a.records,truncated:a.truncated,type:"jobs"})),new Response(null,{status:101,webSocket:e})}async broadcastChange(){const t=this.state.getWebSockets?.();if(t===void 0||t.length===0)return;const{records:e,truncated:s}=await this.listPage(r,m),a=JSON.stringify({records:e,truncated:s,type:"jobs"});for(const n of t)try{n.send(a)}catch{}}async listPage(t,e,s){const a=await this.state.storage.list({limit:e+1,prefix:t,...s===void 0?{}:{startAfter:s}}),n=[...a.keys()],o=[...a.values()],c=o.length>e;return c?{cursor:n[e-1],records:o.slice(0,e),truncated:c}:{records:o,truncated:c}}async forEachPage(t,e,s=m){let a;for(;;){const n=await this.state.storage.list(a===void 0?{limit:s,prefix:t}:{limit:s,prefix:t,startAfter:a});if(n.size===0)break;for(const[c,h]of n.entries())e(h,c);if(a=[...n.keys()].at(-1),n.size<s)break}}async signDispatch(t){const e=typeof this.env.LUNORA_SCHEDULER_SECRET=="string"?this.env.LUNORA_SCHEDULER_SECRET:void 0;if(!e||e.length===0)return;const s=new TextEncoder,a=await crypto.subtle.importKey("raw",s.encode(e),{hash:"SHA-256",name:"HMAC"},!1,["sign"]),n=await crypto.subtle.sign("HMAC",a,s.encode(t));return b(new Uint8Array(n))}async recordRetry(t){const e=(t.attempts??0)+1,{backoff:s,baseMs:a,maxAttempts:n,maxMs:o}=i.resolveRetry(t),c=s==="linear"?a*e:a*2**(e-1),h=o===void 0?c:Math.min(c,o),l=Math.round(Date.now()+h);if(e>n){await this.parkDead(t,e,`after ${String(e)} attempts`);return}if(!w(l)){await this.parkDead(t,e,`at attempt ${String(e)}: the retry backoff exceeded the largest schedulable time`);return}const d={...t,attempts:e,scheduledFor:l};await this.state.storage.put(`${f}${t.id}`,d),await this.state.storage.put(`${r}${t.id}`,d),await this.state.storage.put(i.indexKey(l,t.id),t.id)}async parkDead(t,e,s){await this.state.storage.put(`${g}${t.id}`,{...t,attempts:e}),await this.state.storage.delete([`${f}${t.id}`,`${r}${t.id}`]),console.warn(`@lunora/scheduler: job "${t.id}" (${t.functionPath??t.workflow??"unknown"}) parked in dead-letter ${s}`)}async loadPool(t,e){const s=await this.state.storage.get(`${y}${t}`);return s!==void 0?Array.isArray(s.inFlightIds)?{inFlight:s.inFlightIds.length,inFlightIds:[...s.inFlightIds],maxConcurrency:s.maxConcurrency}:{inFlight:Math.max(0,s.inFlight),maxConcurrency:s.maxConcurrency}:{inFlight:0,inFlightIds:[],maxConcurrency:i.normalizeConcurrency(e,1)}}async savePool(t,e){await this.state.storage.put(`${y}${t}`,e)}async requeuePooled(t){const e=Date.now()+N,s={...t,scheduledFor:e};await this.state.storage.put(`${r}${t.id}`,s),await this.state.storage.put(i.indexKey(e,t.id),t.id)}async handleComplete(t){const e=await t.json().catch(()=>{}),s=typeof e?.pool=="string"&&e.pool.length>0?e.pool:void 0,a=typeof e?.id=="string"&&e.id.length>0?e.id:void 0;if(s===void 0)return i.error(400,"INVALID_INPUT","pool is required");const n=await this.loadPool(s),o=a===void 0?i.releaseFirstSlot(n):i.releaseSlot(n,a);return await this.savePool(s,o),await this.armAlarmIfEarlier(Date.now()),i.json({inFlight:o.inFlight})}async handlePoolStatus(t){const e=t.searchParams.get("name");if(e===null||e.length===0)return i.error(400,"INVALID_INPUT","name is required");const s=await this.loadPool(e);let a=0;return await this.forEachPage(r,n=>{n.pool===e&&(a+=1)}),i.json({inFlight:s.inFlight,maxConcurrency:s.maxConcurrency,queued:a})}async handleStatus(){const t=new Map;await this.forEachPage(r,o=>{o.pool!==void 0&&t.set(o.pool,(t.get(o.pool)??0)+1)});const e=[];let s=0,a=0;await this.forEachPage(y,(o,c)=>{const h=c.slice(y.length),l=Math.max(0,o.inFlight),d=t.get(h)??0;e.push({inFlight:l,maxConcurrency:o.maxConcurrency,name:h,queued:d}),s+=d,a+=l});const n={backlog:s,inFlight:a,pools:e};return i.json(n)}async persistPoolCap(t,e){const s=await this.loadPool(t,e);await this.savePool(t,{inFlight:s.inFlight,...s.inFlightIds===void 0?{}:{inFlightIds:s.inFlightIds},maxConcurrency:i.normalizeConcurrency(e,s.maxConcurrency)})}async idConflict(t){if(await this.state.storage.get(`${r}${t}`)!==void 0)return i.error(409,"DUPLICATE_SCHEDULE_ID",`a job with id "${t}" is already scheduled — cancel it first, or schedule under a different id`);if(await this.state.storage.get(`${g}${t}`)!==void 0)return i.error(409,"DUPLICATE_SCHEDULE_ID",`id "${t}" is held by a dead-letter record — retry or cancel it (POST /dead/retry, POST /dead/cancel) first, or schedule under a different id`)}async resolveId(t){let e;try{e=P(t)}catch(s){return i.error(400,"INVALID_SCHEDULE_ID",s instanceof Error?s.message:"invalid `id`")}return t===void 0?e:await this.idConflict(e)??e}async handleSchedule(t){const e=await t.json().catch(()=>{}),s=i.resolveScheduleTarget(e);if(!e||s===void 0)return i.error(400,"INVALID_INPUT","functionPath or workflow is required");const{functionPath:a,workflow:n}=s;if(typeof e.scheduledFor!="number"||!w(e.scheduledFor))return i.error(400,"INVALID_INPUT","scheduledFor must be a positive integer epoch-millisecond number no greater than 999999999999999");if(typeof this.env.LUNORA_ORIGIN_URL!="string"||this.env.LUNORA_ORIGIN_URL.length===0)return i.error(500,"ORIGIN_NOT_CONFIGURED","LUNORA_ORIGIN_URL env binding must be set on the SchedulerDO");const o=typeof e.pool=="string"&&e.pool.length>0?e.pool:void 0,c=typeof e.instanceName=="string"&&e.instanceName.length>0?e.instanceName:void 0,h=i.normalizeRetry(e.retry),l=await this.resolveId(e.id);if(l instanceof Response)return l;const d=l,p={args:e.args??{},enqueuedAt:Date.now(),id:d,...a===void 0?{}:{functionPath:a},...c===void 0?{}:{instanceName:c},...o===void 0?{}:{pool:o},...h===void 0?{}:{retry:h},scheduledFor:e.scheduledFor,shardKey:e.shardKey,...n===void 0?{}:{workflow:n}};return o!==void 0&&await this.persistPoolCap(o,e.maxConcurrency),await this.state.storage.put(`${r}${d}`,p),await this.state.storage.put(i.indexKey(p.scheduledFor,d),d),await this.armAlarmIfEarlier(p.scheduledFor),await this.broadcastChange(),i.json({id:d,scheduledFor:p.scheduledFor})}async handleCancel(t){const e=await t.json().catch(()=>{});if(!e?.id)return i.error(400,"INVALID_INPUT","id is required");const s=await this.state.storage.get(`${r}${e.id}`);return s?(await this.removeRecord(s),await this.rescheduleAlarm(),await this.broadcastChange(),i.json({cancelled:!0})):i.json({cancelled:!1})}async handleList(t){const{cursor:e,records:s,truncated:a}=await this.listPage(r,m,t.searchParams.get("cursor")??void 0);return i.json({cursor:e,records:s,truncated:a})}async handleDeadList(t){const{cursor:e,records:s,truncated:a}=await this.listPage(g,m,t.searchParams.get("cursor")??void 0);return i.json({cursor:e,records:s,truncated:a})}async handleDeadRetry(t){const e=await t.json().catch(()=>{});if(typeof e?.id!="string"||e.id.length===0)return i.error(400,"INVALID_INPUT","id is required");const s=await this.state.storage.get(`${g}${e.id}`);if(s===void 0)return i.json({retried:!1});const a=Date.now(),n={...s,attempts:0,scheduledFor:a};return await this.state.storage.put(`${r}${s.id}`,n),await this.state.storage.put(i.indexKey(a,s.id),s.id),await this.state.storage.delete(`${g}${s.id}`),await this.armAlarmIfEarlier(a),await this.broadcastChange(),i.json({id:s.id,retried:!0,scheduledFor:a})}async handleDeadCancel(t){const e=await t.json().catch(()=>{});if(typeof e?.id!="string"||e.id.length===0)return i.error(400,"INVALID_INPUT","id is required");const s=await this.state.storage.delete(`${g}${e.id}`);return i.json({removed:!!s})}async handleGet(t){const e=t.searchParams.get("id");if(e===null||e.length===0)return i.error(400,"INVALID_INPUT","id is required");const s=await this.state.storage.get(`${r}${e}`);return i.json(s===void 0?{}:{record:s})}async removeRecord(t){await this.state.storage.delete([`${r}${t.id}`,i.indexKey(t.scheduledFor,t.id),`${f}${t.id}`])}async armAlarmIfEarlier(t){const e=await this.state.storage.getAlarm();(e===null||t<e)&&await this.state.storage.setAlarm(t)}async reindexOrphanedRecords(){if(this.reindexed)return;this.reindexed=!0;const t=new Set;await this.forEachPage("t:",s=>{t.add(s)});const e=[];await this.forEachPage(r,s=>{!t.has(s.id)&&w(s.scheduledFor)&&e.push(s)});for(const s of e)await this.state.storage.put(i.indexKey(s.scheduledFor,s.id),s.id),await this.armAlarmIfEarlier(s.scheduledFor)}async rescheduleAlarm(){const e=(await this.state.storage.list({limit:1,prefix:"t:"})).entries().next();if(e.done){await this.state.storage.deleteAlarm();return}const[s]=e.value,a=Number.parseInt(s.slice(2,s.indexOf(":",2)),10);Number.isFinite(a)&&await this.state.storage.setAlarm(a)}}export{R as MAX_RETRY_ATTEMPTS,A as RETRY_BASE_DELAY_MS,i as SchedulerDO};
|