@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
|
-
*
|
|
973
|
-
*
|
|
974
|
-
*
|
|
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.
|
|
980
|
-
* the
|
|
981
|
-
*
|
|
982
|
-
*
|
|
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
|
-
*
|
|
1023
|
-
* lanes from both reading the same pre-reservation row and
|
|
1024
|
-
* slot was free — without it the pool oversubscribes past
|
|
1025
|
-
* and one holder's id is dropped from `inFlightIds`, so its
|
|
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
|
-
*
|
|
1210
|
-
*
|
|
1211
|
-
*
|
|
1212
|
-
*
|
|
1213
|
-
*
|
|
1214
|
-
*
|
|
1215
|
-
*
|
|
1216
|
-
*
|
|
1217
|
-
*
|
|
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
|
-
*
|
|
1221
|
-
*
|
|
1222
|
-
*
|
|
1223
|
-
*
|
|
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
|
-
*
|
|
1227
|
-
*
|
|
1228
|
-
*
|
|
1229
|
-
* the
|
|
1230
|
-
*
|
|
1231
|
-
*
|
|
1232
|
-
*
|
|
1233
|
-
*
|
|
1234
|
-
* its
|
|
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
|
-
*
|
|
973
|
-
*
|
|
974
|
-
*
|
|
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.
|
|
980
|
-
* the
|
|
981
|
-
*
|
|
982
|
-
*
|
|
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
|
-
*
|
|
1023
|
-
* lanes from both reading the same pre-reservation row and
|
|
1024
|
-
* slot was free — without it the pool oversubscribes past
|
|
1025
|
-
* and one holder's id is dropped from `inFlightIds`, so its
|
|
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
|
-
*
|
|
1210
|
-
*
|
|
1211
|
-
*
|
|
1212
|
-
*
|
|
1213
|
-
*
|
|
1214
|
-
*
|
|
1215
|
-
*
|
|
1216
|
-
*
|
|
1217
|
-
*
|
|
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
|
-
*
|
|
1221
|
-
*
|
|
1222
|
-
*
|
|
1223
|
-
*
|
|
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
|
-
*
|
|
1227
|
-
*
|
|
1228
|
-
*
|
|
1229
|
-
* the
|
|
1230
|
-
*
|
|
1231
|
-
*
|
|
1232
|
-
*
|
|
1233
|
-
*
|
|
1234
|
-
* its
|
|
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-
|
|
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 +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};
|