@lunora/scheduler 1.0.0-alpha.32 → 1.0.0-alpha.34

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
@@ -614,6 +614,21 @@ interface SchedulerEnv {
614
614
  */
615
615
  LUNORA_SCHEDULER_SECRET?: string;
616
616
  }
617
+ /**
618
+ * Retries allowed after the original attempt before {@link SchedulerDO.recordRetry}
619
+ * parks a record in the dead-letter (`dead:`) prefix. Overridable per job via
620
+ * {@link RetryPolicy.maxAttempts}. Exported so test doubles of the scheduler
621
+ * (`@lunora/testing`'s fake scheduler) model the same budget instead of
622
+ * duplicating the number.
623
+ */
624
+ declare const MAX_RETRY_ATTEMPTS = 5;
625
+ /**
626
+ * Backoff before the first retry, in milliseconds; doubles on each subsequent
627
+ * retry under the default `"exponential"` backoff (`baseMs * 2 ** (attempts - 1)`).
628
+ * Overridable per job via {@link RetryPolicy.baseMs}. Exported alongside
629
+ * {@link MAX_RETRY_ATTEMPTS} for the same reason.
630
+ */
631
+ declare const RETRY_BASE_DELAY_MS = 3e4;
617
632
  /**
618
633
  * One pool's live backlog, as surfaced by `GET /status`. `inFlight`/
619
634
  * `maxConcurrency` mirror the durable {@link PoolState} semaphore; `queued`
@@ -966,4 +981,4 @@ declare const warnIfSecondsLeading: (schedule: string, context: string) => void;
966
981
  * {@link warnIfSecondsLeading}.
967
982
  */
968
983
  declare const assertValidCronExpression: (schedule: string, context?: string) => void;
969
- export { type ArgsOf, CRON_SCHEDULE_KINDS, type CronJob, type CronJobsBuilder, type CronScheduleKind, type CronTarget, type CronTriggerOptions, type CronTriggerSnippet, type DailySchedule, type DurableObjectIdLike, type DurableObjectJurisdiction, type DurableObjectNamespaceLike, type DurableObjectStubLike, type EnqueueOptions, type FunctionReference, type HourlySchedule, type HttpDispatcherOptions, type IntervalSchedule, type LunoraSchedulerOptions, type MessageBatchLike, type MonthlySchedule, type QueueConsumerOptions, type QueueDispatch, type QueueEnqueueOptions, type QueueJob, type QueueLike, type QueueMessageLike, type QueueSendOptionsLike, type QueueSendRequestLike, type QueueWorkpool, type QueueWorkpoolOptions, type RetryPolicy, type RunOptions, type ScheduleRecord, type Scheduler, SchedulerDO, type SchedulerDOState, type SchedulerEnv, type SchedulerHostOptions, type SchedulerPoolStatus, type SchedulerStatus, type WeeklySchedule, type WorkflowReference, type Workpool, type WorkpoolOptions, assertValidCronExpression, compileCronSchedule, createCronTrigger, createQueueConsumer, createQueueWorkpool, createScheduler, createSchedulerHost, createWorkpool, cronJobs, httpDispatcher, isValidCronExpression, isWorkflowReference, warnIfSecondsLeading };
984
+ export { type ArgsOf, CRON_SCHEDULE_KINDS, type CronJob, type CronJobsBuilder, type CronScheduleKind, type CronTarget, type CronTriggerOptions, type CronTriggerSnippet, type DailySchedule, type DurableObjectIdLike, type DurableObjectJurisdiction, type DurableObjectNamespaceLike, type DurableObjectStubLike, type EnqueueOptions, type FunctionReference, type HourlySchedule, type HttpDispatcherOptions, type IntervalSchedule, type LunoraSchedulerOptions, MAX_RETRY_ATTEMPTS, type MessageBatchLike, type MonthlySchedule, type QueueConsumerOptions, type QueueDispatch, type QueueEnqueueOptions, type QueueJob, type QueueLike, type QueueMessageLike, type QueueSendOptionsLike, type QueueSendRequestLike, type QueueWorkpool, type QueueWorkpoolOptions, RETRY_BASE_DELAY_MS, type RetryPolicy, type RunOptions, type ScheduleRecord, type Scheduler, SchedulerDO, type SchedulerDOState, type SchedulerEnv, type SchedulerHostOptions, type SchedulerPoolStatus, type SchedulerStatus, type WeeklySchedule, type WorkflowReference, type Workpool, type WorkpoolOptions, assertValidCronExpression, compileCronSchedule, createCronTrigger, createQueueConsumer, createQueueWorkpool, createScheduler, createSchedulerHost, createWorkpool, cronJobs, httpDispatcher, isValidCronExpression, isWorkflowReference, warnIfSecondsLeading };
package/dist/index.d.ts CHANGED
@@ -614,6 +614,21 @@ interface SchedulerEnv {
614
614
  */
615
615
  LUNORA_SCHEDULER_SECRET?: string;
616
616
  }
