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

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
@@ -886,6 +886,23 @@ declare class SchedulerDO {
886
886
  * them.
887
887
  */
888
888
  private poolLock;
889
+ /**
890
+ * Record id → the `t:` index key its in-flight claim currently holds, for
891
+ * every dispatch this instance has open. See `drainRecordGuarded()`.
892
+ *
893
+ * A lease moves a record's index entry without rewriting its `scheduledFor`,
894
+ * so while a claim is held the live key is NOT the one derivable from the
895
+ * record. Anything that has to remove such a record — `removeRecord()`, on
896
+ * the `/cancel` path — would otherwise delete a key that no longer exists
897
+ * and strand the real one.
898
+ *
899
+ * In-memory and per-instance on purpose: it answers "is THIS instance
900
+ * dispatching that record right now", which is exactly when the divergence
901
+ * can be observed by another request. A lease left behind by an instance that
902
+ * died has no live dispatch to protect and is reconciled from storage
903
+ * instead — `alarm()` drops it as a dangling entry once the header is gone.
904
+ */
905
+ private readonly activeLeases;
889
906
  constructor(state: SchedulerDOState, env: SchedulerEnv);
890
907
  fetch(request: Request): Promise<Response>;
891
908
  /** Called by the Workers runtime when the alarm previously set by `rescheduleAlarm()` fires. */
@@ -969,22 +986,46 @@ declare class SchedulerDO {
969
986
  * throw can never abort the whole alarm pass (which would skip the remaining
970
987
  * due records and the `rescheduleAlarm()` that re-arms the clock).
971
988
  *
972
- * Claims the job by deleting its time-index entry BEFORE dispatch (an alarm
973
- * re-fire then won't pick it up again), runs {@link drainRecord}, and on a
974
- * thrown storage op re-asserts the claim so the job stays re-fireable.
989
+ * The claim is a LEASE, not a deletion. The record's `t:` entry is moved
990
+ * from its due time to `now + DISPATCH_LEASE_MS` before
991
+ * {@link SchedulerDO.dispatch} is called, so this alarm pass (and the next)
992
+ * will not pick it up again, while the record is never left WITHOUT an index
993
+ * entry. That distinction is the whole point: deleting the entry outright
994
+ * made an instance evicted mid-dispatch leave an `id:` header with no index,
995
+ * which {@link SchedulerDO.reindexOrphanedRecords} re-armed and the
996
+ * successor fired AGAIN on sight — concurrently with an attempt that could
997
+ * still be running at the origin. A leased record is not an orphan, so the
998
+ * successor leaves it alone until the lease lapses; see
999
+ * {@link DISPATCH_LEASE_MS} for why that horizon is fifteen minutes and what
1000
+ * it does and does not bound.
1001
+ *
1002
+ * The lease is released as soon as this instance knows the attempt settled —
1003
+ * dispatched, re-armed for retry, backpressured, or dead-lettered — so the
1004
+ * horizon only ever governs the one case nobody is left to report: a lost
1005
+ * instance.
975
1006
  *
976
1007
  * A throw reaching here always means the job was NOT dispatched:
977
1008
  * {@link drainRecord} swallows its own post-dispatch cleanup errors and
978
1009
  * returns instead of throwing once a kick succeeds, so every escaping throw
979
- * comes from the pre-dispatch or failed-dispatch paths. We therefore re-assert
980
- * the time-index claim so a later alarm re-attempts it (at-least-once): the
981
- * claim delete may have removed it and recordRetry()/requeuePooled() may not
982
- * have re-armed it before throwing, and re-inserting the same key is
983
- * idempotent, so a surviving claim is simply rewritten to its prior value.
1010
+ * comes from the pre-dispatch or failed-dispatch paths. Nothing is in flight,
1011
+ * so the lease is dropped and the due-time claim re-asserted — keeping the
1012
+ * job re-fireable on the very next alarm (at-least-once) rather than letting
1013
+ * a transient storage blip cost it a whole lease.
984
1014
  *
985
1015
  * With one exception, checked first: a record that already has a durable
986
1016
  * `dead:` row is TERMINAL, and re-claiming it would re-dispatch a job the
987
1017
  * dead-letter says is finished. See the comment on that branch.
1018
+ *
1019
+ * `claimKey` is the index key `alarm()` SELECTED this record under, passed
1020
+ * down rather than recomputed. That is load-bearing, not tidiness: a lease
1021
+ * moves the record's index entry and deliberately does NOT rewrite its
1022
+ * `scheduledFor` (that field is the job's real due time, which `/list`,
1023
+ * `/get`, `/dead` and the studio all show, and which `parkDead` preserves).
1024
+ * So from the moment a lease is taken the live key and the key derivable
1025
+ * from the record disagree — and a claim that recomputed it would delete a
1026
+ * key that no longer exists while adding a second one, leaving the record
1027
+ * indexed twice and dispatched twice. That is the same double-run the lease
1028
+ * exists to close, re-entering through the expiry path.
988
1029
  */
989
1030
  private drainRecordGuarded;
990
1031
  /**
@@ -1018,12 +1059,12 @@ declare class SchedulerDO {
1018
1059
  * across the drain — and the read-modify-write runs under
1019
1060
  * the pool lock (`withPoolLock`). Both halves are load-bearing. Freshness is what
1020
1061
  * 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.
1062
+ * clobbered by a stale in-memory copy (which would leak a slot permanently: a
1063
+ * POOL SLOT has no expiry, unlike the dispatch claim's). The lock is what
1064
+ * keeps two drain lanes from both reading the same pre-reservation row and
1065
+ * both believing a slot was free — without it the pool oversubscribes past
1066
+ * `maxConcurrency` and one holder's id is dropped from `inFlightIds`, so its
1067
+ * slot is never released.
1027
1068
  */
1028
1069
  private reservePoolSlot;
1029
1070
  /**
@@ -1204,39 +1245,55 @@ declare class SchedulerDO {
1204
1245
  */
1205
1246
  private armAlarmIfEarlier;
1206
1247
  /**
1207
- * Re-index every pending job whose time-index entry is gone.
1248
+ * Re-index every pending job whose time-index entry is gone, and fire it now.
1249
+ *
1250
+ * A record with an `id:` header but no `t:` entry is invisible to every
1251
+ * clock in this class: {@link SchedulerDO.rescheduleAlarm} derives the alarm
1252
+ * from `t:` alone, and `alarm()`'s inline reconciliation only handles the
1253
+ * INVERSE orphan (a `t:` entry whose header is gone). Left alone such a job
1254
+ * sits in `/list` and `/status.backlog` forever — never fires, never reaches
1255
+ * `/dead`.
1208
1256
  *
1209
- * {@link SchedulerDO.drainRecordGuarded} claims a job by DELETING its `t:`
1210
- * entry, awaited (so durable) BEFORE {@link SchedulerDO.dispatch}'s outbound
1211
- * fetch. If the Durable Object is evicted or crashes during that fetch, the
1212
- * `id:` header (and any `retry:` row) survives with no `t:` entry — and
1213
- * nothing puts one back: {@link SchedulerDO.rescheduleAlarm} derives the
1214
- * clock from `t:` alone, and `alarm()`'s inline reconciliation only handles
1215
- * the INVERSE orphan (a `t:` entry whose header is gone). The job then sits
1216
- * in `/list` and `/status.backlog` forever, never fires, never reaches
1217
- * `/dead`. The at-least-once contract `drainRecordGuarded` documents covers
1218
- * a thrown storage op, not a lost instance.
1257
+ * **This is no longer how a lost dispatch looks.**
1258
+ * {@link SchedulerDO.drainRecordGuarded} used to claim a record by deleting
1259
+ * its `t:` entry outright, so an instance evicted mid-dispatch minted an
1260
+ * orphan on every claimed record and this method re-fired each of them ON
1261
+ * SIGHT — concurrently with attempts that could still have been running at
1262
+ * the origin. The claim is a lease now (see {@link DISPATCH_LEASE_MS}): a
1263
+ * claimed record keeps a `t:` entry at the lease horizon, so it is not an
1264
+ * orphan, is not recovered here, and is re-fired by the ordinary alarm drain
1265
+ * only once the lease lapses.
1219
1266
  *
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.
1267
+ * What still reaches this method is the residue of a storage failure — a
1268
+ * claim released with no lease written, or a pre-lease record written by an
1269
+ * older build — where nothing was dispatched and firing immediately is
1270
+ * exactly right.
1225
1271
  *
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.
1272
+ * The dispatch is deduplicated by the record id, which the receiver spends
1273
+ * as `x-lunora-mutation-id` for a function target and as the workflow
1274
+ * INSTANCE id for a `workflow` target. A mutation's dedup read runs inside
1275
+ * the shard's single-writer gate, so a mutation is exactly-once even under a
1276
+ * genuinely concurrent re-fire; a workflow re-attaches to the running
1277
+ * instance rather than starting a second one. An ACTION is weaker:
1278
+ * `@lunora/do` deliberately does NOT take the gate for a non-mutation —
1279
+ * gating one would let any caller freeze a whole shard for the length of an
1280
+ * action's outbound I/O — and its dedup row is written only after the
1281
+ * handler returns, so two dispatches genuinely overlapping in time can both
1282
+ * miss the cache. The lease exists to keep them from overlapping; an action
1283
+ * that can outlive it must still be idempotent.
1235
1284
  *
1236
1285
  * Two bounded walks (all `t:` values, then all `id:` headers) rather than a
1237
1286
  * per-header `get`, so the cost is one pass over each prefix.
1238
1287
  */
1239
1288
  private reindexOrphanedRecords;
1289
+ /**
1290
+ * The time component of the earliest pending `t:` entry, or `undefined` when
1291
+ * there is no pending entry at all. Reads exactly one row — the index's
1292
+ * lexical order is its numeric order. A present-but-unreadable key yields
1293
+ * `NaN`, which callers must distinguish from "nothing pending": the two
1294
+ * answers mean opposite things for the alarm.
1295
+ */
1296
+ private earliestPendingTime;
1240
1297
  private rescheduleAlarm;
1241
1298
  }
1242
1299
  /** What the Cloudflare scheduler host needs from the Worker's environment. */
package/dist/index.d.ts CHANGED
@@ -886,6 +886,23 @@ declare class SchedulerDO {
886
886
  * them.
887
887
  */
888
888
  private poolLock;
889
+ /**
890
+ * Record id → the `t:` index key its in-flight claim currently holds, for
891
+ * every dispatch this instance has open. See `drainRecordGuarded()`.
892
+ *
893
+ * A lease moves a record's index entry without rewriting its `scheduledFor`,
894
+ * so while a claim is held the live key is NOT the one derivable from the
895
+ * record. Anything that has to remove such a record — `removeRecord()`, on
896
+ * the `/cancel` path — would otherwise delete a key that no longer exists
897
+ * and strand the real one.
898
+ *
899
+ * In-memory and per-instance on purpose: it answers "is THIS instance
900
+ * dispatching that record right now", which is exactly when the divergence
901
+ * can be observed by another request. A lease left behind by an instance that
902
+ * died has no live dispatch to protect and is reconciled from storage
903
+ * instead — `alarm()` drops it as a dangling entry once the header is gone.
904
+ */
905
+ private readonly activeLeases;
889
906
  constructor(state: SchedulerDOState, env: SchedulerEnv);
890
907
  fetch(request: Request): Promise<Response>;
891
908
  /** Called by the Workers runtime when the alarm previously set by `rescheduleAlarm()` fires. */
@@ -969,22 +986,46 @@ declare class SchedulerDO {
969
986
  * throw can never abort the whole alarm pass (which would skip the remaining
970
987
  * due records and the `rescheduleAlarm()` that re-arms the clock).
971
988
  *
972
- * Claims the job by deleting its time-index entry BEFORE dispatch (an alarm
973
- * re-fire then won't pick it up again), runs {@link drainRecord}, and on a
974
- * thrown storage op re-asserts the claim so the job stays re-fireable.
989
+ * The claim is a LEASE, not a deletion. The record's `t:` entry is moved
990
+ * from its due time to `now + DISPATCH_LEASE_MS` before
991
+ * {@link SchedulerDO.dispatch} is called, so this alarm pass (and the next)
992
+ * will not pick it up again, while the record is never left WITHOUT an index
993
+ * entry. That distinction is the whole point: deleting the entry outright
994
+ * made an instance evicted mid-dispatch leave an `id:` header with no index,
995
+ * which {@link SchedulerDO.reindexOrphanedRecords} re-armed and the
996
+ * successor fired AGAIN on sight — concurrently with an attempt that could
997
+ * still be running at the origin. A leased record is not an orphan, so the
998
+ * successor leaves it alone until the lease lapses; see
999
+ * {@link DISPATCH_LEASE_MS} for why that horizon is fifteen minutes and what
1000
+ * it does and does not bound.
1001
+ *
1002
+ * The lease is released as soon as this instance knows the attempt settled —
1003
+ * dispatched, re-armed for retry, backpressured, or dead-lettered — so the
1004
+ * horizon only ever governs the one case nobody is left to report: a lost
1005
+ * instance.
975
1006
  *
976
1007
  * A throw reaching here always means the job was NOT dispatched:
977
1008
  * {@link drainRecord} swallows its own post-dispatch cleanup errors and
978
1009
  * returns instead of throwing once a kick succeeds, so every escaping throw
979
- * comes from the pre-dispatch or failed-dispatch paths. We therefore re-assert
980
- * the time-index claim so a later alarm re-attempts it (at-least-once): the
981
- * claim delete may have removed it and recordRetry()/requeuePooled() may not
982
- * have re-armed it before throwing, and re-inserting the same key is
983
- * idempotent, so a surviving claim is simply rewritten to its prior value.
1010
+ * comes from the pre-dispatch or failed-dispatch paths. Nothing is in flight,
1011
+ * so the lease is dropped and the due-time claim re-asserted — keeping the
1012
+ * job re-fireable on the very next alarm (at-least-once) rather than letting
1013
+ * a transient storage blip cost it a whole lease.
984
1014
  *
985
1015
  * With one exception, checked first: a record that already has a durable
986
1016
  * `dead:` row is TERMINAL, and re-claiming it would re-dispatch a job the
987
1017
  * dead-letter says is finished. See the comment on that branch.
1018
+ *
1019
+ * `claimKey` is the index key `alarm()` SELECTED this record under, passed
1020
+ * down rather than recomputed. That is load-bearing, not tidiness: a lease
1021
+ * moves the record's index entry and deliberately does NOT rewrite its
1022
+ * `scheduledFor` (that field is the job's real due time, which `/list`,
1023
+ * `/get`, `/dead` and the studio all show, and which `parkDead` preserves).
1024
+ * So from the moment a lease is taken the live key and the key derivable
1025
+ * from the record disagree — and a claim that recomputed it would delete a
1026
+ * key that no longer exists while adding a second one, leaving the record
1027
+ * indexed twice and dispatched twice. That is the same double-run the lease
1028
+ * exists to close, re-entering through the expiry path.
988
1029
  */
989
1030
  private drainRecordGuarded;
990
1031
  /**
@@ -1018,12 +1059,12 @@ declare class SchedulerDO {
1018
1059
  * across the drain — and the read-modify-write runs under
1019
1060
  * the pool lock (`withPoolLock`). Both halves are load-bearing. Freshness is what
1020
1061
  * 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.
1062
+ * clobbered by a stale in-memory copy (which would leak a slot permanently: a
1063
+ * POOL SLOT has no expiry, unlike the dispatch claim's). The lock is what
1064
+ * keeps two drain lanes from both reading the same pre-reservation row and
1065
+ * both believing a slot was free — without it the pool oversubscribes past
1066
+ * `maxConcurrency` and one holder's id is dropped from `inFlightIds`, so its
1067
+ * slot is never released.
1027
1068
  */
1028
1069
  private reservePoolSlot;
1029
1070
  /**
@@ -1204,39 +1245,55 @@ declare class SchedulerDO {
1204
1245
  */
1205
1246
  private armAlarmIfEarlier;
1206
1247
  /**
1207
- * Re-index every pending job whose time-index entry is gone.
1248
+ * Re-index every pending job whose time-index entry is gone, and fire it now.
1249
+ *
1250
+ * A record with an `id:` header but no `t:` entry is invisible to every
1251
+ * clock in this class: {@link SchedulerDO.rescheduleAlarm} derives the alarm
1252
+ * from `t:` alone, and `alarm()`'s inline reconciliation only handles the
1253
+ * INVERSE orphan (a `t:` entry whose header is gone). Left alone such a job
1254
+ * sits in `/list` and `/status.backlog` forever — never fires, never reaches
1255
+ * `/dead`.
1208
1256
  *
1209
- * {@link SchedulerDO.drainRecordGuarded} claims a job by DELETING its `t:`
1210
- * entry, awaited (so durable) BEFORE {@link SchedulerDO.dispatch}'s outbound
1211
- * fetch. If the Durable Object is evicted or crashes during that fetch, the
1212
- * `id:` header (and any `retry:` row) survives with no `t:` entry — and
1213
- * nothing puts one back: {@link SchedulerDO.rescheduleAlarm} derives the
1214
- * clock from `t:` alone, and `alarm()`'s inline reconciliation only handles
1215
- * the INVERSE orphan (a `t:` entry whose header is gone). The job then sits
1216
- * in `/list` and `/status.backlog` forever, never fires, never reaches
1217
- * `/dead`. The at-least-once contract `drainRecordGuarded` documents covers
1218
- * a thrown storage op, not a lost instance.
1257
+ * **This is no longer how a lost dispatch looks.**
1258
+ * {@link SchedulerDO.drainRecordGuarded} used to claim a record by deleting
1259
+ * its `t:` entry outright, so an instance evicted mid-dispatch minted an
1260
+ * orphan on every claimed record and this method re-fired each of them ON
1261
+ * SIGHT — concurrently with attempts that could still have been running at
1262
+ * the origin. The claim is a lease now (see {@link DISPATCH_LEASE_MS}): a
1263
+ * claimed record keeps a `t:` entry at the lease horizon, so it is not an
1264
+ * orphan, is not recovered here, and is re-fired by the ordinary alarm drain
1265
+ * only once the lease lapses.
1219
1266
  *
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.
1267
+ * What still reaches this method is the residue of a storage failure — a
1268
+ * claim released with no lease written, or a pre-lease record written by an
1269
+ * older build — where nothing was dispatched and firing immediately is
1270
+ * exactly right.
1225
1271
  *
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.
1272
+ * The dispatch is deduplicated by the record id, which the receiver spends
1273
+ * as `x-lunora-mutation-id` for a function target and as the workflow
1274
+ * INSTANCE id for a `workflow` target. A mutation's dedup read runs inside
1275
+ * the shard's single-writer gate, so a mutation is exactly-once even under a
1276
+ * genuinely concurrent re-fire; a workflow re-attaches to the running
1277
+ * instance rather than starting a second one. An ACTION is weaker:
1278
+ * `@lunora/do` deliberately does NOT take the gate for a non-mutation —
1279
+ * gating one would let any caller freeze a whole shard for the length of an
1280
+ * action's outbound I/O — and its dedup row is written only after the
1281
+ * handler returns, so two dispatches genuinely overlapping in time can both
1282
+ * miss the cache. The lease exists to keep them from overlapping; an action
1283
+ * that can outlive it must still be idempotent.
1235
1284
  *
1236
1285
  * Two bounded walks (all `t:` values, then all `id:` headers) rather than a
1237
1286
  * per-header `get`, so the cost is one pass over each prefix.
1238
1287
  */
1239
1288
  private reindexOrphanedRecords;
1289
+ /**
1290
+ * The time component of the earliest pending `t:` entry, or `undefined` when
1291
+ * there is no pending entry at all. Reads exactly one row — the index's
1292
+ * lexical order is its numeric order. A present-but-unreadable key yields
1293
+ * `NaN`, which callers must distinguish from "nothing pending": the two
1294
+ * answers mean opposite things for the alarm.
1295
+ */
1296
+ private earliestPendingTime;
1240
1297
  private rescheduleAlarm;
1241
1298
  }
1242
1299
  /** What the Cloudflare scheduler host needs from the Worker's environment. */
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-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};
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-DiT3r4jp.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 v=(u,t=200,e)=>{const s=new Headers({"content-type":"application/json"});for(const[a,i]of Object.entries({}))s.set(a,i);return Response.json(u,{headers:s,status:t})},A="lunora-ping",N="lunora-pong",c="id:",f="retry:",g="dead:",y="pool:",m=100,F=5,R=3e4,x=1e3,E=6,$=9e5,L=999999999999999,k=15,I=u=>String(u).padStart(k,"0"),p=u=>Number.isInteger(u)&&u>0&&u<=L;class n{static indexKey(t,e){return`t:${I(t)}:${e}`}static json(t,e=200){return v(t,e)}static error(t,e,s){return n.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:F,a=typeof e?.baseMs=="number"&&Number.isFinite(e.baseMs)&&e.baseMs>=0?e.baseMs:R,i=e?.backoff==="linear"?"linear":"exponential",o=typeof e?.maxMs=="number"&&Number.isFinite(e.maxMs)&&e.maxMs>=0?e.maxMs:void 0;return{backoff:i,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();activeLeases=new Map;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 v({error:{code:"NOT_FOUND"}},404)}async alarm(){await this.reindexOrphanedRecords();const t=Date.now(),e=[],s=new Set,a=await this.state.storage.list({end:`t:${I(t)}:~`,limit:100,prefix:"t:"});for(const[i,o]of a.entries()){const r=Number.parseInt(i.slice(2,i.indexOf(":",2)),10);if(Number.isFinite(r)&&r<=t){const d=await this.state.storage.get(`${c}${o}`);d&&s.has(o)?await this.state.storage.delete(i):d?(s.add(o),e.push({claimKey:i,record:d})):await this.state.storage.delete(i)}}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"},i=await this.signDispatch(s);return i!==void 0?a["x-lunora-scheduler-signature"]=i: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(A,N))}async drainDue(t){const e=[...t],s=[],a=Math.min(E,e.length);for(let i=0;i<a;i+=1)s.push((async()=>{for(let o=e.shift();o!==void 0;o=e.shift())await this.drainRecordGuarded(o.record,o.claimKey)})());await Promise.all(s)}async withPoolLock(t){const e=this.poolLock.then(t,t);return this.poolLock=e.then(()=>{},()=>{}),e}async drainRecordGuarded(t,e){const s=n.indexKey(Date.now()+$,t.id);try{await this.state.storage.delete(e),await this.state.storage.put(s,t.id),this.activeLeases.set(t.id,s),await this.drainRecord(t)}catch{try{if(await this.state.storage.get(`${g}${t.id}`)!==void 0){await this.state.storage.delete(s),await this.state.storage.delete([`${f}${t.id}`,`${c}${t.id}`]);return}await this.state.storage.put(e,t.id),await this.state.storage.delete(s)}catch{}return}finally{this.activeLeases.delete(t.id)}try{await this.state.storage.delete(s)}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 i=await this.loadPool(a);await this.savePool(a,n.releaseSlot(i,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 i=a.inFlightIds??[];return i.includes(t.id)||i.push(t.id),a.inFlightIds=i,a.inFlight=i.length,await this.savePool(e,a),!0});return s||await this.requeuePooled(t),s}async handleWebSocketUpgrade(){if(this.state.acceptWebSocket===void 0)return n.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 i of t)try{i.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}}),i=[...a.keys()],o=[...a.values()],r=o.length>e;return r?{cursor:i[e-1],records:o.slice(0,e),truncated:r}:{records:o,truncated:r}}async forEachPage(t,e,s=m){let a;for(;;){const i=await this.state.storage.list(a===void 0?{limit:s,prefix:t}:{limit:s,prefix:t,startAfter:a});if(i.size===0)break;for(const[r,d]of i.entries())e(d,r);if(a=[...i.keys()].at(-1),i.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"]),i=await crypto.subtle.sign("HMAC",a,s.encode(t));return P(new Uint8Array(i))}async recordRetry(t){const e=(t.attempts??0)+1,{backoff:s,baseMs:a,maxAttempts:i,maxMs:o}=n.resolveRetry(t),r=s==="linear"?a*e:a*2**(e-1),d=o===void 0?r:Math.min(r,o),h=Math.round(Date.now()+d);if(e>i){await this.parkDead(t,e,`after ${String(e)} attempts`);return}if(!p(h)){await this.parkDead(t,e,`at attempt ${String(e)}: the retry backoff exceeded the largest schedulable time`);return}const l={...t,attempts:e,scheduledFor:h};await this.state.storage.put(`${f}${t.id}`,l),await this.state.storage.put(`${c}${t.id}`,l),await this.state.storage.put(n.indexKey(h,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(`${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:n.normalizeConcurrency(e,1)}}async savePool(t,e){await this.state.storage.put(`${y}${t}`,e)}async requeuePooled(t){const e=Date.now()+x,s={...t,scheduledFor:e};await this.state.storage.put(`${c}${t.id}`,s),await this.state.storage.put(n.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 n.error(400,"INVALID_INPUT","pool is required");const i=await this.withPoolLock(async()=>{const o=await this.loadPool(s),r=a===void 0?n.releaseFirstSlot(o):n.releaseSlot(o,a);return await this.savePool(s,r),r});return await this.armAlarmIfEarlier(Date.now()),n.json({inFlight:i.inFlight})}async handlePoolStatus(t){const e=t.searchParams.get("name");if(e===null||e.length===0)return n.error(400,"INVALID_INPUT","name is required");const s=await this.loadPool(e);let a=0;return await this.forEachPage(c,i=>{i.pool===e&&(a+=1)}),n.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(y,(o,r)=>{const d=r.slice(y.length),h=Math.max(0,o.inFlight),l=t.get(d)??0;e.push({inFlight:h,maxConcurrency:o.maxConcurrency,name:d,queued:l}),s+=l,a+=h});const i={backlog:s,inFlight:a,pools:e};return n.json(i)}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:n.normalizeConcurrency(e,s.maxConcurrency)})}async idConflict(t){if(await this.state.storage.get(`${c}${t}`)!==void 0)return n.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 n.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 n.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=n.resolveScheduleTarget(e);if(!e||s===void 0)return n.error(400,"INVALID_INPUT","functionPath or workflow is required");const{functionPath:a,workflow:i}=s;if(typeof e.scheduledFor!="number"||!p(e.scheduledFor))return n.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 n.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,d=n.normalizeRetry(e.retry),h=await this.resolveId(e.id);if(h instanceof Response)return h;const l=h,w={args:e.args??{},enqueuedAt:Date.now(),id:l,...a===void 0?{}:{functionPath:a},...r===void 0?{}:{instanceName:r},...o===void 0?{}:{pool:o},...d===void 0?{}:{retry:d},scheduledFor:e.scheduledFor,shardKey:e.shardKey,...i===void 0?{}:{workflow:i}};return o!==void 0&&await this.persistPoolCap(o,e.maxConcurrency),await this.state.storage.put(`${c}${l}`,w),await this.state.storage.put(n.indexKey(w.scheduledFor,l),l),await this.armAlarmIfEarlier(w.scheduledFor),await this.broadcastChange(),n.json({id:l,scheduledFor:w.scheduledFor})}async handleCancel(t){const e=await t.json().catch(()=>{});if(!e?.id)return n.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(),n.json({cancelled:!0})):n.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 n.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 n.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 n.error(400,"INVALID_INPUT","id is required");const s=await this.state.storage.get(`${g}${e.id}`);if(s===void 0)return n.json({retried:!1});const a=Date.now(),i={...s,attempts:0,scheduledFor:a};return await this.state.storage.put(`${c}${s.id}`,i),await this.state.storage.put(n.indexKey(a,s.id),s.id),await this.state.storage.delete(`${g}${s.id}`),await this.armAlarmIfEarlier(a),await this.broadcastChange(),n.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 n.error(400,"INVALID_INPUT","id is required");const s=await this.state.storage.delete(`${g}${e.id}`);return n.json({removed:!!s})}async handleGet(t){const e=t.searchParams.get("id");if(e===null||e.length===0)return n.error(400,"INVALID_INPUT","id is required");const s=await this.state.storage.get(`${c}${e}`);return n.json(s===void 0?{}:{record:s})}async removeRecord(t){const e=[`${c}${t.id}`,n.indexKey(t.scheduledFor,t.id),`${f}${t.id}`],s=this.activeLeases.get(t.id);s!==void 0&&e.push(s),await this.state.storage.delete(e)}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)&&p(s.scheduledFor)&&e.push(s)});for(const s of e)await this.state.storage.put(n.indexKey(s.scheduledFor,s.id),s.id),await this.armAlarmIfEarlier(s.scheduledFor);if(await this.state.storage.getAlarm()===null){const s=await this.earliestPendingTime();s!==void 0&&Number.isFinite(s)&&await this.state.storage.setAlarm(s)}}async earliestPendingTime(){const e=(await this.state.storage.list({limit:1,prefix:"t:"})).entries().next();if(e.done)return;const[s]=e.value;return Number.parseInt(s.slice(2,s.indexOf(":",2)),10)}async rescheduleAlarm(){const t=await this.earliestPendingTime();if(t===void 0){await this.state.storage.deleteAlarm();return}Number.isFinite(t)&&await this.state.storage.setAlarm(t)}}export{$ as DISPATCH_LEASE_MS,E as MAX_CONCURRENT_DISPATCHES,F as MAX_RETRY_ATTEMPTS,R as RETRY_BASE_DELAY_MS,n as SchedulerDO};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/scheduler",
3
- "version": "1.0.0-alpha.79",
3
+ "version": "1.0.0-alpha.80",
4
4
  "description": "Scheduling for Lunora: runAfter / runAt and Cron Triggers via SchedulerDO",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -1 +0,0 @@
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};