@lunora/scheduler 1.0.0-alpha.12 → 1.0.0-alpha.13
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.mjs +1 -8
- package/dist/packem_shared/CRON_SCHEDULE_KINDS-CZFw97XW.mjs +1 -0
- package/dist/packem_shared/SchedulerDO-DGgeZ-uI.mjs +1 -0
- package/dist/packem_shared/assertValidCronExpression-DHvO7Vm9.mjs +1 -0
- package/dist/packem_shared/createCronTrigger-CyPdVKNF.mjs +1 -0
- package/dist/packem_shared/createQueueConsumer-B0qNXRUJ.mjs +1 -0
- package/dist/packem_shared/createScheduler-B2YOy_ph.mjs +1 -0
- package/dist/packem_shared/createWorkpool-DyVdhB6o.mjs +1 -0
- package/dist/packem_shared/do-client-BFps7NIj.mjs +1 -0
- package/dist/packem_shared/isWorkflowReference-CT3tdefh.mjs +1 -0
- package/package.json +2 -2
- package/dist/packem_shared/CRON_SCHEDULE_KINDS-C2FflEnq.mjs +0 -140
- package/dist/packem_shared/SchedulerDO-DeJHI379.mjs +0 -705
- package/dist/packem_shared/assertValidCronExpression-B9m75qU0.mjs +0 -24
- package/dist/packem_shared/createCronTrigger-Dh4VLxD9.mjs +0 -28
- package/dist/packem_shared/createQueueConsumer-Cy-Mp-El.mjs +0 -61
- package/dist/packem_shared/createScheduler-Df9JUjQb.mjs +0 -52
- package/dist/packem_shared/createWorkpool-Ce0kNM2p.mjs +0 -37
- package/dist/packem_shared/do-client-CMJtLoHO.mjs +0 -42
- package/dist/packem_shared/isWorkflowReference-C9mQkMXt.mjs +0 -3
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export { default as createWorkpool } from './packem_shared/createWorkpool-Ce0kNM2p.mjs';
|
|
3
|
-
export { createCronTrigger } from './packem_shared/createCronTrigger-Dh4VLxD9.mjs';
|
|
4
|
-
export { CRON_SCHEDULE_KINDS, compileCronSchedule, cronJobs } from './packem_shared/CRON_SCHEDULE_KINDS-C2FflEnq.mjs';
|
|
5
|
-
export { createQueueConsumer, createQueueWorkpool, httpDispatcher } from './packem_shared/createQueueConsumer-Cy-Mp-El.mjs';
|
|
6
|
-
export { SchedulerDO } from './packem_shared/SchedulerDO-DeJHI379.mjs';
|
|
7
|
-
export { isWorkflowReference } from './packem_shared/isWorkflowReference-C9mQkMXt.mjs';
|
|
8
|
-
export { assertValidCronExpression, isValidCronExpression } from './packem_shared/assertValidCronExpression-B9m75qU0.mjs';
|
|
1
|
+
import{default as o}from"./packem_shared/createScheduler-B2YOy_ph.mjs";import{default as p}from"./packem_shared/createWorkpool-DyVdhB6o.mjs";import{createCronTrigger as s}from"./packem_shared/createCronTrigger-CyPdVKNF.mjs";import{CRON_SCHEDULE_KINDS as f,compileCronSchedule as l,cronJobs as m}from"./packem_shared/CRON_SCHEDULE_KINDS-CZFw97XW.mjs";import{createQueueConsumer as x,createQueueWorkpool as i,httpDispatcher as n}from"./packem_shared/createQueueConsumer-B0qNXRUJ.mjs";import{SchedulerDO as C}from"./packem_shared/SchedulerDO-DGgeZ-uI.mjs";import{isWorkflowReference as S}from"./packem_shared/isWorkflowReference-CT3tdefh.mjs";import{assertValidCronExpression as E,isValidCronExpression as k}from"./packem_shared/assertValidCronExpression-DHvO7Vm9.mjs";export{f as CRON_SCHEDULE_KINDS,C as SchedulerDO,E as assertValidCronExpression,l as compileCronSchedule,s as createCronTrigger,x as createQueueConsumer,i as createQueueWorkpool,o as createScheduler,p as createWorkpool,m as cronJobs,n as httpDispatcher,k as isValidCronExpression,S as isWorkflowReference};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as u}from"@lunora/errors";import{isWorkflowReference as m}from"./isWorkflowReference-CT3tdefh.mjs";import{assertValidCronExpression as h}from"./assertValidCronExpression-DHvO7Vm9.mjs";const y={friday:5,monday:1,saturday:6,sunday:0,thursday:4,tuesday:2,wednesday:3},l=(e,r,n,t)=>{if(!Number.isInteger(e)||e<n||e>t)throw new u("INTERNAL",`@lunora/scheduler: cronJobs ${r} must be an integer in [${n.toFixed(0)}, ${t.toFixed(0)}], got ${String(e)}`);return e.toFixed(0)},c=(e,r,n)=>{const t=l(e,r,1,n-1);if(n%e!==0)throw new u("INTERNAL",`@lunora/scheduler: ${r} must evenly divide ${n.toFixed(0)} for a fixed "every ${e.toFixed(0)}" interval — cron "*/${e.toFixed(0)}" means "at values divisible by ${e.toFixed(0)}", which wraps unevenly; pick a divisor of ${n.toFixed(0)}`);return t},f=e=>{const r=["seconds","minutes","hours"].filter(o=>e[o]!==void 0);if(r.length!==1)throw new u("INTERNAL","@lunora/scheduler: interval schedule must specify exactly one of { seconds, minutes, hours }");const n=r[0],t=e[n];return n==="seconds"?`*/${c(t,"interval.seconds",60)} * * * * *`:n==="minutes"?`*/${c(t,"interval.minutes",60)} * * * *`:`0 */${c(t,"interval.hours",24)} * * *`},w=e=>{const r=l(e.minuteUTC,"daily.minuteUTC",0,59),n=l(e.hourUTC,"daily.hourUTC",0,23);return`${r} ${n} * * *`},$=e=>{const r=y[e.dayOfWeek];if(r===void 0)throw new u("INTERNAL",`@lunora/scheduler: weekly schedule has invalid dayOfWeek "${e.dayOfWeek}"`);const n=l(e.minuteUTC,"weekly.minuteUTC",0,59),t=l(e.hourUTC,"weekly.hourUTC",0,23);return`${n} ${t} * * ${r.toFixed(0)}`},T=e=>{const r=l(e.day,"monthly.day",1,31),n=l(e.minuteUTC,"monthly.minuteUTC",0,59),t=l(e.hourUTC,"monthly.hourUTC",0,23);return`${n} ${t} ${r} * *`},C=new Set(["daily","interval","monthly","weekly"]),d=(e,r)=>{switch(e){case"daily":return w(r);case"interval":return f(r);case"monthly":return T(r);case"weekly":return $(r);default:throw new u("INTERNAL",`@lunora/scheduler: unknown cron schedule kind "${String(e)}"`)}},b=()=>{const e=[],r=new Set,n=(o,s,i,a)=>{if(typeof o!="string"||o.trim()==="")throw new u("INTERNAL","@lunora/scheduler: cron job name must be a non-empty string");if(r.has(o))throw new u("INTERNAL",`@lunora/scheduler: duplicate cron job name "${o}" — names must be unique within one cronJobs()`);if(m(i)){h(s,`cron expression for job "${o}"`),r.add(o),e.push({args:a??{},cron:s,name:o,workflow:typeof i.name=="string"?i.name:""});return}if(!i||typeof i.__lunoraRef!="string")throw new u("INTERNAL",`@lunora/scheduler: cron job "${o}" requires a function reference (e.g. internal.email.digest) or a workflow reference`);h(s,`cron expression for job "${o}"`),r.add(o),e.push({args:a??{},cron:s,functionPath:i.__lunoraRef,name:o})},t={cron(o,s,i,a){return n(o,s,i,a),t},daily(o,s,i,a){return n(o,d("daily",s),i,a),t},interval(o,s,i,a){return n(o,d("interval",s),i,a),t},jobs:()=>[...e],monthly(o,s,i,a){return n(o,d("monthly",s),i,a),t},weekly(o,s,i,a){return n(o,d("weekly",s),i,a),t}};return t};export{C as CRON_SCHEDULE_KINDS,d as compileCronSchedule,b as cronJobs};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const y=(u,t=200,e)=>Response.json(u,{headers:{"content-type":"application/json",...e},status:t});const m="retry:",g="dead:",p="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}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}}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}`,`${m}${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([`${m}${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(`${m}${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(`${p}${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(`${p}${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:p}),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(p.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),`${m}${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};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as s}from"@lunora/errors";import{CronExpressionParser as o}from"cron-parser";const n=r=>{if(typeof r!="string"||r.trim()==="")return!1;try{return o.parse(r.trim()),!0}catch{return!1}},a=(r,e="cron expression")=>{if(!n(r))throw new s("INTERNAL",`@lunora/scheduler: invalid ${e} "${r}" — expected a standard 5- or 6-field cron expression (e.g. "0 * * * *")`)};export{a as assertValidCronExpression,n as isValidCronExpression};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as s}from"@lunora/errors";import{assertValidCronExpression as n}from"./assertValidCronExpression-DHvO7Vm9.mjs";const a=r=>{if(!r.schedule||!r.fn)throw new s("INTERNAL","@lunora/scheduler: createCronTrigger() requires `schedule` and `fn`");n(r.schedule);const e=JSON.stringify({triggers:{crons:[r.schedule]}},void 0,2);return{crons:[r.schedule],dispatcher:{args:r.args??{},functionPath:r.fn.__lunoraRef},wranglerJsonc:e}};export{a as createCronTrigger};
|
|
@@ -0,0 +1 @@
|
|
|
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};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as i}from"@lunora/errors";import{l,u as s}from"./do-client-BFps7NIj.mjs";import{isWorkflowReference as g}from"./isWorkflowReference-CT3tdefh.mjs";const m=n=>{if(!n.namespace)throw new i("INTERNAL","@lunora/scheduler: `namespace` (SchedulerDO binding) is required");if(!n.originUrl)throw new i("INTERNAL","@lunora/scheduler: `originUrl` is required so the DO can dispatch back to the Worker");const a=async(e,r,o,t={})=>{const u=e instanceof Date?e.getTime():e,c={args:o,originUrl:n.originUrl,pool:t.pool,retry:t.retry,scheduledFor:u,shardKey:t.shardKey};if(g(r)){if(typeof r.binding!="string"||r.binding.length===0)throw new i("INTERNAL","@lunora/scheduler: workflow/agent schedule target is missing its `binding` — pass the generated `workflows.<name>` / `agents.<name>` reference");return s(n,"/schedule",{...c,workflow:r.binding})}const d=typeof r=="string"?r:r.__lunoraRef;return s(n,"/schedule",{...c,functionPath:d})};return{cancel:async e=>s(n,"/cancel",{id:e}),get:async e=>(await l(n,`/get?id=${encodeURIComponent(e)}`)).record??null,list:async()=>{const e=await l(n,"/list");return Array.isArray(e.records)?e.records:[]},runAfter:async(e,r,o,t={})=>{if(!Number.isFinite(e)||e<0)throw new i("INTERNAL","@lunora/scheduler: `delayMs` must be a non-negative finite number");return a(Date.now()+e,r,o,t)},runAt:a}};export{m as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as n}from"@lunora/errors";import{l as c,u as i}from"./do-client-BFps7NIj.mjs";const m=e=>{if(!e.namespace)throw new n("INTERNAL","@lunora/scheduler: `namespace` (SchedulerDO binding) is required");if(!e.originUrl)throw new n("INTERNAL","@lunora/scheduler: `originUrl` is required so the DO can dispatch back to the Worker");if(!Number.isInteger(e.maxConcurrency)||e.maxConcurrency<=0)throw new n("INTERNAL","@lunora/scheduler: `maxConcurrency` must be a positive integer");const r=typeof e.name=="string"&&e.name.length>0?e.name:"default";return{cancel:async a=>i(e,"/cancel",{id:a}),enqueue:async(a,u,o={})=>{const t=o.delayMs??0;if(!Number.isFinite(t)||t<0)throw new n("INTERNAL","@lunora/scheduler: `delayMs` must be a non-negative finite number");return i(e,"/schedule",{args:u,functionPath:a.__lunoraRef,instanceName:e.instanceName??"default",maxConcurrency:e.maxConcurrency,originUrl:e.originUrl,pool:r,retry:o.retry,scheduledFor:Date.now()+t,shardKey:o.shardKey})},name:r,status:async()=>c(e,`/pool?name=${encodeURIComponent(r)}`)}};export{m as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as o}from"@lunora/errors";const s=(e,t)=>{if(t===void 0)return e;if(typeof e.jurisdiction!="function")throw new TypeError(`@lunora/scheduler: Durable Object namespace does not support jurisdiction("${t}") — update @cloudflare/workers-types or remove the jurisdiction option`);return e.jurisdiction(t)},i=e=>{const t=s(e.namespace,e.jurisdiction);return t.get(t.idFromName(e.instanceName??"default"))},u=async(e,t,r)=>{const n=await i(e).fetch(`https://scheduler.internal${t}`,{body:JSON.stringify(r),headers:{"content-type":"application/json"},method:"POST"});if(!n.ok){const a=await n.text();throw new o("INTERNAL",`@lunora/scheduler: SchedulerDO ${t} failed (${String(n.status)}): ${a}`)}return await n.json()},d=async(e,t)=>{const r=await i(e).fetch(`https://scheduler.internal${t}`,{method:"GET"});if(!r.ok){const n=await r.text();throw new o("INTERNAL",`@lunora/scheduler: SchedulerDO ${t} failed (${String(r.status)}): ${n}`)}return await r.json()};export{d as l,u};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const e=o=>typeof o=="object"&&o!==null&&o.isLunoraWorkflow===!0;export{e as isWorkflowReference};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunora/scheduler",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.13",
|
|
4
4
|
"description": "Scheduling for Lunora: runAfter / runAt and Cron Triggers via SchedulerDO",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"access": "public"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@lunora/errors": "1.0.0-alpha.
|
|
49
|
+
"@lunora/errors": "1.0.0-alpha.9",
|
|
50
50
|
"cron-parser": "5.6.2"
|
|
51
51
|
},
|
|
52
52
|
"engines": {
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import { LunoraError } from '@lunora/errors';
|
|
2
|
-
import { isWorkflowReference } from './isWorkflowReference-C9mQkMXt.mjs';
|
|
3
|
-
import { assertValidCronExpression } from './assertValidCronExpression-B9m75qU0.mjs';
|
|
4
|
-
|
|
5
|
-
const WEEKDAY_INDEX = {
|
|
6
|
-
friday: 5,
|
|
7
|
-
monday: 1,
|
|
8
|
-
saturday: 6,
|
|
9
|
-
sunday: 0,
|
|
10
|
-
thursday: 4,
|
|
11
|
-
tuesday: 2,
|
|
12
|
-
wednesday: 3
|
|
13
|
-
};
|
|
14
|
-
const field = (value, label, min, max) => {
|
|
15
|
-
if (!Number.isInteger(value) || value < min || value > max) {
|
|
16
|
-
throw new LunoraError(
|
|
17
|
-
"INTERNAL",
|
|
18
|
-
`@lunora/scheduler: cronJobs ${label} must be an integer in [${min.toFixed(0)}, ${max.toFixed(0)}], got ${String(value)}`
|
|
19
|
-
);
|
|
20
|
-
}
|
|
21
|
-
return value.toFixed(0);
|
|
22
|
-
};
|
|
23
|
-
const stepField = (value, label, period) => {
|
|
24
|
-
const rendered = field(value, label, 1, period - 1);
|
|
25
|
-
if (period % value !== 0) {
|
|
26
|
-
throw new LunoraError(
|
|
27
|
-
"INTERNAL",
|
|
28
|
-
`@lunora/scheduler: ${label} must evenly divide ${period.toFixed(0)} for a fixed "every ${value.toFixed(0)}" interval — cron "*/${value.toFixed(0)}" means "at values divisible by ${value.toFixed(0)}", which wraps unevenly; pick a divisor of ${period.toFixed(0)}`
|
|
29
|
-
);
|
|
30
|
-
}
|
|
31
|
-
return rendered;
|
|
32
|
-
};
|
|
33
|
-
const compileInterval = (schedule) => {
|
|
34
|
-
const units = ["seconds", "minutes", "hours"].filter((unit2) => schedule[unit2] !== void 0);
|
|
35
|
-
if (units.length !== 1) {
|
|
36
|
-
throw new LunoraError("INTERNAL", `@lunora/scheduler: interval schedule must specify exactly one of { seconds, minutes, hours }`);
|
|
37
|
-
}
|
|
38
|
-
const unit = units[0];
|
|
39
|
-
const value = schedule[unit];
|
|
40
|
-
if (unit === "seconds") {
|
|
41
|
-
return `*/${stepField(value, "interval.seconds", 60)} * * * * *`;
|
|
42
|
-
}
|
|
43
|
-
if (unit === "minutes") {
|
|
44
|
-
return `*/${stepField(value, "interval.minutes", 60)} * * * *`;
|
|
45
|
-
}
|
|
46
|
-
return `0 */${stepField(value, "interval.hours", 24)} * * *`;
|
|
47
|
-
};
|
|
48
|
-
const compileDaily = (schedule) => {
|
|
49
|
-
const minute = field(schedule.minuteUTC, "daily.minuteUTC", 0, 59);
|
|
50
|
-
const hour = field(schedule.hourUTC, "daily.hourUTC", 0, 23);
|
|
51
|
-
return `${minute} ${hour} * * *`;
|
|
52
|
-
};
|
|
53
|
-
const compileWeekly = (schedule) => {
|
|
54
|
-
const index = WEEKDAY_INDEX[schedule.dayOfWeek];
|
|
55
|
-
if (index === void 0) {
|
|
56
|
-
throw new LunoraError("INTERNAL", `@lunora/scheduler: weekly schedule has invalid dayOfWeek "${schedule.dayOfWeek}"`);
|
|
57
|
-
}
|
|
58
|
-
const minute = field(schedule.minuteUTC, "weekly.minuteUTC", 0, 59);
|
|
59
|
-
const hour = field(schedule.hourUTC, "weekly.hourUTC", 0, 23);
|
|
60
|
-
return `${minute} ${hour} * * ${index.toFixed(0)}`;
|
|
61
|
-
};
|
|
62
|
-
const compileMonthly = (schedule) => {
|
|
63
|
-
const day = field(schedule.day, "monthly.day", 1, 31);
|
|
64
|
-
const minute = field(schedule.minuteUTC, "monthly.minuteUTC", 0, 59);
|
|
65
|
-
const hour = field(schedule.hourUTC, "monthly.hourUTC", 0, 23);
|
|
66
|
-
return `${minute} ${hour} ${day} * *`;
|
|
67
|
-
};
|
|
68
|
-
const CRON_SCHEDULE_KINDS = /* @__PURE__ */ new Set(["daily", "interval", "monthly", "weekly"]);
|
|
69
|
-
const compileCronSchedule = (kind, schedule) => {
|
|
70
|
-
switch (kind) {
|
|
71
|
-
case "daily": {
|
|
72
|
-
return compileDaily(schedule);
|
|
73
|
-
}
|
|
74
|
-
case "interval": {
|
|
75
|
-
return compileInterval(schedule);
|
|
76
|
-
}
|
|
77
|
-
case "monthly": {
|
|
78
|
-
return compileMonthly(schedule);
|
|
79
|
-
}
|
|
80
|
-
case "weekly": {
|
|
81
|
-
return compileWeekly(schedule);
|
|
82
|
-
}
|
|
83
|
-
default: {
|
|
84
|
-
throw new LunoraError("INTERNAL", `@lunora/scheduler: unknown cron schedule kind "${String(kind)}"`);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
const cronJobs = () => {
|
|
89
|
-
const jobs = [];
|
|
90
|
-
const seen = /* @__PURE__ */ new Set();
|
|
91
|
-
const register = (name, cron, target, args) => {
|
|
92
|
-
if (typeof name !== "string" || name.trim() === "") {
|
|
93
|
-
throw new LunoraError("INTERNAL", `@lunora/scheduler: cron job name must be a non-empty string`);
|
|
94
|
-
}
|
|
95
|
-
if (seen.has(name)) {
|
|
96
|
-
throw new LunoraError("INTERNAL", `@lunora/scheduler: duplicate cron job name "${name}" — names must be unique within one cronJobs()`);
|
|
97
|
-
}
|
|
98
|
-
if (isWorkflowReference(target)) {
|
|
99
|
-
assertValidCronExpression(cron, `cron expression for job "${name}"`);
|
|
100
|
-
seen.add(name);
|
|
101
|
-
jobs.push({ args: args ?? {}, cron, name, workflow: typeof target.name === "string" ? target.name : "" });
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
if (!target || typeof target.__lunoraRef !== "string") {
|
|
105
|
-
throw new LunoraError(
|
|
106
|
-
"INTERNAL",
|
|
107
|
-
`@lunora/scheduler: cron job "${name}" requires a function reference (e.g. internal.email.digest) or a workflow reference`
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
assertValidCronExpression(cron, `cron expression for job "${name}"`);
|
|
111
|
-
seen.add(name);
|
|
112
|
-
jobs.push({ args: args ?? {}, cron, functionPath: target.__lunoraRef, name });
|
|
113
|
-
};
|
|
114
|
-
const builder = {
|
|
115
|
-
cron(name, cronExpr, function_, args) {
|
|
116
|
-
register(name, cronExpr, function_, args);
|
|
117
|
-
return builder;
|
|
118
|
-
},
|
|
119
|
-
daily(name, schedule, function_, args) {
|
|
120
|
-
register(name, compileCronSchedule("daily", schedule), function_, args);
|
|
121
|
-
return builder;
|
|
122
|
-
},
|
|
123
|
-
interval(name, schedule, function_, args) {
|
|
124
|
-
register(name, compileCronSchedule("interval", schedule), function_, args);
|
|
125
|
-
return builder;
|
|
126
|
-
},
|
|
127
|
-
jobs: () => [...jobs],
|
|
128
|
-
monthly(name, schedule, function_, args) {
|
|
129
|
-
register(name, compileCronSchedule("monthly", schedule), function_, args);
|
|
130
|
-
return builder;
|
|
131
|
-
},
|
|
132
|
-
weekly(name, schedule, function_, args) {
|
|
133
|
-
register(name, compileCronSchedule("weekly", schedule), function_, args);
|
|
134
|
-
return builder;
|
|
135
|
-
}
|
|
136
|
-
};
|
|
137
|
-
return builder;
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
export { CRON_SCHEDULE_KINDS, compileCronSchedule, cronJobs };
|
|
@@ -1,705 +0,0 @@
|
|
|
1
|
-
const jsonResponse = (body, status = 200, headers) => Response.json(body, { headers: { "content-type": "application/json", ...headers }, status });
|
|
2
|
-
|
|
3
|
-
const HEADER_PREFIX = "id:";
|
|
4
|
-
const RETRY_PREFIX = "retry:";
|
|
5
|
-
const DEAD_PREFIX = "dead:";
|
|
6
|
-
const POOL_PREFIX = "pool:";
|
|
7
|
-
const MAX_RETRY_ATTEMPTS = 5;
|
|
8
|
-
const RETRY_BASE_DELAY_MS = 3e4;
|
|
9
|
-
const POOL_BACKPRESSURE_DELAY_MS = 1e3;
|
|
10
|
-
const MAX_SCHEDULED_FOR_MS = 999999999999999;
|
|
11
|
-
const TIME_PAD = 15;
|
|
12
|
-
const padTime = (n) => String(n).padStart(TIME_PAD, "0");
|
|
13
|
-
const generateId = () => {
|
|
14
|
-
const bytes = crypto.getRandomValues(new Uint8Array(12));
|
|
15
|
-
let binary = "";
|
|
16
|
-
for (const byte of bytes) {
|
|
17
|
-
binary += String.fromCodePoint(byte);
|
|
18
|
-
}
|
|
19
|
-
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
20
|
-
};
|
|
21
|
-
class SchedulerDO {
|
|
22
|
-
static indexKey(scheduledFor, id) {
|
|
23
|
-
return `t:${padTime(scheduledFor)}:${id}`;
|
|
24
|
-
}
|
|
25
|
-
static json(body, status = 200) {
|
|
26
|
-
return jsonResponse(body, status);
|
|
27
|
-
}
|
|
28
|
-
static error(status, code, message) {
|
|
29
|
-
return SchedulerDO.json({ error: { code, message } }, status);
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* Resolve the effective retry parameters for a record: its per-job
|
|
33
|
-
* {@link RetryPolicy} merged over the DO's built-in defaults. Callers that
|
|
34
|
-
* never set `record.retry` get today's behaviour verbatim
|
|
35
|
-
* (`maxAttempts: 5`, exponential, `baseMs: 30_000`, no ceiling).
|
|
36
|
-
*/
|
|
37
|
-
static resolveRetry(record) {
|
|
38
|
-
const policy = record.retry;
|
|
39
|
-
const maxAttempts = typeof policy?.maxAttempts === "number" && Number.isInteger(policy.maxAttempts) && policy.maxAttempts > 0 ? policy.maxAttempts : MAX_RETRY_ATTEMPTS;
|
|
40
|
-
const baseMs = typeof policy?.baseMs === "number" && Number.isFinite(policy.baseMs) && policy.baseMs >= 0 ? policy.baseMs : RETRY_BASE_DELAY_MS;
|
|
41
|
-
const backoff = policy?.backoff === "linear" ? "linear" : "exponential";
|
|
42
|
-
const maxMs = typeof policy?.maxMs === "number" && Number.isFinite(policy.maxMs) && policy.maxMs >= 0 ? policy.maxMs : void 0;
|
|
43
|
-
return { backoff, baseMs, maxAttempts, maxMs };
|
|
44
|
-
}
|
|
45
|
-
/** Clamp an untrusted `maxConcurrency` to a positive integer, else fall back. */
|
|
46
|
-
static normalizeConcurrency(value, fallback) {
|
|
47
|
-
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Sanitize an untrusted retry policy from the wire into a `RetryPolicy` (or
|
|
51
|
-
* `undefined` when nothing valid was provided). Keeps obviously-bad values
|
|
52
|
-
* out of storage so {@link SchedulerDO.resolveRetry} never has to re-guard.
|
|
53
|
-
* @returns The normalized policy, or `undefined` if no valid policy was found.
|
|
54
|
-
*/
|
|
55
|
-
static normalizeRetry(value) {
|
|
56
|
-
if (typeof value !== "object" || value === null) {
|
|
57
|
-
return void 0;
|
|
58
|
-
}
|
|
59
|
-
const raw = value;
|
|
60
|
-
const policy = {};
|
|
61
|
-
if (typeof raw.maxAttempts === "number" && Number.isInteger(raw.maxAttempts) && raw.maxAttempts > 0) {
|
|
62
|
-
policy.maxAttempts = raw.maxAttempts;
|
|
63
|
-
}
|
|
64
|
-
if (typeof raw.baseMs === "number" && Number.isFinite(raw.baseMs) && raw.baseMs >= 0) {
|
|
65
|
-
policy.baseMs = raw.baseMs;
|
|
66
|
-
}
|
|
67
|
-
if (raw.backoff === "exponential" || raw.backoff === "linear") {
|
|
68
|
-
policy.backoff = raw.backoff;
|
|
69
|
-
}
|
|
70
|
-
if (typeof raw.maxMs === "number" && Number.isFinite(raw.maxMs) && raw.maxMs >= 0) {
|
|
71
|
-
policy.maxMs = raw.maxMs;
|
|
72
|
-
}
|
|
73
|
-
return Object.keys(policy).length === 0 ? void 0 : policy;
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* Idempotently release the slot held by `jobId`, returning the updated
|
|
77
|
-
* {@link PoolState} (pure — the caller persists it). A duplicate release for
|
|
78
|
-
* an id that no longer holds a slot is a no-op, so an at-least-once
|
|
79
|
-
* `/complete` (or a complete racing a failed-kick release) can never push
|
|
80
|
-
* `inFlight` below the true number of running jobs and oversubscribe the
|
|
81
|
-
* pool. Pools persisted before `inFlightIds` existed fall back to a clamped
|
|
82
|
-
* counter decrement.
|
|
83
|
-
*/
|
|
84
|
-
static releaseSlot(pool, jobId) {
|
|
85
|
-
if (pool.inFlightIds === void 0) {
|
|
86
|
-
return { ...pool, inFlight: Math.max(0, pool.inFlight - 1) };
|
|
87
|
-
}
|
|
88
|
-
const next = pool.inFlightIds.filter((id) => id !== jobId);
|
|
89
|
-
return { ...pool, inFlight: next.length, inFlightIds: next };
|
|
90
|
-
}
|
|
91
|
-
/**
|
|
92
|
-
* Best-effort release with no job id (legacy `/complete` payloads). Drops one
|
|
93
|
-
* tracked id if the set exists, else clamps the counter. Less precise than
|
|
94
|
-
* {@link SchedulerDO.releaseSlot} — a duplicate id-less complete CAN
|
|
95
|
-
* over-release — but every current client sends the id, so this is the
|
|
96
|
-
* compatibility shim, not the hot path.
|
|
97
|
-
*/
|
|
98
|
-
static releaseFirstSlot(pool) {
|
|
99
|
-
if (pool.inFlightIds === void 0) {
|
|
100
|
-
return { ...pool, inFlight: Math.max(0, pool.inFlight - 1) };
|
|
101
|
-
}
|
|
102
|
-
const next = pool.inFlightIds.slice(0, Math.max(0, pool.inFlightIds.length - 1));
|
|
103
|
-
return { ...pool, inFlight: next.length, inFlightIds: next };
|
|
104
|
-
}
|
|
105
|
-
/**
|
|
106
|
-
* Normalize the mutually-exclusive dispatch target off an untrusted body: a
|
|
107
|
-
* one-shot function path (`functionPath`) or a durable workflow/agent
|
|
108
|
-
* instance (`workflow`, a `WORKFLOW_*`/`AGENT_*` binding). Returns `undefined`
|
|
109
|
-
* when neither is present so the caller can reject the schedule.
|
|
110
|
-
*/
|
|
111
|
-
static resolveScheduleTarget(body) {
|
|
112
|
-
const functionPath = typeof body?.functionPath === "string" && body.functionPath.length > 0 ? body.functionPath : void 0;
|
|
113
|
-
const workflow = typeof body?.workflow === "string" && body.workflow.length > 0 ? body.workflow : void 0;
|
|
114
|
-
if (functionPath === void 0 && workflow === void 0) {
|
|
115
|
-
return void 0;
|
|
116
|
-
}
|
|
117
|
-
return { functionPath, workflow };
|
|
118
|
-
}
|
|
119
|
-
state;
|
|
120
|
-
env;
|
|
121
|
-
constructor(state, env) {
|
|
122
|
-
this.state = state;
|
|
123
|
-
this.env = env;
|
|
124
|
-
}
|
|
125
|
-
async fetch(request) {
|
|
126
|
-
const url = new URL(request.url);
|
|
127
|
-
if (url.pathname === "/ws" && request.headers.get("Upgrade") === "websocket") {
|
|
128
|
-
return this.handleWebSocketUpgrade();
|
|
129
|
-
}
|
|
130
|
-
switch (`${request.method} ${url.pathname}`) {
|
|
131
|
-
case "GET /dead": {
|
|
132
|
-
return this.handleDeadList();
|
|
133
|
-
}
|
|
134
|
-
case "GET /get": {
|
|
135
|
-
return this.handleGet(url);
|
|
136
|
-
}
|
|
137
|
-
case "GET /list": {
|
|
138
|
-
return this.handleList();
|
|
139
|
-
}
|
|
140
|
-
case "GET /pool": {
|
|
141
|
-
return this.handlePoolStatus(url);
|
|
142
|
-
}
|
|
143
|
-
case "GET /status": {
|
|
144
|
-
return this.handleStatus();
|
|
145
|
-
}
|
|
146
|
-
case "POST /cancel": {
|
|
147
|
-
return this.handleCancel(request);
|
|
148
|
-
}
|
|
149
|
-
case "POST /complete": {
|
|
150
|
-
return this.handleComplete(request);
|
|
151
|
-
}
|
|
152
|
-
case "POST /dead/cancel": {
|
|
153
|
-
return this.handleDeadCancel(request);
|
|
154
|
-
}
|
|
155
|
-
case "POST /dead/retry": {
|
|
156
|
-
return this.handleDeadRetry(request);
|
|
157
|
-
}
|
|
158
|
-
case "POST /schedule": {
|
|
159
|
-
return this.handleSchedule(request);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
return jsonResponse({ error: { code: "NOT_FOUND" } }, 404);
|
|
163
|
-
}
|
|
164
|
-
/** Called by the Workers runtime when the alarm previously set by `_rescheduleAlarm()` fires. */
|
|
165
|
-
async alarm() {
|
|
166
|
-
const now = Date.now();
|
|
167
|
-
const due = [];
|
|
168
|
-
const indexEntries = await this.state.storage.list({
|
|
169
|
-
end: `t:${padTime(now)}:~`,
|
|
170
|
-
limit: 100,
|
|
171
|
-
prefix: "t:"
|
|
172
|
-
});
|
|
173
|
-
for (const [indexKey, recordId] of indexEntries.entries()) {
|
|
174
|
-
const dueAt = Number.parseInt(indexKey.slice(2, indexKey.indexOf(":", 2)), 10);
|
|
175
|
-
if (Number.isFinite(dueAt) && dueAt <= now) {
|
|
176
|
-
const record = await this.state.storage.get(`${HEADER_PREFIX}${recordId}`);
|
|
177
|
-
if (record) {
|
|
178
|
-
due.push(record);
|
|
179
|
-
} else {
|
|
180
|
-
await this.state.storage.delete(indexKey);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
try {
|
|
185
|
-
for (const record of due) {
|
|
186
|
-
await this.drainRecordGuarded(record);
|
|
187
|
-
}
|
|
188
|
-
} finally {
|
|
189
|
-
await this.rescheduleAlarm();
|
|
190
|
-
}
|
|
191
|
-
if (due.length > 0) {
|
|
192
|
-
await this.broadcastChange();
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
/**
|
|
196
|
-
* Internal dispatch hook; overridden in unit tests to capture the outgoing
|
|
197
|
-
* request. Returns `true` ONLY on an explicit 2xx response (`response.ok`).
|
|
198
|
-
* Anything else — a network failure, a 5xx, OR a non-2xx such as 404
|
|
199
|
-
* (receiver route not mounted) / 401 / 403 / 4xx — returns `false` and
|
|
200
|
-
* enters the retry pipeline via {@link recordRetry}. Treating 4xx as
|
|
201
|
-
* success used to permanently delete the job; since the receiver may simply
|
|
202
|
-
* be missing (404) or transiently failing, we retry rather than silently
|
|
203
|
-
* drop. After {@link MAX_RETRY_ATTEMPTS} the record is parked under a
|
|
204
|
-
* `dead:` key for inspection — never silently deleted.
|
|
205
|
-
*
|
|
206
|
-
* The dispatch target is taken from `env.LUNORA_ORIGIN_URL` (NOT from the
|
|
207
|
-
* stored record) to prevent SSRF via a forged `originUrl` on the schedule
|
|
208
|
-
* request. If that env var is missing at fire time (a deploy/binding
|
|
209
|
-
* regression — schedule time already enforced its presence) we return
|
|
210
|
-
* `false` so the record is retried rather than silently dropped.
|
|
211
|
-
*/
|
|
212
|
-
async dispatch(record) {
|
|
213
|
-
const originUrl = typeof this.env.LUNORA_ORIGIN_URL === "string" && this.env.LUNORA_ORIGIN_URL.length > 0 ? this.env.LUNORA_ORIGIN_URL : void 0;
|
|
214
|
-
if (!originUrl) {
|
|
215
|
-
return false;
|
|
216
|
-
}
|
|
217
|
-
const body = JSON.stringify({
|
|
218
|
-
args: record.args,
|
|
219
|
-
functionPath: record.functionPath,
|
|
220
|
-
id: record.id,
|
|
221
|
-
// Echoed so the receiver can call back the SAME DO instance.
|
|
222
|
-
instanceName: record.instanceName,
|
|
223
|
-
// When the job belongs to a workpool, the receiver must report
|
|
224
|
-
// completion back to the SchedulerDO (`POST /complete { pool, id }`)
|
|
225
|
-
// so the pool's concurrency slot is released — see handleComplete().
|
|
226
|
-
pool: record.pool,
|
|
227
|
-
scheduledFor: record.scheduledFor,
|
|
228
|
-
shardKey: record.shardKey,
|
|
229
|
-
// Present instead of `functionPath` for a workflow/agent target; the
|
|
230
|
-
// runtime starts a fresh instance of this binding. `undefined` when
|
|
231
|
-
// absent, so JSON.stringify drops it and the payload is unchanged for
|
|
232
|
-
// ordinary function dispatches.
|
|
233
|
-
workflow: record.workflow
|
|
234
|
-
});
|
|
235
|
-
try {
|
|
236
|
-
const headers = { "content-type": "application/json" };
|
|
237
|
-
const signature = await this.signDispatch(body);
|
|
238
|
-
if (signature !== void 0) {
|
|
239
|
-
headers["x-lunora-scheduler-signature"] = signature;
|
|
240
|
-
} else if (typeof this.env.LUNORA_ADMIN_TOKEN === "string" && this.env.LUNORA_ADMIN_TOKEN.length > 0) {
|
|
241
|
-
headers.authorization = `Bearer ${this.env.LUNORA_ADMIN_TOKEN}`;
|
|
242
|
-
}
|
|
243
|
-
const response = await fetch(`${originUrl}/_lunora/scheduler/dispatch`, {
|
|
244
|
-
body,
|
|
245
|
-
headers,
|
|
246
|
-
method: "POST"
|
|
247
|
-
});
|
|
248
|
-
return response.ok;
|
|
249
|
-
} catch {
|
|
250
|
-
return false;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
/**
|
|
254
|
-
* Claim + drain one due record with per-record fault isolation, so a storage
|
|
255
|
-
* throw can never abort the whole alarm pass (which would skip the remaining
|
|
256
|
-
* due records and the `rescheduleAlarm()` that re-arms the clock).
|
|
257
|
-
*
|
|
258
|
-
* Claims the job by deleting its time-index entry BEFORE dispatch (an alarm
|
|
259
|
-
* re-fire then won't pick it up again), runs {@link drainRecord}, and on a
|
|
260
|
-
* thrown storage op re-asserts the claim so the job stays re-fireable.
|
|
261
|
-
*
|
|
262
|
-
* A throw reaching here always means the job was NOT dispatched:
|
|
263
|
-
* {@link drainRecord} swallows its own post-dispatch cleanup errors and
|
|
264
|
-
* returns instead of throwing once a kick succeeds, so every escaping throw
|
|
265
|
-
* comes from the pre-dispatch or failed-dispatch paths. We therefore always
|
|
266
|
-
* re-assert the time-index claim so a later alarm re-attempts it
|
|
267
|
-
* (at-least-once): the claim delete may have removed it and
|
|
268
|
-
* recordRetry()/requeuePooled() may not have re-armed it before throwing, and
|
|
269
|
-
* re-inserting the same key is idempotent, so a surviving claim is simply
|
|
270
|
-
* rewritten to its prior value.
|
|
271
|
-
*/
|
|
272
|
-
async drainRecordGuarded(record) {
|
|
273
|
-
try {
|
|
274
|
-
await this.state.storage.delete(SchedulerDO.indexKey(record.scheduledFor, record.id));
|
|
275
|
-
await this.drainRecord(record);
|
|
276
|
-
} catch {
|
|
277
|
-
try {
|
|
278
|
-
await this.state.storage.put(SchedulerDO.indexKey(record.scheduledFor, record.id), record.id);
|
|
279
|
-
} catch {
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
/**
|
|
284
|
-
* Process one due (already index-claimed) record within an alarm drain:
|
|
285
|
-
* apply the workpool concurrency gate, dispatch, and settle the result.
|
|
286
|
-
* A saturated pool re-arms the job (backpressure, no attempt charged); a
|
|
287
|
-
* free slot is reserved durably before dispatch and released immediately if
|
|
288
|
-
* the kick fails (success holds it until the runtime reports completion).
|
|
289
|
-
* Success clears the `id:`/`retry:` rows; failure routes to
|
|
290
|
-
* {@link recordRetry}. Pool state is read FRESH from storage per record (see
|
|
291
|
-
* {@link reservePoolSlot}) and never held across the dispatch() await, so a
|
|
292
|
-
* concurrent /complete landing mid-dispatch can't be clobbered.
|
|
293
|
-
* Once a kick succeeds, post-dispatch cleanup (clearing the `id:`/`retry:`
|
|
294
|
-
* rows) is swallowed rather than allowed to throw, so a successful dispatch
|
|
295
|
-
* NEVER propagates an error to {@link drainRecordGuarded}: every throw that
|
|
296
|
-
* escapes comes from the pre-dispatch or failed-dispatch paths, where the job
|
|
297
|
-
* is still re-fireable and the guard safely re-claims the time index.
|
|
298
|
-
* @returns `true` only when the record was successfully dispatched (a 2xx
|
|
299
|
-
* kick); `false` on pool backpressure or a failed dispatch (the job is still
|
|
300
|
-
* re-fireable — already re-armed here). The value is informational (the guard
|
|
301
|
-
* branches on throw/no-throw, not on this boolean).
|
|
302
|
-
*/
|
|
303
|
-
async drainRecord(record) {
|
|
304
|
-
const reserved = await this.reservePoolSlot(record);
|
|
305
|
-
if (!reserved) {
|
|
306
|
-
return false;
|
|
307
|
-
}
|
|
308
|
-
const ok = await this.dispatch(record);
|
|
309
|
-
if (!ok && record.pool !== void 0) {
|
|
310
|
-
const pool = await this.loadPool(record.pool);
|
|
311
|
-
const released = SchedulerDO.releaseSlot(pool, record.id);
|
|
312
|
-
await this.savePool(record.pool, released);
|
|
313
|
-
}
|
|
314
|
-
if (ok) {
|
|
315
|
-
try {
|
|
316
|
-
await this.state.storage.delete([`${HEADER_PREFIX}${record.id}`, `${RETRY_PREFIX}${record.id}`]);
|
|
317
|
-
} catch {
|
|
318
|
-
}
|
|
319
|
-
return true;
|
|
320
|
-
}
|
|
321
|
-
await this.recordRetry(record);
|
|
322
|
-
return false;
|
|
323
|
-
}
|
|
324
|
-
/**
|
|
325
|
-
* Concurrency gate for a pooled record. Returns `false` (and re-arms the
|
|
326
|
-
* job via {@link requeuePooled}) when the pool is at `maxConcurrency`;
|
|
327
|
-
* otherwise reserves a slot durably and returns `true`. Non-pooled records
|
|
328
|
-
* always return `true` without touching any pool state.
|
|
329
|
-
*
|
|
330
|
-
* The pool row is read FRESH from storage on every call — never cached
|
|
331
|
-
* across the drain. Each reservation durably `savePool()`s before the next
|
|
332
|
-
* record runs, so a same-pass reservation is still visible to the next
|
|
333
|
-
* record's fresh read (the budget carries forward); and because dispatch()
|
|
334
|
-
* awaits an outbound fetch between records, a concurrent /complete that
|
|
335
|
-
* decrements the row mid-drain IS reflected here instead of being clobbered
|
|
336
|
-
* by a stale in-memory copy (which would leak a slot permanently).
|
|
337
|
-
*/
|
|
338
|
-
async reservePoolSlot(record) {
|
|
339
|
-
if (record.pool === void 0) {
|
|
340
|
-
return true;
|
|
341
|
-
}
|
|
342
|
-
const pool = await this.loadPool(record.pool);
|
|
343
|
-
if (pool.inFlight >= pool.maxConcurrency) {
|
|
344
|
-
await this.requeuePooled(record);
|
|
345
|
-
return false;
|
|
346
|
-
}
|
|
347
|
-
const ids = pool.inFlightIds ?? [];
|
|
348
|
-
if (!ids.includes(record.id)) {
|
|
349
|
-
ids.push(record.id);
|
|
350
|
-
}
|
|
351
|
-
pool.inFlightIds = ids;
|
|
352
|
-
pool.inFlight = ids.length;
|
|
353
|
-
await this.savePool(record.pool, pool);
|
|
354
|
-
return true;
|
|
355
|
-
}
|
|
356
|
-
/**
|
|
357
|
-
* Accept a hibernatable live subscription to the job list. The scheduler has
|
|
358
|
-
* exactly one subscription shape (the whole list), so there's no per-socket
|
|
359
|
-
* registry or dependency tracking — every accepted socket gets the full list
|
|
360
|
-
* on connect and on every change. The worker is responsible for gating the
|
|
361
|
-
* upgrade behind the admin token before it reaches here.
|
|
362
|
-
*/
|
|
363
|
-
async handleWebSocketUpgrade() {
|
|
364
|
-
if (this.state.acceptWebSocket === void 0) {
|
|
365
|
-
return SchedulerDO.error(501, "WS_UNSUPPORTED", "WebSocket subscriptions are not supported in this runtime");
|
|
366
|
-
}
|
|
367
|
-
const pair = new WebSocketPair();
|
|
368
|
-
const client = pair[0];
|
|
369
|
-
const server = pair[1];
|
|
370
|
-
this.state.acceptWebSocket(server);
|
|
371
|
-
server.send(JSON.stringify({ records: await this.listRecords(), type: "jobs" }));
|
|
372
|
-
return new Response(null, { status: 101, webSocket: client });
|
|
373
|
-
}
|
|
374
|
-
/**
|
|
375
|
-
* Re-list the jobs and push them to every connected subscriber. Called after
|
|
376
|
-
* any change (schedule / cancel / alarm-fire) so live studios reflect it
|
|
377
|
-
* immediately. A no-op when the runtime doesn't support hibernated sockets.
|
|
378
|
-
*/
|
|
379
|
-
async broadcastChange() {
|
|
380
|
-
const sockets = this.state.getWebSockets?.();
|
|
381
|
-
if (sockets === void 0 || sockets.length === 0) {
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
const message = JSON.stringify({ records: await this.listRecords(), type: "jobs" });
|
|
385
|
-
for (const socket of sockets) {
|
|
386
|
-
try {
|
|
387
|
-
socket.send(message);
|
|
388
|
-
} catch {
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
/** The current pending job records (shared by `/list` and the live channel). */
|
|
393
|
-
async listRecords() {
|
|
394
|
-
const entries = await this.state.storage.list({ prefix: HEADER_PREFIX });
|
|
395
|
-
return [...entries.values()];
|
|
396
|
-
}
|
|
397
|
-
/**
|
|
398
|
-
* HMAC-SHA-256 sign the dispatch body with `env.LUNORA_SCHEDULER_SECRET`,
|
|
399
|
-
* returning a base64url signature, or `undefined` when no secret is
|
|
400
|
-
* configured. Mirrors `@lunora/storage`'s signed-URL HMAC pattern (WebCrypto
|
|
401
|
-
* `crypto.subtle`, available in workerd).
|
|
402
|
-
*/
|
|
403
|
-
async signDispatch(body) {
|
|
404
|
-
const secret = typeof this.env.LUNORA_SCHEDULER_SECRET === "string" ? this.env.LUNORA_SCHEDULER_SECRET : void 0;
|
|
405
|
-
if (!secret || secret.length === 0) {
|
|
406
|
-
return void 0;
|
|
407
|
-
}
|
|
408
|
-
const encoder = new TextEncoder();
|
|
409
|
-
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { hash: "SHA-256", name: "HMAC" }, false, ["sign"]);
|
|
410
|
-
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
|
|
411
|
-
const bytes = new Uint8Array(signature);
|
|
412
|
-
let binary = "";
|
|
413
|
-
for (const byte of bytes) {
|
|
414
|
-
binary += String.fromCodePoint(byte);
|
|
415
|
-
}
|
|
416
|
-
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
417
|
-
}
|
|
418
|
-
/**
|
|
419
|
-
* Move a failed record into the retry pipeline with configurable backoff.
|
|
420
|
-
* The retry budget/backoff comes from the record's {@link RetryPolicy}
|
|
421
|
-
* (falling back to the DO defaults); on exhaustion the record is parked
|
|
422
|
-
* under a `dead:` key for manual inspection.
|
|
423
|
-
*/
|
|
424
|
-
async recordRetry(record) {
|
|
425
|
-
const attempts = (record.attempts ?? 0) + 1;
|
|
426
|
-
const { backoff, baseMs, maxAttempts, maxMs } = SchedulerDO.resolveRetry(record);
|
|
427
|
-
if (attempts > maxAttempts) {
|
|
428
|
-
await this.state.storage.put(`${DEAD_PREFIX}${record.id}`, { ...record, attempts });
|
|
429
|
-
await this.state.storage.delete([`${RETRY_PREFIX}${record.id}`, `${HEADER_PREFIX}${record.id}`]);
|
|
430
|
-
console.warn(
|
|
431
|
-
`@lunora/scheduler: job "${record.id}" (${record.functionPath ?? record.workflow ?? "unknown"}) parked in dead-letter after ${String(attempts)} attempts`
|
|
432
|
-
);
|
|
433
|
-
return;
|
|
434
|
-
}
|
|
435
|
-
const rawDelay = backoff === "linear" ? baseMs * attempts : baseMs * 2 ** (attempts - 1);
|
|
436
|
-
const delayMs = maxMs === void 0 ? rawDelay : Math.min(rawDelay, maxMs);
|
|
437
|
-
const nextScheduledFor = Date.now() + delayMs;
|
|
438
|
-
const retryRecord = {
|
|
439
|
-
...record,
|
|
440
|
-
attempts,
|
|
441
|
-
scheduledFor: nextScheduledFor
|
|
442
|
-
};
|
|
443
|
-
await this.state.storage.put(`${RETRY_PREFIX}${record.id}`, retryRecord);
|
|
444
|
-
await this.state.storage.put(`${HEADER_PREFIX}${record.id}`, retryRecord);
|
|
445
|
-
await this.state.storage.put(SchedulerDO.indexKey(nextScheduledFor, record.id), record.id);
|
|
446
|
-
}
|
|
447
|
-
/** Read the durable `pool:<name>` row, defaulting to a fresh `inFlight: 0` pool. */
|
|
448
|
-
async loadPool(name, maxConcurrencyHint) {
|
|
449
|
-
const stored = await this.state.storage.get(`${POOL_PREFIX}${name}`);
|
|
450
|
-
if (stored !== void 0) {
|
|
451
|
-
if (Array.isArray(stored.inFlightIds)) {
|
|
452
|
-
return { inFlight: stored.inFlightIds.length, inFlightIds: [...stored.inFlightIds], maxConcurrency: stored.maxConcurrency };
|
|
453
|
-
}
|
|
454
|
-
return { inFlight: Math.max(0, stored.inFlight), maxConcurrency: stored.maxConcurrency };
|
|
455
|
-
}
|
|
456
|
-
return { inFlight: 0, inFlightIds: [], maxConcurrency: SchedulerDO.normalizeConcurrency(maxConcurrencyHint, 1) };
|
|
457
|
-
}
|
|
458
|
-
async savePool(name, pool) {
|
|
459
|
-
await this.state.storage.put(`${POOL_PREFIX}${name}`, pool);
|
|
460
|
-
}
|
|
461
|
-
/**
|
|
462
|
-
* Re-arm a pooled job that couldn't run because its pool was at capacity.
|
|
463
|
-
* No attempt is charged (this is backpressure, not a failure): the job is
|
|
464
|
-
* pushed `POOL_BACKPRESSURE_DELAY_MS` into the future so a later alarm
|
|
465
|
-
* drains it once a slot frees, keeping its `id:` header and retry policy.
|
|
466
|
-
*/
|
|
467
|
-
async requeuePooled(record) {
|
|
468
|
-
const nextScheduledFor = Date.now() + POOL_BACKPRESSURE_DELAY_MS;
|
|
469
|
-
const requeued = { ...record, scheduledFor: nextScheduledFor };
|
|
470
|
-
await this.state.storage.put(`${HEADER_PREFIX}${record.id}`, requeued);
|
|
471
|
-
await this.state.storage.put(SchedulerDO.indexKey(nextScheduledFor, record.id), record.id);
|
|
472
|
-
}
|
|
473
|
-
/**
|
|
474
|
-
* Release a pool slot when the runtime reports an action finished. This is
|
|
475
|
-
* the durable-semaphore decrement: dispatch() only KICKS the action and
|
|
476
|
-
* holds the slot; the runtime calls back here (`POST /complete { id }`) once
|
|
477
|
-
* the action settles, freeing the slot for the next queued job. Idempotent
|
|
478
|
-
* and safe if the job/pool is already gone.
|
|
479
|
-
*/
|
|
480
|
-
async handleComplete(request) {
|
|
481
|
-
const body = await request.json().catch(() => void 0);
|
|
482
|
-
const poolName = typeof body?.pool === "string" && body.pool.length > 0 ? body.pool : void 0;
|
|
483
|
-
const jobId = typeof body?.id === "string" && body.id.length > 0 ? body.id : void 0;
|
|
484
|
-
if (poolName === void 0) {
|
|
485
|
-
return SchedulerDO.error(400, "INVALID_INPUT", "pool is required");
|
|
486
|
-
}
|
|
487
|
-
const pool = await this.loadPool(poolName);
|
|
488
|
-
const next = jobId === void 0 ? SchedulerDO.releaseFirstSlot(pool) : SchedulerDO.releaseSlot(pool, jobId);
|
|
489
|
-
await this.savePool(poolName, next);
|
|
490
|
-
await this.armAlarmIfEarlier(Date.now());
|
|
491
|
-
return SchedulerDO.json({ inFlight: next.inFlight });
|
|
492
|
-
}
|
|
493
|
-
/** `GET /pool?name=` — inspect a pool's slot usage + queued count. */
|
|
494
|
-
async handlePoolStatus(url) {
|
|
495
|
-
const name = url.searchParams.get("name");
|
|
496
|
-
if (name === null || name.length === 0) {
|
|
497
|
-
return SchedulerDO.error(400, "INVALID_INPUT", "name is required");
|
|
498
|
-
}
|
|
499
|
-
const pool = await this.loadPool(name);
|
|
500
|
-
const headers = await this.state.storage.list({ prefix: HEADER_PREFIX });
|
|
501
|
-
let queued = 0;
|
|
502
|
-
for (const record of headers.values()) {
|
|
503
|
-
if (record.pool === name) {
|
|
504
|
-
queued += 1;
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
return SchedulerDO.json({ inFlight: pool.inFlight, maxConcurrency: pool.maxConcurrency, queued });
|
|
508
|
-
}
|
|
509
|
-
/**
|
|
510
|
-
* `GET /status` — the app-level backlog signal that powers the studio's
|
|
511
|
-
* SLO view. Enumerates every durable `pool:<name>` row for its `inFlight`/
|
|
512
|
-
* `maxConcurrency` semaphore, counts the pending (not-yet-dispatched) jobs
|
|
513
|
-
* routed to each pool with the same single-pass scan {@link handlePoolStatus}
|
|
514
|
-
* uses, and rolls those up into app-wide `backlog` (sum of `queued`) and
|
|
515
|
-
* `inFlight` (sum of held slots) totals.
|
|
516
|
-
*
|
|
517
|
-
* Pools that have rows but no queued jobs still appear (with `queued: 0`) so
|
|
518
|
-
* a saturated-but-idle pool stays visible; a pool that only ever existed as
|
|
519
|
-
* queued jobs without a persisted row is unreachable here (the schedule path
|
|
520
|
-
* always writes a `pool:<name>` row before the job's header), so a single
|
|
521
|
-
* scan over `pool:`/`id:` is sufficient.
|
|
522
|
-
*/
|
|
523
|
-
async handleStatus() {
|
|
524
|
-
const poolRows = await this.state.storage.list({ prefix: POOL_PREFIX });
|
|
525
|
-
const headers = await this.state.storage.list({ prefix: HEADER_PREFIX });
|
|
526
|
-
const queuedByPool = /* @__PURE__ */ new Map();
|
|
527
|
-
for (const record of headers.values()) {
|
|
528
|
-
if (record.pool !== void 0) {
|
|
529
|
-
queuedByPool.set(record.pool, (queuedByPool.get(record.pool) ?? 0) + 1);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
const pools = [];
|
|
533
|
-
let backlog = 0;
|
|
534
|
-
let inFlight = 0;
|
|
535
|
-
for (const [key, pool] of poolRows.entries()) {
|
|
536
|
-
const name = key.slice(POOL_PREFIX.length);
|
|
537
|
-
const slots = Math.max(0, pool.inFlight);
|
|
538
|
-
const queued = queuedByPool.get(name) ?? 0;
|
|
539
|
-
pools.push({ inFlight: slots, maxConcurrency: pool.maxConcurrency, name, queued });
|
|
540
|
-
backlog += queued;
|
|
541
|
-
inFlight += slots;
|
|
542
|
-
}
|
|
543
|
-
const status = { backlog, inFlight, pools };
|
|
544
|
-
return SchedulerDO.json(status);
|
|
545
|
-
}
|
|
546
|
-
async handleSchedule(request) {
|
|
547
|
-
const body = await request.json().catch(() => void 0);
|
|
548
|
-
const target = SchedulerDO.resolveScheduleTarget(body);
|
|
549
|
-
if (!body || target === void 0) {
|
|
550
|
-
return SchedulerDO.error(400, "INVALID_INPUT", "functionPath or workflow is required");
|
|
551
|
-
}
|
|
552
|
-
const { functionPath, workflow } = target;
|
|
553
|
-
if (typeof body.scheduledFor !== "number" || !Number.isInteger(body.scheduledFor) || body.scheduledFor <= 0 || body.scheduledFor > MAX_SCHEDULED_FOR_MS) {
|
|
554
|
-
return SchedulerDO.error(400, "INVALID_INPUT", "scheduledFor must be a positive integer epoch-millisecond number no greater than 999999999999999");
|
|
555
|
-
}
|
|
556
|
-
if (typeof this.env.LUNORA_ORIGIN_URL !== "string" || this.env.LUNORA_ORIGIN_URL.length === 0) {
|
|
557
|
-
return SchedulerDO.error(500, "ORIGIN_NOT_CONFIGURED", "LUNORA_ORIGIN_URL env binding must be set on the SchedulerDO");
|
|
558
|
-
}
|
|
559
|
-
const pool = typeof body.pool === "string" && body.pool.length > 0 ? body.pool : void 0;
|
|
560
|
-
const instanceName = typeof body.instanceName === "string" && body.instanceName.length > 0 ? body.instanceName : void 0;
|
|
561
|
-
const retry = SchedulerDO.normalizeRetry(body.retry);
|
|
562
|
-
const id = generateId();
|
|
563
|
-
const record = {
|
|
564
|
-
// body is parsed from an untrusted request; args may be absent at runtime
|
|
565
|
-
// despite the type, so the ?? fallback is a real guard.
|
|
566
|
-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- parsed wire data can omit args
|
|
567
|
-
args: body.args ?? {},
|
|
568
|
-
enqueuedAt: Date.now(),
|
|
569
|
-
id,
|
|
570
|
-
...functionPath === void 0 ? {} : { functionPath },
|
|
571
|
-
...instanceName === void 0 ? {} : { instanceName },
|
|
572
|
-
...pool === void 0 ? {} : { pool },
|
|
573
|
-
...retry === void 0 ? {} : { retry },
|
|
574
|
-
scheduledFor: body.scheduledFor,
|
|
575
|
-
shardKey: body.shardKey,
|
|
576
|
-
...workflow === void 0 ? {} : { workflow }
|
|
577
|
-
};
|
|
578
|
-
if (pool !== void 0) {
|
|
579
|
-
const current = await this.loadPool(pool, body.maxConcurrency);
|
|
580
|
-
await this.savePool(pool, {
|
|
581
|
-
inFlight: current.inFlight,
|
|
582
|
-
// Preserve the in-flight id set so refreshing the cap on a new
|
|
583
|
-
// enqueue can't wipe the held-slot bookkeeping (which would let
|
|
584
|
-
// a later /complete over-release).
|
|
585
|
-
...current.inFlightIds === void 0 ? {} : { inFlightIds: current.inFlightIds },
|
|
586
|
-
maxConcurrency: SchedulerDO.normalizeConcurrency(body.maxConcurrency, current.maxConcurrency)
|
|
587
|
-
});
|
|
588
|
-
}
|
|
589
|
-
await this.state.storage.put(`${HEADER_PREFIX}${id}`, record);
|
|
590
|
-
await this.state.storage.put(SchedulerDO.indexKey(record.scheduledFor, id), id);
|
|
591
|
-
await this.armAlarmIfEarlier(record.scheduledFor);
|
|
592
|
-
await this.broadcastChange();
|
|
593
|
-
return SchedulerDO.json({ id, scheduledFor: record.scheduledFor });
|
|
594
|
-
}
|
|
595
|
-
async handleCancel(request) {
|
|
596
|
-
const body = await request.json().catch(() => void 0);
|
|
597
|
-
if (!body?.id) {
|
|
598
|
-
return SchedulerDO.error(400, "INVALID_INPUT", "id is required");
|
|
599
|
-
}
|
|
600
|
-
const record = await this.state.storage.get(`${HEADER_PREFIX}${body.id}`);
|
|
601
|
-
if (!record) {
|
|
602
|
-
return SchedulerDO.json({ cancelled: false });
|
|
603
|
-
}
|
|
604
|
-
await this.removeRecord(record);
|
|
605
|
-
await this.rescheduleAlarm();
|
|
606
|
-
await this.broadcastChange();
|
|
607
|
-
return SchedulerDO.json({ cancelled: true });
|
|
608
|
-
}
|
|
609
|
-
async handleList() {
|
|
610
|
-
return SchedulerDO.json({ records: await this.listRecords() });
|
|
611
|
-
}
|
|
612
|
-
/**
|
|
613
|
-
* `GET /dead` — list the dead-letter records: jobs that exhausted their
|
|
614
|
-
* retry budget ({@link recordRetry}) and were parked under `dead:<id>`
|
|
615
|
-
* instead of being silently dropped. These never appear in `/list` (their
|
|
616
|
-
* `id:` header is deleted on park), so this is the ONLY way the studio can
|
|
617
|
-
* surface — and recover — a permanently-failed job.
|
|
618
|
-
*/
|
|
619
|
-
async handleDeadList() {
|
|
620
|
-
const entries = await this.state.storage.list({ prefix: DEAD_PREFIX });
|
|
621
|
-
return SchedulerDO.json({ records: [...entries.values()] });
|
|
622
|
-
}
|
|
623
|
-
/**
|
|
624
|
-
* `POST /dead/retry { id }` — resurrect a dead-letter record: reset its
|
|
625
|
-
* exhausted attempt count to 0 (a fresh retry budget), re-arm it for
|
|
626
|
-
* immediate dispatch via the standard time index, and drop the `dead:` row.
|
|
627
|
-
* The new `id:` header makes it visible to `/list` and the live `/ws`
|
|
628
|
-
* subscription again. A miss is a no-op (`{ retried: false }`).
|
|
629
|
-
*/
|
|
630
|
-
async handleDeadRetry(request) {
|
|
631
|
-
const body = await request.json().catch(() => void 0);
|
|
632
|
-
if (typeof body?.id !== "string" || body.id.length === 0) {
|
|
633
|
-
return SchedulerDO.error(400, "INVALID_INPUT", "id is required");
|
|
634
|
-
}
|
|
635
|
-
const dead = await this.state.storage.get(`${DEAD_PREFIX}${body.id}`);
|
|
636
|
-
if (dead === void 0) {
|
|
637
|
-
return SchedulerDO.json({ retried: false });
|
|
638
|
-
}
|
|
639
|
-
const scheduledFor = Date.now();
|
|
640
|
-
const revived = { ...dead, attempts: 0, scheduledFor };
|
|
641
|
-
await this.state.storage.put(`${HEADER_PREFIX}${dead.id}`, revived);
|
|
642
|
-
await this.state.storage.put(SchedulerDO.indexKey(scheduledFor, dead.id), dead.id);
|
|
643
|
-
await this.state.storage.delete(`${DEAD_PREFIX}${dead.id}`);
|
|
644
|
-
await this.armAlarmIfEarlier(scheduledFor);
|
|
645
|
-
await this.broadcastChange();
|
|
646
|
-
return SchedulerDO.json({ id: dead.id, retried: true, scheduledFor });
|
|
647
|
-
}
|
|
648
|
-
/**
|
|
649
|
-
* `POST /dead/cancel { id }` — permanently drop a dead-letter record the
|
|
650
|
-
* operator has decided not to recover. Returns `{ removed }` (false when
|
|
651
|
-
* nothing matched). Idempotent: a repeated purge is a harmless no-op.
|
|
652
|
-
*/
|
|
653
|
-
async handleDeadCancel(request) {
|
|
654
|
-
const body = await request.json().catch(() => void 0);
|
|
655
|
-
if (typeof body?.id !== "string" || body.id.length === 0) {
|
|
656
|
-
return SchedulerDO.error(400, "INVALID_INPUT", "id is required");
|
|
657
|
-
}
|
|
658
|
-
const removed = await this.state.storage.delete(`${DEAD_PREFIX}${body.id}`);
|
|
659
|
-
return SchedulerDO.json({ removed: Boolean(removed) });
|
|
660
|
-
}
|
|
661
|
-
/**
|
|
662
|
-
* Resolve a single pending job by id via a direct `id:<id>` storage read —
|
|
663
|
-
* O(1), versus scanning the whole `/list` view. Responds `{ record }` on a
|
|
664
|
-
* hit and `{}` on a miss (an absent `record` field — JSON has no `undefined`
|
|
665
|
-
* — which the client reads back as `null`).
|
|
666
|
-
*/
|
|
667
|
-
async handleGet(url) {
|
|
668
|
-
const id = url.searchParams.get("id");
|
|
669
|
-
if (id === null || id.length === 0) {
|
|
670
|
-
return SchedulerDO.error(400, "INVALID_INPUT", "id is required");
|
|
671
|
-
}
|
|
672
|
-
const record = await this.state.storage.get(`${HEADER_PREFIX}${id}`);
|
|
673
|
-
return SchedulerDO.json(record === void 0 ? {} : { record });
|
|
674
|
-
}
|
|
675
|
-
async removeRecord(record) {
|
|
676
|
-
await this.state.storage.delete([`${HEADER_PREFIX}${record.id}`, SchedulerDO.indexKey(record.scheduledFor, record.id), `${RETRY_PREFIX}${record.id}`]);
|
|
677
|
-
}
|
|
678
|
-
/**
|
|
679
|
-
* Arm the alarm for `scheduledFor` only if it is sooner than the currently
|
|
680
|
-
* set alarm (or none is set). Used on the schedule path: inserting a job
|
|
681
|
-
* can only ever pull the earliest-pending time *earlier*, never later, so a
|
|
682
|
-
* full `t:` rescan is unnecessary unless the new job is the new earliest.
|
|
683
|
-
*/
|
|
684
|
-
async armAlarmIfEarlier(scheduledFor) {
|
|
685
|
-
const current = await this.state.storage.getAlarm();
|
|
686
|
-
if (current === null || scheduledFor < current) {
|
|
687
|
-
await this.state.storage.setAlarm(scheduledFor);
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
async rescheduleAlarm() {
|
|
691
|
-
const entries = await this.state.storage.list({ limit: 1, prefix: "t:" });
|
|
692
|
-
const first = entries.entries().next();
|
|
693
|
-
if (first.done) {
|
|
694
|
-
await this.state.storage.deleteAlarm();
|
|
695
|
-
return;
|
|
696
|
-
}
|
|
697
|
-
const [indexKey] = first.value;
|
|
698
|
-
const dueAt = Number.parseInt(indexKey.slice(2, indexKey.indexOf(":", 2)), 10);
|
|
699
|
-
if (Number.isFinite(dueAt)) {
|
|
700
|
-
await this.state.storage.setAlarm(dueAt);
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
export { SchedulerDO };
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { LunoraError } from '@lunora/errors';
|
|
2
|
-
import { CronExpressionParser } from 'cron-parser';
|
|
3
|
-
|
|
4
|
-
const isValidCronExpression = (schedule) => {
|
|
5
|
-
if (typeof schedule !== "string" || schedule.trim() === "") {
|
|
6
|
-
return false;
|
|
7
|
-
}
|
|
8
|
-
try {
|
|
9
|
-
CronExpressionParser.parse(schedule.trim());
|
|
10
|
-
return true;
|
|
11
|
-
} catch {
|
|
12
|
-
return false;
|
|
13
|
-
}
|
|
14
|
-
};
|
|
15
|
-
const assertValidCronExpression = (schedule, context = "cron expression") => {
|
|
16
|
-
if (!isValidCronExpression(schedule)) {
|
|
17
|
-
throw new LunoraError(
|
|
18
|
-
"INTERNAL",
|
|
19
|
-
`@lunora/scheduler: invalid ${context} "${schedule}" — expected a standard 5- or 6-field cron expression (e.g. "0 * * * *")`
|
|
20
|
-
);
|
|
21
|
-
}
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export { assertValidCronExpression, isValidCronExpression };
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { LunoraError } from '@lunora/errors';
|
|
2
|
-
import { assertValidCronExpression } from './assertValidCronExpression-B9m75qU0.mjs';
|
|
3
|
-
|
|
4
|
-
const createCronTrigger = (options) => {
|
|
5
|
-
if (!options.schedule || !options.fn) {
|
|
6
|
-
throw new LunoraError("INTERNAL", "@lunora/scheduler: createCronTrigger() requires `schedule` and `fn`");
|
|
7
|
-
}
|
|
8
|
-
assertValidCronExpression(options.schedule);
|
|
9
|
-
const snippet = JSON.stringify(
|
|
10
|
-
{
|
|
11
|
-
triggers: {
|
|
12
|
-
crons: [options.schedule]
|
|
13
|
-
}
|
|
14
|
-
},
|
|
15
|
-
void 0,
|
|
16
|
-
2
|
|
17
|
-
);
|
|
18
|
-
return {
|
|
19
|
-
crons: [options.schedule],
|
|
20
|
-
dispatcher: {
|
|
21
|
-
args: options.args ?? {},
|
|
22
|
-
functionPath: options.fn.__lunoraRef
|
|
23
|
-
},
|
|
24
|
-
wranglerJsonc: snippet
|
|
25
|
-
};
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
export { createCronTrigger };
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { LunoraError } from '@lunora/errors';
|
|
2
|
-
|
|
3
|
-
const trimTrailingSlashes = (value) => {
|
|
4
|
-
let end = value.length;
|
|
5
|
-
while (end > 0 && value[end - 1] === "/") {
|
|
6
|
-
end -= 1;
|
|
7
|
-
}
|
|
8
|
-
return value.slice(0, end);
|
|
9
|
-
};
|
|
10
|
-
const createQueueWorkpool = (options) => {
|
|
11
|
-
if (!options.queue) {
|
|
12
|
-
throw new LunoraError("INTERNAL", "@lunora/scheduler: `queue` (a Cloudflare Queue binding) is required");
|
|
13
|
-
}
|
|
14
|
-
const enqueue = async (function_, args, enqueueOptions = {}) => {
|
|
15
|
-
const job = { args, functionPath: function_.__lunoraRef, shardKey: enqueueOptions.shardKey };
|
|
16
|
-
const sendOptions = enqueueOptions.delaySeconds === void 0 ? void 0 : { delaySeconds: enqueueOptions.delaySeconds };
|
|
17
|
-
await options.queue.send(job, sendOptions);
|
|
18
|
-
};
|
|
19
|
-
const enqueueBatch = async (jobs, sendOptions) => {
|
|
20
|
-
const messages = jobs.map((job) => {
|
|
21
|
-
return { body: { args: job.args, functionPath: job.ref.__lunoraRef, shardKey: job.shardKey } };
|
|
22
|
-
});
|
|
23
|
-
await options.queue.sendBatch(messages, sendOptions);
|
|
24
|
-
};
|
|
25
|
-
return { enqueue, enqueueBatch };
|
|
26
|
-
};
|
|
27
|
-
const isQueueJob = (value) => typeof value === "object" && value !== null && typeof value.functionPath === "string";
|
|
28
|
-
const createQueueConsumer = (options) => async (batch) => {
|
|
29
|
-
await Promise.all(
|
|
30
|
-
batch.messages.map(async (message) => {
|
|
31
|
-
try {
|
|
32
|
-
if (!isQueueJob(message.body)) {
|
|
33
|
-
throw new LunoraError("INTERNAL", "@lunora/scheduler: queue message body is not a QueueJob (missing functionPath)");
|
|
34
|
-
}
|
|
35
|
-
await options.dispatch(message.body);
|
|
36
|
-
message.ack();
|
|
37
|
-
} catch {
|
|
38
|
-
message.retry();
|
|
39
|
-
}
|
|
40
|
-
})
|
|
41
|
-
);
|
|
42
|
-
};
|
|
43
|
-
const httpDispatcher = (options) => {
|
|
44
|
-
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
45
|
-
if (typeof fetchImpl !== "function") {
|
|
46
|
-
throw new TypeError("@lunora/scheduler: no fetch implementation available — pass fetchImpl or run on a platform with global fetch");
|
|
47
|
-
}
|
|
48
|
-
const url = `${trimTrailingSlashes(options.originUrl)}/_lunora/scheduler/dispatch`;
|
|
49
|
-
return async (job) => {
|
|
50
|
-
const response = await fetchImpl(url, {
|
|
51
|
-
body: JSON.stringify({ args: job.args ?? {}, functionPath: job.functionPath, shardKey: job.shardKey }),
|
|
52
|
-
headers: { authorization: `Bearer ${options.adminToken}`, "content-type": "application/json" },
|
|
53
|
-
method: "POST"
|
|
54
|
-
});
|
|
55
|
-
if (!response.ok) {
|
|
56
|
-
throw new LunoraError("INTERNAL", `@lunora/scheduler: queue dispatch failed (${response.status.toString()}): ${await response.text()}`);
|
|
57
|
-
}
|
|
58
|
-
};
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
export { createQueueConsumer, createQueueWorkpool, httpDispatcher };
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { LunoraError } from '@lunora/errors';
|
|
2
|
-
import { c as callDO, g as getDO } from './do-client-CMJtLoHO.mjs';
|
|
3
|
-
import { isWorkflowReference } from './isWorkflowReference-C9mQkMXt.mjs';
|
|
4
|
-
|
|
5
|
-
const createScheduler = (options) => {
|
|
6
|
-
if (!options.namespace) {
|
|
7
|
-
throw new LunoraError("INTERNAL", "@lunora/scheduler: `namespace` (SchedulerDO binding) is required");
|
|
8
|
-
}
|
|
9
|
-
if (!options.originUrl) {
|
|
10
|
-
throw new LunoraError("INTERNAL", "@lunora/scheduler: `originUrl` is required so the DO can dispatch back to the Worker");
|
|
11
|
-
}
|
|
12
|
-
const runAt = async (date, target, args, options_ = {}) => {
|
|
13
|
-
const scheduledFor = date instanceof Date ? date.getTime() : date;
|
|
14
|
-
const base = {
|
|
15
|
-
args,
|
|
16
|
-
originUrl: options.originUrl,
|
|
17
|
-
pool: options_.pool,
|
|
18
|
-
retry: options_.retry,
|
|
19
|
-
scheduledFor,
|
|
20
|
-
shardKey: options_.shardKey
|
|
21
|
-
};
|
|
22
|
-
if (isWorkflowReference(target)) {
|
|
23
|
-
if (typeof target.binding !== "string" || target.binding.length === 0) {
|
|
24
|
-
throw new LunoraError(
|
|
25
|
-
"INTERNAL",
|
|
26
|
-
"@lunora/scheduler: workflow/agent schedule target is missing its `binding` — pass the generated `workflows.<name>` / `agents.<name>` reference"
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
return callDO(options, "/schedule", { ...base, workflow: target.binding });
|
|
30
|
-
}
|
|
31
|
-
const functionPath = typeof target === "string" ? target : target.__lunoraRef;
|
|
32
|
-
return callDO(options, "/schedule", { ...base, functionPath });
|
|
33
|
-
};
|
|
34
|
-
const runAfter = async (delayMs, target, args, options_ = {}) => {
|
|
35
|
-
if (!Number.isFinite(delayMs) || delayMs < 0) {
|
|
36
|
-
throw new LunoraError("INTERNAL", "@lunora/scheduler: `delayMs` must be a non-negative finite number");
|
|
37
|
-
}
|
|
38
|
-
return runAt(Date.now() + delayMs, target, args, options_);
|
|
39
|
-
};
|
|
40
|
-
const cancel = async (id) => callDO(options, "/cancel", { id });
|
|
41
|
-
const list = async () => {
|
|
42
|
-
const body = await getDO(options, "/list");
|
|
43
|
-
return Array.isArray(body.records) ? body.records : [];
|
|
44
|
-
};
|
|
45
|
-
const get = async (id) => {
|
|
46
|
-
const body = await getDO(options, `/get?id=${encodeURIComponent(id)}`);
|
|
47
|
-
return body.record ?? null;
|
|
48
|
-
};
|
|
49
|
-
return { cancel, get, list, runAfter, runAt };
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
export { createScheduler as default };
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { LunoraError } from '@lunora/errors';
|
|
2
|
-
import { g as getDO, c as callDO } from './do-client-CMJtLoHO.mjs';
|
|
3
|
-
|
|
4
|
-
const createWorkpool = (options) => {
|
|
5
|
-
if (!options.namespace) {
|
|
6
|
-
throw new LunoraError("INTERNAL", "@lunora/scheduler: `namespace` (SchedulerDO binding) is required");
|
|
7
|
-
}
|
|
8
|
-
if (!options.originUrl) {
|
|
9
|
-
throw new LunoraError("INTERNAL", "@lunora/scheduler: `originUrl` is required so the DO can dispatch back to the Worker");
|
|
10
|
-
}
|
|
11
|
-
if (!Number.isInteger(options.maxConcurrency) || options.maxConcurrency <= 0) {
|
|
12
|
-
throw new LunoraError("INTERNAL", "@lunora/scheduler: `maxConcurrency` must be a positive integer");
|
|
13
|
-
}
|
|
14
|
-
const name = typeof options.name === "string" && options.name.length > 0 ? options.name : "default";
|
|
15
|
-
const enqueue = async (function_, args, options_ = {}) => {
|
|
16
|
-
const delayMs = options_.delayMs ?? 0;
|
|
17
|
-
if (!Number.isFinite(delayMs) || delayMs < 0) {
|
|
18
|
-
throw new LunoraError("INTERNAL", "@lunora/scheduler: `delayMs` must be a non-negative finite number");
|
|
19
|
-
}
|
|
20
|
-
return callDO(options, "/schedule", {
|
|
21
|
-
args,
|
|
22
|
-
functionPath: function_.__lunoraRef,
|
|
23
|
-
instanceName: options.instanceName ?? "default",
|
|
24
|
-
maxConcurrency: options.maxConcurrency,
|
|
25
|
-
originUrl: options.originUrl,
|
|
26
|
-
pool: name,
|
|
27
|
-
retry: options_.retry,
|
|
28
|
-
scheduledFor: Date.now() + delayMs,
|
|
29
|
-
shardKey: options_.shardKey
|
|
30
|
-
});
|
|
31
|
-
};
|
|
32
|
-
const cancel = async (id) => callDO(options, "/cancel", { id });
|
|
33
|
-
const status = async () => getDO(options, `/pool?name=${encodeURIComponent(name)}`);
|
|
34
|
-
return { cancel, enqueue, name, status };
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
export { createWorkpool as default };
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { LunoraError } from '@lunora/errors';
|
|
2
|
-
|
|
3
|
-
const applyJurisdiction = (namespace, jurisdiction) => {
|
|
4
|
-
if (jurisdiction === void 0) {
|
|
5
|
-
return namespace;
|
|
6
|
-
}
|
|
7
|
-
if (typeof namespace.jurisdiction !== "function") {
|
|
8
|
-
throw new TypeError(
|
|
9
|
-
`@lunora/scheduler: Durable Object namespace does not support jurisdiction("${jurisdiction}") — update @cloudflare/workers-types or remove the jurisdiction option`
|
|
10
|
-
);
|
|
11
|
-
}
|
|
12
|
-
return namespace.jurisdiction(jurisdiction);
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
const schedulerStub = (options) => {
|
|
16
|
-
const namespace = applyJurisdiction(options.namespace, options.jurisdiction);
|
|
17
|
-
return namespace.get(namespace.idFromName(options.instanceName ?? "default"));
|
|
18
|
-
};
|
|
19
|
-
const callDO = async (options, path, body) => {
|
|
20
|
-
const stub = schedulerStub(options);
|
|
21
|
-
const response = await stub.fetch(`https://scheduler.internal${path}`, {
|
|
22
|
-
body: JSON.stringify(body),
|
|
23
|
-
headers: { "content-type": "application/json" },
|
|
24
|
-
method: "POST"
|
|
25
|
-
});
|
|
26
|
-
if (!response.ok) {
|
|
27
|
-
const text = await response.text();
|
|
28
|
-
throw new LunoraError("INTERNAL", `@lunora/scheduler: SchedulerDO ${path} failed (${String(response.status)}): ${text}`);
|
|
29
|
-
}
|
|
30
|
-
return await response.json();
|
|
31
|
-
};
|
|
32
|
-
const getDO = async (options, path) => {
|
|
33
|
-
const stub = schedulerStub(options);
|
|
34
|
-
const response = await stub.fetch(`https://scheduler.internal${path}`, { method: "GET" });
|
|
35
|
-
if (!response.ok) {
|
|
36
|
-
const text = await response.text();
|
|
37
|
-
throw new LunoraError("INTERNAL", `@lunora/scheduler: SchedulerDO ${path} failed (${String(response.status)}): ${text}`);
|
|
38
|
-
}
|
|
39
|
-
return await response.json();
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
export { callDO as c, getDO as g };
|