617
+ /**
618
+ * Retries allowed after the original attempt before {@link SchedulerDO.recordRetry}
619
+ * parks a record in the dead-letter (`dead:`) prefix. Overridable per job via
620
+ * {@link RetryPolicy.maxAttempts}. Exported so test doubles of the scheduler
621
+ * (`@lunora/testing`'s fake scheduler) model the same budget instead of
622
+ * duplicating the number.
623
+ */
624
+ declare const MAX_RETRY_ATTEMPTS = 5;
625
+ /**
626
+ * Backoff before the first retry, in milliseconds; doubles on each subsequent
627
+ * retry under the default `"exponential"` backoff (`baseMs * 2 ** (attempts - 1)`).
628
+ * Overridable per job via {@link RetryPolicy.baseMs}. Exported alongside
629
+ * {@link MAX_RETRY_ATTEMPTS} for the same reason.
630
+ */
631
+ declare const RETRY_BASE_DELAY_MS = 3e4;
617
632
  /**
618
633
  * One pool's live backlog, as surfaced by `GET /status`. `inFlight`/
619
634
  * `maxConcurrency` mirror the durable {@link PoolState} semaphore; `queued`
@@ -966,4 +981,4 @@ declare const warnIfSecondsLeading: (schedule: string, context: string) => void;
966
981
  * {@link warnIfSecondsLeading}.
967
982
  */
968
983
  declare const assertValidCronExpression: (schedule: string, context?: string) => void;
