@lunora/scheduler 1.0.0-alpha.81 → 1.0.0-alpha.83

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
@@ -445,6 +445,13 @@ type QueueDispatch = (job: QueueJob, messageId?: string) => Promise<void>;
445
445
  interface QueueConsumerOptions {
446
446
  /** How each job is executed; e.g. the `httpDispatcher`. */
447
447
  dispatch: QueueDispatch;
448
+ /**
449
+ * The consumer's `max_retries` as deployed in `wrangler.jsonc`, which a
450
+ * consumer cannot read at runtime. Used only to recognise a message's last
451
+ * delivery, so a dispatch declined there is logged as the drop or
452
+ * dead-letter it becomes. Defaults to Cloudflare's default of 3.
453
+ */
454
+ maxRetries?: number;
448
455
  }
449
456
  /** Options for the `httpDispatcher` — the default HTTP dispatcher. */
450
457
  interface HttpDispatcherOptions {
@@ -918,13 +925,18 @@ declare class SchedulerDO {
918
925
  * drop. After {@link MAX_RETRY_ATTEMPTS} the record is parked under a
919
926
  * `dead:` key for inspection — never silently deleted.
920
927
  *
928
+ * One non-2xx is told apart: a `409` carrying `DISPATCH_IN_PROGRESS` returns
929
+ * `"in-progress"`. The shard is saying the first attempt of this very record
930
+ * is still running, so {@link recordRetry} re-arms it without charging an
931
+ * attempt — see there.
932
+ *
921
933
  * The dispatch target is taken from `env.LUNORA_ORIGIN_URL` (NOT from the
922
934
  * stored record) so a schedule request can never name where the DO calls
923
935
  * back — that would be SSRF. If that env var is missing at fire time (a deploy/binding
924
936
  * regression — schedule time already enforced its presence) we return
925
937
  * `false` so the record is retried rather than silently dropped.
926
938
  */
927
- protected dispatch(record: ScheduleRecord): Promise<boolean>;
939
+ protected dispatch(record: ScheduleRecord): Promise<boolean | "in-progress">;
928
940
  /**
929
941
  * Register the hibernation-safe ping/pong keepalive. The runtime answers a
930
942
  * {@link WS_KEEPALIVE_PING} text frame with {@link WS_KEEPALIVE_PONG}
@@ -1115,6 +1127,21 @@ declare class SchedulerDO {
1115
1127
  * The retry budget/backoff comes from the record's {@link RetryPolicy}
1116
1128
  * (falling back to the DO defaults); on exhaustion the record is parked
1117
1129
  * under a `dead:` key for manual inspection.
1130
+ *
1131
+ * **A `DISPATCH_IN_PROGRESS` decline (`inProgress`) is not charged.** It
1132
+ * means the record's first attempt is still running on the shard, not that
1133
+ * anything failed; charging it would spend the budget while that attempt is
1134
+ * doing the work, and if it then failed the job would dead-letter having
1135
+ * run once. It still re-arms at the backoff of the NEXT charged attempt, at
1136
+ * least {@link RETRY_BASE_DELAY_MS} apart — nothing else rate-limits an
1137
+ * uncharged retry, and polling a running attempt faster gains nothing.
1138
+ *
1139
+ * It is bounded, not forever, only because the shard's claim is: a claim
1140
+ * older than the shard's fifteen-minute ceiling is treated as stale and the
1141
+ * next delivery runs (and is charged as usual if it fails). A handler that
1142
+ * hangs therefore costs at most that long in declines before the ordinary
1143
+ * budget applies again. Without that ceiling, not charging would let one hung
1144
+ * handler keep its record retrying and never dead-letter.
1118
1145
  */
1119
1146
  private recordRetry;
1120
1147
  /**
package/dist/index.d.ts CHANGED
@@ -445,6 +445,13 @@ type QueueDispatch = (job: QueueJob, messageId?: string) => Promise<void>;
445
445
  interface QueueConsumerOptions {
446
446
  /** How each job is executed; e.g. the `httpDispatcher`. */
447
447
  dispatch: QueueDispatch;
448
+ /**
449
+ * The consumer's `max_retries` as deployed in `wrangler.jsonc`, which a
450
+ * consumer cannot read at runtime. Used only to recognise a message's last
451
+ * delivery, so a dispatch declined there is logged as the drop or
452
+ * dead-letter it becomes. Defaults to Cloudflare's default of 3.
453
+ */
454
+ maxRetries?: number;
448
455
  }
449
456
  /** Options for the `httpDispatcher` — the default HTTP dispatcher. */
450
457
  interface HttpDispatcherOptions {
@@ -918,13 +925,18 @@ declare class SchedulerDO {
918
925
  * drop. After {@link MAX_RETRY_ATTEMPTS} the record is parked under a
919
926
  * `dead:` key for inspection — never silently deleted.
920
927
  *
928
+ * One non-2xx is told apart: a `409` carrying `DISPATCH_IN_PROGRESS` returns
929
+ * `"in-progress"`. The shard is saying the first attempt of this very record
930
+ * is still running, so {@link recordRetry} re-arms it without charging an
931
+ * attempt — see there.
932
+ *
921
933
  * The dispatch target is taken from `env.LUNORA_ORIGIN_URL` (NOT from the
922
934
  * stored record) so a schedule request can never name where the DO calls
923
935
  * back — that would be SSRF. If that env var is missing at fire time (a deploy/binding
924
936
  * regression — schedule time already enforced its presence) we return
925
937
  * `false` so the record is retried rather than silently dropped.
926
938
  */
927
- protected dispatch(record: ScheduleRecord): Promise<boolean>;
939
+ protected dispatch(record: ScheduleRecord): Promise<boolean | "in-progress">;
928
940
  /**
929
941
  * Register the hibernation-safe ping/pong keepalive. The runtime answers a
930
942
  * {@link WS_KEEPALIVE_PING} text frame with {@link WS_KEEPALIVE_PONG}
@@ -1115,6 +1127,21 @@ declare class SchedulerDO {
1115
1127
  * The retry budget/backoff comes from the record's {@link RetryPolicy}
1116
1128
  * (falling back to the DO defaults); on exhaustion the record is parked
1117
1129
  * under a `dead:` key for manual inspection.
1130
+ *
1131
+ * **A `DISPATCH_IN_PROGRESS` decline (`inProgress`) is not charged.** It
1132
+ * means the record's first attempt is still running on the shard, not that
1133
+ * anything failed; charging it would spend the budget while that attempt is
1134
+ * doing the work, and if it then failed the job would dead-letter having
1135
+ * run once. It still re-arms at the backoff of the NEXT charged attempt, at
1136
+ * least {@link RETRY_BASE_DELAY_MS} apart — nothing else rate-limits an
1137
+ * uncharged retry, and polling a running attempt faster gains nothing.
1138
+ *
1139
+ * It is bounded, not forever, only because the shard's claim is: a claim
1140
+ * older than the shard's fifteen-minute ceiling is treated as stale and the
1141
+ * next delivery runs (and is charged as usual if it fails). A handler that
1142
+ * hangs therefore costs at most that long in declines before the ordinary
1143
+ * budget applies again. Without that ceiling, not charging would let one hung
1144
+ * handler keep its record retrying and never dead-letter.
1118
1145
  */
1119
1146
  private recordRetry;
1120
1147
  /**
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-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};
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-BoSFHUrp.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-BFBSSPrs.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 R}from"./base64-BFBvYeZM.mjs";import N from"./resolveScheduleId-DgysKSrN.mjs";const E="DISPATCH_IN_PROGRESS",x="x-lunora-dispatch-declined",P=(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})},F="lunora-ping",$="lunora-pong",c="id:",m="retry:",g="dead:",y="pool:",w=100,L=5,b=3e4,k=1e3,T=6,C=9e5,M=999999999999999,U=15,A=u=>String(u).padStart(U,"0"),v=u=>Number.isInteger(u)&&u>0&&u<=M;class n{static indexKey(t,e){return`t:${A(t)}:${e}`}static json(t,e=200){return P(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:L,a=typeof e?.baseMs=="number"&&Number.isFinite(e.baseMs)&&e.baseMs>=0?e.baseMs:b,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 P({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:${A(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);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}`);const o=await fetch(`${e}/_lunora/scheduler/dispatch`,{body:s,headers:a,method:"POST"});return o.status===409&&o.headers.get(x)==="1"&&await o.json().then(d=>d?.error?.code===E,()=>!1)?"in-progress":o.ok}catch{return!1}}armWebSocketKeepalive(){const t=this.state.setWebSocketAutoResponse;typeof t!="function"||typeof WebSocketRequestResponsePair>"u"||t.call(this.state,new WebSocketRequestResponsePair(F,$))}async drainDue(t){const e=[...t],s=[],a=Math.min(T,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()+C,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([`${m}${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=s===!0,i=t.pool;if(!a&&i!==void 0&&await this.withPoolLock(async()=>{const o=await this.loadPool(i);await this.savePool(i,n.releaseSlot(o,t.id))}),a){try{await this.state.storage.delete([`${c}${t.id}`,`${m}${t.id}`])}catch{}return!0}return await this.recordRetry(t,s==="in-progress"),!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,w);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,w),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=w){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 R(new Uint8Array(i))}async recordRetry(t,e=!1){const s=(t.attempts??0)+(e?0:1),a=(t.attempts??0)+1,{backoff:i,baseMs:o,maxAttempts:r,maxMs:d}=n.resolveRetry(t),h=i==="linear"?o*a:o*2**(a-1),l=d===void 0?h:Math.min(h,d),f=e?Math.max(l,b):l,p=Math.round(Date.now()+f);if(s>r){await this.parkDead(t,s,`after ${String(s)} attempts`);return}if(!v(p)){await this.parkDead(t,s,`at attempt ${String(s)}: the retry backoff exceeded the largest schedulable time`);return}const I={...t,attempts:s,scheduledFor:p};await this.state.storage.put(`${m}${t.id}`,I),await this.state.storage.put(`${c}${t.id}`,I),await this.state.storage.put(n.indexKey(p,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([`${m}${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()+k,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=N(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"||!v(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,f={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}`,f),await this.state.storage.put(n.indexKey(f.scheduledFor,l),l),await this.armAlarmIfEarlier(f.scheduledFor),await this.broadcastChange(),n.json({id:l,scheduledFor:f.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,w,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,w,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),`${m}${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)&&v(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{C as DISPATCH_LEASE_MS,T as MAX_CONCURRENT_DISPATCHES,L as MAX_RETRY_ATTEMPTS,b as RETRY_BASE_DELAY_MS,n as SchedulerDO};
@@ -0,0 +1 @@
1
+ import{LunoraError as d,isLunoraError as j}from"@lunora/errors";import{a as M}from"./wire-codec-TIVAE0Ds.mjs";const q=9e5,B="DISPATCH_IN_PROGRESS",K="x-lunora-dispatch-declined",k=(e,t,n)=>{if(t===void 0)return{dispose:()=>{},signal:e};const a=new AbortController,r=setTimeout(()=>{a.abort(n())},t);return{dispose:()=>{clearTimeout(r)},signal:a.signal}},J=e=>{let t="";for(let n=0;n<e.length;n+=32768)t+=String.fromCharCode(...e.subarray(n,n+32768));return btoa(t)},C=e=>{const t=atob(e),n=new Uint8Array(t.length);for(let a=0;a<t.length;a+=1)n[a]=t.codePointAt(a)??0;return n},y="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",$=e=>{let t="",n=0;const a=e.length-2;for(;n<a;n+=3){const o=e[n]<<16|e[n+1]<<8|e[n+2];t+=y.charAt(o>>18&63)+y.charAt(o>>12&63)+y.charAt(o>>6&63)+y.charAt(o&63)}const r=e.length-n;if(r===1){const o=e[n]<<16;t+=y.charAt(o>>18&63)+y.charAt(o>>12&63)}else if(r===2){const o=e[n]<<16|e[n+1]<<8;t+=y.charAt(o>>18&63)+y.charAt(o>>12&63)+y.charAt(o>>6&63)}return t};new TextDecoder;const S=new TextEncoder,E="=",Q=e=>{for(let t=0;t<e.length;t+=1)if(e.charCodeAt(t)>255)return!1;return!0},W=e=>$(S.encode(JSON.stringify(e))),F=e=>!e.startsWith(E)&&Q(e)?e:`${E}${$(S.encode(e))}`,G="$lunora.wire$",N=64,R=1024,I="__proto__",O={BigInt64Array,BigUint64Array,Float32Array,Float64Array,Int8Array,Int16Array,Int32Array,Uint8Array,Uint8ClampedArray,Uint16Array,Uint32Array},_={Error,EvalError,RangeError,ReferenceError,SyntaxError,TypeError,URIError},l=(e,t=0)=>{if(t>N)throw new RangeError(`wire-codec: value nesting exceeds the ${N}-level limit`);if(e===null||typeof e!="object")return e;if(Array.isArray(e)){if(e[0]===G)switch(e[1]){case"-inf":return-1/0;case"arr":return e[2].map(r=>l(r,t+1));case"bigint":{const r=e[2];if(typeof r!="string"||r.length>R||!/^-?\d+$/.test(r))throw new RangeError(`wire-codec: invalid or over-long bigint (max ${R} digits)`);return BigInt(r)}case"date":{const r=l(e[2],t+1);if(typeof r!="number")throw new TypeError("wire-codec: malformed date — epoch must be a number");return new Date(r)}case"map":{const r=e[2];return new Map(r.map(o=>{if(!Array.isArray(o)||o.length!==2)throw new TypeError("wire-codec: malformed map entry — expected a [key, value] pair");return[l(o[0],t+1),l(o[1],t+1)]}))}case"set":return new Set(e[2].map(r=>l(r,t+1)));case"url":{const r=e[2];if(typeof r!="string")throw new TypeError("wire-codec: malformed url — href must be a string");return new URL(r)}case"error":{const r=e[2],o=e[3];if(typeof r!="string"||typeof o!="string")throw new TypeError("wire-codec: malformed error — name and message must be strings");const s=(Object.hasOwn(_,r)?_[r]:void 0)??Error,c=new s(o);c.name!==r&&Object.defineProperty(c,"name",{configurable:!0,value:r,writable:!0});const i=l(e[4],t+1);if(i===null||typeof i!="object"||Array.isArray(i))throw new TypeError("wire-codec: malformed error — props must be an object");for(const u of Object.keys(i))u===I?Object.defineProperty(c,u,{configurable:!0,enumerable:!0,value:i[u],writable:!0}):c[u]=i[u];return e.length>5&&Object.defineProperty(c,"cause",{configurable:!0,value:l(e[5],t+1),writable:!0}),c}case"bytes":{const r=e[2];if(typeof r!="string")throw new TypeError("wire-codec: malformed bytes — payload must be a base64 string");const o=C(r);if(J(o)!==r)throw new TypeError("wire-codec: malformed bytes — payload must be canonical padded base64");const s=e[3]??"Uint8Array";if(s==="ArrayBuffer")return o.buffer.byteLength===o.byteLength?o.buffer:o.slice().buffer;const c=Object.hasOwn(O,s)?O[s]:void 0;return c?new c(o.slice().buffer):o}case"inf":return 1/0;case"nan":return Number.NaN;case"undefined":return;default:return e.map(r=>l(r,t+1))}return e.map(r=>l(r,t+1))}const n=e,a={};for(const r of Object.keys(n)){const o=l(n[r],t+1);r===I?Object.defineProperty(a,r,{configurable:!0,enumerable:!0,value:o,writable:!0}):a[r]=o}return a},H="/_lunora/scheduler/dispatch",z=3e4,V=e=>{let t=e.length;for(;t>0&&e[t-1]==="/";)t-=1;return e.slice(0,t)},L=Symbol("lunoraDispatchFailure"),U=Symbol("lunoraDispatchDecline"),X=Symbol("lunoraDispatchMessageId"),x=(e,t)=>(t!==void 0&&Object.defineProperty(e,X,{value:t}),e),Y=(e,t)=>(Object.defineProperty(e,L,{value:!0}),x(e,t)),Z=(e,t,n,a)=>{const{status:r}=t;try{const o=JSON.parse(n)?.error;if(typeof o=="object"&&o!==null&&typeof o.code=="string"){const{code:s,data:c,message:i}=o,u=Y(new d(s,typeof i=="string"?i:void 0,{data:c,status:r}),a);return t.headers.get(K)==="1"&&Object.defineProperty(u,U,{value:!0}),u}}catch{}return x(new d("INTERNAL",`${e}: function dispatch failed (${String(r)}): ${n}`,{status:r}),a)},ee=e=>j(e)&&e[L]===!0,te=e=>ee(e)&&e[U]===!0,re=q/1e3,ne=3,oe=(e,t)=>{const n=t.maxRetries??ne;e.retry({delaySeconds:re}),typeof e.attempts=="number"&&e.attempts>n&&console.error(`${t.where}: message ${e.id} was declined (${B}) on its last delivery (attempt ${String(e.attempts)} of ${String(n+1)}) — its earlier delivery is still running the call. It is retried anyway, but its retries are exhausted, so it will be dead-lettered if the queue has a deadLetterQueue and dropped if not. Raise max_retries so a slow call cannot exhaust it.`)},ae=(e,t,n)=>new d("INTERNAL",`${e}: function dispatch to "${t}" timed out after ${String(n)}ms`,{status:503}),se=e=>{const{label:t}=e,n=globalThis.fetch,a=e.fetchImpl??(typeof n=="function"?n.bind(globalThis):void 0);return async(r,o,s={})=>{if(typeof a!="function")throw new TypeError(`${t}: no fetch implementation available — pass fetchImpl or run on a platform with global fetch`);const c=()=>o??{},i=e.env.LUNORA_ORIGIN_URL;if(typeof i!="string"||i.length===0)throw new d("INTERNAL",`${t}: \`LUNORA_ORIGIN_URL\` must be set on the Worker env so a handler can call back into Lunora functions`);const u=e.env.LUNORA_ADMIN_TOKEN;if(typeof u!="string"||u.length===0)throw new d("INTERNAL",`${t}: \`LUNORA_ADMIN_TOKEN\` must be set on the Worker env to authenticate function dispatch`);const P=`${V(i)}${H}`,w={authorization:`Bearer ${u}`,"content-type":"application/json"};e.traceparent!==void 0&&e.traceparent.length>0&&(w.traceparent=e.traceparent),e.identity?.userId!==void 0&&(w["x-lunora-userid"]=F(e.identity.userId)),e.identity?.claims!==void 0&&(w["x-lunora-identity"]=W(e.identity.claims));const g=s.timeoutMs??z,A=k(void 0,g,()=>new DOMException(`dispatch timed out after ${String(g)}ms`,"TimeoutError")),b=h=>{throw h instanceof Error&&h.name==="TimeoutError"?ae(t,r.__lunoraRef,g):h};let f;try{try{f=await a(P,{body:JSON.stringify({args:c(),functionPath:r.__lunoraRef,id:s.dedupId,shardKey:s.shardKey}),headers:w,method:"POST",signal:A.signal})}catch(m){return b(m)}if(!f.ok){let m;try{m=await f.text()}catch(D){return b(D)}throw Z(t,f,m,s.messageId)}let h;try{h=await f.text()}catch(m){return b(m)}if(h.length===0)return;let p;try{p=JSON.parse(h)}catch{throw new d("INTERNAL",`${t}: function dispatch returned a non-JSON body (${String(f.status)}): ${h}`,{status:f.status})}if(p===null||typeof p!="object"||Array.isArray(p)||!("result"in p))throw new d("INTERNAL",`${t}: function dispatch returned a JSON body that is not a { result } envelope (${String(f.status)}): ${h}`,{status:f.status});return l(p.result)}finally{A.dispose()}}},T=100,ce=3e5,v=e=>e===void 0?void 0:M(e),de=e=>{if(!e.queue)throw new d("INTERNAL","@lunora/scheduler: `queue` (a Cloudflare Queue binding) is required");return{enqueue:async(a,r,o={})=>{const s={args:v(r),functionPath:a.__lunoraRef,shardKey:o.shardKey},c=o.delaySeconds===void 0?void 0:{delaySeconds:o.delaySeconds};await e.queue.send(s,c)},enqueueBatch:async(a,r)=>{if(a.length>T)throw new d("VALIDATION_ERROR",`@lunora/scheduler: enqueueBatch exceeds ${String(T)} (got ${String(a.length)}) — split across calls`);const o=a.map(s=>({body:{args:v(s.args),functionPath:s.ref.__lunoraRef,shardKey:s.shardKey}}));await e.queue.sendBatch(o,r)}}},ie=e=>typeof e=="object"&&e!==null&&typeof e.functionPath=="string",fe=e=>async t=>{await Promise.all(t.messages.map(async n=>{try{if(!ie(n.body))throw new d("INTERNAL","@lunora/scheduler: queue message body is not a QueueJob (missing functionPath)");await e.dispatch(n.body,n.id),n.ack()}catch(a){if(te(a)){oe(n,{maxRetries:e.maxRetries,where:`@lunora/scheduler: queue "${t.queue}"`});return}n.retry()}}))},he=e=>{const t=se({env:{LUNORA_ADMIN_TOKEN:e.adminToken,LUNORA_ORIGIN_URL:e.originUrl},fetchImpl:e.fetchImpl,label:"@lunora/scheduler"}),n=e.timeoutMs??ce;return async(a,r)=>{await t({__lunoraRef:a.functionPath},a.args,{dedupId:r,messageId:r,shardKey:a.shardKey,timeoutMs:n})}};export{fe as createQueueConsumer,de as createQueueWorkpool,he as httpDispatcher};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/scheduler",
3
- "version": "1.0.0-alpha.81",
3
+ "version": "1.0.0-alpha.83",
4
4
  "description": "Scheduling for Lunora: runAfter / runAt and Cron Triggers via SchedulerDO",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@lunora/errors": "1.0.0-alpha.41",
50
- "@lunora/platform": "1.0.0-alpha.33",
50
+ "@lunora/platform": "1.0.0-alpha.35",
51
51
  "cron-parser": "5.10.0"
52
52
  },
53
53
  "engines": {
@@ -1 +0,0 @@
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};
@@ -1 +0,0 @@
1
- import{LunoraError as l}from"@lunora/errors";import{a as x}from"./wire-codec-TIVAE0Ds.mjs";const j=(e,t,o)=>{if(t===void 0)return{dispose:()=>{},signal:e};const a=new AbortController,r=setTimeout(()=>{a.abort(o())},t);return{dispose:()=>{clearTimeout(r)},signal:a.signal}},M=e=>{let t="";for(let o=0;o<e.length;o+=32768)t+=String.fromCharCode(...e.subarray(o,o+32768));return btoa(t)},B=e=>{const t=atob(e),o=new Uint8Array(t.length);for(let a=0;a<t.length;a+=1)o[a]=t.codePointAt(a)??0;return o},y="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",$=e=>{let t="",o=0;const a=e.length-2;for(;o<a;o+=3){const n=e[o]<<16|e[o+1]<<8|e[o+2];t+=y.charAt(n>>18&63)+y.charAt(n>>12&63)+y.charAt(n>>6&63)+y.charAt(n&63)}const r=e.length-o;if(r===1){const n=e[o]<<16;t+=y.charAt(n>>18&63)+y.charAt(n>>12&63)}else if(r===2){const n=e[o]<<16|e[o+1]<<8;t+=y.charAt(n>>18&63)+y.charAt(n>>12&63)+y.charAt(n>>6&63)}return t};new TextDecoder;const L=new TextEncoder,E="=",D=e=>{for(let t=0;t<e.length;t+=1)if(e.charCodeAt(t)>255)return!1;return!0},k=e=>$(L.encode(JSON.stringify(e))),K=e=>!e.startsWith(E)&&D(e)?e:`${E}${$(L.encode(e))}`,J="$lunora.wire$",N=64,I=1024,O="__proto__",T={BigInt64Array,BigUint64Array,Float32Array,Float64Array,Int8Array,Int16Array,Int32Array,Uint8Array,Uint8ClampedArray,Uint16Array,Uint32Array},R={Error,EvalError,RangeError,ReferenceError,SyntaxError,TypeError,URIError},i=(e,t=0)=>{if(t>N)throw new RangeError(`wire-codec: value nesting exceeds the ${N}-level limit`);if(e===null||typeof e!="object")return e;if(Array.isArray(e)){if(e[0]===J)switch(e[1]){case"-inf":return-1/0;case"arr":return e[2].map(r=>i(r,t+1));case"bigint":{const r=e[2];if(typeof r!="string"||r.length>I||!/^-?\d+$/.test(r))throw new RangeError(`wire-codec: invalid or over-long bigint (max ${I} digits)`);return BigInt(r)}case"date":{const r=i(e[2],t+1);if(typeof r!="number")throw new TypeError("wire-codec: malformed date — epoch must be a number");return new Date(r)}case"map":{const r=e[2];return new Map(r.map(n=>{if(!Array.isArray(n)||n.length!==2)throw new TypeError("wire-codec: malformed map entry — expected a [key, value] pair");return[i(n[0],t+1),i(n[1],t+1)]}))}case"set":return new Set(e[2].map(r=>i(r,t+1)));case"url":{const r=e[2];if(typeof r!="string")throw new TypeError("wire-codec: malformed url — href must be a string");return new URL(r)}case"error":{const r=e[2],n=e[3];if(typeof r!="string"||typeof n!="string")throw new TypeError("wire-codec: malformed error — name and message must be strings");const s=(Object.hasOwn(R,r)?R[r]:void 0)??Error,c=new s(n);c.name!==r&&Object.defineProperty(c,"name",{configurable:!0,value:r,writable:!0});const u=i(e[4],t+1);if(u===null||typeof u!="object"||Array.isArray(u))throw new TypeError("wire-codec: malformed error — props must be an object");for(const d of Object.keys(u))d===O?Object.defineProperty(c,d,{configurable:!0,enumerable:!0,value:u[d],writable:!0}):c[d]=u[d];return e.length>5&&Object.defineProperty(c,"cause",{configurable:!0,value:i(e[5],t+1),writable:!0}),c}case"bytes":{const r=e[2];if(typeof r!="string")throw new TypeError("wire-codec: malformed bytes — payload must be a base64 string");const n=B(r);if(M(n)!==r)throw new TypeError("wire-codec: malformed bytes — payload must be canonical padded base64");const s=e[3]??"Uint8Array";if(s==="ArrayBuffer")return n.buffer.byteLength===n.byteLength?n.buffer:n.slice().buffer;const c=Object.hasOwn(T,s)?T[s]:void 0;return c?new c(n.slice().buffer):n}case"inf":return 1/0;case"nan":return Number.NaN;case"undefined":return;default:return e.map(r=>i(r,t+1))}return e.map(r=>i(r,t+1))}const o=e,a={};for(const r of Object.keys(o)){const n=i(o[r],t+1);r===O?Object.defineProperty(a,r,{configurable:!0,enumerable:!0,value:n,writable:!0}):a[r]=n}return a},q="/_lunora/scheduler/dispatch",C=3e4,Q=e=>{let t=e.length;for(;t>0&&e[t-1]==="/";)t-=1;return e.slice(0,t)},W=Symbol("lunoraDispatchFailure"),F=Symbol("lunoraDispatchMessageId"),S=(e,t)=>(t!==void 0&&Object.defineProperty(e,F,{value:t}),e),G=(e,t)=>(Object.defineProperty(e,W,{value:!0}),S(e,t)),z=(e,t,o,a)=>{try{const r=JSON.parse(o)?.error;if(typeof r=="object"&&r!==null&&typeof r.code=="string"){const{code:n,data:s,message:c}=r;return G(new l(n,typeof c=="string"?c:void 0,{data:s,status:t}),a)}}catch{}return S(new l("INTERNAL",`${e}: function dispatch failed (${String(t)}): ${o}`,{status:t}),a)},H=(e,t,o)=>new l("INTERNAL",`${e}: function dispatch to "${t}" timed out after ${String(o)}ms`,{status:503}),V=e=>{const{label:t}=e,o=globalThis.fetch,a=e.fetchImpl??(typeof o=="function"?o.bind(globalThis):void 0);return async(r,n,s={})=>{if(typeof a!="function")throw new TypeError(`${t}: no fetch implementation available — pass fetchImpl or run on a platform with global fetch`);const c=()=>n??{},u=e.env.LUNORA_ORIGIN_URL;if(typeof u!="string"||u.length===0)throw new l("INTERNAL",`${t}: \`LUNORA_ORIGIN_URL\` must be set on the Worker env so a handler can call back into Lunora functions`);const d=e.env.LUNORA_ADMIN_TOKEN;if(typeof d!="string"||d.length===0)throw new l("INTERNAL",`${t}: \`LUNORA_ADMIN_TOKEN\` must be set on the Worker env to authenticate function dispatch`);const U=`${Q(u)}${q}`,m={authorization:`Bearer ${d}`,"content-type":"application/json"};e.traceparent!==void 0&&e.traceparent.length>0&&(m.traceparent=e.traceparent),e.identity?.userId!==void 0&&(m["x-lunora-userid"]=K(e.identity.userId)),e.identity?.claims!==void 0&&(m["x-lunora-identity"]=k(e.identity.claims));const g=s.timeoutMs??C,A=j(void 0,g,()=>new DOMException(`dispatch timed out after ${String(g)}ms`,"TimeoutError")),b=h=>{throw h instanceof Error&&h.name==="TimeoutError"?H(t,r.__lunoraRef,g):h};let f;try{try{f=await a(U,{body:JSON.stringify({args:c(),functionPath:r.__lunoraRef,id:s.dedupId,shardKey:s.shardKey}),headers:m,method:"POST",signal:A.signal})}catch(w){return b(w)}if(!f.ok){let w;try{w=await f.text()}catch(P){return b(P)}throw z(t,f.status,w,s.messageId)}let h;try{h=await f.text()}catch(w){return b(w)}if(h.length===0)return;let p;try{p=JSON.parse(h)}catch{throw new l("INTERNAL",`${t}: function dispatch returned a non-JSON body (${String(f.status)}): ${h}`,{status:f.status})}if(p===null||typeof p!="object"||Array.isArray(p)||!("result"in p))throw new l("INTERNAL",`${t}: function dispatch returned a JSON body that is not a { result } envelope (${String(f.status)}): ${h}`,{status:f.status});return i(p.result)}finally{A.dispose()}}},_=100,X=3e5,v=e=>e===void 0?void 0:x(e),te=e=>{if(!e.queue)throw new l("INTERNAL","@lunora/scheduler: `queue` (a Cloudflare Queue binding) is required");return{enqueue:async(a,r,n={})=>{const s={args:v(r),functionPath:a.__lunoraRef,shardKey:n.shardKey},c=n.delaySeconds===void 0?void 0:{delaySeconds:n.delaySeconds};await e.queue.send(s,c)},enqueueBatch:async(a,r)=>{if(a.length>_)throw new l("VALIDATION_ERROR",`@lunora/scheduler: enqueueBatch exceeds ${String(_)} (got ${String(a.length)}) — split across calls`);const n=a.map(s=>({body:{args:v(s.args),functionPath:s.ref.__lunoraRef,shardKey:s.shardKey}}));await e.queue.sendBatch(n,r)}}},Y=e=>typeof e=="object"&&e!==null&&typeof e.functionPath=="string",re=e=>async t=>{await Promise.all(t.messages.map(async o=>{try{if(!Y(o.body))throw new l("INTERNAL","@lunora/scheduler: queue message body is not a QueueJob (missing functionPath)");await e.dispatch(o.body,o.id),o.ack()}catch{o.retry()}}))},ne=e=>{const t=V({env:{LUNORA_ADMIN_TOKEN:e.adminToken,LUNORA_ORIGIN_URL:e.originUrl},fetchImpl:e.fetchImpl,label:"@lunora/scheduler"}),o=e.timeoutMs??X;return async(a,r)=>{await t({__lunoraRef:a.functionPath},a.args,{dedupId:r,messageId:r,shardKey:a.shardKey,timeoutMs:o})}};export{re as createQueueConsumer,te as createQueueWorkpool,ne as httpDispatcher};