@lunora/scheduler 1.0.0-alpha.17 → 1.0.0-alpha.18

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
@@ -586,6 +586,7 @@ interface SchedulerDOState {
586
586
  end?: string;
587
587
  limit?: number;
588
588
  prefix?: string;
589
+ startAfter?: string;
589
590
  }) => Promise<Map<string, T>>;
590
591
  put: <T = unknown>(entries: Record<string, T> | string, value?: T) => Promise<void>;
591
592
  setAlarm: (scheduledTime: number | Date) => Promise<void> | void;
@@ -798,13 +799,28 @@ declare class SchedulerDO {
798
799
  */
799
800
  private handleWebSocketUpgrade;
800
801
  /**
801
- * Re-list the jobs and push them to every connected subscriber. Called after
802
- * any change (schedule / cancel / alarm-fire) so live studios reflect it
803
- * immediately. A no-op when the runtime doesn't support hibernated sockets.
802
+ * Re-list the jobs (bounded see {@link listRecords}) and push them to
803
+ * every connected subscriber. Called after any change (schedule / cancel /
804
+ * alarm-fire) so live studios reflect it immediately. A no-op when the
805
+ * runtime doesn't support hibernated sockets.
804
806
  */
805
807
  private broadcastChange;
806
- /** The current pending job records (shared by `/list` and the live channel). */
808
+ /**
809
+ * The current pending job records (shared by `/list` and the live channel),
810
+ * bounded to `limit` (default {@link DEFAULT_LIST_LIMIT}) so a large backlog
811
+ * can't be JSON-serialized and fanned out to every socket in one shot. Lists
812
+ * `limit + 1` and slices back down so `truncated` reflects whether there was
813
+ * a next row, without a second round-trip.
814
+ */
807
815
  private listRecords;
816
+ /**
817
+ * Page through every `id:` header exactly once with bounded per-page memory
818
+ * (a `limit`+`startAfter` cursor loop), invoking `visit` for each record.
819
+ * Unlike {@link listRecords}, which intentionally truncates for the studio's
820
+ * live view, `/status` and `/pool` need EXACT counts — this walks the full
821
+ * set, but never materializes more than one page at a time.
822
+ */
823
+ private countHeaders;
808
824
  /**
809
825
  * HMAC-SHA-256 sign the dispatch body with `env.LUNORA_SCHEDULER_SECRET`,
810
826
  * returning a base64url signature, or `undefined` when no secret is
@@ -851,7 +867,7 @@ declare class SchedulerDO {
851
867
  * a saturated-but-idle pool stays visible; a pool that only ever existed as
852
868
  * queued jobs without a persisted row is unreachable here (the schedule path
853
869
  * always writes a `pool:&lt;name>` row before the job's header), so a single
854
- * scan over `pool:`/`id:` is sufficient.
870
+ * scan over `pool:` plus a cursor loop over `id:` is sufficient.
855
871
  */
856
872
  private handleStatus;
857
873
  private handleSchedule;
package/dist/index.d.ts CHANGED
@@ -586,6 +586,7 @@ interface SchedulerDOState {
586
586
  end?: string;
587
587
  limit?: number;
588
588
  prefix?: string;
589
+ startAfter?: string;
589
590
  }) => Promise<Map<string, T>>;
590
591
  put: <T = unknown>(entries: Record<string, T> | string, value?: T) => Promise<void>;
591
592
  setAlarm: (scheduledTime: number | Date) => Promise<void> | void;
