@lunora/scheduler 1.0.0-alpha.77 → 1.0.0-alpha.79

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. Each reservation durably `savePool()`s before the next
954
- * record runs, so a same-pass reservation is still visible to the next
955
- * record's fresh read (the budget carries forward); and because dispatch()
956
- * awaits an outbound fetch between records, a concurrent /complete that
957
- * decrements the row mid-drain IS reflected here instead of being clobbered
958
- * by a stale in-memory copy (which would leak a slot permanently).
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 safe: the dispatch carries the record id, which the receiver
1153
- * spends as `x-lunora-mutation-id` for a function target and as the workflow
1154
- * INSTANCE id for a `workflow` target, so a job that DID reach the origin
1155
- * before the crash is not run twice either way.
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. Each reservation durably `savePool()`s before the next
954
- * record runs, so a same-pass reservation is still visible to the next
955
- * record's fresh read (the budget carries forward); and because dispatch()
956
- * awaits an outbound fetch between records, a concurrent /complete that
957
- * decrements the row mid-drain IS reflected here instead of being clobbered
958
- * by a stale in-memory copy (which would leak a slot permanently).
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 safe: the dispatch carries the record id, which the receiver
1153
- * spends as `x-lunora-mutation-id` for a function target and as the workflow
1154
- * INSTANCE id for a `workflow` target, so a job that DID reach the origin
1155
- * before the crash is not run twice either way.
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-CoEnaemn.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};
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/scheduler",
3
- "version": "1.0.0-alpha.77",
3
+ "version": "1.0.0-alpha.79",
4
4
  "description": "Scheduling for Lunora: runAfter / runAt and Cron Triggers via SchedulerDO",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -46,7 +46,7 @@
46
46
  "access": "public"
47
47
  },
48
48
  "dependencies": {
49
- "@lunora/errors": "1.0.0-alpha.39",
49
+ "@lunora/errors": "1.0.0-alpha.40",
50
50
  "@lunora/platform": "1.0.0-alpha.33",
51
51
  "cron-parser": "5.10.0"
52
52
  },
@@ -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};