969
- export { type ArgsOf, CRON_SCHEDULE_KINDS, type CronJob, type CronJobsBuilder, type CronScheduleKind, type CronTarget, type CronTriggerOptions, type CronTriggerSnippet, type DailySchedule, type DurableObjectIdLike, type DurableObjectJurisdiction, type DurableObjectNamespaceLike, type DurableObjectStubLike, type EnqueueOptions, type FunctionReference, type HourlySchedule, type HttpDispatcherOptions, type IntervalSchedule, type LunoraSchedulerOptions, type MessageBatchLike, type MonthlySchedule, type QueueConsumerOptions, type QueueDispatch, type QueueEnqueueOptions, type QueueJob, type QueueLike, type QueueMessageLike, type QueueSendOptionsLike, type QueueSendRequestLike, type QueueWorkpool, type QueueWorkpoolOptions, type RetryPolicy, type RunOptions, type ScheduleRecord, type Scheduler, SchedulerDO, type SchedulerDOState, type SchedulerEnv, type SchedulerHostOptions, type SchedulerPoolStatus, type SchedulerStatus, type WeeklySchedule, type WorkflowReference, type Workpool, type WorkpoolOptions, assertValidCronExpression, compileCronSchedule, createCronTrigger, createQueueConsumer, createQueueWorkpool, createScheduler, createSchedulerHost, createWorkpool, cronJobs, httpDispatcher, isValidCronExpression, isWorkflowReference, warnIfSecondsLeading };
984
+ export { type ArgsOf, CRON_SCHEDULE_KINDS, type CronJob, type CronJobsBuilder, type CronScheduleKind, type CronTarget, type CronTriggerOptions, type CronTriggerSnippet, type DailySchedule, type DurableObjectIdLike, type DurableObjectJurisdiction, type DurableObjectNamespaceLike, type DurableObjectStubLike, type EnqueueOptions, type FunctionReference, type HourlySchedule, type HttpDispatcherOptions, type IntervalSchedule, type LunoraSchedulerOptions, MAX_RETRY_ATTEMPTS, type MessageBatchLike, type MonthlySchedule, type QueueConsumerOptions, type QueueDispatch, type QueueEnqueueOptions, type QueueJob, type QueueLike, type QueueMessageLike, type QueueSendOptionsLike, type QueueSendRequestLike, type QueueWorkpool, type QueueWorkpoolOptions, RETRY_BASE_DELAY_MS, type RetryPolicy, type RunOptions, type ScheduleRecord, type Scheduler, SchedulerDO, type SchedulerDOState, type SchedulerEnv, type SchedulerHostOptions, type SchedulerPoolStatus, type SchedulerStatus, type WeeklySchedule, type WorkflowReference, type Workpool, type WorkpoolOptions, assertValidCronExpression, compileCronSchedule, createCronTrigger, createQueueConsumer, createQueueWorkpool, createScheduler, createSchedulerHost, createWorkpool, cronJobs, httpDispatcher, isValidCronExpression, isWorkflowReference, warnIfSecondsLeading };
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{default as o}from"./packem_shared/createScheduler-DrXa1iXV.mjs";import{default as a}from"./packem_shared/createWorkpool-d3THj5gc.mjs";import{createCronTrigger as c}from"./packem_shared/createCronTrigger-DSuDZtqj.mjs";import{CRON_SCHEDULE_KINDS as f,compileCronSchedule as l,cronJobs as n}from"./packem_shared/CRON_SCHEDULE_KINDS-xyFk800s.mjs";import{createQueueConsumer as u,createQueueWorkpool as x,httpDispatcher as d}from"./packem_shared/createQueueConsumer-CNhmGnLG.mjs";import{SchedulerDO as C}from"./packem_shared/SchedulerDO-DE5vJpCd.mjs";import{createSchedulerHost as h}from"./packem_shared/createSchedulerHost-C4HUMyFs.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-DnBtukpq.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-DrXa1iXV.mjs";import{default as a}from"./packem_shared/createWorkpool-d3THj5gc.mjs";import{createCronTrigger as c}from"./packem_shared/createCronTrigger-DSuDZtqj.mjs";import{CRON_SCHEDULE_KINDS as f,compileCronSchedule as l,cronJobs as n}from"./packem_shared/CRON_SCHEDULE_KINDS-xyFk800s.mjs";import{createQueueConsumer as u,createQueueWorkpool as x,httpDispatcher as d}from"./packem_shared/createQueueConsumer-CNhmGnLG.mjs";import{MAX_RETRY_ATTEMPTS as S,RETRY_BASE_DELAY_MS as E,SchedulerDO as C}from"./packem_shared/MAX_RETRY_ATTEMPTS-DU_w9jpi.mjs";import{createSchedulerHost as h}from"./packem_shared/createSchedulerHost-C4HUMyFs.mjs";import{isWorkflowReference as T}from"./packem_shared/isWorkflowReference-CT3tdefh.mjs";import{assertValidCronExpression as A,isValidCronExpression as g,warnIfSecondsLeading as k}from"./packem_shared/assertValidCronExpression-DnBtukpq.mjs";export{f as CRON_SCHEDULE_KINDS,S as MAX_RETRY_ATTEMPTS,E as RETRY_BASE_DELAY_MS,C as SchedulerDO,A 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,g as isValidCronExpression,T as isWorkflowReference,k as warnIfSecondsLeading};
@@ -0,0 +1 @@
1
+ const m=(u,t=200,e)=>Response.json(u,{headers:{"content-type":"application/json",...e},status:t}),R="lunora-ping",I="lunora-pong";const f="retry:",g="dead:",p="pool:";const A=5,F=3e4;const y=u=>String(u).padStart(15,"0"),w=u=>{let t="";for(const e of u)t+=String.fromCodePoint(e);return btoa(t).replaceAll("+","-").replaceAll("/","_").replaceAll("=","")},E=()=>w(crypto.getRandomValues(new Uint8Array(12)));class n{static indexKey(t,e){return`t:${y(t)}:${e}`}static json(t,e=200){return m(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:5,a=typeof e?.baseMs=="number"&&Number.isFinite(e.baseMs)&&e.baseMs>=0?e.baseMs:3e4,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;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 m({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,i]of s.entries()){const o=Number.parseInt(a.slice(2,a.indexOf(":",2)),10);if(Number.isFinite(o)&&o<=t){const r=await this.state.storage.get(`id:${i}`);r?e.push(r):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"},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(R,I))}async drainRecordGuarded(t){try{await this.state.storage.delete(n.indexKey(t.scheduledFor,t.id)),await this.drainRecord(t)}catch{try{await this.state.storage.put(n.indexKey(t.scheduledFor,t.id),t.id)}catch{}}}async drainRecord(t){if(!await this.reservePoolSlot(t))return!1;const s=await this.dispatch(t);if(!s&&t.pool!==void 0){const a=await this.loadPool(t.pool),i=n.releaseSlot(a,t.id);await this.savePool(t.pool,i)}if(s){try{await this.state.storage.delete([`id:${t.id}`,`${f}${t.id}`])}catch{}return!0}return await this.recordRetry(t),!1}async reservePoolSlot(t){if(t.pool===void 0)return!0;const e=await this.loadPool(t.pool);if(e.inFlight>=e.maxConcurrency)return await this.requeuePooled(t),!1;const s=e.inFlightIds??[];return s.includes(t.id)||s.push(t.id),e.inFlightIds=s,e.inFlight=s.length,await this.savePool(t.pool,e),!0}async handleWebSocketUpgrade(){if(this.state.acceptWebSocket===void 0)return 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.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 i of t)try{i.send(a)}catch{}}async listRecords(t=100){const s=[...(await this.state.storage.list({limit:t+1,prefix:"id:"})).values()],a=s.length>t;return{records:a?s.slice(0,t):s,truncated:a}}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 o of a.values())t(o);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"]),i=await crypto.subtle.sign("HMAC",a,s.encode(t));return w(new Uint8Array(i))}async recordRetry(t){const e=(t.attempts??0)+1,{backoff:s,baseMs:a,maxAttempts:i,maxMs:o}=n.resolveRetry(t);if(e>i){await this.state.storage.put(`${g}${t.id}`,{...t,attempts:e}),await this.state.storage.delete([`${f}${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 r=s==="linear"?a*e:a*2**(e-1),d=o===void 0?r:Math.min(r,o),c=Date.now()+d,l={...t,attempts:e,scheduledFor:c};await this.state.storage.put(`${f}${t.id}`,l),await this.state.storage.put(`id:${t.id}`,l),await this.state.storage.put(n.indexKey(c,t.id),t.id)}async loadPool(t,e){const s=await this.state.storage.get(`${p}${t}`);return s!==void 0?Array.isArray(s.inFlightIds)?{inFlight:s.inFlightIds.length,inFlightIds:[...s.inFlightIds],maxConcurrency:s.maxConcurrency}:{inFlight:Math.max(0,s.inFlight),maxConcurrency:s.maxConcurrency}:{inFlight:0,inFlightIds:[],maxConcurrency:n.normalizeConcurrency(e,1)}}async savePool(t,e){await this.state.storage.put(`${p}${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(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.loadPool(s),o=a===void 0?n.releaseFirstSlot(i):n.releaseSlot(i,a);return await this.savePool(s,o),await this.armAlarmIfEarlier(Date.now()),n.json({inFlight:o.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.countHeaders(i=>{i.pool===e&&(a+=1)}),n.json({inFlight:s.inFlight,maxConcurrency:s.maxConcurrency,queued:a})}async handleStatus(){const t=await this.state.storage.list({prefix:p}),e=new Map;await this.countHeaders(r=>{r.pool!==void 0&&e.set(r.pool,(e.get(r.pool)??0)+1)});const s=[];let a=0,i=0;for(const[r,d]of t.entries()){const c=r.slice(p.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,i+=l}const o={backlog:a,inFlight:i,pools:s};return n.json(o)}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"||!Number.isInteger(e.scheduledFor)||e.scheduledFor<=0||e.scheduledFor>999999999999999)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),c=E(),l={args:e.args??{},enqueuedAt:Date.now(),id:c,...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}};if(o!==void 0){const h=await this.loadPool(o,e.maxConcurrency);await this.savePool(o,{inFlight:h.inFlight,...h.inFlightIds===void 0?{}:{inFlightIds:h.inFlightIds},maxConcurrency:n.normalizeConcurrency(e.maxConcurrency,h.maxConcurrency)})}return await this.state.storage.put(`id:${c}`,l),await this.state.storage.put(n.indexKey(l.scheduledFor,c),c),await this.armAlarmIfEarlier(l.scheduledFor),await this.broadcastChange(),n.json({id:c,scheduledFor:l.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(`id:${e.id}`);return s?(await this.removeRecord(s),await this.rescheduleAlarm(),await this.broadcastChange(),n.json({cancelled:!0})):n.json({cancelled:!1})}async handleList(){const{records:t,truncated:e}=await this.listRecords();return n.json({records:t,truncated:e})}async handleDeadList(){const t=await this.state.storage.list({prefix:g});return n.json({records:[...t.values()]})}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(`id:${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(`id:${e}`);return n.json(s===void 0?{}:{record:s})}async removeRecord(t){await this.state.storage.delete([`id:${t.id}`,n.indexKey(t.scheduledFor,t.id),`${f}${t.id}`])}async armAlarmIfEarlier(t){const e=await this.state.storage.getAlarm();(e===null||t<e)&&await this.state.storage.setAlarm(t)}async rescheduleAlarm(){const e=(await this.state.storage.list({limit:1,prefix:"t:"})).entries().next();if(e.done){await this.state.storage.deleteAlarm();return}const[s]=e.value,a=Number.parseInt(s.slice(2,s.indexOf(":",2)),10);Number.isFinite(a)&&await this.state.storage.setAlarm(a)}}export{A as MAX_RETRY_ATTEMPTS,F as RETRY_BASE_DELAY_MS,n as SchedulerDO};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/scheduler",
3
- "version": "1.0.0-alpha.32",
3
+ "version": "1.0.0-alpha.34",
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.22",
50
- "@lunora/platform": "1.0.0-alpha.12",
50
+ "@lunora/platform": "1.0.0-alpha.14",
51
51
  "cron-parser": "5.8.1"
52
52
  },
53
53
  "engines": {
@@ -1 +0,0 @@
1
- const m=(u,t=200,e)=>Response.json(u,{headers:{"content-type":"application/json",...e},status:t}),R="lunora-ping",I="lunora-pong";const f="retry:",g="dead:",p="pool:";const y=u=>String(u).padStart(15,"0"),w=u=>{let t="";for(const e of u)t+=String.fromCodePoint(e);return btoa(t).replaceAll("+","-").replaceAll("/","_").replaceAll("=","")},E=()=>w(crypto.getRandomValues(new Uint8Array(12)));class n{static indexKey(t,e){return`t:${y(t)}:${e}`}static json(t,e=200){return m(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:5,a=typeof e?.baseMs=="number"&&Number.isFinite(e.baseMs)&&e.baseMs>=0?e.baseMs:3e4,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;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 m({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,i]of s.entries()){const o=Number.parseInt(a.slice(2,a.indexOf(":",2)),10);if(Number.isFinite(o)&&o<=t){const r=await this.state.storage.get(`id:${i}`);r?e.push(r):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"},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(R,I))}async drainRecordGuarded(t){try{await this.state.storage.delete(n.indexKey(t.scheduledFor,t.id)),await this.drainRecord(t)}catch{try{await this.state.storage.put(n.indexKey(t.scheduledFor,t.id),t.id)}catch{}}}async drainRecord(t){if(!await this.reservePoolSlot(t))return!1;const s=await this.dispatch(t);if(!s&&t.pool!==void 0){const a=await this.loadPool(t.pool),i=n.releaseSlot(a,t.id);await this.savePool(t.pool,i)}if(s){try{await this.state.storage.delete([`id:${t.id}`,`${f}${t.id}`])}catch{}return!0}return await this.recordRetry(t),!1}async reservePoolSlot(t){if(t.pool===void 0)return!0;const e=await this.loadPool(t.pool);if(e.inFlight>=e.maxConcurrency)return await this.requeuePooled(t),!1;const s=e.inFlightIds??[];return s.includes(t.id)||s.push(t.id),e.inFlightIds=s,e.inFlight=s.length,await this.savePool(t.pool,e),!0}async handleWebSocketUpgrade(){if(this.state.acceptWebSocket===void 0)return 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.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 i of t)try{i.send(a)}catch{}}async listRecords(t=100){const s=[...(await this.state.storage.list({limit:t+1,prefix:"id:"})).values()],a=s.length>t;return{records:a?s.slice(0,t):s,truncated:a}}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 o of a.values())t(o);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"]),i=await crypto.subtle.sign("HMAC",a,s.encode(t));return w(new Uint8Array(i))}async recordRetry(t){const e=(t.attempts??0)+1,{backoff:s,baseMs:a,maxAttempts:i,maxMs:o}=n.resolveRetry(t);if(e>i){await this.state.storage.put(`${g}${t.id}`,{...t,attempts:e}),await this.state.storage.delete([`${f}${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 r=s==="linear"?a*e:a*2**(e-1),d=o===void 0?r:Math.min(r,o),c=Date.now()+d,l={...t,attempts:e,scheduledFor:c};await this.state.storage.put(`${f}${t.id}`,l),await this.state.storage.put(`id:${t.id}`,l),await this.state.storage.put(n.indexKey(c,t.id),t.id)}async loadPool(t,e){const s=await this.state.storage.get(`${p}${t}`);return s!==void 0?Array.isArray(s.inFlightIds)?{inFlight:s.inFlightIds.length,inFlightIds:[...s.inFlightIds],maxConcurrency:s.maxConcurrency}:{inFlight:Math.max(0,s.inFlight),maxConcurrency:s.maxConcurrency}:{inFlight:0,inFlightIds:[],maxConcurrency:n.normalizeConcurrency(e,1)}}async savePool(t,e){await this.state.storage.put(`${p}${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(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.loadPool(s),o=a===void 0?n.releaseFirstSlot(i):n.releaseSlot(i,a);return await this.savePool(s,o),await this.armAlarmIfEarlier(Date.now()),n.json({inFlight:o.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.countHeaders(i=>{i.pool===e&&(a+=1)}),n.json({inFlight:s.inFlight,maxConcurrency:s.maxConcurrency,queued:a})}async handleStatus(){const t=await this.state.storage.list({prefix:p}),e=new Map;await this.countHeaders(r=>{r.pool!==void 0&&e.set(r.pool,(e.get(r.pool)??0)+1)});const s=[];let a=0,i=0;for(const[r,d]of t.entries()){const c=r.slice(p.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,i+=l}const o={backlog:a,inFlight:i,pools:s};return n.json(o)}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"||!Number.isInteger(e.scheduledFor)||e.scheduledFor<=0||e.scheduledFor>999999999999999)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),c=E(),l={args:e.args??{},enqueuedAt:Date.now(),id:c,...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}};if(o!==void 0){const h=await this.loadPool(o,e.maxConcurrency);await this.savePool(o,{inFlight:h.inFlight,...h.inFlightIds===void 0?{}:{inFlightIds:h.inFlightIds},maxConcurrency:n.normalizeConcurrency(e.maxConcurrency,h.maxConcurrency)})}return await this.state.storage.put(`id:${c}`,l),await this.state.storage.put(n.indexKey(l.scheduledFor,c),c),await this.armAlarmIfEarlier(l.scheduledFor),await this.broadcastChange(),n.json({id:c,scheduledFor:l.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(`id:${e.id}`);return s?(await this.removeRecord(s),await this.rescheduleAlarm(),await this.broadcastChange(),n.json({cancelled:!0})):n.json({cancelled:!1})}async handleList(){const{records:t,truncated:e}=await this.listRecords();return n.json({records:t,truncated:e})}async handleDeadList(){const t=await this.state.storage.list({prefix:g});return n.json({records:[...t.values()]})}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(`id:${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(`id:${e}`);return n.json(s===void 0?{}:{record:s})}async removeRecord(t){await this.state.storage.delete([`id:${t.id}`,n.indexKey(t.scheduledFor,t.id),`${f}${t.id}`])}async armAlarmIfEarlier(t){const e=await this.state.storage.getAlarm();(e===null||t<e)&&await this.state.storage.setAlarm(t)}async rescheduleAlarm(){const e=(await this.state.storage.list({limit:1,prefix:"t:"})).entries().next();if(e.done){await this.state.storage.deleteAlarm();return}const[s]=e.value,a=Number.parseInt(s.slice(2,s.indexOf(":",2)),10);Number.isFinite(a)&&await this.state.storage.setAlarm(a)}}export{n as SchedulerDO};