@@ -798,13 +799,28 @@ declare class SchedulerDO {
798
799
  */
799
800
  private handleWebSocketUpgrade;
800
801
  /**
801
- * Re-list the jobs and push them to every connected subscriber. Called after
802
- * any change (schedule / cancel / alarm-fire) so live studios reflect it
803
- * immediately. A no-op when the runtime doesn't support hibernated sockets.
802
+ * Re-list the jobs (bounded see {@link listRecords}) and push them to
803
+ * every connected subscriber. Called after any change (schedule / cancel /
804
+ * alarm-fire) so live studios reflect it immediately. A no-op when the
805
+ * runtime doesn't support hibernated sockets.
804
806
  */
805
807
  private broadcastChange;
806
- /** The current pending job records (shared by `/list` and the live channel). */
808
+ /**
809
+ * The current pending job records (shared by `/list` and the live channel),
810
+ * bounded to `limit` (default {@link DEFAULT_LIST_LIMIT}) so a large backlog
811
+ * can't be JSON-serialized and fanned out to every socket in one shot. Lists
812
+ * `limit + 1` and slices back down so `truncated` reflects whether there was
813
+ * a next row, without a second round-trip.
814
+ */
807
815
  private listRecords;
816
+ /**
817
+ * Page through every `id:` header exactly once with bounded per-page memory
818
+ * (a `limit`+`startAfter` cursor loop), invoking `visit` for each record.
819
+ * Unlike {@link listRecords}, which intentionally truncates for the studio's
820
+ * live view, `/status` and `/pool` need EXACT counts — this walks the full
821
+ * set, but never materializes more than one page at a time.
822
+ */
823
+ private countHeaders;
808
824
  /**
809
825
  * HMAC-SHA-256 sign the dispatch body with `env.LUNORA_SCHEDULER_SECRET`,
810
826
  * returning a base64url signature, or `undefined` when no secret is
@@ -851,7 +867,7 @@ declare class SchedulerDO {
851
867
  * a saturated-but-idle pool stays visible; a pool that only ever existed as
852
868
  * queued jobs without a persisted row is unreachable here (the schedule path
853
869
  * always writes a `pool:&lt;name>` row before the job's header), so a single
854
- * scan over `pool:`/`id:` is sufficient.
870
+ * scan over `pool:` plus a cursor loop over `id:` is sufficient.
855
871
  */
856
872
  private handleStatus;
857
873
  private handleSchedule;
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{default as o}from"./packem_shared/createScheduler-CsRAEtdb.mjs";import{default as a}from"./packem_shared/createWorkpool-DyVdhB6o.mjs";import{createCronTrigger as c}from"./packem_shared/createCronTrigger-C4iUW_zz.mjs";import{CRON_SCHEDULE_KINDS as f,compileCronSchedule as l,cronJobs as n}from"./packem_shared/CRON_SCHEDULE_KINDS-DMu4DfhG.mjs";import{createQueueConsumer as u,createQueueWorkpool as x,httpDispatcher as d}from"./packem_shared/createQueueConsumer-B0qNXRUJ.mjs";import{SchedulerDO as C}from"./packem_shared/SchedulerDO-BAq6x7mo.mjs";import{createSchedulerHost as h}from"./packem_shared/createSchedulerHost-C5XacwXv.mjs";import{isWorkflowReference as E}from"./packem_shared/isWorkflowReference-CT3tdefh.mjs";import{assertValidCronExpression as k,isValidCronExpression as W,warnIfSecondsLeading as w}from"./packem_shared/assertValidCronExpression-B2lN8n3O.mjs";export{f as CRON_SCHEDULE_KINDS,C as SchedulerDO,k as assertValidCronExpression,l as compileCronSchedule,c as createCronTrigger,u as createQueueConsumer,x as createQueueWorkpool,o as createScheduler,h as createSchedulerHost,a as createWorkpool,n as cronJobs,d as httpDispatcher,W as isValidCronExpression,E as isWorkflowReference,w as warnIfSecondsLeading};
1
+ import{default as o}from"./packem_shared/createScheduler-CsRAEtdb.mjs";import{default as a}from"./packem_shared/createWorkpool-DyVdhB6o.mjs";import{createCronTrigger as c}from"./packem_shared/createCronTrigger-C4iUW_zz.mjs";import{CRON_SCHEDULE_KINDS as f,compileCronSchedule as l,cronJobs as n}from"./packem_shared/CRON_SCHEDULE_KINDS-DMu4DfhG.mjs";import{createQueueConsumer as u,createQueueWorkpool as x,httpDispatcher as d}from"./packem_shared/createQueueConsumer-BnX7wkJO.mjs";import{SchedulerDO as C}from"./packem_shared/SchedulerDO-CCP9LV5x.mjs";import{createSchedulerHost as h}from"./packem_shared/createSchedulerHost-C5XacwXv.mjs";import{isWorkflowReference as E}from"./packem_shared/isWorkflowReference-CT3tdefh.mjs";import{assertValidCronExpression as k,isValidCronExpression as W,warnIfSecondsLeading as w}from"./packem_shared/assertValidCronExpression-B2lN8n3O.mjs";export{f as CRON_SCHEDULE_KINDS,C as SchedulerDO,k as assertValidCronExpression,l as compileCronSchedule,c as createCronTrigger,u as createQueueConsumer,x as createQueueWorkpool,o as createScheduler,h as createSchedulerHost,a as createWorkpool,n as cronJobs,d as httpDispatcher,W as isValidCronExpression,E as isWorkflowReference,w as warnIfSecondsLeading};
@@ -0,0 +1 @@
1
+ const f=(u,t=200,e)=>Response.json(u,{headers:{"content-type":"application/json",...e},status:t}),w="lunora-ping",b="lunora-pong";const p="retry:",g="dead:",m="pool:";const y=u=>String(u).padStart(15,"0"),v=()=>{const u=crypto.getRandomValues(new Uint8Array(12));let t="";for(const e of u)t+=String.fromCodePoint(e);return btoa(t).replaceAll("+","-").replaceAll("/","_").replaceAll("=","")};class r{static indexKey(t,e){return`t:${y(t)}:${e}`}static json(t,e=200){return f(t,e)}static error(t,e,s){return r.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:5,a=typeof e?.baseMs=="number"&&Number.isFinite(e.baseMs)&&e.baseMs>=0?e.baseMs:3e4,n=e?.backoff==="linear"?"linear":"exponential",i=typeof e?.maxMs=="number"&&Number.isFinite(e.maxMs)&&e.maxMs>=0?e.maxMs:void 0;return{backoff:n,baseMs:a,maxAttempts:s,maxMs:i}}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;constructor(t,e){this.state=t,this.env=e,this.armWebSocketKeepalive()}async fetch(t){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();case"GET /get":return this.handleGet(e);case"GET /list":return this.handleList();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 f({error:{code:"NOT_FOUND"}},404)}async alarm(){const t=Date.now(),e=[],s=await this.state.storage.list({end:`t:${y(t)}:~`,limit:100,prefix:"t:"});for(const[a,n]of s.entries()){const i=Number.parseInt(a.slice(2,a.indexOf(":",2)),10);if(Number.isFinite(i)&&i<=t){const o=await this.state.storage.get(`id:${n}`);o?e.push(o):await this.state.storage.delete(a)}}try{for(const a of e)await this.drainRecordGuarded(a)}finally{await this.rescheduleAlarm()}e.length>0&&await this.broadcastChange()}async dispatch(t){const e=typeof this.env.LUNORA_ORIGIN_URL=="string"&&this.env.LUNORA_ORIGIN_URL.length>0?this.env.LUNORA_ORIGIN_URL:void 0;if(!e)return!1;const s=JSON.stringify({args:t.args,functionPath:t.functionPath,id:t.id,instanceName:t.instanceName,pool:t.pool,scheduledFor:t.scheduledFor,shardKey:t.shardKey,workflow:t.workflow});try{const a={"content-type":"application/json"},n=await this.signDispatch(s);return n!==void 0?a["x-lunora-scheduler-signature"]=n:typeof this.env.LUNORA_ADMIN_TOKEN=="string"&&this.env.LUNORA_ADMIN_TOKEN.length>0&&(a.authorization=`Bearer ${this.env.LUNORA_ADMIN_TOKEN}`),(await fetch(`${e}/_lunora/scheduler/dispatch`,{body:s,headers:a,method:"POST"})).ok}catch{return!1}}armWebSocketKeepalive(){const t=this.state.setWebSocketAutoResponse;typeof t!="function"||typeof WebSocketRequestResponsePair>"u"||t.call(this.state,new WebSocketRequestResponsePair(w,b))}async drainRecordGuarded(t){try{await this.state.storage.delete(r.indexKey(t.scheduledFor,t.id)),await this.drainRecord(t)}catch{try{await this.state.storage.put(r.indexKey(t.scheduledFor,t.id),t.id)}catch{}}}async drainRecord(t){if(!await this.reservePoolSlot(t))return!1;const e=await this.dispatch(t);if(!e&&t.pool!==void 0){const s=await this.loadPool(t.pool),a=r.releaseSlot(s,t.id);await this.savePool(t.pool,a)}if(e){try{await this.state.storage.delete([`id:${t.id}`,`${p}${t.id}`])}catch{}return!0}return await this.recordRetry(t),!1}async reservePoolSlot(t){if(t.pool===void 0)return!0;const e=await this.loadPool(t.pool);if(e.inFlight>=e.maxConcurrency)return await this.requeuePooled(t),!1;const s=e.inFlightIds??[];return s.includes(t.id)||s.push(t.id),e.inFlightIds=s,e.inFlight=s.length,await this.savePool(t.pool,e),!0}async handleWebSocketUpgrade(){if(this.state.acceptWebSocket===void 0)return r.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.listRecords();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.listRecords(),a=JSON.stringify({records:e,truncated:s,type:"jobs"});for(const n of t)try{n.send(a)}catch{}}async listRecords(t=100){const e=[...(await this.state.storage.list({limit:t+1,prefix:"id:"})).values()],s=e.length>t;return{records:s?e.slice(0,t):e,truncated:s}}async countHeaders(t,e=100){let s;for(;;){const a=await this.state.storage.list(s===void 0?{limit:e,prefix:"id:"}:{limit:e,prefix:"id:",startAfter:s});if(a.size===0)break;for(const n of a.values())t(n);if(s=[...a.keys()].at(-1),a.size<e)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)),i=new Uint8Array(n);let o="";for(const d of i)o+=String.fromCodePoint(d);return btoa(o).replaceAll("+","-").replaceAll("/","_").replaceAll("=","")}async recordRetry(t){const e=(t.attempts??0)+1,{backoff:s,baseMs:a,maxAttempts:n,maxMs:i}=r.resolveRetry(t);if(e>n){await this.state.storage.put(`${g}${t.id}`,{...t,attempts:e}),await this.state.storage.delete([`${p}${t.id}`,`id:${t.id}`]),console.warn(`@lunora/scheduler: job "${t.id}" (${t.functionPath??t.workflow??"unknown"}) parked in dead-letter after ${String(e)} attempts`);return}const o=s==="linear"?a*e:a*2**(e-1),d=i===void 0?o:Math.min(o,i),c=Date.now()+d,l={...t,attempts:e,scheduledFor:c};await this.state.storage.put(`${p}${t.id}`,l),await this.state.storage.put(`id:${t.id}`,l),await this.state.storage.put(r.indexKey(c,t.id),t.id)}async loadPool(t,e){const s=await this.state.storage.get(`${m}${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:r.normalizeConcurrency(e,1)}}async savePool(t,e){await this.state.storage.put(`${m}${t}`,e)}async requeuePooled(t){const e=Date.now()+1e3,s={...t,scheduledFor:e};await this.state.storage.put(`id:${t.id}`,s),await this.state.storage.put(r.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 r.error(400,"INVALID_INPUT","pool is required");const n=await this.loadPool(s),i=a===void 0?r.releaseFirstSlot(n):r.releaseSlot(n,a);return await this.savePool(s,i),await this.armAlarmIfEarlier(Date.now()),r.json({inFlight:i.inFlight})}async handlePoolStatus(t){const e=t.searchParams.get("name");if(e===null||e.length===0)return r.error(400,"INVALID_INPUT","name is required");const s=await this.loadPool(e);let a=0;return await this.countHeaders(n=>{n.pool===e&&(a+=1)}),r.json({inFlight:s.inFlight,maxConcurrency:s.maxConcurrency,queued:a})}async handleStatus(){const t=await this.state.storage.list({prefix:m}),e=new Map;await this.countHeaders(o=>{o.pool!==void 0&&e.set(o.pool,(e.get(o.pool)??0)+1)});const s=[];let a=0,n=0;for(const[o,d]of t.entries()){const c=o.slice(m.length),l=Math.max(0,d.inFlight),h=e.get(c)??0;s.push({inFlight:l,maxConcurrency:d.maxConcurrency,name:c,queued:h}),a+=h,n+=l}const i={backlog:a,inFlight:n,pools:s};return r.json(i)}async handleSchedule(t){const e=await t.json().catch(()=>{}),s=r.resolveScheduleTarget(e);if(!e||s===void 0)return r.error(400,"INVALID_INPUT","functionPath or workflow is required");const{functionPath:a,workflow:n}=s;if(typeof e.scheduledFor!="number"||!Number.isInteger(e.scheduledFor)||e.scheduledFor<=0||e.scheduledFor>999999999999999)return r.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 r.error(500,"ORIGIN_NOT_CONFIGURED","LUNORA_ORIGIN_URL env binding must be set on the SchedulerDO");const i=typeof e.pool=="string"&&e.pool.length>0?e.pool:void 0,o=typeof e.instanceName=="string"&&e.instanceName.length>0?e.instanceName:void 0,d=r.normalizeRetry(e.retry),c=v(),l={args:e.args??{},enqueuedAt:Date.now(),id:c,...a===void 0?{}:{functionPath:a},...o===void 0?{}:{instanceName:o},...i===void 0?{}:{pool:i},...d===void 0?{}:{retry:d},scheduledFor:e.scheduledFor,shardKey:e.shardKey,...n===void 0?{}:{workflow:n}};if(i!==void 0){const h=await this.loadPool(i,e.maxConcurrency);await this.savePool(i,{inFlight:h.inFlight,...h.inFlightIds===void 0?{}:{inFlightIds:h.inFlightIds},maxConcurrency:r.normalizeConcurrency(e.maxConcurrency,h.maxConcurrency)})}return await this.state.storage.put(`id:${c}`,l),await this.state.storage.put(r.indexKey(l.scheduledFor,c),c),await this.armAlarmIfEarlier(l.scheduledFor),await this.broadcastChange(),r.json({id:c,scheduledFor:l.scheduledFor})}async handleCancel(t){const e=await t.json().catch(()=>{});if(!e?.id)return r.error(400,"INVALID_INPUT","id is required");const s=await this.state.storage.get(`id:${e.id}`);return s?(await this.removeRecord(s),await this.rescheduleAlarm(),await this.broadcastChange(),r.json({cancelled:!0})):r.json({cancelled:!1})}async handleList(){const{records:t,truncated:e}=await this.listRecords();return r.json({records:t,truncated:e})}async handleDeadList(){const t=await this.state.storage.list({prefix:g});return r.json({records:[...t.values()]})}async handleDeadRetry(t){const e=await t.json().catch(()=>{});if(typeof e?.id!="string"||e.id.length===0)return r.error(400,"INVALID_INPUT","id is required");const s=await this.state.storage.get(`${g}${e.id}`);if(s===void 0)return r.json({retried:!1});const a=Date.now(),n={...s,attempts:0,scheduledFor:a};return await this.state.storage.put(`id:${s.id}`,n),await this.state.storage.put(r.indexKey(a,s.id),s.id),await this.state.storage.delete(`${g}${s.id}`),await this.armAlarmIfEarlier(a),await this.broadcastChange(),r.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 r.error(400,"INVALID_INPUT","id is required");const s=await this.state.storage.delete(`${g}${e.id}`);return r.json({removed:!!s})}async handleGet(t){const e=t.searchParams.get("id");if(e===null||e.length===0)return r.error(400,"INVALID_INPUT","id is required");const s=await this.state.storage.get(`id:${e}`);return r.json(s===void 0?{}:{record:s})}async removeRecord(t){await this.state.storage.delete([`id:${t.id}`,r.indexKey(t.scheduledFor,t.id),`${p}${t.id}`])}async armAlarmIfEarlier(t){const e=await this.state.storage.getAlarm();(e===null||t<e)&&await this.state.storage.setAlarm(t)}async rescheduleAlarm(){const t=(await this.state.storage.list({limit:1,prefix:"t:"})).entries().next();if(t.done){await this.state.storage.deleteAlarm();return}const[e]=t.value,s=Number.parseInt(e.slice(2,e.indexOf(":",2)),10);Number.isFinite(s)&&await this.state.storage.setAlarm(s)}}export{r as SchedulerDO};
@@ -0,0 +1 @@
1
+ import{LunoraError as o}from"@lunora/errors";const s=100,i=e=>{let a=e.length;for(;a>0&&e[a-1]==="/";)a-=1;return e.slice(0,a)},l=e=>{if(!e.queue)throw new o("INTERNAL","@lunora/scheduler: `queue` (a Cloudflare Queue binding) is required");return{enqueue:async(a,t,n={})=>{const r={args:t,functionPath:a.__lunoraRef,shardKey:n.shardKey},u=n.delaySeconds===void 0?void 0:{delaySeconds:n.delaySeconds};await e.queue.send(r,u)},enqueueBatch:async(a,t)=>{if(a.length>s)throw new o("INTERNAL",`@lunora/scheduler: enqueueBatch exceeds ${String(s)} (got ${String(a.length)}) — split across calls`);const n=a.map(r=>({body:{args:r.args,functionPath:r.ref.__lunoraRef,shardKey:r.shardKey}}));await e.queue.sendBatch(n,t)}}},c=e=>typeof e=="object"&&e!==null&&typeof e.functionPath=="string",d=e=>async a=>{await Promise.all(a.messages.map(async t=>{try{if(!c(t.body))throw new o("INTERNAL","@lunora/scheduler: queue message body is not a QueueJob (missing functionPath)");await e.dispatch(t.body),t.ack()}catch{t.retry()}}))},f=e=>{const a=e.fetchImpl??globalThis.fetch;if(typeof a!="function")throw new TypeError("@lunora/scheduler: no fetch implementation available — pass fetchImpl or run on a platform with global fetch");const t=`${i(e.originUrl)}/_lunora/scheduler/dispatch`;return async n=>{const r=await a(t,{body:JSON.stringify({args:n.args??{},functionPath:n.functionPath,shardKey:n.shardKey}),headers:{authorization:`Bearer ${e.adminToken}`,"content-type":"application/json"},method:"POST"});if(!r.ok)throw new o("INTERNAL",`@lunora/scheduler: queue dispatch failed (${r.status.toString()}): ${await r.text()}`)}};export{d as createQueueConsumer,l as createQueueWorkpool,f as httpDispatcher};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/scheduler",
3
- "version": "1.0.0-alpha.17",
3
+ "version": "1.0.0-alpha.18",
4
4
  "description": "Scheduling for Lunora: runAfter / runAt and Cron Triggers via SchedulerDO",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -46,8 +46,8 @@
46
46
  "access": "public"
47
47
  },
48
48
  "dependencies": {
49
- "@lunora/errors": "1.0.0-alpha.11",
50
- "@lunora/platform": "1.0.0-alpha.2",
49
+ "@lunora/errors": "1.0.0-alpha.12",
50
+ "@lunora/platform": "1.0.0-alpha.3",
51
51
  "cron-parser": "5.6.2"
52
52
  },
53
53
  "engines": {
@@ -1 +0,0 @@
1
- const y=(u,t=200,e)=>Response.json(u,{headers:{"content-type":"application/json",...e},status:t}),b="lunora-ping",v="lunora-pong";const p="retry:",g="dead:",m="pool:";const w=u=>String(u).padStart(15,"0"),I=()=>{const u=crypto.getRandomValues(new Uint8Array(12));let t="";for(const e of u)t+=String.fromCodePoint(e);return btoa(t).replaceAll("+","-").replaceAll("/","_").replaceAll("=","")};class i{static indexKey(t,e){return`t:${w(t)}:${e}`}static json(t,e=200){return y(t,e)}static error(t,e,a){return i.json({error:{code:e,message:a}},t)}static resolveRetry(t){const e=t.retry,a=typeof e?.maxAttempts=="number"&&Number.isInteger(e.maxAttempts)&&e.maxAttempts>0?e.maxAttempts:5,r=typeof e?.baseMs=="number"&&Number.isFinite(e.baseMs)&&e.baseMs>=0?e.baseMs:3e4,s=e?.backoff==="linear"?"linear":"exponential",n=typeof e?.maxMs=="number"&&Number.isFinite(e.maxMs)&&e.maxMs>=0?e.maxMs:void 0;return{backoff:s,baseMs:r,maxAttempts:a,maxMs:n}}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,a={};return typeof e.maxAttempts=="number"&&Number.isInteger(e.maxAttempts)&&e.maxAttempts>0&&(a.maxAttempts=e.maxAttempts),typeof e.baseMs=="number"&&Number.isFinite(e.baseMs)&&e.baseMs>=0&&(a.baseMs=e.baseMs),(e.backoff==="exponential"||e.backoff==="linear")&&(a.backoff=e.backoff),typeof e.maxMs=="number"&&Number.isFinite(e.maxMs)&&e.maxMs>=0&&(a.maxMs=e.maxMs),Object.keys(a).length===0?void 0:a}static releaseSlot(t,e){if(t.inFlightIds===void 0)return{...t,inFlight:Math.max(0,t.inFlight-1)};const a=t.inFlightIds.filter(r=>r!==e);return{...t,inFlight:a.length,inFlightIds:a}}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,a=typeof t?.workflow=="string"&&t.workflow.length>0?t.workflow:void 0;if(!(e===void 0&&a===void 0))return{functionPath:e,workflow:a}}state;env;constructor(t,e){this.state=t,this.env=e,this.armWebSocketKeepalive()}async fetch(t){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();case"GET /get":return this.handleGet(e);case"GET /list":return this.handleList();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 y({error:{code:"NOT_FOUND"}},404)}async alarm(){const t=Date.now(),e=[],a=await this.state.storage.list({end:`t:${w(t)}:~`,limit:100,prefix:"t:"});for(const[r,s]of a.entries()){const n=Number.parseInt(r.slice(2,r.indexOf(":",2)),10);if(Number.isFinite(n)&&n<=t){const o=await this.state.storage.get(`id:${s}`);o?e.push(o):await this.state.storage.delete(r)}}try{for(const r of e)await this.drainRecordGuarded(r)}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 a=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 r={"content-type":"application/json"},s=await this.signDispatch(a);return s!==void 0?r["x-lunora-scheduler-signature"]=s:typeof this.env.LUNORA_ADMIN_TOKEN=="string"&&this.env.LUNORA_ADMIN_TOKEN.length>0&&(r.authorization=`Bearer ${this.env.LUNORA_ADMIN_TOKEN}`),(await fetch(`${e}/_lunora/scheduler/dispatch`,{body:a,headers:r,method:"POST"})).ok}catch{return!1}}armWebSocketKeepalive(){const t=this.state.setWebSocketAutoResponse;typeof t!="function"||typeof WebSocketRequestResponsePair>"u"||t.call(this.state,new WebSocketRequestResponsePair(b,v))}async drainRecordGuarded(t){try{await this.state.storage.delete(i.indexKey(t.scheduledFor,t.id)),await this.drainRecord(t)}catch{try{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 e=await this.dispatch(t);if(!e&&t.pool!==void 0){const a=await this.loadPool(t.pool),r=i.releaseSlot(a,t.id);await this.savePool(t.pool,r)}if(e){try{await this.state.storage.delete([`id:${t.id}`,`${p}${t.id}`])}catch{}return!0}return await this.recordRetry(t),!1}async reservePoolSlot(t){if(t.pool===void 0)return!0;const e=await this.loadPool(t.pool);if(e.inFlight>=e.maxConcurrency)return await this.requeuePooled(t),!1;const a=e.inFlightIds??[];return a.includes(t.id)||a.push(t.id),e.inFlightIds=a,e.inFlight=a.length,await this.savePool(t.pool,e),!0}async handleWebSocketUpgrade(){if(this.state.acceptWebSocket===void 0)return i.error(501,"WS_UNSUPPORTED","WebSocket subscriptions are not supported in this runtime");const t=new WebSocketPair,e=t[0],a=t[1];return this.state.acceptWebSocket(a),a.send(JSON.stringify({records:await this.listRecords(),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 e=JSON.stringify({records:await this.listRecords(),type:"jobs"});for(const a of t)try{a.send(e)}catch{}}async listRecords(){return[...(await this.state.storage.list({prefix:"id:"})).values()]}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 a=new TextEncoder,r=await crypto.subtle.importKey("raw",a.encode(e),{hash:"SHA-256",name:"HMAC"},!1,["sign"]),s=await crypto.subtle.sign("HMAC",r,a.encode(t)),n=new Uint8Array(s);let o="";for(const l of n)o+=String.fromCodePoint(l);return btoa(o).replaceAll("+","-").replaceAll("/","_").replaceAll("=","")}async recordRetry(t){const e=(t.attempts??0)+1,{backoff:a,baseMs:r,maxAttempts:s,maxMs:n}=i.resolveRetry(t);if(e>s){await this.state.storage.put(`${g}${t.id}`,{...t,attempts:e}),await this.state.storage.delete([`${p}${t.id}`,`id:${t.id}`]),console.warn(`@lunora/scheduler: job "${t.id}" (${t.functionPath??t.workflow??"unknown"}) parked in dead-letter after ${String(e)} attempts`);return}const o=a==="linear"?r*e:r*2**(e-1),l=n===void 0?o:Math.min(o,n),c=Date.now()+l,d={...t,attempts:e,scheduledFor:c};await this.state.storage.put(`${p}${t.id}`,d),await this.state.storage.put(`id:${t.id}`,d),await this.state.storage.put(i.indexKey(c,t.id),t.id)}async loadPool(t,e){const a=await this.state.storage.get(`${m}${t}`);return a!==void 0?Array.isArray(a.inFlightIds)?{inFlight:a.inFlightIds.length,inFlightIds:[...a.inFlightIds],maxConcurrency:a.maxConcurrency}:{inFlight:Math.max(0,a.inFlight),maxConcurrency:a.maxConcurrency}:{inFlight:0,inFlightIds:[],maxConcurrency:i.normalizeConcurrency(e,1)}}async savePool(t,e){await this.state.storage.put(`${m}${t}`,e)}async requeuePooled(t){const e=Date.now()+1e3,a={...t,scheduledFor:e};await this.state.storage.put(`id:${t.id}`,a),await this.state.storage.put(i.indexKey(e,t.id),t.id)}async handleComplete(t){const e=await t.json().catch(()=>{}),a=typeof e?.pool=="string"&&e.pool.length>0?e.pool:void 0,r=typeof e?.id=="string"&&e.id.length>0?e.id:void 0;if(a===void 0)return i.error(400,"INVALID_INPUT","pool is required");const s=await this.loadPool(a),n=r===void 0?i.releaseFirstSlot(s):i.releaseSlot(s,r);return await this.savePool(a,n),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 a=await this.loadPool(e),r=await this.state.storage.list({prefix:"id:"});let s=0;for(const n of r.values())n.pool===e&&(s+=1);return i.json({inFlight:a.inFlight,maxConcurrency:a.maxConcurrency,queued:s})}async handleStatus(){const t=await this.state.storage.list({prefix:m}),e=await this.state.storage.list({prefix:"id:"}),a=new Map;for(const l of e.values())l.pool!==void 0&&a.set(l.pool,(a.get(l.pool)??0)+1);const r=[];let s=0,n=0;for(const[l,c]of t.entries()){const d=l.slice(m.length),h=Math.max(0,c.inFlight),f=a.get(d)??0;r.push({inFlight:h,maxConcurrency:c.maxConcurrency,name:d,queued:f}),s+=f,n+=h}const o={backlog:s,inFlight:n,pools:r};return i.json(o)}async handleSchedule(t){const e=await t.json().catch(()=>{}),a=i.resolveScheduleTarget(e);if(!e||a===void 0)return i.error(400,"INVALID_INPUT","functionPath or workflow is required");const{functionPath:r,workflow:s}=a;if(typeof e.scheduledFor!="number"||!Number.isInteger(e.scheduledFor)||e.scheduledFor<=0||e.scheduledFor>999999999999999)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 n=typeof e.pool=="string"&&e.pool.length>0?e.pool:void 0,o=typeof e.instanceName=="string"&&e.instanceName.length>0?e.instanceName:void 0,l=i.normalizeRetry(e.retry),c=I(),d={args:e.args??{},enqueuedAt:Date.now(),id:c,...r===void 0?{}:{functionPath:r},...o===void 0?{}:{instanceName:o},...n===void 0?{}:{pool:n},...l===void 0?{}:{retry:l},scheduledFor:e.scheduledFor,shardKey:e.shardKey,...s===void 0?{}:{workflow:s}};if(n!==void 0){const h=await this.loadPool(n,e.maxConcurrency);await this.savePool(n,{inFlight:h.inFlight,...h.inFlightIds===void 0?{}:{inFlightIds:h.inFlightIds},maxConcurrency:i.normalizeConcurrency(e.maxConcurrency,h.maxConcurrency)})}return await this.state.storage.put(`id:${c}`,d),await this.state.storage.put(i.indexKey(d.scheduledFor,c),c),await this.armAlarmIfEarlier(d.scheduledFor),await this.broadcastChange(),i.json({id:c,scheduledFor:d.scheduledFor})}async handleCancel(t){const e=await t.json().catch(()=>{});if(!e?.id)return i.error(400,"INVALID_INPUT","id is required");const a=await this.state.storage.get(`id:${e.id}`);return a?(await this.removeRecord(a),await this.rescheduleAlarm(),await this.broadcastChange(),i.json({cancelled:!0})):i.json({cancelled:!1})}async handleList(){return i.json({records:await this.listRecords()})}async handleDeadList(){const t=await this.state.storage.list({prefix:g});return i.json({records:[...t.values()]})}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 a=await this.state.storage.get(`${g}${e.id}`);if(a===void 0)return i.json({retried:!1});const r=Date.now(),s={...a,attempts:0,scheduledFor:r};return await this.state.storage.put(`id:${a.id}`,s),await this.state.storage.put(i.indexKey(r,a.id),a.id),await this.state.storage.delete(`${g}${a.id}`),await this.armAlarmIfEarlier(r),await this.broadcastChange(),i.json({id:a.id,retried:!0,scheduledFor:r})}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 a=await this.state.storage.delete(`${g}${e.id}`);return i.json({removed:!!a})}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 a=await this.state.storage.get(`id:${e}`);return i.json(a===void 0?{}:{record:a})}async removeRecord(t){await this.state.storage.delete([`id:${t.id}`,i.indexKey(t.scheduledFor,t.id),`${p}${t.id}`])}async armAlarmIfEarlier(t){const e=await this.state.storage.getAlarm();(e===null||t<e)&&await this.state.storage.setAlarm(t)}async rescheduleAlarm(){const t=(await this.state.storage.list({limit:1,prefix:"t:"})).entries().next();if(t.done){await this.state.storage.deleteAlarm();return}const[e]=t.value,a=Number.parseInt(e.slice(2,e.indexOf(":",2)),10);Number.isFinite(a)&&await this.state.storage.setAlarm(a)}}export{i as SchedulerDO};
@@ -1 +0,0 @@
1
- import{LunoraError as r}from"@lunora/errors";const u=e=>{let a=e.length;for(;a>0&&e[a-1]==="/";)a-=1;return e.slice(0,a)},h=e=>{if(!e.queue)throw new r("INTERNAL","@lunora/scheduler: `queue` (a Cloudflare Queue binding) is required");return{enqueue:async(a,t,n={})=>{const o={args:t,functionPath:a.__lunoraRef,shardKey:n.shardKey},s=n.delaySeconds===void 0?void 0:{delaySeconds:n.delaySeconds};await e.queue.send(o,s)},enqueueBatch:async(a,t)=>{const n=a.map(o=>({body:{args:o.args,functionPath:o.ref.__lunoraRef,shardKey:o.shardKey}}));await e.queue.sendBatch(n,t)}}},i=e=>typeof e=="object"&&e!==null&&typeof e.functionPath=="string",l=e=>async a=>{await Promise.all(a.messages.map(async t=>{try{if(!i(t.body))throw new r("INTERNAL","@lunora/scheduler: queue message body is not a QueueJob (missing functionPath)");await e.dispatch(t.body),t.ack()}catch{t.retry()}}))},d=e=>{const a=e.fetchImpl??globalThis.fetch;if(typeof a!="function")throw new TypeError("@lunora/scheduler: no fetch implementation available — pass fetchImpl or run on a platform with global fetch");const t=`${u(e.originUrl)}/_lunora/scheduler/dispatch`;return async n=>{const o=await a(t,{body:JSON.stringify({args:n.args??{},functionPath:n.functionPath,shardKey:n.shardKey}),headers:{authorization:`Bearer ${e.adminToken}`,"content-type":"application/json"},method:"POST"});if(!o.ok)throw new r("INTERNAL",`@lunora/scheduler: queue dispatch failed (${o.status.toString()}): ${await o.text()}`)}};export{l as createQueueConsumer,h as createQueueWorkpool,d as httpDispatcher};