@stacksjs/queue 0.75.423 → 0.75.432

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/README.md CHANGED
@@ -1,32 +1,16 @@
1
- # Stacks Router
1
+ # Stacks Queue
2
2
 
3
- This package contains the Stacks Router.
3
+ Database and Redis job dispatch, workers, scheduling, dead-letter queues, and quarantine.
4
4
 
5
- ## ☘️ Features
5
+ ## Reliability
6
6
 
7
- wip
7
+ Database dead-letter retries lock and move the original row in one transaction. Concurrent retries enqueue one copy, and a failed insert preserves the dead-letter entry. `listDeadLetterJobs()` returns at most 100 rows by default; pass a positive integer `limit` to override it.
8
8
 
9
- - ⚡️
9
+ Scheduled database dispatch commits the queue row and its per-minute marker together. An enqueue failure rolls back the marker so the same minute can retry. Disabled job configurations are excluded from automatic scheduling. Run-marker persistence degrades to the in-memory guard if its table cannot be created.
10
10
 
11
- wip
11
+ Circuit observations and poison failure counts seed and lock their state rows inside transactions, including concurrent first observations. Quarantine thresholds apply to the first failure and after a window reset.
12
12
 
13
- ## 🤖 Usage
14
-
15
- wip
16
-
17
- ```bash
18
- bun install -d @stacksjs/actions
19
- ```
20
-
21
- Now, you can use it in your project:
22
-
23
- ```js
24
- import * as router from '@stacksjs/router'
25
-
26
- // wip
27
- ```
28
-
29
- Learn more in the docs.
13
+ New payload quarantine keys use a `v2:` prefix and canonical JSON object key ordering, including nested objects. Array order remains significant. The exported `hashPayload()` retains its legacy digest contract. Existing unversioned rows are checked using that digest and upgraded when a matching original payload representation is encountered. An old digest cannot reveal its original object key order: to make a legacy quarantine immediately order independent, quarantine the payload again or use class-wide `quarantineJob(name)` during migration.
30
14
 
31
15
  ## 🧪 Testing
32
16
 
package/dist/action.js CHANGED
@@ -1 +1 @@
1
- import{env as envVars}from"@stacksjs/env";import{assertEnvelopeSerializable,createEnvelope,serializeEnvelope}from"./envelope";import{runNamedAction}from"./action-runner";let testingModule,databaseModule;function getQueueDriver(){return envVars.QUEUE_DRIVER||"sync"}export class Job{name;description;action;handle;queue;rate;tries;timeout;backoff;backoffConfig;enabled;constructor(options){this.name=options.name;this.description=options.description;this.handle=options.handle;this.queue=options.queue;this.rate=options.rate;this.action=options.action;this.tries=options.tries;this.timeout=options.timeout;this.backoff=options.backoff;this.backoffConfig=options.backoffConfig;this.enabled=options.enabled}async dispatch(...[payload]){const{isFaked,getFakeQueue}=await(testingModule??=import("./testing").catch((error)=>{testingModule=void 0;throw error}));if(isFaked()){getFakeQueue()?.dispatch(this.name||"UnknownJob",payload,{queue:this.queue,tries:this.tries,timeout:this.timeout});return}const driver=getQueueDriver();if(driver==="sync")return this.dispatchNow(...[payload]);if(driver==="redis")return this.dispatchToRedis(payload);if(driver==="database")return this.dispatchToDatabase(payload);if(driver==="sqs"||driver==="memory"||driver==="beanstalkd")throw Error(`[queue] Driver "${driver}" is not implemented yet. Set QUEUE_DRIVER to one of: redis, database, sync.`);throw Error(`[queue] Unknown QUEUE_DRIVER "${driver}". Allowed values: redis, database, sync.`)}async dispatchIf(condition,...[payload]){if(condition)return this.dispatch(...[payload])}async dispatchUnless(condition,...[payload]){if(!condition)return this.dispatch(...[payload])}async dispatchAfter(delaySeconds,...[payload]){const driver=getQueueDriver();if(driver==="redis")return this.dispatchToRedis(payload,{delay:delaySeconds});if(driver==="database")return this.dispatchToDatabase(payload,{delay:delaySeconds});await new Promise((resolve)=>setTimeout(resolve,delaySeconds*1000));return await this.dispatchNow(...[payload])}async dispatchNow(...[payload]){if(typeof this.handle==="function")await this.handle(payload);else if(typeof this.action==="string")await runNamedAction(this.action);else if(typeof this.action==="function")await this.action();else throw Error(`Job ${this.name} does not have a valid handler`)}async dispatchToDatabase(payload,opts){const now=Math.floor(Date.now()/1000),availableAt=opts?.delay?now+opts.delay:now,envelope=createEnvelope(this.name??this.constructor.name,payload,{queue:this.queue??"default",tries:typeof this.tries==="number"?this.tries:void 0,timeout:this.timeout,backoff:Array.isArray(this.backoff)||typeof this.backoff==="number"?this.backoff:void 0}),payloadJson=serializeEnvelope(envelope),{db}=await(databaseModule??=import("@stacksjs/database/runtime").catch((error)=>{databaseModule=void 0;throw error}));await db.insertInto("jobs").values({queue:this.queue||"default",payload:payloadJson,attempts:0,reserved_at:null,available_at:availableAt,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute()}async dispatchToRedis(payload,opts){const envelope=createEnvelope(this.name??this.constructor.name,payload,{queue:this.queue??"default",tries:typeof this.tries==="number"?this.tries:void 0,timeout:this.timeout,backoff:Array.isArray(this.backoff)||typeof this.backoff==="number"?this.backoff:void 0});assertEnvelopeSerializable(envelope);const{sharedRedisQueue}=await import("./drivers/redis"),{queue:queueConfig}=await import("@stacksjs/config"),redisConfig=queueConfig?.connections?.redis;if(!redisConfig)throw Error("Redis queue connection is not configured. Check config/queue.ts");await sharedRedisQueue(this.queue||"default",redisConfig).add(envelope,{delay:opts?.delay,maxTries:typeof this.tries==="number"?this.tries:void 0,timeout:this.timeout,backoff:Array.isArray(this.backoff)||typeof this.backoff==="number"?this.backoff:void 0})}}
1
+ import{resolveQueueDriver}from"./driver";import{assertEnvelopeSerializable,createEnvelope,serializeEnvelope}from"./envelope";import{runNamedAction}from"./action-runner";let testingModule,databaseModule;function getQueueDriver(){return resolveQueueDriver()}export class Job{name;description;action;handle;queue;rate;tries;timeout;backoff;backoffConfig;enabled;constructor(options){this.name=options.name;this.description=options.description;this.handle=options.handle;this.queue=options.queue;this.rate=options.rate;this.action=options.action;this.tries=options.tries;this.timeout=options.timeout;this.backoff=options.backoff;this.backoffConfig=options.backoffConfig;this.enabled=options.enabled}async dispatch(...[payload]){const{isFaked,getFakeQueue}=await(testingModule??=import("./testing").catch((error)=>{testingModule=void 0;throw error}));if(isFaked()){getFakeQueue()?.dispatch(this.name||"UnknownJob",payload,{queue:this.queue,tries:this.tries,timeout:this.timeout});return}const driver=getQueueDriver();if(driver==="sync")return this.dispatchNow(...[payload]);if(driver==="redis")return this.dispatchToRedis(payload);if(driver==="database")return this.dispatchToDatabase(payload);if(driver==="sqs"||driver==="memory"||driver==="beanstalkd")throw Error(`[queue] Driver "${driver}" is not implemented yet. Set QUEUE_DRIVER to one of: redis, database, sync.`);throw Error(`[queue] Unknown QUEUE_DRIVER "${driver}". Allowed values: redis, database, sync.`)}async dispatchIf(condition,...[payload]){if(condition)return this.dispatch(...[payload])}async dispatchUnless(condition,...[payload]){if(!condition)return this.dispatch(...[payload])}async dispatchAfter(delaySeconds,...[payload]){const driver=getQueueDriver();if(driver==="redis")return this.dispatchToRedis(payload,{delay:delaySeconds});if(driver==="database")return this.dispatchToDatabase(payload,{delay:delaySeconds});await new Promise((resolve)=>setTimeout(resolve,delaySeconds*1000));return await this.dispatchNow(...[payload])}async dispatchNow(...[payload]){if(typeof this.handle==="function")await this.handle(payload);else if(typeof this.action==="string")await runNamedAction(this.action);else if(typeof this.action==="function")await this.action();else throw Error(`Job ${this.name} does not have a valid handler`)}async dispatchToDatabase(payload,opts){const now=Math.floor(Date.now()/1000),availableAt=opts?.delay?now+opts.delay:now,envelope=createEnvelope(this.name??this.constructor.name,payload,{queue:this.queue??"default",tries:typeof this.tries==="number"?this.tries:void 0,timeout:this.timeout,backoff:Array.isArray(this.backoff)||typeof this.backoff==="number"?this.backoff:void 0}),payloadJson=serializeEnvelope(envelope),{db}=await(databaseModule??=import("@stacksjs/database/runtime").catch((error)=>{databaseModule=void 0;throw error}));await db.insertInto("jobs").values({queue:this.queue||"default",payload:payloadJson,attempts:0,reserved_at:null,available_at:availableAt,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute()}async dispatchToRedis(payload,opts){const envelope=createEnvelope(this.name??this.constructor.name,payload,{queue:this.queue??"default",tries:typeof this.tries==="number"?this.tries:void 0,timeout:this.timeout,backoff:Array.isArray(this.backoff)||typeof this.backoff==="number"?this.backoff:void 0});assertEnvelopeSerializable(envelope);const{sharedRedisQueue}=await import("./drivers/redis"),{queue:queueConfig}=await import("@stacksjs/config"),redisConfig=queueConfig?.connections?.redis;if(!redisConfig)throw Error("Redis queue connection is not configured. Check config/queue.ts");await sharedRedisQueue(this.queue||"default",redisConfig).add(envelope,{delay:opts?.delay,maxTries:typeof this.tries==="number"?this.tries:void 0,timeout:this.timeout,backoff:Array.isArray(this.backoff)||typeof this.backoff==="number"?this.backoff:void 0})}}
@@ -1,34 +1,9 @@
1
- /**
2
- * Is the queue currently paused? Returns `true` when `paused_at`
3
- * is set and `resume_at` hasn't passed yet. Also auto-clears the
4
- * paused state when the cooldown elapses so the next call sees
5
- * the queue as available again.
6
- */
1
+ /** Read and, when necessary, resume a queue while holding its state row. */
7
2
  export declare function isCircuitOpen(queue: string): Promise<boolean>;
8
- /**
9
- * Record a successful job. Maintained alongside failure_count so
10
- * the failure-rate calculation is meaningful. Window resets after
11
- * `windowSeconds` so an old failure burst doesn't permanently
12
- * skew the rate.
13
- */
14
3
  export declare function recordCircuitSuccess(queue: string, config?: CircuitBreakerConfig): Promise<void>;
15
- /**
16
- * Record a failed job. After incrementing, evaluates whether the
17
- * failure rate has crossed the threshold and trips the breaker if
18
- * so. Returns `true` when this call triggered the trip.
19
- */
20
4
  export declare function recordCircuitFailure(queue: string, config?: CircuitBreakerConfig): Promise<boolean>;
21
- /**
22
- * Manually pause a queue for the given cooldown (default matches
23
- * the configured `pauseSeconds`). Useful when an operator knows
24
- * downstream is broken and wants to skip jobs immediately rather
25
- * than wait for the auto-trip.
26
- */
5
+ /** Manually pause without racing counter updates or automatic resume. */
27
6
  export declare function pauseQueue(queue: string, pauseSeconds?: number): Promise<void>;
28
- /**
29
- * Lift the pause on a queue (manual resume). Workers resume
30
- * processing on the next poll cycle.
31
- */
32
7
  export declare function resumeQueue(queue: string): Promise<void>;
33
8
  /**
34
9
  * Snapshot the current circuit state for every tracked queue.
@@ -1 +1 @@
1
- import{db}from"@stacksjs/database/runtime";import{isUniqueViolation}from"@stacksjs/error-handling";import{isMissingTableError}from"./missing-table";let warnedAboutMissingTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingTable)return;warnedAboutMissingTable=!0;console.warn("[queue/circuit-breaker] queue_circuit_state table missing - circuit breaker disabled. Run migrations to enable.")}async function getOrCreateRow(queue,nowStr){try{const existing=await db.selectFrom("queue_circuit_state").where("queue_name","=",queue).selectAll().executeTakeFirst();if(existing)return existing;await db.insertInto("queue_circuit_state").values({queue_name:queue,success_count:0,failure_count:0,window_start:nowStr,paused_at:null,resume_at:null}).execute();return{queue_name:queue,success_count:0,failure_count:0,window_start:nowStr,paused_at:null,resume_at:null}}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return null}if(isUniqueViolation(err))return await db.selectFrom("queue_circuit_state").where("queue_name","=",queue).selectAll().executeTakeFirst();throw err}}export async function isCircuitOpen(queue){try{const row=await db.selectFrom("queue_circuit_state").where("queue_name","=",queue).select(["paused_at","resume_at"]).executeTakeFirst();if(!row||!row.paused_at)return!1;if(row.resume_at){const resumeMs=Date.parse(row.resume_at.replace(" ","T")+"Z");if(Number.isFinite(resumeMs)&&Date.now()>=resumeMs){await db.updateTable("queue_circuit_state").set({paused_at:null,resume_at:null,success_count:0,failure_count:0}).where("queue_name","=",queue).execute();return!1}}return!0}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function recordCircuitSuccess(queue,config={}){const windowSeconds=config.windowSeconds??300,nowStr=new Date().toISOString().slice(0,19).replace("T"," "),row=await getOrCreateRow(queue,nowStr);if(!row)return;try{if(shouldResetWindow(row.window_start,windowSeconds)){await db.updateTable("queue_circuit_state").set({success_count:1,failure_count:0,window_start:nowStr}).where("queue_name","=",queue).execute();return}await db.updateTable("queue_circuit_state").set({success_count:row.success_count+1}).where("queue_name","=",queue).execute()}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return}throw err}}export async function recordCircuitFailure(queue,config={}){const threshold=config.failureRateThreshold??0.5,windowSeconds=config.windowSeconds??300,pauseSeconds=config.pauseSeconds??300,minObservations=config.minObservations??10,now=new Date,nowStr=now.toISOString().slice(0,19).replace("T"," "),row=await getOrCreateRow(queue,nowStr);if(!row)return!1;if(row.paused_at)return!1;try{let{success_count:successCount,failure_count:failureCount}=row;if(shouldResetWindow(row.window_start,windowSeconds)){successCount=0;failureCount=0}failureCount+=1;const observed=successCount+failureCount,rate=observed===0?0:failureCount/observed;if(observed>=minObservations&&rate>=threshold){const resumeAt=new Date(now.getTime()+pauseSeconds*1000).toISOString().slice(0,19).replace("T"," ");await db.updateTable("queue_circuit_state").set({success_count:successCount,failure_count:failureCount,window_start:nowStr,paused_at:nowStr,resume_at:resumeAt}).where("queue_name","=",queue).execute();return!0}await db.updateTable("queue_circuit_state").set({success_count:successCount,failure_count:failureCount,window_start:shouldResetWindow(row.window_start,windowSeconds)?nowStr:row.window_start}).where("queue_name","=",queue).execute();return!1}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}function shouldResetWindow(windowStart,windowSeconds){if(!windowStart)return!0;const startMs=Date.parse(windowStart.replace(" ","T")+"Z");if(!Number.isFinite(startMs))return!0;return Date.now()-startMs>windowSeconds*1000}export async function pauseQueue(queue,pauseSeconds=300){const now=new Date,nowStr=now.toISOString().slice(0,19).replace("T"," "),resumeAt=new Date(now.getTime()+pauseSeconds*1000).toISOString().slice(0,19).replace("T"," ");await getOrCreateRow(queue,nowStr);try{await db.updateTable("queue_circuit_state").set({paused_at:nowStr,resume_at:resumeAt}).where("queue_name","=",queue).execute()}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return}throw err}}export async function resumeQueue(queue){try{await db.updateTable("queue_circuit_state").set({paused_at:null,resume_at:null,success_count:0,failure_count:0}).where("queue_name","=",queue).execute()}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return}throw err}}export async function listCircuitState(){try{return await db.selectFrom("queue_circuit_state").selectAll().execute()??[]}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return[]}throw err}}
1
+ import{db,lockRow,sqliteWriteOptions,parseSqlDateTime}from"@stacksjs/database/runtime";import{seedStateRow}from"./state-row";import{isMissingTableError}from"./missing-table";let warnedAboutMissingTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingTable)return;warnedAboutMissingTable=!0;console.warn("[queue/circuit-breaker] queue_circuit_state table missing - circuit breaker disabled. Run migrations to enable.")}async function seedCircuitRow(tx,queue,now){await seedStateRow(tx,"queue_circuit_state",["queue_name"],{queue_name:queue,success_count:0,failure_count:0,window_start:now,paused_at:null,resume_at:null})}export async function isCircuitOpen(queue){try{return await db.transaction(async(tx)=>{const row=await lockRow(tx,"queue_circuit_state",{queue_name:queue});if(!row?.paused_at)return!1;const resume=parseSqlDateTime(row.resume_at)?.getTime();if(resume!==void 0&&Date.now()>=resume){await tx.updateTable("queue_circuit_state").set({paused_at:null,resume_at:null,success_count:0,failure_count:0,window_start:new Date().toISOString().slice(0,19).replace("T"," ")}).where("queue_name","=",queue).execute();return!1}return!0},sqliteWriteOptions())}catch(error){if(isMissingTableError(error)){warnOnceAboutMissingTable();return!1}throw error}}async function recordObservation(queue,success,config){const now=new Date,nowStr=now.toISOString().slice(0,19).replace("T"," ");try{return await db.transaction(async(tx)=>{await seedCircuitRow(tx,queue,nowStr);const row=await lockRow(tx,"queue_circuit_state",{queue_name:queue}),start=parseSqlDateTime(row.window_start)?.getTime(),reset=start===void 0||now.getTime()-start>=(config.windowSeconds??300)*1000,successes=(reset?0:Number(row.success_count))+(success?1:0),failures=(reset?0:Number(row.failure_count))+(success?0:1),observed=successes+failures,trip=!success&&!row.paused_at&&observed>=(config.minObservations??10)&&failures/observed>=(config.failureRateThreshold??0.5);await tx.updateTable("queue_circuit_state").set({success_count:successes,failure_count:failures,window_start:reset?nowStr:row.window_start,...trip?{paused_at:nowStr,resume_at:new Date(now.getTime()+(config.pauseSeconds??300)*1000).toISOString().slice(0,19).replace("T"," ")}:{}}).where("queue_name","=",queue).execute();return trip},sqliteWriteOptions())}catch(error){if(isMissingTableError(error)){warnOnceAboutMissingTable();return!1}throw error}}export async function recordCircuitSuccess(queue,config={}){await recordObservation(queue,!0,config)}export async function recordCircuitFailure(queue,config={}){return recordObservation(queue,!1,config)}export async function pauseQueue(queue,pauseSeconds=300){const now=new Date,nowStr=now.toISOString().slice(0,19).replace("T"," ");try{await db.transaction(async(tx)=>{await seedCircuitRow(tx,queue,nowStr);await lockRow(tx,"queue_circuit_state",{queue_name:queue});await tx.updateTable("queue_circuit_state").set({paused_at:nowStr,resume_at:new Date(now.getTime()+pauseSeconds*1000).toISOString().slice(0,19).replace("T"," ")}).where("queue_name","=",queue).execute()},sqliteWriteOptions())}catch(error){if(isMissingTableError(error)){warnOnceAboutMissingTable();return}throw error}}export async function resumeQueue(queue){try{await db.transaction(async(tx)=>{if(!await lockRow(tx,"queue_circuit_state",{queue_name:queue}))return;await tx.updateTable("queue_circuit_state").set({paused_at:null,resume_at:null,success_count:0,failure_count:0,window_start:new Date().toISOString().slice(0,19).replace("T"," ")}).where("queue_name","=",queue).execute()},sqliteWriteOptions())}catch(error){if(isMissingTableError(error)){warnOnceAboutMissingTable();return}throw error}}export async function listCircuitState(){try{return await db.selectFrom("queue_circuit_state").selectAll().execute()??[]}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return[]}throw err}}
@@ -1 +1 @@
1
- import{db,mutationCount}from"@stacksjs/database/runtime";import{isMissingTableError}from"./missing-table";let warnedAboutMissingTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingTable)return;warnedAboutMissingTable=!0;console.warn("[queue/dlq] dead_letter_jobs table missing - DLQ disabled. Run migrations to enable poison-message isolation.")}export async function moveToDeadLetter(failedJob,reason,totalFailures=1){const now=new Date().toISOString().slice(0,19).replace("T"," ");try{await db.insertInto("dead_letter_jobs").values({uuid:failedJob.uuid??crypto.randomUUID(),connection:failedJob.connection??"database",queue:failedJob.queue??"default",payload:failedJob.payload??"{}",exception:failedJob.exception??"unknown",reason,total_failures:totalFailures,first_failed_at:failedJob.failed_at??now,last_failed_at:now,dead_lettered_at:now}).execute();return!0}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function listDeadLetterJobs(filter={}){try{let q=db.selectFrom("dead_letter_jobs").selectAll();if(filter.queue)q=q.where("queue","=",filter.queue);if(filter.reason)q=q.where("reason","=",filter.reason);if(filter.sinceCutoffMs){const cutoff=new Date(filter.sinceCutoffMs).toISOString().slice(0,19).replace("T"," ");q=q.where("dead_lettered_at",">=",cutoff)}if(filter.limit&&filter.limit>0)q=q.limit(filter.limit);return await q.execute()??[]}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return[]}throw err}}export async function retryDeadLetterJob(id){try{const row=await db.selectFrom("dead_letter_jobs").where("id","=",id).selectAll().executeTakeFirst();if(!row)return!1;const nowSec=Math.floor(Date.now()/1000);await db.insertInto("jobs").values({queue:row.queue,payload:row.payload,attempts:0,reserved_at:null,available_at:nowSec,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute();await db.deleteFrom("dead_letter_jobs").where("id","=",id).execute();return!0}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function purgeDeadLetterJobs(olderThanDays=30){try{const cutoff=new Date(Date.now()-olderThanDays*24*60*60*1000).toISOString().slice(0,19).replace("T"," "),result=await db.deleteFrom("dead_letter_jobs").where("dead_lettered_at","<",cutoff).execute();return mutationCount(result)}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return 0}throw err}}
1
+ import{db,lockRow,sqliteWriteOptions,mutationCount}from"@stacksjs/database/runtime";import{isMissingTableError}from"./missing-table";let warnedAboutMissingTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingTable)return;warnedAboutMissingTable=!0;console.warn("[queue/dlq] dead_letter_jobs table missing - DLQ disabled. Run migrations to enable poison-message isolation.")}export async function moveToDeadLetter(failedJob,reason,totalFailures=1){const now=new Date().toISOString().slice(0,19).replace("T"," ");try{await db.insertInto("dead_letter_jobs").values({uuid:failedJob.uuid??crypto.randomUUID(),connection:failedJob.connection??"database",queue:failedJob.queue??"default",payload:failedJob.payload??"{}",exception:failedJob.exception??"unknown",reason,total_failures:totalFailures,first_failed_at:failedJob.failed_at??now,last_failed_at:now,dead_lettered_at:now}).execute();return!0}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function listDeadLetterJobs(filter={}){const limit=filter.limit??100;if(!Number.isSafeInteger(limit)||limit<=0)throw RangeError("Dead-letter limit must be a positive integer");try{let q=db.selectFrom("dead_letter_jobs").selectAll();if(filter.queue)q=q.where("queue","=",filter.queue);if(filter.reason)q=q.where("reason","=",filter.reason);if(filter.sinceCutoffMs){const cutoff=new Date(filter.sinceCutoffMs).toISOString().slice(0,19).replace("T"," ");q=q.where("dead_lettered_at",">=",cutoff)}q=q.limit(limit);return await q.execute()??[]}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return[]}throw err}}export async function retryDeadLetterJob(id){try{return await db.transaction(async(tx)=>{const row=await lockRow(tx,"dead_letter_jobs",{id});if(!row)return!1;const nowSec=Math.floor(Date.now()/1000);await tx.insertInto("jobs").values({queue:row.queue,payload:row.payload,attempts:0,reserved_at:null,available_at:nowSec,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute();await tx.deleteFrom("dead_letter_jobs").where("id","=",id).execute();return!0},sqliteWriteOptions())}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function purgeDeadLetterJobs(olderThanDays=30){try{const cutoff=new Date(Date.now()-olderThanDays*24*60*60*1000).toISOString().slice(0,19).replace("T"," "),result=await db.deleteFrom("dead_letter_jobs").where("dead_lettered_at","<",cutoff).execute();return mutationCount(result)}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return 0}throw err}}
@@ -43,6 +43,7 @@ export declare interface JobConfig {
43
43
  description?: string
44
44
  queue?: string
45
45
  tries?: number
46
+ enabled?: boolean
46
47
  backoff?: number | number[]
47
48
  rate?: string
48
49
  timeout?: number
package/dist/discovery.js CHANGED
@@ -1 +1 @@
1
- import{log}from"@stacksjs/logging";import*as p from"@stacksjs/path";class JobRegistry{jobs=new Map;initialized=!1;register(job){this.jobs.set(job.name,job);log.debug(`Registered job: ${job.name} (${job.type})`)}get(name){return this.jobs.get(name)}all(){return Array.from(this.jobs.values())}byQueue(queue){return this.all().filter((job)=>job.config.queue===queue)}scheduled(){return this.all().filter((job)=>job.config.rate||job.config.schedule)}has(name){return this.jobs.has(name)}clear(){this.jobs.clear();this.initialized=!1}setInitialized(value){this.initialized=value}isInitialized(){return this.initialized}}export const jobRegistry=new JobRegistry;export async function discoverJobs(jobsPath){const basePath=jobsPath||p.userJobsPath(),discovered=[];try{const glob=new Bun.Glob("**/*.{ts,js}"),scanOptions={cwd:basePath,onlyFiles:!0,absolute:!0};for await(const file of glob.scan(scanOptions)){if(file.includes(".test.")||file.includes(".spec.")||file.endsWith("index.ts")||file.endsWith("index.js"))continue;try{const job=await loadJob(file);if(job){discovered.push(job);jobRegistry.register(job)}}catch(error){log.warn(`Failed to load job from ${file}: ${error.message}`)}}jobRegistry.setInitialized(!0);log.info(`Discovered ${discovered.length} jobs from ${basePath}`);return discovered}catch(error){log.error(`Failed to discover jobs: ${error.message}`);return[]}}async function loadJob(filePath){try{const module=await import(filePath),fileName=filePath.split("/").pop()?.replace(/\.(ts|js)$/,"")||"UnknownJob";if(module.default&&typeof module.default==="function"){const JobClass=module.default;if(typeof JobClass.handle==="function"||typeof JobClass.prototype?.handle==="function"){const config=JobClass.config||{};return{name:config.name||fileName,path:filePath,config:{name:config.name||fileName,description:config.description,queue:config.queue||"default",tries:config.retries||3,timeout:config.timeout,withoutOverlapping:config.withoutOverlapping,schedule:config.schedule,retryAfter:config.retryAfter},type:"class",module:JobClass}}}if(module.default&&typeof module.default==="object"){const jobConfig=module.default;if(typeof jobConfig.handle==="function"||typeof jobConfig.action==="string")return{name:jobConfig.name||fileName,path:filePath,config:{name:jobConfig.name||fileName,description:jobConfig.description,queue:jobConfig.queue||"default",tries:jobConfig.tries||3,backoff:jobConfig.backoff,rate:jobConfig.rate,timeout:jobConfig.timeout||jobConfig.timeOut,backoffConfig:jobConfig.backoffConfig},type:"function",module:jobConfig}}return null}catch(error){log.debug(`Could not load job from ${filePath}: ${error.message}`);return null}}export function getJob(name){return jobRegistry.get(name)}export function getAllJobs(){return jobRegistry.all()}export function getScheduledJobs(){return jobRegistry.scheduled()}export async function executeJob(name,payload){const job=jobRegistry.get(name);if(!job)throw Error(`Job "${name}" not found. Did you run discoverJobs()?`);try{if(job.type==="class"){if(typeof job.module.handle==="function")return await job.module.handle(payload);return await new job.module().handle(payload)}else{if(typeof job.module.handle==="function")return await job.module.handle(payload);throw Error(`Job "${name}" does not have a handle method`)}}catch(error){log.error(`Failed to execute job "${name}": ${error.message}`);throw error}}export function toJobOptions(job){const config=job.config;return{name:config.name,queue:config.queue,tries:config.tries,backoff:config.backoff,timeout:config.timeout,backoffConfig:config.backoffConfig,rate:config.rate}}
1
+ import{log}from"@stacksjs/logging";import*as p from"@stacksjs/path";class JobRegistry{jobs=new Map;initialized=!1;register(job){this.jobs.set(job.name,job);log.debug(`Registered job: ${job.name} (${job.type})`)}get(name){return this.jobs.get(name)}all(){return Array.from(this.jobs.values())}byQueue(queue){return this.all().filter((job)=>job.config.queue===queue)}scheduled(){return this.all().filter((job)=>job.config.enabled!==!1&&(job.config.rate||job.config.schedule))}has(name){return this.jobs.has(name)}clear(){this.jobs.clear();this.initialized=!1}setInitialized(value){this.initialized=value}isInitialized(){return this.initialized}}export const jobRegistry=new JobRegistry;export async function discoverJobs(jobsPath){const basePath=jobsPath||p.userJobsPath(),discovered=[];try{const glob=new Bun.Glob("**/*.{ts,js}"),scanOptions={cwd:basePath,onlyFiles:!0,absolute:!0};for await(const file of glob.scan(scanOptions)){if(file.includes(".test.")||file.includes(".spec.")||file.endsWith("index.ts")||file.endsWith("index.js"))continue;try{const job=await loadJob(file);if(job){discovered.push(job);jobRegistry.register(job)}}catch(error){log.warn(`Failed to load job from ${file}: ${error.message}`)}}jobRegistry.setInitialized(!0);log.info(`Discovered ${discovered.length} jobs from ${basePath}`);return discovered}catch(error){log.error(`Failed to discover jobs: ${error.message}`);return[]}}async function loadJob(filePath){try{const module=await import(filePath),fileName=filePath.split("/").pop()?.replace(/\.(ts|js)$/,"")||"UnknownJob";if(module.default&&typeof module.default==="function"){const JobClass=module.default;if(typeof JobClass.handle==="function"||typeof JobClass.prototype?.handle==="function"){const config=JobClass.config||{};return{name:config.name||fileName,path:filePath,config:normalizeConfig(config,fileName),type:"class",module:JobClass}}}if(module.default&&typeof module.default==="object"){const jobConfig=module.default;if(typeof jobConfig.handle==="function"||typeof jobConfig.action==="string")return{name:jobConfig.name||fileName,path:filePath,config:normalizeConfig(jobConfig,fileName),type:"function",module:jobConfig}}return null}catch(error){log.debug(`Could not load job from ${filePath}: ${error.message}`);return null}}function normalizeConfig(config,fileName){return{name:config.name||fileName,description:config.description,queue:config.queue||"default",enabled:config.enabled,tries:config.tries??config.retries??3,backoff:config.backoff??config.retryAfter,rate:config.rate,schedule:config.schedule,timeout:config.timeout??config.timeOut,withoutOverlapping:config.withoutOverlapping,retryAfter:config.retryAfter,backoffConfig:config.backoffConfig}}export function getJob(name){return jobRegistry.get(name)}export function getAllJobs(){return jobRegistry.all()}export function getScheduledJobs(){return jobRegistry.scheduled()}export async function executeJob(name,payload){const job=jobRegistry.get(name);if(!job)throw Error(`Job "${name}" not found. Did you run discoverJobs()?`);try{if(job.type==="class"){if(typeof job.module.handle==="function")return await job.module.handle(payload);return await new job.module().handle(payload)}else{if(typeof job.module.handle==="function")return await job.module.handle(payload);throw Error(`Job "${name}" does not have a handle method`)}}catch(error){log.error(`Failed to execute job "${name}": ${error.message}`);throw error}}export function toJobOptions(job){const config=job.config;return{name:config.name,queue:config.queue,tries:config.tries,backoff:config.backoff,timeout:config.timeout,backoffConfig:config.backoffConfig,rate:config.rate}}
@@ -0,0 +1,2 @@
1
+ /** One driver policy for dispatchers and workers, including production durability. */
2
+ export declare function resolveQueueDriver(driver?: string | undefined, environment?: string | undefined): 'sync' | 'database' | 'redis';
package/dist/driver.js ADDED
@@ -0,0 +1 @@
1
+ import{env}from"@stacksjs/env";export function resolveQueueDriver(driver=env.QUEUE_DRIVER,environment=env.APP_ENV){const production=environment==="production",selected=driver||(production?"database":"sync");if(!["sync","database","redis"].includes(selected))throw Error(["sqs","memory","beanstalkd"].includes(selected)?`[queue] Driver "${selected}" is not implemented yet. Set QUEUE_DRIVER to one of: redis, database, sync.`:`[queue] Unknown QUEUE_DRIVER "${selected}". Allowed values: redis, database, sync.`);if(production&&selected==="sync")throw Error("[queue] Production requires a durable database or redis queue; sync executes inline.");return selected}
package/dist/index.d.ts CHANGED
@@ -216,3 +216,4 @@ export {
216
216
  startProcessor,
217
217
  stopProcessor,
218
218
  } from './worker';
219
+ export { resolveQueueDriver } from './driver';
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export*from"./bun-queue";export{Job}from"./action";export{getActionRunner,setActionRunner}from"./action-runner";export{Jobs,job,jobBatch,runJob}from"./job";export{claimDispatchKey,hasDispatchedKey,recordDispatchedKey,releaseDispatchKey}from"./idempotency";export{listDeadLetterJobs,moveToDeadLetter,purgeDeadLetterJobs,retryDeadLetterJob}from"./dead-letter";export{hashPayload,isQuarantined,listQuarantined,quarantineJob,recordFailureForPoison,unquarantineJob}from"./poison";export{isCircuitOpen,listCircuitState,pauseQueue,recordCircuitFailure,recordCircuitSuccess,resumeQueue}from"./circuit-breaker";export{assertEnvelopeSerializable,clearEnvelopeWarnings,createEnvelope,JOB_ENVELOPE_VERSION,parseEnvelope,serializeEnvelope}from"./envelope";export{setJobProgress,getJobProgress,cancelJob,isJobCancelled,clearJobState}from"./job-progress";export{discoverJobs,executeJob,getAllJobs,getJob,getScheduledJobs,jobRegistry,toJobOptions}from"./discovery";export{getRegisteredJobs,getSchedulerStatus,isSchedulerRunning,startScheduler,stopScheduler,triggerJob}from"./scheduler";export{emitQueueEvent,getGlobalMetrics,getQueueEvents,getWorkerTracker,onQueueEvent,OnQueueEvent,QueueEvents,QueueMetrics,withEvents}from"./events";export{checkQueueHealth,createHealthCheckHandler,isQueueHealthy,queuedJobState}from"./health";export{configureFailedJobNotifications,FailedJobNotifier,getFailedJobNotifier,notifyJobFailed}from"./notifications";export{createQueueTester,expectJobToFail,fake,getFakeQueue,isFaked,QueueTester,restore,runJob as runTestJob}from"./testing";export{Batch,DispatchedBatch,PendingBatch,getBatchCallbacks,isBatchCancelled,recordBatchJobCompletion,recordBatchJobFailure}from"./batch";export{executeFailedJobs,getActiveJobCount,isWorkerRunning,retryFailedJob,startProcessor,stopProcessor}from"./worker";export async function getRedisQueue(){const{RedisQueue}=await import("./drivers/redis");return RedisQueue}
1
+ export*from"./bun-queue";export{Job}from"./action";export{getActionRunner,setActionRunner}from"./action-runner";export{Jobs,job,jobBatch,runJob}from"./job";export{claimDispatchKey,hasDispatchedKey,recordDispatchedKey,releaseDispatchKey}from"./idempotency";export{listDeadLetterJobs,moveToDeadLetter,purgeDeadLetterJobs,retryDeadLetterJob}from"./dead-letter";export{hashPayload,isQuarantined,listQuarantined,quarantineJob,recordFailureForPoison,unquarantineJob}from"./poison";export{isCircuitOpen,listCircuitState,pauseQueue,recordCircuitFailure,recordCircuitSuccess,resumeQueue}from"./circuit-breaker";export{assertEnvelopeSerializable,clearEnvelopeWarnings,createEnvelope,JOB_ENVELOPE_VERSION,parseEnvelope,serializeEnvelope}from"./envelope";export{setJobProgress,getJobProgress,cancelJob,isJobCancelled,clearJobState}from"./job-progress";export{discoverJobs,executeJob,getAllJobs,getJob,getScheduledJobs,jobRegistry,toJobOptions}from"./discovery";export{getRegisteredJobs,getSchedulerStatus,isSchedulerRunning,startScheduler,stopScheduler,triggerJob}from"./scheduler";export{emitQueueEvent,getGlobalMetrics,getQueueEvents,getWorkerTracker,onQueueEvent,OnQueueEvent,QueueEvents,QueueMetrics,withEvents}from"./events";export{checkQueueHealth,createHealthCheckHandler,isQueueHealthy,queuedJobState}from"./health";export{configureFailedJobNotifications,FailedJobNotifier,getFailedJobNotifier,notifyJobFailed}from"./notifications";export{createQueueTester,expectJobToFail,fake,getFakeQueue,isFaked,QueueTester,restore,runJob as runTestJob}from"./testing";export{Batch,DispatchedBatch,PendingBatch,getBatchCallbacks,isBatchCancelled,recordBatchJobCompletion,recordBatchJobFailure}from"./batch";export{executeFailedJobs,getActiveJobCount,isWorkerRunning,retryFailedJob,startProcessor,stopProcessor}from"./worker";export async function getRedisQueue(){const{RedisQueue}=await import("./drivers/redis");return RedisQueue}export{resolveQueueDriver}from"./driver";
package/dist/job.js CHANGED
@@ -1 +1 @@
1
- var {require}=import.meta;import{basename}from"node:path";import{appPath,frameworkPath}from"@stacksjs/path";import{env as envVars}from"@stacksjs/env";import{enqueueAfterCommit,isInTransaction}from"@stacksjs/database/runtime";import{moveToDeadLetter}from"./dead-letter";import{assertEnvelopeSerializable,createEnvelope,serializeEnvelope}from"./envelope";import{claimDispatchKey,releaseDispatchKey}from"./idempotency";import{isQuarantined}from"./poison";import{runNamedAction}from"./action-runner";let testingModule,databaseModule,traceModule;function loadTraceModule(){return traceModule??=import("@stacksjs/router").catch((error)=>{traceModule=void 0;throw error})}function getQueueDriver(){return envVars.QUEUE_DRIVER||"sync"}let _warnedAfterCommitNoTx=!1;function warnAfterCommitOutsideTransaction(){if(_warnedAfterCommitNoTx)return;_warnedAfterCommitNoTx=!0;console.warn("[queue] .afterCommit() was called outside of `db.transaction(...)`. Dispatching immediately. Wrap the call in a transaction or drop .afterCommit() to silence this message.")}class JobBuilder{name;payload;options={};txMode="auto";constructor(name,payload){this.name=name;this.payload=payload}onQueue(queue){this.options.queue=queue;return this}delay(seconds){this.options.delay=seconds;return this}tries(count){this.options.tries=count;return this}timeout(seconds){this.options.timeout=seconds;return this}backoff(delays){this.options.backoff=delays;return this}withContext(context){this.options.context=context;return this}withIdempotencyKey(key){this.options.idempotencyKey=key;return this}afterCommit(){this.txMode="after";return this}withoutCommit(){this.txMode="immediate";return this}async dispatch(){const{isFaked,getFakeQueue}=await(testingModule??=import("./testing").catch((error)=>{testingModule=void 0;throw error}));if(isFaked()){getFakeQueue()?.dispatch(this.name,this.payload,this.options);return}if(this.txMode!=="immediate"){if(isInTransaction()){const buffered=this.runDispatchPipeline.bind(this);if(enqueueAfterCommit(async()=>{await buffered()}))return}else if(this.txMode==="after")warnAfterCommitOutsideTransaction()}await this.runDispatchPipeline()}async runDispatchPipeline(){let claimedKey=!1;if(this.options.idempotencyKey){const claim=await claimDispatchKey(this.options.idempotencyKey,this.name,this.options.queue);if(claim==="duplicate")return;claimedKey=claim==="claimed"}try{if(await isQuarantined(this.name,this.payload)){const envelope=createEnvelope(this.name,this.payload,{queue:this.options.queue,timeout:this.options.timeout,tries:this.options.tries,backoff:this.options.backoff},await currentTraceId(),this.options.context);if(await moveToDeadLetter({queue:this.options.queue||"default",payload:serializeEnvelope(envelope),exception:`quarantined: ${this.name}`},"poison-detected"))return}const driver=getQueueDriver();if(driver==="database")await this.dispatchToDatabase();else if(driver==="redis")await this.dispatchToRedis();else if(driver==="sync")await runJob(this.name,{payload:this.payload,context:this.options.context,traceId:await currentTraceId()});else if(driver==="sqs"||driver==="memory"||driver==="beanstalkd")throw Error(`[queue] Driver "${driver}" is not implemented yet. Set QUEUE_DRIVER to one of: redis, database, sync.`);else throw Error(`[queue] Unknown QUEUE_DRIVER "${driver}". Allowed values: redis, database, sync.`)}catch(err){if(claimedKey&&this.options.idempotencyKey)await releaseDispatchKey(this.options.idempotencyKey);throw err}}async dispatchIf(condition){if(condition)await this.dispatch()}async dispatchUnless(condition){if(!condition)await this.dispatch()}async dispatchToDatabase(){const now=Math.floor(Date.now()/1000),availableAt=this.options.delay?now+this.options.delay:now,envelope=createEnvelope(this.name,this.payload,{queue:this.options.queue,timeout:this.options.timeout,tries:this.options.tries,backoff:this.options.backoff},await currentTraceId(),this.options.context),payloadJson=serializeEnvelope(envelope),{db}=await(databaseModule??=import("@stacksjs/database/runtime").catch((error)=>{databaseModule=void 0;throw error}));await db.insertInto("jobs").values({queue:this.options.queue||"default",payload:payloadJson,attempts:0,reserved_at:null,available_at:availableAt,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute()}async dispatchToRedis(){const envelope=createEnvelope(this.name,this.payload,{queue:this.options.queue,timeout:this.options.timeout,tries:this.options.tries,backoff:this.options.backoff},await currentTraceId(),this.options.context);assertEnvelopeSerializable(envelope);const{sharedRedisQueue}=await import("./drivers/redis"),{queue:queueConfig}=await import("@stacksjs/config"),redisConfig=queueConfig?.connections?.redis;if(!redisConfig)throw Error("Redis queue connection is not configured. Check config/queue.ts");await sharedRedisQueue(this.options.queue||"default",redisConfig).add(envelope,{delay:this.options.delay,maxTries:this.options.tries,timeout:this.options.timeout,backoff:this.options.backoff})}async dispatchNow(){await runJob(this.name,{payload:this.payload,context:this.options.context})}}export function job(name,payload){return new JobBuilder(name,payload)}export const Jobs={make(name,payload){return new JobBuilder(name,payload)},async dispatch(name,payload){await new JobBuilder(name,payload).dispatch()},async dispatchIf(condition,name,payload){if(condition)await new JobBuilder(name,payload).dispatch()},async dispatchUnless(condition,name,payload){if(!condition)await new JobBuilder(name,payload).dispatch()},async dispatchNow(name,payload){await new JobBuilder(name,payload).dispatchNow()},dispatchAfter(seconds,name,payload){return new JobBuilder(name,payload).delay(seconds)},async dispatchOnce(key,name,payload){await new JobBuilder(name,payload).withIdempotencyKey(key).dispatch()},async dispatchAfterCommit(name,payload){await new JobBuilder(name,payload).afterCommit().dispatch()}};export function jobBatch(jobs){const{PendingBatch}=require("./batch");return new PendingBatch(jobs)}export async function resolveJobFile(name){const files=name.endsWith("Job")?[`${name}.ts`]:[`${name}.ts`,`${name}Job.ts`],candidates=[...files.map((file)=>appPath(`Jobs/${file}`)),...files.map((file)=>frameworkPath(`defaults/app/Jobs/${file}`))];for(const candidate of candidates)if(await Bun.file(candidate).exists())return candidate;let packageRoot;try{const pkgUrl=import.meta.resolve("@stacksjs/defaults/package.json");packageRoot=new URL(".",pkgUrl).pathname}catch{}if(packageRoot)for(const file of files){const packageCandidate=`${packageRoot}app/Jobs/${file}`;if(await Bun.file(packageCandidate).exists())return packageCandidate}const directories=[appPath("Jobs"),frameworkPath("defaults/app/Jobs"),...packageRoot?[`${packageRoot}app/Jobs`]:[]];for(const directory of directories){const declared=(await declaredJobNames(directory)).get(name);if(declared)return declared}return null}const declaredNamesByDirectory=new Map;function declaredJobNames(directory){let index=declaredNamesByDirectory.get(directory);if(!index){index=(async()=>{const byName=new Map,{existsSync}=await import("node:fs");if(!existsSync(directory))return byName;const glob=new Bun.Glob("*.ts");for await(const file of glob.scan({cwd:directory,absolute:!0,onlyFiles:!0})){if(file.endsWith(".d.ts")||file.includes(".test."))continue;const fileName=basename(file,".ts");let declared;try{declared=(await import(file)).default?.name}catch{continue}if(typeof declared!=="string"||declared===""||declared===fileName)continue;const existing=byName.get(declared);if(existing)throw Error(`Jobs ${existing} and ${file} both declare the name "${declared}"; rename one so a dispatch under it has one job to run`);byName.set(declared,file)}return byName})();index.catch(()=>declaredNamesByDirectory.delete(directory));declaredNamesByDirectory.set(directory,index)}return index}export function clearJobNameIndex(){declaredNamesByDirectory.clear()}export async function jobDefaults(name){try{const jobPath=await resolveJobFile(name);if(!jobPath)return{};const definition=(await import(jobPath)).default;if(!definition||typeof definition!=="object")return{};const tries=Number(definition.tries),timeout=Number(definition.timeout),backoff=definition.backoff;return{tries:Number.isInteger(tries)&&tries>0?tries:void 0,timeout:Number.isFinite(timeout)&&timeout>0?timeout:void 0,backoff:typeof backoff==="number"||Array.isArray(backoff)&&backoff.every((delay)=>typeof delay==="number")?backoff:void 0}}catch{return{}}}export async function runJob(name,options={}){const{withTraceId}=await loadTraceModule(),traceId=options.traceId??`job:${name}:${Math.random().toString(36).slice(2,10)}`;await withTraceId(traceId,async()=>{const jobPath=await resolveJobFile(name);if(!jobPath)throw Error(`Job ${name} not found. Looked in app/Jobs/${name}.ts${name.endsWith("Job")?"":` (and ${name}Job.ts)`} and the framework defaults (storage/framework/defaults/app/Jobs, @stacksjs/defaults).`);const jobConfig=(await import(jobPath)).default;if(!jobConfig)throw Error(`Job ${name} does not export a default`);if(typeof jobConfig.handle==="function")await jobConfig.handle(options.payload,options.context);else if(typeof jobConfig.action==="string")await runNamedAction(jobConfig.action);else if(typeof jobConfig.action==="function")await jobConfig.action();else if(typeof jobConfig==="function")await jobConfig(options.payload,options.context);else throw Error(`Job ${name} does not have a valid handler`)})}async function currentTraceId(){try{const{getTraceId}=await loadTraceModule();return getTraceId()}catch{return}}
1
+ var {require}=import.meta;import{resolveQueueDriver}from"./driver";import{basename}from"node:path";import{appPath,frameworkPath}from"@stacksjs/path";import{enqueueAfterCommit,isInTransaction}from"@stacksjs/database/runtime";import{moveToDeadLetter}from"./dead-letter";import{assertEnvelopeSerializable,createEnvelope,serializeEnvelope}from"./envelope";import{claimDispatchKey,releaseDispatchKey}from"./idempotency";import{isQuarantined}from"./poison";import{runNamedAction}from"./action-runner";let testingModule,databaseModule,traceModule;function loadTraceModule(){return traceModule??=import("@stacksjs/router").catch((error)=>{traceModule=void 0;throw error})}function getQueueDriver(){return resolveQueueDriver()}let _warnedAfterCommitNoTx=!1;function warnAfterCommitOutsideTransaction(){if(_warnedAfterCommitNoTx)return;_warnedAfterCommitNoTx=!0;console.warn("[queue] .afterCommit() was called outside of `db.transaction(...)`. Dispatching immediately. Wrap the call in a transaction or drop .afterCommit() to silence this message.")}class JobBuilder{name;payload;options={};txMode="auto";constructor(name,payload){this.name=name;this.payload=payload}onQueue(queue){this.options.queue=queue;return this}delay(seconds){this.options.delay=seconds;return this}tries(count){this.options.tries=count;return this}timeout(seconds){this.options.timeout=seconds;return this}backoff(delays){this.options.backoff=delays;return this}withContext(context){this.options.context=context;return this}withIdempotencyKey(key){this.options.idempotencyKey=key;return this}afterCommit(){this.txMode="after";return this}withoutCommit(){this.txMode="immediate";return this}async dispatch(){const{isFaked,getFakeQueue}=await(testingModule??=import("./testing").catch((error)=>{testingModule=void 0;throw error}));if(isFaked()){getFakeQueue()?.dispatch(this.name,this.payload,this.options);return}if(this.txMode!=="immediate"){if(isInTransaction()){const buffered=this.runDispatchPipeline.bind(this);if(enqueueAfterCommit(async()=>{await buffered()}))return}else if(this.txMode==="after")warnAfterCommitOutsideTransaction()}await this.runDispatchPipeline()}async runDispatchPipeline(){let claimedKey=!1;if(this.options.idempotencyKey){const claim=await claimDispatchKey(this.options.idempotencyKey,this.name,this.options.queue);if(claim==="duplicate")return;claimedKey=claim==="claimed"}try{if(await isQuarantined(this.name,this.payload)){const envelope=createEnvelope(this.name,this.payload,{queue:this.options.queue,timeout:this.options.timeout,tries:this.options.tries,backoff:this.options.backoff},await currentTraceId(),this.options.context);if(await moveToDeadLetter({queue:this.options.queue||"default",payload:serializeEnvelope(envelope),exception:`quarantined: ${this.name}`},"poison-detected"))return}const driver=getQueueDriver();if(driver==="database")await this.dispatchToDatabase();else if(driver==="redis")await this.dispatchToRedis();else if(driver==="sync")await runJob(this.name,{payload:this.payload,context:this.options.context,traceId:await currentTraceId()});else if(driver==="sqs"||driver==="memory"||driver==="beanstalkd")throw Error(`[queue] Driver "${driver}" is not implemented yet. Set QUEUE_DRIVER to one of: redis, database, sync.`);else throw Error(`[queue] Unknown QUEUE_DRIVER "${driver}". Allowed values: redis, database, sync.`)}catch(err){if(claimedKey&&this.options.idempotencyKey)await releaseDispatchKey(this.options.idempotencyKey);throw err}}async dispatchIf(condition){if(condition)await this.dispatch()}async dispatchUnless(condition){if(!condition)await this.dispatch()}async dispatchToDatabase(){const now=Math.floor(Date.now()/1000),availableAt=this.options.delay?now+this.options.delay:now,envelope=createEnvelope(this.name,this.payload,{queue:this.options.queue,timeout:this.options.timeout,tries:this.options.tries,backoff:this.options.backoff},await currentTraceId(),this.options.context),payloadJson=serializeEnvelope(envelope),{db}=await(databaseModule??=import("@stacksjs/database/runtime").catch((error)=>{databaseModule=void 0;throw error}));await db.insertInto("jobs").values({queue:this.options.queue||"default",payload:payloadJson,attempts:0,reserved_at:null,available_at:availableAt,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute()}async dispatchToRedis(){const envelope=createEnvelope(this.name,this.payload,{queue:this.options.queue,timeout:this.options.timeout,tries:this.options.tries,backoff:this.options.backoff},await currentTraceId(),this.options.context);assertEnvelopeSerializable(envelope);const{sharedRedisQueue}=await import("./drivers/redis"),{queue:queueConfig}=await import("@stacksjs/config"),redisConfig=queueConfig?.connections?.redis;if(!redisConfig)throw Error("Redis queue connection is not configured. Check config/queue.ts");await sharedRedisQueue(this.options.queue||"default",redisConfig).add(envelope,{delay:this.options.delay,maxTries:this.options.tries,timeout:this.options.timeout,backoff:this.options.backoff})}async dispatchNow(){await runJob(this.name,{payload:this.payload,context:this.options.context})}}export function job(name,payload){return new JobBuilder(name,payload)}export const Jobs={make(name,payload){return new JobBuilder(name,payload)},async dispatch(name,payload){await new JobBuilder(name,payload).dispatch()},async dispatchIf(condition,name,payload){if(condition)await new JobBuilder(name,payload).dispatch()},async dispatchUnless(condition,name,payload){if(!condition)await new JobBuilder(name,payload).dispatch()},async dispatchNow(name,payload){await new JobBuilder(name,payload).dispatchNow()},dispatchAfter(seconds,name,payload){return new JobBuilder(name,payload).delay(seconds)},async dispatchOnce(key,name,payload){await new JobBuilder(name,payload).withIdempotencyKey(key).dispatch()},async dispatchAfterCommit(name,payload){await new JobBuilder(name,payload).afterCommit().dispatch()}};export function jobBatch(jobs){const{PendingBatch}=require("./batch");return new PendingBatch(jobs)}export async function resolveJobFile(name){const files=name.endsWith("Job")?[`${name}.ts`]:[`${name}.ts`,`${name}Job.ts`],candidates=[...files.map((file)=>appPath(`Jobs/${file}`)),...files.map((file)=>frameworkPath(`defaults/app/Jobs/${file}`))];for(const candidate of candidates)if(await Bun.file(candidate).exists())return candidate;let packageRoot;try{const pkgUrl=import.meta.resolve("@stacksjs/defaults/package.json");packageRoot=new URL(".",pkgUrl).pathname}catch{}if(packageRoot)for(const file of files){const packageCandidate=`${packageRoot}app/Jobs/${file}`;if(await Bun.file(packageCandidate).exists())return packageCandidate}const directories=[appPath("Jobs"),frameworkPath("defaults/app/Jobs"),...packageRoot?[`${packageRoot}app/Jobs`]:[]];for(const directory of directories){const declared=(await declaredJobNames(directory)).get(name);if(declared)return declared}return null}const declaredNamesByDirectory=new Map;function declaredJobNames(directory){let index=declaredNamesByDirectory.get(directory);if(!index){index=(async()=>{const byName=new Map,{existsSync}=await import("node:fs");if(!existsSync(directory))return byName;const glob=new Bun.Glob("*.ts");for await(const file of glob.scan({cwd:directory,absolute:!0,onlyFiles:!0})){if(file.endsWith(".d.ts")||file.includes(".test."))continue;const fileName=basename(file,".ts");let declared;try{declared=(await import(file)).default?.name}catch{continue}if(typeof declared!=="string"||declared===""||declared===fileName)continue;const existing=byName.get(declared);if(existing)throw Error(`Jobs ${existing} and ${file} both declare the name "${declared}"; rename one so a dispatch under it has one job to run`);byName.set(declared,file)}return byName})();index.catch(()=>declaredNamesByDirectory.delete(directory));declaredNamesByDirectory.set(directory,index)}return index}export function clearJobNameIndex(){declaredNamesByDirectory.clear()}export async function jobDefaults(name){try{const jobPath=await resolveJobFile(name);if(!jobPath)return{};const definition=(await import(jobPath)).default;if(!definition||typeof definition!=="object")return{};const tries=Number(definition.tries),timeout=Number(definition.timeout),backoff=definition.backoff;return{tries:Number.isInteger(tries)&&tries>0?tries:void 0,timeout:Number.isFinite(timeout)&&timeout>0?timeout:void 0,backoff:typeof backoff==="number"||Array.isArray(backoff)&&backoff.every((delay)=>typeof delay==="number")?backoff:void 0}}catch{return{}}}export async function runJob(name,options={}){const{withTraceId}=await loadTraceModule(),traceId=options.traceId??`job:${name}:${Math.random().toString(36).slice(2,10)}`;await withTraceId(traceId,async()=>{const jobPath=await resolveJobFile(name);if(!jobPath)throw Error(`Job ${name} not found. Looked in app/Jobs/${name}.ts${name.endsWith("Job")?"":` (and ${name}Job.ts)`} and the framework defaults (storage/framework/defaults/app/Jobs, @stacksjs/defaults).`);const jobConfig=(await import(jobPath)).default;if(!jobConfig)throw Error(`Job ${name} does not export a default`);if(typeof jobConfig.handle==="function")await jobConfig.handle(options.payload,options.context);else if(typeof jobConfig.action==="string")await runNamedAction(jobConfig.action);else if(typeof jobConfig.action==="function")await jobConfig.action();else if(typeof jobConfig==="function")await jobConfig(options.payload,options.context);else throw Error(`Job ${name} does not have a valid handler`)})}async function currentTraceId(){try{const{getTraceId}=await loadTraceModule();return getTraceId()}catch{return}}
package/dist/poison.d.ts CHANGED
@@ -1,8 +1,5 @@
1
- /**
2
- * Compute the dedup key for a job+payload combination. Stable
3
- * sha256 truncated to 32 hex chars — short enough to fit in
4
- * indexed columns, wide enough to avoid collisions in practice.
5
- */
1
+ import { db } from '@stacksjs/database/runtime';
2
+ /** Legacy hash API: retained so persisted v1 rows and callers keep their vectors. */
6
3
  export declare function hashPayload(payload: unknown): string;
7
4
  /**
8
5
  * Record a failure against `(jobName, payload-hash)`. If this push
@@ -51,3 +48,4 @@ export declare interface PoisonConfig {
51
48
  maxFailures?: number
52
49
  windowMinutes?: number
53
50
  }
51
+ declare type QueueTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
package/dist/poison.js CHANGED
@@ -1 +1 @@
1
- import{hash}from"node:crypto";import{db}from"@stacksjs/database/runtime";import{isMissingTableError}from"./missing-table";let warnedAboutMissingTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingTable)return;warnedAboutMissingTable=!0;console.warn("[queue/poison] job_quarantine table missing - poison detection disabled. Run migrations to enable.")}export function hashPayload(payload){let serialized;try{serialized=typeof payload==="string"?payload:JSON.stringify(payload??null)}catch{serialized=String(payload)}return hash("sha256",serialized,"hex").slice(0,32)}export async function recordFailureForPoison(jobName,payload,config={}){const maxFailures=config.maxFailures??5,windowMinutes=config.windowMinutes??60,payloadHash=hashPayload(payload),now=new Date,nowStr=now.toISOString().slice(0,19).replace("T"," ");try{const existing=await db.selectFrom("job_quarantine").where("job_name","=",jobName).where("payload_hash","=",payloadHash).selectAll().executeTakeFirst();if(!existing){await db.insertInto("job_quarantine").values({job_name:jobName,payload_hash:payloadHash,failure_count:1,window_start:nowStr,quarantined_at:null}).execute();return!1}if(existing.quarantined_at)return!0;const windowStartMs=Date.parse(existing.window_start.replace(" ","T")+"Z"),ageMs=now.getTime()-windowStartMs,windowMs=windowMinutes*60*1000;if(Number.isFinite(windowStartMs)&&ageMs>windowMs){await db.updateTable("job_quarantine").set({failure_count:1,window_start:nowStr}).where("id","=",existing.id).execute();return!1}const newCount=existing.failure_count+1;if(newCount>=maxFailures){await db.updateTable("job_quarantine").set({failure_count:newCount,quarantined_at:nowStr}).where("id","=",existing.id).execute();return!0}await db.updateTable("job_quarantine").set({failure_count:newCount}).where("id","=",existing.id).execute();return!1}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function isQuarantined(jobName,payload){const payloadHash=hashPayload(payload);try{const row=await db.selectFrom("job_quarantine").where("job_name","=",jobName).where("payload_hash","in",[payloadHash,"*"]).whereNotNull("quarantined_at").select(["id"]).executeTakeFirst();return Boolean(row)}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function quarantineJob(jobName,payload){const payloadHash=payload===void 0?"*":hashPayload(payload),nowStr=new Date().toISOString().slice(0,19).replace("T"," ");try{const existing=await db.selectFrom("job_quarantine").where("job_name","=",jobName).where("payload_hash","=",payloadHash).select(["id"]).executeTakeFirst();if(existing)await db.updateTable("job_quarantine").set({quarantined_at:nowStr}).where("id","=",existing.id).execute();else await db.insertInto("job_quarantine").values({job_name:jobName,payload_hash:payloadHash,failure_count:0,window_start:nowStr,quarantined_at:nowStr}).execute()}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return}throw err}}export async function unquarantineJob(jobName){try{await db.deleteFrom("job_quarantine").where("job_name","=",jobName).execute()}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return}throw err}}export async function listQuarantined(){try{return await db.selectFrom("job_quarantine").whereNotNull("quarantined_at").selectAll().execute()??[]}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return[]}throw err}}
1
+ import{hash}from"node:crypto";import{db,lockRow,sqliteWriteOptions,parseSqlDateTime}from"@stacksjs/database/runtime";import{seedStateRow}from"./state-row";import{isMissingTableError}from"./missing-table";let warnedAboutMissingTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingTable)return;warnedAboutMissingTable=!0;console.warn("[queue/poison] job_quarantine table missing - poison detection disabled. Run migrations to enable.")}function serializePayload(payload){try{return typeof payload==="string"?payload:JSON.stringify(payload??null)}catch{return String(payload)}}export function hashPayload(payload){return hash("sha256",serializePayload(payload),"hex").slice(0,32)}function canonicalJson(value){if(Array.isArray(value))return value.map(canonicalJson);if(value&&typeof value==="object"){const record=value;return Object.fromEntries(Object.keys(record).sort().map((key)=>[key,canonicalJson(record[key])]))}return value}function payloadKeys(payload){const serialized=serializePayload(payload),legacy=hash("sha256",serialized,"hex").slice(0,32);let value=serialized;if(typeof payload!=="string")try{value=JSON.parse(serialized)}catch{}return{current:`v2:${hash("sha256",JSON.stringify([typeof payload,canonicalJson(value)]),"hex").slice(0,32)}`,legacy}}async function payloadState(tx,jobName,keys,now,windowMs){await seedStateRow(tx,"job_quarantine",["job_name","payload_hash"],{job_name:jobName,payload_hash:keys.current,failure_count:0,window_start:now,quarantined_at:null});const current=await lockRow(tx,"job_quarantine",{job_name:jobName,payload_hash:keys.current});if(!current)throw Error("Poison state disappeared while locked");if(keys.legacy===keys.current)return current;const legacy=await lockRow(tx,"job_quarantine",{job_name:jobName,payload_hash:keys.legacy});if(!legacy)return current;const cutoff=Date.now()-windowMs,active=[current,legacy].filter((row)=>(parseSqlDateTime(row.window_start)?.getTime()??0)>=cutoff),first=active.map((row)=>parseSqlDateTime(row.window_start).getTime()),merged={failure_count:active.reduce((total,row)=>total+Number(row.failure_count),0),window_start:first.length?new Date(Math.min(...first)).toISOString():now,quarantined_at:current.quarantined_at??legacy.quarantined_at};await tx.updateTable("job_quarantine").set(merged).where("id","=",Number(current.id)).execute();await tx.deleteFrom("job_quarantine").where("id","=",Number(legacy.id)).execute();return{...current,...merged}}export async function recordFailureForPoison(jobName,payload,config={}){const maxFailures=config.maxFailures??5,windowMinutes=config.windowMinutes??60,keys=payloadKeys(payload),now=new Date,nowStr=now.toISOString().slice(0,19).replace("T"," ");try{return await db.transaction(async(tx)=>{const row=await payloadState(tx,jobName,keys,nowStr,windowMinutes*60000);if(row.quarantined_at)return!0;const start=parseSqlDateTime(row.window_start)?.getTime(),reset=start===void 0||now.getTime()-start>=windowMinutes*60000,count=(reset?0:Number(row.failure_count))+1,quarantined=count>=maxFailures;await tx.updateTable("job_quarantine").set({failure_count:count,window_start:reset?nowStr:row.window_start,quarantined_at:quarantined?nowStr:null}).where("id","=",Number(row.id)).execute();return quarantined},sqliteWriteOptions())}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function isQuarantined(jobName,payload){const keys=payloadKeys(payload);try{const row=await db.selectFrom("job_quarantine").where("job_name","=",jobName).where("payload_hash","in",[keys.current,keys.legacy,"*"]).whereNotNull("quarantined_at").select(["id","payload_hash"]).executeTakeFirst();if(row?.payload_hash===keys.legacy)await db.transaction((tx)=>payloadState(tx,jobName,keys,new Date().toISOString(),3600000),sqliteWriteOptions());return Boolean(row)}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function quarantineJob(jobName,payload){const keys=payload===void 0?{current:"*",legacy:"*"}:payloadKeys(payload),nowStr=new Date().toISOString().slice(0,19).replace("T"," ");try{await db.transaction(async(tx)=>{const row=await payloadState(tx,jobName,keys,nowStr,3600000);await tx.updateTable("job_quarantine").set({quarantined_at:nowStr}).where("id","=",Number(row.id)).execute()},sqliteWriteOptions())}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return}throw err}}export async function unquarantineJob(jobName){try{await db.deleteFrom("job_quarantine").where("job_name","=",jobName).execute()}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return}throw err}}export async function listQuarantined(){try{return await db.selectFrom("job_quarantine").whereNotNull("quarantined_at").selectAll().execute()??[]}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return[]}throw err}}
@@ -1,16 +1,14 @@
1
+ import { storeJob } from './utils';
1
2
  /**
2
3
  * Load a job's persisted `lastRun`, or null when there's none / persistence is
3
4
  * unavailable. Used at startup so a restart doesn't re-fire a job that already
4
5
  * ran this minute.
5
6
  */
6
7
  export declare function loadPersistedLastRun(jobName: string): Promise<Date | null>;
7
- /**
8
- * Persist a job's `lastRun` (bounded to one row per job). Delete-then-insert is
9
- * a portable upsert that avoids the per-dialect UPDATE-affected-rows ambiguity
10
- * (MySQL reports 0 rows on an unchanged value); a single scheduler process owns
11
- * this table, so there is no concurrent-writer race. Best-effort.
12
- */
8
+ /** Persist a marker without exposing a delete/insert gap to concurrent readers. */
13
9
  export declare function persistLastRun(jobName: string, when: Date): Promise<void>;
10
+ /** Commit the queue row and its once-per-minute marker together. */
11
+ export declare function storeScheduledRun(jobName: string, when: Date, options: Parameters<typeof storeJob>[1]): Promise<boolean>;
14
12
  /**
15
13
  * Whether a previous dispatch of this scheduled job is still unfinished.
16
14
  *
@@ -1 +1 @@
1
- import{log}from"@stacksjs/logging";let ensured=!1;async function ensureTable(){if(ensured)return!0;try{const{db}=await import("@stacksjs/database/runtime");await db.unsafe("CREATE TABLE IF NOT EXISTS scheduled_job_runs (job_name VARCHAR(255) PRIMARY KEY, last_run_at VARCHAR(64) NOT NULL)").execute();ensured=!0;return!0}catch(err){log.debug(`[scheduler] run-marker persistence unavailable, using in-memory lastRun: ${err instanceof Error?err.message:String(err)}`);return!1}}export async function loadPersistedLastRun(jobName){if(!await ensureTable())return null;try{const{db}=await import("@stacksjs/database/runtime"),row=await db.selectFrom("scheduled_job_runs").where("job_name","=",jobName).select(["last_run_at"]).executeTakeFirst();if(!row?.last_run_at)return null;const d=new Date(row.last_run_at);return Number.isNaN(d.getTime())?null:d}catch{return null}}export async function persistLastRun(jobName,when){if(!await ensureTable())return;try{const{db}=await import("@stacksjs/database/runtime"),iso=when.toISOString();await db.deleteFrom("scheduled_job_runs").where("job_name","=",jobName).execute();await db.insertInto("scheduled_job_runs").values({job_name:jobName,last_run_at:iso}).execute()}catch{}}export function overlapPayloadPattern(jobName){return`%"jobName":"${jobName.replace(/[\\%_]/g,(character)=>`\\${character}`)}"%`}export async function hasUnfinishedRun(jobName){try{const{db,getDatabaseDialect,sqlHelpers}=await import("@stacksjs/database/runtime"),{param}=sqlHelpers(getDatabaseDialect()),raw=await db.unsafe(`SELECT 1 AS present FROM jobs WHERE payload LIKE ${param(1)} ESCAPE '\\' LIMIT 1`,[overlapPayloadPattern(jobName)]).execute(),list=Array.isArray(raw)?raw:raw?.rows??[];return Array.isArray(list)&&list.length>0}catch(err){log.debug(`[scheduler] overlap check unavailable, dispatching anyway: ${err instanceof Error?err.message:String(err)}`);return!1}}export function __resetSchedulerPersistenceForTests(){ensured=!1}
1
+ import{sqliteWriteOptions}from"@stacksjs/database/runtime";import{log}from"@stacksjs/logging";import{buildScheduledJobRow,storeJob}from"./utils";import{seedStateRow}from"./state-row";let ensured=!1;async function ensureTable(){if(ensured)return!0;try{const{db}=await import("@stacksjs/database/runtime");await db.unsafe("CREATE TABLE IF NOT EXISTS scheduled_job_runs (job_name VARCHAR(255) PRIMARY KEY, last_run_at VARCHAR(64) NOT NULL)").execute();ensured=!0;return!0}catch(err){log.debug(`[scheduler] run-marker persistence unavailable, using in-memory lastRun: ${err instanceof Error?err.message:String(err)}`);return!1}}export async function loadPersistedLastRun(jobName){if(!await ensureTable())return null;try{const{db}=await import("@stacksjs/database/runtime"),row=await db.selectFrom("scheduled_job_runs").where("job_name","=",jobName).select(["last_run_at"]).executeTakeFirst();if(!row?.last_run_at)return null;const d=new Date(row.last_run_at);return Number.isNaN(d.getTime())?null:d}catch{return null}}export async function persistLastRun(jobName,when){if(!await ensureTable())return;try{const{db}=await import("@stacksjs/database/runtime");await db.transaction(async(tx)=>{await seedStateRow(tx,"scheduled_job_runs",["job_name"],{job_name:jobName,last_run_at:when.toISOString()});await tx.updateTable("scheduled_job_runs").set({last_run_at:when.toISOString()}).where("job_name","=",jobName).execute()},sqliteWriteOptions())}catch{}}export async function storeScheduledRun(jobName,when,options){if(!await ensureTable()){await storeJob(jobName,options);return!0}const row=buildScheduledJobRow(jobName,options),{db,lockRow}=await import("@stacksjs/database/runtime");return db.transaction(async(tx)=>{await seedStateRow(tx,"scheduled_job_runs",["job_name"],{job_name:jobName,last_run_at:""});const previous=await lockRow(tx,"scheduled_job_runs",{job_name:jobName});if(previous&&Math.floor(new Date(String(previous.last_run_at)).getTime()/60000)===Math.floor(when.getTime()/60000))return!1;await tx.insertInto("jobs").values({...row}).execute();await tx.updateTable("scheduled_job_runs").set({last_run_at:when.toISOString()}).where("job_name","=",jobName).execute();return!0},sqliteWriteOptions())}export function overlapPayloadPattern(jobName){return`%"jobName":"${jobName.replace(/[\\%_]/g,(character)=>`\\${character}`)}"%`}export async function hasUnfinishedRun(jobName){try{const{db,getDatabaseDialect,sqlHelpers}=await import("@stacksjs/database/runtime"),{param}=sqlHelpers(getDatabaseDialect()),raw=await db.unsafe(`SELECT 1 AS present FROM jobs WHERE payload LIKE ${param(1)} ESCAPE '\\' LIMIT 1`,[overlapPayloadPattern(jobName)]).execute(),list=Array.isArray(raw)?raw:raw?.rows??[];return Array.isArray(list)&&list.length>0}catch(err){log.debug(`[scheduler] overlap check unavailable, dispatching anyway: ${err instanceof Error?err.message:String(err)}`);return!1}}export function __resetSchedulerPersistenceForTests(){ensured=!1}
package/dist/scheduler.js CHANGED
@@ -1 +1 @@
1
- import{parseCron}from"@stacksjs/cron";import{log}from"@stacksjs/logging";import{discoverJobs,getScheduledJobs}from"./discovery";import{emitQueueEvent}from"./events";import{hasUnfinishedRun,loadPersistedLastRun,persistLastRun}from"./scheduler-persistence";import{storeJob}from"./utils";const DEFAULT_CONFIG={checkInterval:60000,preventOverlapping:!0},schedulerState={isRunning:!1,isShuttingDown:!1,checkInterval:null,jobs:new Map,config:{...DEFAULT_CONFIG}};let warnedBadTimezone=!1;const tzFormatterCache=new Map;function tzFormatter(timeZone){let f=tzFormatterCache.get(timeZone);if(!f){f=new Intl.DateTimeFormat("en-US",{timeZone,hour12:!1,month:"numeric",day:"numeric",hour:"numeric",minute:"numeric",weekday:"short"});tzFormatterCache.set(timeZone,f)}return f}export function getCronParts(date,timeZone){if(timeZone&&timeZone!=="local"&&timeZone!=="system")try{const parts=tzFormatter(timeZone).formatToParts(date),get=(t)=>parts.find((p)=>p.type===t)?.value??"",weekday={Sun:0,Mon:1,Tue:2,Wed:3,Thu:4,Fri:5,Sat:6};let hour=Number(get("hour"));if(hour===24)hour=0;return{minute:Number(get("minute")),hour,day:Number(get("day")),month:Number(get("month")),dayOfWeek:weekday[get("weekday")]??date.getDay()}}catch{if(!warnedBadTimezone){warnedBadTimezone=!0;log.warn(`[scheduler] Invalid timezone "${timeZone}"; falling back to system local time.`)}}return{minute:date.getMinutes(),hour:date.getHours(),day:date.getDate(),month:date.getMonth()+1,dayOfWeek:date.getDay()}}function cronTimeZone(timeZone){const system=Intl.DateTimeFormat().resolvedOptions().timeZone;if(!timeZone||timeZone==="local"||timeZone==="system")return system;try{new Intl.DateTimeFormat("en-US",{timeZone});return timeZone}catch{if(!warnedBadTimezone){warnedBadTimezone=!0;log.warn(`[scheduler] Invalid timezone "${timeZone}"; falling back to system local time.`)}return system}}const warnedInvalidCron=new Set;function nextCronMinute(cronExpression,from,timeZone){try{return parseCron(cronExpression,from.getTime()-60000,{tz:cronTimeZone(timeZone)})}catch(error){if(!warnedInvalidCron.has(cronExpression)){warnedInvalidCron.add(cronExpression);log.warn(`Invalid cron expression: ${cronExpression} (${error instanceof Error?error.message:String(error)})`)}return null}}export function shouldRunNow(cronExpression,lastRun,timeZone){const minute=new Date;minute.setSeconds(0,0);if(lastRun&&Math.floor(lastRun.getTime()/60000)===Math.floor(minute.getTime()/60000))return!1;return nextCronMinute(cronExpression,minute,timeZone)?.getTime()===minute.getTime()}function parseScheduleString(schedule){const mapped={"@yearly":"0 0 1 1 *","@annually":"0 0 1 1 *","@monthly":"0 0 1 * *","@weekly":"0 0 * * 0","@daily":"0 0 * * *","@midnight":"0 0 * * *","@hourly":"0 * * * *"}[schedule.toLowerCase()];if(mapped)return mapped;const everyMatch=schedule.match(/^Every\.(\w+)$/i);if(everyMatch&&everyMatch[1]!==void 0){const interval=everyMatch[1].toLowerCase();return{second:"* * * * *",fiveseconds:"* * * * *",tenseconds:"* * * * *",thirtyseconds:"* * * * *",minute:"* * * * *",fiveminutes:"*/5 * * * *",tenminutes:"*/10 * * * *",fifteenminutes:"*/15 * * * *",thirtyminutes:"*/30 * * * *",hour:"0 * * * *",twohours:"0 */2 * * *",sixhours:"0 */6 * * *",twelvehours:"0 */12 * * *",day:"0 0 * * *",week:"0 0 * * 0",month:"0 0 1 * *"}[interval]||null}const partCount=schedule.split(/\s+/).length;if(partCount>=5&&partCount<=6)return schedule;return null}export function calculateNextRun(cronExpression,timeZone){const next=new Date;next.setSeconds(0,0);next.setTime(next.getTime()+60000);return nextCronMinute(cronExpression,next,timeZone)}export async function startScheduler(config={}){if(schedulerState.isRunning){log.warn("Scheduler is already running");return}schedulerState.config={...DEFAULT_CONFIG,...config};schedulerState.isRunning=!0;schedulerState.isShuttingDown=!1;await discoverJobs();const scheduledJobs=getScheduledJobs();for(const job of scheduledJobs){const schedule=job.config.rate||job.config.schedule;if(schedule){const cronExpression=parseScheduleString(schedule);if(cronExpression){const lastRun=await loadPersistedLastRun(job.name);schedulerState.jobs.set(job.name,{job,lastRun,nextRun:calculateNextRun(cronExpression,schedulerState.config.timezone),isRunning:!1});log.info(`Registered scheduled job: ${job.name} (${cronExpression})`)}else log.warn(`Invalid schedule for job ${job.name}: ${schedule}`)}}if(schedulerState.jobs.size===0){log.info("No scheduled jobs found");return}log.info(`Scheduler started with ${schedulerState.jobs.size} job(s)`);process.on("SIGINT",()=>stopScheduler());process.on("SIGTERM",()=>stopScheduler());let isChecking=!1;const scheduleNextTick=()=>{if(schedulerState.isShuttingDown)return;const interval=schedulerState.config.checkInterval,delay=interval-Date.now()%interval;schedulerState.checkInterval=setTimeout(()=>{if(!schedulerState.isShuttingDown&&!isChecking){isChecking=!0;checkScheduledJobs().catch((err)=>log.error("Scheduler check failed:",err)).finally(()=>{isChecking=!1})}scheduleNextTick()},delay);schedulerState.checkInterval?.unref?.()};scheduleNextTick();await checkScheduledJobs()}async function checkScheduledJobs(){for(const[name,state]of schedulerState.jobs){const schedule=state.job.config.rate||state.job.config.schedule;if(!schedule)continue;const cronExpression=parseScheduleString(schedule);if(!cronExpression)continue;if(shouldRunNow(cronExpression,state.lastRun,schedulerState.config.timezone)){if((schedulerState.config.preventOverlapping||state.job.config.withoutOverlapping)&&await hasUnfinishedRun(name)){log.debug(`Skipping ${name}: previous execution still running`);continue}if(state.isRunning){log.debug(`Skipping ${name}: a dispatch for it is already in flight`);continue}try{state.isRunning=!0;state.lastRun=new Date;state.nextRun=calculateNextRun(cronExpression,schedulerState.config.timezone);await persistLastRun(name,state.lastRun);log.info(`Dispatching scheduled job: ${name}`);await emitQueueEvent("job:added",{jobId:`scheduled-${name}-${Date.now()}`,queueName:state.job.config.queue||"default",jobName:name});await storeJob(name,scheduledDispatchOptions(state.job.config));state.isRunning=!1;log.info(`Scheduled job ${name} dispatched to queue`)}catch(error){state.isRunning=!1;log.error(`Failed to dispatch scheduled job ${name}:`,error)}}}}export async function stopScheduler(){if(!schedulerState.isRunning)return;log.info("Stopping scheduler...");schedulerState.isShuttingDown=!0;if(schedulerState.checkInterval){clearTimeout(schedulerState.checkInterval);schedulerState.checkInterval=null}schedulerState.isRunning=!1;schedulerState.jobs.clear();log.info("Scheduler stopped")}export function getSchedulerStatus(){return{isRunning:schedulerState.isRunning,jobCount:schedulerState.jobs.size,jobs:Array.from(schedulerState.jobs.entries()).map(([name,state])=>({name,schedule:state.job.config.rate||state.job.config.schedule,lastRun:state.lastRun,nextRun:state.nextRun,isRunning:state.isRunning}))}}export function isSchedulerRunning(){return schedulerState.isRunning}export function getRegisteredJobs(){return new Map(schedulerState.jobs)}export async function triggerJob(name){const state=schedulerState.jobs.get(name);if(!state)throw Error(`Scheduled job "${name}" not found`);log.info(`Manually triggering scheduled job: ${name}`);await storeJob(name,scheduledDispatchOptions(state.job.config))}export function scheduledDispatchOptions(config){const backoff=config.backoff;return{queue:config.queue||"default",payload:{},maxTries:config.tries??3,timeout:config.timeout,backoff:typeof backoff==="number"||Array.isArray(backoff)?backoff:void 0}}
1
+ import{parseCron}from"@stacksjs/cron";import{log}from"@stacksjs/logging";import{discoverJobs,getScheduledJobs}from"./discovery";import{emitQueueEvent}from"./events";import{hasUnfinishedRun,loadPersistedLastRun,storeScheduledRun}from"./scheduler-persistence";import{storeJob}from"./utils";const DEFAULT_CONFIG={checkInterval:60000,preventOverlapping:!0},schedulerState={isRunning:!1,isShuttingDown:!1,checkInterval:null,jobs:new Map,config:{...DEFAULT_CONFIG}};let warnedBadTimezone=!1;const tzFormatterCache=new Map;function tzFormatter(timeZone){let f=tzFormatterCache.get(timeZone);if(!f){f=new Intl.DateTimeFormat("en-US",{timeZone,hour12:!1,month:"numeric",day:"numeric",hour:"numeric",minute:"numeric",weekday:"short"});tzFormatterCache.set(timeZone,f)}return f}export function getCronParts(date,timeZone){if(timeZone&&timeZone!=="local"&&timeZone!=="system")try{const parts=tzFormatter(timeZone).formatToParts(date),get=(t)=>parts.find((p)=>p.type===t)?.value??"",weekday={Sun:0,Mon:1,Tue:2,Wed:3,Thu:4,Fri:5,Sat:6};let hour=Number(get("hour"));if(hour===24)hour=0;return{minute:Number(get("minute")),hour,day:Number(get("day")),month:Number(get("month")),dayOfWeek:weekday[get("weekday")]??date.getDay()}}catch{if(!warnedBadTimezone){warnedBadTimezone=!0;log.warn(`[scheduler] Invalid timezone "${timeZone}"; falling back to system local time.`)}}return{minute:date.getMinutes(),hour:date.getHours(),day:date.getDate(),month:date.getMonth()+1,dayOfWeek:date.getDay()}}function cronTimeZone(timeZone){const system=Intl.DateTimeFormat().resolvedOptions().timeZone;if(!timeZone||timeZone==="local"||timeZone==="system")return system;try{new Intl.DateTimeFormat("en-US",{timeZone});return timeZone}catch{if(!warnedBadTimezone){warnedBadTimezone=!0;log.warn(`[scheduler] Invalid timezone "${timeZone}"; falling back to system local time.`)}return system}}const warnedInvalidCron=new Set;function nextCronMinute(cronExpression,from,timeZone){try{return parseCron(cronExpression,from.getTime()-60000,{tz:cronTimeZone(timeZone)})}catch(error){if(!warnedInvalidCron.has(cronExpression)){warnedInvalidCron.add(cronExpression);log.warn(`Invalid cron expression: ${cronExpression} (${error instanceof Error?error.message:String(error)})`)}return null}}export function shouldRunNow(cronExpression,lastRun,timeZone){const minute=new Date;minute.setSeconds(0,0);if(lastRun&&Math.floor(lastRun.getTime()/60000)===Math.floor(minute.getTime()/60000))return!1;return nextCronMinute(cronExpression,minute,timeZone)?.getTime()===minute.getTime()}function parseScheduleString(schedule){const mapped={"@yearly":"0 0 1 1 *","@annually":"0 0 1 1 *","@monthly":"0 0 1 * *","@weekly":"0 0 * * 0","@daily":"0 0 * * *","@midnight":"0 0 * * *","@hourly":"0 * * * *"}[schedule.toLowerCase()];if(mapped)return mapped;const everyMatch=schedule.match(/^Every\.(\w+)$/i);if(everyMatch&&everyMatch[1]!==void 0){const interval=everyMatch[1].toLowerCase();return{second:"* * * * *",fiveseconds:"* * * * *",tenseconds:"* * * * *",thirtyseconds:"* * * * *",minute:"* * * * *",fiveminutes:"*/5 * * * *",tenminutes:"*/10 * * * *",fifteenminutes:"*/15 * * * *",thirtyminutes:"*/30 * * * *",hour:"0 * * * *",twohours:"0 */2 * * *",sixhours:"0 */6 * * *",twelvehours:"0 */12 * * *",day:"0 0 * * *",week:"0 0 * * 0",month:"0 0 1 * *"}[interval]||null}const partCount=schedule.split(/\s+/).length;if(partCount>=5&&partCount<=6)return schedule;return null}export function calculateNextRun(cronExpression,timeZone){const next=new Date;next.setSeconds(0,0);next.setTime(next.getTime()+60000);return nextCronMinute(cronExpression,next,timeZone)}export async function startScheduler(config={}){if(schedulerState.isRunning){log.warn("Scheduler is already running");return}schedulerState.config={...DEFAULT_CONFIG,...config};schedulerState.isRunning=!0;schedulerState.isShuttingDown=!1;await discoverJobs();const scheduledJobs=getScheduledJobs();for(const job of scheduledJobs){const schedule=job.config.rate||job.config.schedule;if(schedule){const cronExpression=parseScheduleString(schedule);if(cronExpression){const lastRun=await loadPersistedLastRun(job.name);schedulerState.jobs.set(job.name,{job,lastRun,nextRun:calculateNextRun(cronExpression,schedulerState.config.timezone),isRunning:!1});log.info(`Registered scheduled job: ${job.name} (${cronExpression})`)}else log.warn(`Invalid schedule for job ${job.name}: ${schedule}`)}}if(schedulerState.jobs.size===0){log.info("No scheduled jobs found");return}log.info(`Scheduler started with ${schedulerState.jobs.size} job(s)`);process.on("SIGINT",()=>stopScheduler());process.on("SIGTERM",()=>stopScheduler());let isChecking=!1;const scheduleNextTick=()=>{if(schedulerState.isShuttingDown)return;const interval=schedulerState.config.checkInterval,delay=interval-Date.now()%interval;schedulerState.checkInterval=setTimeout(()=>{if(!schedulerState.isShuttingDown&&!isChecking){isChecking=!0;checkScheduledJobs().catch((err)=>log.error("Scheduler check failed:",err)).finally(()=>{isChecking=!1})}scheduleNextTick()},delay);schedulerState.checkInterval?.unref?.()};scheduleNextTick();await checkScheduledJobs()}async function checkScheduledJobs(){for(const[name,state]of schedulerState.jobs){const schedule=state.job.config.rate||state.job.config.schedule;if(!schedule)continue;const cronExpression=parseScheduleString(schedule);if(!cronExpression)continue;if(shouldRunNow(cronExpression,state.lastRun,schedulerState.config.timezone)){if((schedulerState.config.preventOverlapping||state.job.config.withoutOverlapping)&&await hasUnfinishedRun(name)){log.debug(`Skipping ${name}: previous execution still running`);continue}if(state.isRunning){log.debug(`Skipping ${name}: a dispatch for it is already in flight`);continue}try{state.isRunning=!0;const when=new Date,stored=await storeScheduledRun(name,when,scheduledDispatchOptions(state.job.config));state.lastRun=stored?when:await loadPersistedLastRun(name);state.nextRun=calculateNextRun(cronExpression,schedulerState.config.timezone);state.isRunning=!1;if(!stored)continue;await emitQueueEvent("job:added",{jobId:`scheduled-${name}-${when.getTime()}`,queueName:state.job.config.queue||"default",jobName:name});log.info(`Scheduled job ${name} dispatched to queue`)}catch(error){state.isRunning=!1;log.error(`Failed to dispatch scheduled job ${name}:`,error)}}}}export async function stopScheduler(){if(!schedulerState.isRunning)return;log.info("Stopping scheduler...");schedulerState.isShuttingDown=!0;if(schedulerState.checkInterval){clearTimeout(schedulerState.checkInterval);schedulerState.checkInterval=null}schedulerState.isRunning=!1;schedulerState.jobs.clear();log.info("Scheduler stopped")}export function getSchedulerStatus(){return{isRunning:schedulerState.isRunning,jobCount:schedulerState.jobs.size,jobs:Array.from(schedulerState.jobs.entries()).map(([name,state])=>({name,schedule:state.job.config.rate||state.job.config.schedule,lastRun:state.lastRun,nextRun:state.nextRun,isRunning:state.isRunning}))}}export function isSchedulerRunning(){return schedulerState.isRunning}export function getRegisteredJobs(){return new Map(schedulerState.jobs)}export async function triggerJob(name){const state=schedulerState.jobs.get(name);if(!state)throw Error(`Scheduled job "${name}" not found`);log.info(`Manually triggering scheduled job: ${name}`);await storeJob(name,scheduledDispatchOptions(state.job.config))}export function scheduledDispatchOptions(config){const backoff=config.backoff;return{queue:config.queue||"default",payload:{},maxTries:config.tries??3,timeout:config.timeout,backoff:typeof backoff==="number"||Array.isArray(backoff)?backoff:void 0}}
@@ -0,0 +1,5 @@
1
+ /** Seed a unique state row without aborting a PostgreSQL transaction on a race. */
2
+ export declare function seedStateRow(connection: QueueStateConnection, table: 'queue_circuit_state' | 'job_quarantine' | 'scheduled_job_runs', keys: readonly string[], values: Record<string, string | number | null>): Promise<void>;
3
+ export declare interface QueueStateConnection {
4
+ unsafe: (query: string, params?: unknown[]) => PromiseLike<Record<string, unknown>[]>
5
+ }
@@ -0,0 +1 @@
1
+ import{assertSqlIdentifier,getDatabaseDialect,sqlHelpers}from"@stacksjs/database/runtime";export async function seedStateRow(connection,table,keys,values){const dialect=sqlHelpers(getDatabaseDialect()),quote=(name)=>{assertSqlIdentifier(name);return dialect.isMysql?`\`${name}\``:`"${name}"`},columns=Object.keys(values),conflict=dialect.isMysql?`ON DUPLICATE KEY UPDATE ${quote(keys[0])} = ${quote(keys[0])}`:`ON CONFLICT (${keys.map(quote).join(", ")}) DO NOTHING`;await connection.unsafe(`INSERT INTO ${quote(table)} (${columns.map(quote).join(", ")}) VALUES (${columns.map((_,index)=>dialect.param(index+1)).join(", ")}) ${conflict}`,Object.values(values))}
package/dist/worker.js CHANGED
@@ -1 +1 @@
1
- import{err,ok}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import process from"node:process";import{parseEnvelope}from"./envelope";import{updatedRowCount}from"./utils";let workerCrashHandlersInstalled=!1;function installWorkerCrashHandlers(){if(workerCrashHandlersInstalled)return;workerCrashHandlersInstalled=!0;process.on("unhandledRejection",(reason,_promise)=>{log.error(`Unhandled Rejection: ${reason}`)});process.on("uncaughtException",(error)=>{log.error(`Uncaught Exception: ${error.message}`)})}import{env as envVars}from"@stacksjs/env";let activeJobCount=0,workerRunning=!1,workerId="";const inFlightJobs=new Set,reserveErrorLoggedAt=new Map;function trackInFlight(promise){inFlightJobs.add(promise);promise.finally(()=>inFlightJobs.delete(promise));return promise}function getReservationTtlSec(){const raw=process.env.STACKS_QUEUE_RESERVATION_TTL_SEC,n=raw===void 0?Number.NaN:Number.parseInt(raw,10);return Number.isFinite(n)&&n>0?n:3600}function getRetryJitterRatio(){const raw=process.env.STACKS_QUEUE_RETRY_JITTER;if(raw===void 0)return 0.2;const n=Number(raw);if(!Number.isFinite(n)||n<0)return 0.2;return Math.min(n,1)}export function applyRetryJitter(delaySeconds,ratio,random=Math.random){if(!(delaySeconds>0)||ratio<=0)return delaySeconds;return Math.round(delaySeconds+delaySeconds*ratio*random())}function getSweepIntervalMs(){const raw=process.env.STACKS_QUEUE_SWEEP_INTERVAL_SEC,n=raw===void 0?Number.NaN:Number.parseInt(raw,10);return(Number.isFinite(n)&&n>0?n:60)*1000}async function sweepStaleReservations(){const ttlSec=getReservationTtlSec(),cutoff=Math.floor(Date.now()/1000)-ttlSec,now=Math.floor(Date.now()/1000);try{const{db}=await import("@stacksjs/database/runtime"),result=await db.updateTable("jobs").set({reserved_at:null,available_at:now}).where("reserved_at","<=",cutoff).executeTakeFirst(),requeued=updatedRowCount(result);if(requeued>0)log.warn(`[queue] Requeued ${requeued} job(s) whose reservation exceeded the ${ttlSec}s TTL - likely victims of a worker crash. Set STACKS_QUEUE_RESERVATION_TTL_SEC to tune.`);return requeued}catch(error){log.error("[queue] Reservation sweep failed",{reason:error instanceof Error?error.message:String(error)});return 0}}function isNonRetryableError(e){if(!e||typeof e!=="object")return!1;const status=e.status??e.statusCode;if(typeof status==="number"&&status>=400&&status<500)return!0;const name=e.name;if(typeof name==="string"&&(name==="ValidationError"||name==="ModelNotFoundError"))return!0;return!1}function getQueueDriver(){return envVars.QUEUE_DRIVER||"sync"}export async function startProcessor(queueName,options={}){try{log.info("Starting queue processor...");installWorkerCrashHandlers();workerRunning=!0;workerId=`worker-${process.pid}-${Date.now()}`;const concurrency=options.concurrency||1,queueDriver=getQueueDriver(),{getWorkerTracker,getGlobalMetrics}=await import("./events");getGlobalMetrics();getWorkerTracker().register(workerId,queueName||"default");if(queueDriver==="redis"){log.info("Using Redis queue driver (bun-queue)");await processJobsFromRedis(queueName||"default",concurrency);return ok(void 0)}const named=namedWorkerQueues(queueName);let queues;if(named.length>0)queues=named;else{queues=await getAllQueues();if(queues.length===0)queues=["default"]}log.info(`Processing queues: ${queues.join(", ")}`);await processJobsFromDatabase(queues,concurrency,{followNewQueues:named.length===0});return ok(void 0)}catch(error){workerRunning=!1;return err(error)}}async function getAllQueues(){try{const{db}=await import("@stacksjs/database/runtime"),queues=(await db.selectFrom("jobs").select("queue").distinct().execute()).map((r)=>r.queue).filter((q)=>Boolean(q));return queues.length>0?queues:["default"]}catch{return["default"]}}export function namedWorkerQueues(queueName){if(typeof queueName!=="string")return[];return[...new Set(queueName.split(",").map((name)=>name.trim()).filter(Boolean))]}async function processJobsFromDatabase(initialQueues,concurrency,options={followNewQueues:!0}){log.info("Listening for jobs...");let queues=initialQueues,lastQueueRefresh=Date.now();const queueRefreshInterval=1e4;let lastSweep=Date.now();const sweepIntervalMs=getSweepIntervalMs();await sweepStaleReservations();while(workerRunning)try{const now=Date.now();if(options.followNewQueues&&now-lastQueueRefresh>queueRefreshInterval){try{const refreshedQueues=await getAllQueues();if(refreshedQueues.length>0)queues=refreshedQueues}catch{}lastQueueRefresh=now}if(now-lastSweep>sweepIntervalMs){await sweepStaleReservations();lastSweep=now}for(const queueName of queues){try{const{isCircuitOpen}=await import("./circuit-breaker");if(await isCircuitOpen(queueName))continue}catch{}let jobs=[];try{jobs=await fetchPendingJobs(queueName,concurrency)}catch(error){const now=Date.now(),last=reserveErrorLoggedAt.get(queueName)??0;if(now-last>60000){reserveErrorLoggedAt.set(queueName,now);log.error(`[queue] Could not reserve jobs on "${queueName}" - retrying each cycle:`,error)}continue}await Promise.all(jobs.map(async(job)=>{try{log.info(`Processing job ${job.id} from queue "${queueName}"`);await trackInFlight(processJob(job))}catch{log.error(`Unexpected error processing job ${job.id}`)}}))}await sleep(1000)}catch{await sleep(3000)}}async function fetchPendingJobs(queueName,limit){const now=Math.floor(Date.now()/1000),{db}=await import("@stacksjs/database/runtime"),claimed=[];for(let i=0;i<limit;i++){const job=await db.selectFrom("jobs").where("queue","=",queueName).whereNull("reserved_at").where("available_at","<=",now).orderBy("id","asc").limit(1).selectAll().executeTakeFirst();if(!job)break;const result=await db.updateTable("jobs").set({reserved_at:now,attempts:Number(job.attempts||0)+1}).where("id","=",job.id).whereNull("reserved_at").executeTakeFirst();if(updatedRowCount(result)>0)claimed.push(job)}return claimed}async function processJob(job){const jobId=job.id,queueName=job.queue||"default";activeJobCount++;const startTime=Date.now(),{emitQueueEvent,getWorkerTracker}=await import("./events"),tracker=getWorkerTracker();tracker.markActive(workerId);let parsedJobName;try{parsedJobName=JSON.parse(job.payload||"{}").jobName}catch{}await emitQueueEvent("job:processing",{jobId:String(jobId),queueName,jobName:parsedJobName});try{const batchId=JSON.parse(job.payload||"{}").payload?._batchId;if(batchId){const{isBatchCancelled}=await import("./batch");if(await isBatchCancelled(batchId)){log.info(`[Queue] Skipping job ${jobId} - batch ${batchId} has been cancelled`);await deleteJob(jobId);activeJobCount--;tracker.markIdle(workerId);return}}}catch{}let jobError=null;const declared=parsedJobName?await(await import("./job")).jobDefaults(parsedJobName):{};try{const payload=JSON.parse(job.payload||"{}"),timeoutSec=readJobTimeoutSec(payload)??declared.timeout;if(timeoutSec===void 0)await executeJobPayload(payload);else await raceWithTimeout(executeJobPayload(payload),timeoutSec*1000,`Job ${jobId} exceeded ${timeoutSec}s timeout`)}catch(e){jobError=e instanceof Error?e:Error(String(e))}if(!jobError)try{await deleteJob(jobId);log.info(`[Queue] Job ${jobId} completed`);tracker.recordCompletion(workerId);try{const{recordCircuitSuccess}=await import("./circuit-breaker");await recordCircuitSuccess(job.queue??"default")}catch{}await emitQueueEvent("job:completed",{jobId:String(jobId),queueName,duration:Date.now()-startTime});try{const batchId=JSON.parse(job.payload||"{}").payload?._batchId;if(batchId){const{recordBatchJobCompletion}=await import("./batch");await recordBatchJobCompletion(batchId)}}catch{}}catch{log.info(`[Queue] Failed to delete completed job ${jobId}`)}else{const errorMessage=jobError.message;log.info(`[Queue] Job ${jobId} failed: ${errorMessage}`);tracker.recordFailure(workerId);await emitQueueEvent("job:failed",{jobId:String(jobId),queueName,error:jobError,duration:Date.now()-startTime,attemptsMade:(job.attempts||0)+1});let maxAttempts=1,parsedPayload={};try{parsedPayload=JSON.parse(job.payload||"{}");maxAttempts=parsedPayload.options?.tries||declared.tries||1}catch{}const currentAttempts=(job.attempts||0)+1;if(currentAttempts>=maxAttempts){log.info(`[Queue] Job ${jobId} exceeded max attempts (${currentAttempts}/${maxAttempts})`);let persisted=!1;if(parsedPayload?._retriedFromFailed===!0)try{const{moveToDeadLetter}=await import("./dead-letter");persisted=await moveToDeadLetter({queue:job.queue,payload:job.payload,exception:jobError.stack||jobError.message},"repeat-failure",2);if(persisted)log.info(`[Queue] Job ${jobId} re-failed after retry - moved to dead_letter_jobs`)}catch{persisted=!1}if(!persisted){log.info(`[Queue] Moving job ${jobId} to failed_jobs`);try{persisted=await moveToFailedJobs(job,jobError,{attempts:currentAttempts,maxAttempts,durationMs:Date.now()-startTime})}catch{persisted=!1}}if(persisted)try{await deleteJob(jobId)}catch{log.info(`[Queue] Failed to delete failed job ${jobId}`)}else log.error(`[Queue] Job ${jobId} exhausted its retries but could NOT be persisted to failed_jobs - leaving it in the queue to avoid data loss (the reservation sweep will retry it). Check that the failed_jobs table exists and is writable.`);try{const{recordFailureForPoison}=await import("./poison"),{recordCircuitFailure}=await import("./circuit-breaker"),jobName=parsedPayload?.jobName??"unknown";await recordFailureForPoison(jobName,parsedPayload?.payload);await recordCircuitFailure(job.queue??"default")}catch{}try{const batchId=parsedPayload.payload?._batchId;if(batchId){const{recordBatchJobFailure}=await import("./batch");await recordBatchJobFailure(batchId,String(jobId),jobError)}}catch{}}else{const backoffDelays=parsedPayload.options?.backoff??declared.backoff;let retryDelay=30;if(Array.isArray(backoffDelays)&&backoffDelays.length>0){const backoffIndex=Math.min(currentAttempts-1,backoffDelays.length-1);retryDelay=backoffDelays[backoffIndex]}else if(typeof backoffDelays==="number"&&backoffDelays>0)retryDelay=backoffDelays;retryDelay=Number(retryDelay);if(!Number.isFinite(retryDelay)||retryDelay<0)retryDelay=30;retryDelay=applyRetryJitter(retryDelay,getRetryJitterRatio());log.info(`[Queue] Job ${jobId} will be retried in ${retryDelay}s (attempt ${currentAttempts}/${maxAttempts})`);try{await releaseJob(jobId,retryDelay);log.info(`[Queue] Job ${jobId} released for retry`)}catch{log.info(`[Queue] Failed to release job ${jobId} for retry`)}}}activeJobCount--;tracker.markIdle(workerId)}async function deleteJob(jobId){const{db}=await import("@stacksjs/database/runtime");await db.deleteFrom("jobs").where("id","=",jobId).execute()}async function releaseJob(jobId,delaySeconds=30){const retryAt=Math.floor(Date.now()/1000)+delaySeconds;log.debug(`Releasing job ${jobId} for retry at ${retryAt}`);try{const{db}=await import("@stacksjs/database/runtime");await db.updateTable("jobs").set({reserved_at:null,available_at:retryAt}).where("id","=",jobId).execute();log.debug(`Job ${jobId} released successfully`)}catch{log.error(`Failed to release job ${jobId}`)}}async function moveToFailedJobs(job,error,metrics){try{const failedAt=new Date().toISOString().slice(0,19).replace("T"," "),uuid=crypto.randomUUID(),exception=error.stack||error.message,{db}=await import("@stacksjs/database/runtime");await db.insertInto("failed_jobs").values({uuid,connection:"database",queue:job.queue,payload:job.payload,exception,attempts:metrics.attempts,max_attempts:metrics.maxAttempts,duration_ms:metrics.durationMs,failed_at:failedAt}).execute();return!0}catch(insertError){log.error("Failed to log failed job:",insertError);return!1}}function readJobTimeoutSec(payload){if(!payload||typeof payload!=="object")return;const t=payload.options?.timeout;if(typeof t!=="number"||!Number.isFinite(t)||t<=0)return;return t}async function raceWithTimeout(task,timeoutMs,message){let timer;const timeout=new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error(message)),timeoutMs)});try{return await Promise.race([task,timeout])}finally{if(timer!==void 0)clearTimeout(timer)}}async function executeJobPayload(payload){const parsed=parseEnvelope(payload);if(!parsed.ok)throw Error(`[queue] Cannot deserialize job envelope: ${parsed.reason}`+(parsed.detail?` (${parsed.detail})`:""));const{runJob}=await import("./job");await runJob(parsed.envelope.jobName,{payload:parsed.envelope.payload,context:parsed.envelope.context,traceId:parsed.envelope.traceId})}async function processJobsFromRedis(queueName,concurrency){const{RedisQueue}=await import("./drivers/redis"),{queue:queueConfig}=await import("@stacksjs/config"),redisConfig=queueConfig?.connections?.redis;if(!redisConfig)throw Error("Redis queue connection is not configured. Check config/queue.ts");const queue=new RedisQueue(queueName,redisConfig),{emitQueueEvent,getWorkerTracker}=await import("./events"),tracker=getWorkerTracker(),handleRedisJob=async(bunJob)=>{activeJobCount++;tracker.markActive(workerId);const startTime=Date.now(),parsed=parseEnvelope(bunJob.data);if(!parsed.ok){activeJobCount--;tracker.markIdle(workerId);log.error(`[Queue] Skipping Redis job ${bunJob.id} - unparseable envelope: ${parsed.reason}${parsed.detail?` (${parsed.detail})`:""}`);return}const data=parsed.envelope,batchId=data.payload?._batchId;if(batchId)try{const{isBatchCancelled}=await import("./batch");if(await isBatchCancelled(batchId)){log.info(`[Queue] Skipping Redis job ${bunJob.id} - batch ${batchId} has been cancelled`);activeJobCount--;tracker.markIdle(workerId);return}}catch{}await emitQueueEvent("job:processing",{jobId:String(bunJob.id),queueName});try{const{runJob}=await import("./job");await runJob(data.jobName,{payload:data.payload,context:data.context,traceId:data.traceId});tracker.recordCompletion(workerId);await emitQueueEvent("job:completed",{jobId:String(bunJob.id),queueName,duration:Date.now()-startTime});if(batchId)try{const{recordBatchJobCompletion}=await import("./batch");await recordBatchJobCompletion(batchId)}catch{}log.info(`[Queue] Redis job ${bunJob.id} completed`)}catch(e){tracker.recordFailure(workerId);await emitQueueEvent("job:failed",{jobId:String(bunJob.id),queueName,error:e instanceof Error?e:Error(String(e)),duration:Date.now()-startTime});if(batchId)try{const{recordBatchJobFailure}=await import("./batch");await recordBatchJobFailure(batchId,String(bunJob.id),e instanceof Error?e:Error(String(e)))}catch{}log.error(`[Queue] Redis job ${bunJob.id} failed: ${e}`);if(isNonRetryableError(e)){log.info(`[Queue] Redis job ${bunJob.id} hit a non-retryable error - skipping retry`);return}throw e}finally{activeJobCount--;tracker.markIdle(workerId)}};queue.process(concurrency,(bunJob)=>trackInFlight(handleRedisJob(bunJob)));log.info(`Listening for Redis jobs on queue "${queueName}" with concurrency ${concurrency}...`);while(workerRunning)await sleep(1000);await queue.close()}export async function stopProcessor(options={}){const graceMs=options.graceMs??1e4;workerRunning=!1;if(inFlightJobs.size>0&&graceMs>0){log.info(`[queue] Draining ${inFlightJobs.size} in-flight job(s) (grace ${graceMs}ms)`);const drain=Promise.allSettled([...inFlightJobs]),timeout=new Promise((resolve)=>setTimeout(()=>resolve("timeout"),graceMs));if(await Promise.race([drain.then(()=>"drained"),timeout])==="timeout"&&inFlightJobs.size>0)log.warn(`[queue] Drain timed out with ${inFlightJobs.size} job(s) still active. Their reservations will be reclaimed by the next worker's sweep (Q-2).`)}if(workerId){const{getWorkerTracker}=await import("./events");getWorkerTracker().unregister(workerId)}if(getQueueDriver()==="redis"){const{closeSharedRedisQueues}=await import("./drivers/redis");await closeSharedRedisQueues()}log.info("Queue processor stopped")}export async function executeFailedJobs(){const{db}=await import("@stacksjs/database/runtime"),failedJobs=await db.selectFrom("failed_jobs").selectAll().execute();for(const failedJob of failedJobs)await retryFailedJob(Number(failedJob.id))}export async function retryFailedJob(id){const now=Math.floor(Date.now()/1000),createdAt=new Date().toISOString().slice(0,19).replace("T"," "),{db}=await import("@stacksjs/database/runtime"),failedJob=(await db.selectFrom("failed_jobs").where("id","=",id).selectAll().execute())[0];if(!failedJob)throw Error(`Failed job ${id} not found`);const storedPayload=typeof failedJob.payload==="string"?failedJob.payload:"";let payloadToRequeue=storedPayload;try{const env=JSON.parse(storedPayload||"{}");env._retriedFromFailed=!0;payloadToRequeue=JSON.stringify(env)}catch{}await db.insertInto("jobs").values({queue:failedJob.queue,payload:payloadToRequeue,attempts:0,reserved_at:null,available_at:now,created_at:createdAt}).execute();await db.deleteFrom("failed_jobs").where("id","=",id).execute();log.info(`Failed job ${id} has been re-queued`)}export function getActiveJobCount(){return activeJobCount}export function isWorkerRunning(){return workerRunning}function sleep(ms){return new Promise((resolve)=>setTimeout(resolve,ms))}
1
+ import{resolveQueueDriver}from"./driver";import{err,ok}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import process from"node:process";import{parseEnvelope}from"./envelope";import{updatedRowCount}from"./utils";let workerCrashHandlersInstalled=!1;function installWorkerCrashHandlers(){if(workerCrashHandlersInstalled)return;workerCrashHandlersInstalled=!0;process.on("unhandledRejection",(reason,_promise)=>{log.error(`Unhandled Rejection: ${reason}`)});process.on("uncaughtException",(error)=>{log.error(`Uncaught Exception: ${error.message}`)})}let activeJobCount=0,workerRunning=!1,workerId="";const inFlightJobs=new Set,reserveErrorLoggedAt=new Map;function trackInFlight(promise){inFlightJobs.add(promise);promise.finally(()=>inFlightJobs.delete(promise));return promise}function getReservationTtlSec(){const raw=process.env.STACKS_QUEUE_RESERVATION_TTL_SEC,n=raw===void 0?Number.NaN:Number.parseInt(raw,10);return Number.isFinite(n)&&n>0?n:3600}function getRetryJitterRatio(){const raw=process.env.STACKS_QUEUE_RETRY_JITTER;if(raw===void 0)return 0.2;const n=Number(raw);if(!Number.isFinite(n)||n<0)return 0.2;return Math.min(n,1)}export function applyRetryJitter(delaySeconds,ratio,random=Math.random){if(!(delaySeconds>0)||ratio<=0)return delaySeconds;return Math.round(delaySeconds+delaySeconds*ratio*random())}function getSweepIntervalMs(){const raw=process.env.STACKS_QUEUE_SWEEP_INTERVAL_SEC,n=raw===void 0?Number.NaN:Number.parseInt(raw,10);return(Number.isFinite(n)&&n>0?n:60)*1000}async function sweepStaleReservations(){const ttlSec=getReservationTtlSec(),cutoff=Math.floor(Date.now()/1000)-ttlSec,now=Math.floor(Date.now()/1000);try{const{db}=await import("@stacksjs/database/runtime"),result=await db.updateTable("jobs").set({reserved_at:null,available_at:now}).where("reserved_at","<=",cutoff).executeTakeFirst(),requeued=updatedRowCount(result);if(requeued>0)log.warn(`[queue] Requeued ${requeued} job(s) whose reservation exceeded the ${ttlSec}s TTL - likely victims of a worker crash. Set STACKS_QUEUE_RESERVATION_TTL_SEC to tune.`);return requeued}catch(error){log.error("[queue] Reservation sweep failed",{reason:error instanceof Error?error.message:String(error)});return 0}}function isNonRetryableError(e){if(!e||typeof e!=="object")return!1;const status=e.status??e.statusCode;if(typeof status==="number"&&status>=400&&status<500)return!0;const name=e.name;if(typeof name==="string"&&(name==="ValidationError"||name==="ModelNotFoundError"))return!0;return!1}function getQueueDriver(){return resolveQueueDriver()}export async function startProcessor(queueName,options={}){try{log.info("Starting queue processor...");installWorkerCrashHandlers();workerRunning=!0;workerId=`worker-${process.pid}-${Date.now()}`;const concurrency=options.concurrency||1,queueDriver=getQueueDriver(),{getWorkerTracker,getGlobalMetrics}=await import("./events");getGlobalMetrics();getWorkerTracker().register(workerId,queueName||"default");if(queueDriver==="redis"){log.info("Using Redis queue driver (bun-queue)");await processJobsFromRedis(queueName||"default",concurrency);return ok(void 0)}const named=namedWorkerQueues(queueName);let queues;if(named.length>0)queues=named;else{queues=await getAllQueues();if(queues.length===0)queues=["default"]}log.info(`Processing queues: ${queues.join(", ")}`);await processJobsFromDatabase(queues,concurrency,{followNewQueues:named.length===0});return ok(void 0)}catch(error){workerRunning=!1;return err(error)}}async function getAllQueues(){try{const{db}=await import("@stacksjs/database/runtime"),queues=(await db.selectFrom("jobs").select("queue").distinct().execute()).map((r)=>r.queue).filter((q)=>Boolean(q));return queues.length>0?queues:["default"]}catch{return["default"]}}export function namedWorkerQueues(queueName){if(typeof queueName!=="string")return[];return[...new Set(queueName.split(",").map((name)=>name.trim()).filter(Boolean))]}async function processJobsFromDatabase(initialQueues,concurrency,options={followNewQueues:!0}){log.info("Listening for jobs...");let queues=initialQueues,lastQueueRefresh=Date.now();const queueRefreshInterval=1e4;let lastSweep=Date.now();const sweepIntervalMs=getSweepIntervalMs();await sweepStaleReservations();while(workerRunning)try{const now=Date.now();if(options.followNewQueues&&now-lastQueueRefresh>queueRefreshInterval){try{const refreshedQueues=await getAllQueues();if(refreshedQueues.length>0)queues=refreshedQueues}catch{}lastQueueRefresh=now}if(now-lastSweep>sweepIntervalMs){await sweepStaleReservations();lastSweep=now}for(const queueName of queues){try{const{isCircuitOpen}=await import("./circuit-breaker");if(await isCircuitOpen(queueName))continue}catch{}let jobs=[];try{jobs=await fetchPendingJobs(queueName,concurrency)}catch(error){const now=Date.now(),last=reserveErrorLoggedAt.get(queueName)??0;if(now-last>60000){reserveErrorLoggedAt.set(queueName,now);log.error(`[queue] Could not reserve jobs on "${queueName}" - retrying each cycle:`,error)}continue}await Promise.all(jobs.map(async(job)=>{try{log.info(`Processing job ${job.id} from queue "${queueName}"`);await trackInFlight(processJob(job))}catch{log.error(`Unexpected error processing job ${job.id}`)}}))}await sleep(1000)}catch{await sleep(3000)}}async function fetchPendingJobs(queueName,limit){const now=Math.floor(Date.now()/1000),{db}=await import("@stacksjs/database/runtime"),claimed=[];for(let i=0;i<limit;i++){const job=await db.selectFrom("jobs").where("queue","=",queueName).whereNull("reserved_at").where("available_at","<=",now).orderBy("id","asc").limit(1).selectAll().executeTakeFirst();if(!job)break;const result=await db.updateTable("jobs").set({reserved_at:now,attempts:Number(job.attempts||0)+1}).where("id","=",job.id).whereNull("reserved_at").executeTakeFirst();if(updatedRowCount(result)>0)claimed.push(job)}return claimed}async function processJob(job){const jobId=job.id,queueName=job.queue||"default";activeJobCount++;const startTime=Date.now(),{emitQueueEvent,getWorkerTracker}=await import("./events"),tracker=getWorkerTracker();tracker.markActive(workerId);let parsedJobName;try{parsedJobName=JSON.parse(job.payload||"{}").jobName}catch{}await emitQueueEvent("job:processing",{jobId:String(jobId),queueName,jobName:parsedJobName});try{const batchId=JSON.parse(job.payload||"{}").payload?._batchId;if(batchId){const{isBatchCancelled}=await import("./batch");if(await isBatchCancelled(batchId)){log.info(`[Queue] Skipping job ${jobId} - batch ${batchId} has been cancelled`);await deleteJob(jobId);activeJobCount--;tracker.markIdle(workerId);return}}}catch{}let jobError=null;const declared=parsedJobName?await(await import("./job")).jobDefaults(parsedJobName):{};try{const payload=JSON.parse(job.payload||"{}"),timeoutSec=readJobTimeoutSec(payload)??declared.timeout;if(timeoutSec===void 0)await executeJobPayload(payload);else await raceWithTimeout(executeJobPayload(payload),timeoutSec*1000,`Job ${jobId} exceeded ${timeoutSec}s timeout`)}catch(e){jobError=e instanceof Error?e:Error(String(e))}if(!jobError)try{await deleteJob(jobId);log.info(`[Queue] Job ${jobId} completed`);tracker.recordCompletion(workerId);try{const{recordCircuitSuccess}=await import("./circuit-breaker");await recordCircuitSuccess(job.queue??"default")}catch{}await emitQueueEvent("job:completed",{jobId:String(jobId),queueName,duration:Date.now()-startTime});try{const batchId=JSON.parse(job.payload||"{}").payload?._batchId;if(batchId){const{recordBatchJobCompletion}=await import("./batch");await recordBatchJobCompletion(batchId)}}catch{}}catch{log.info(`[Queue] Failed to delete completed job ${jobId}`)}else{const errorMessage=jobError.message;log.info(`[Queue] Job ${jobId} failed: ${errorMessage}`);tracker.recordFailure(workerId);await emitQueueEvent("job:failed",{jobId:String(jobId),queueName,error:jobError,duration:Date.now()-startTime,attemptsMade:(job.attempts||0)+1});let maxAttempts=1,parsedPayload={};try{parsedPayload=JSON.parse(job.payload||"{}");maxAttempts=parsedPayload.options?.tries||declared.tries||1}catch{}const currentAttempts=(job.attempts||0)+1;if(currentAttempts>=maxAttempts){log.info(`[Queue] Job ${jobId} exceeded max attempts (${currentAttempts}/${maxAttempts})`);let persisted=!1;if(parsedPayload?._retriedFromFailed===!0)try{const{moveToDeadLetter}=await import("./dead-letter");persisted=await moveToDeadLetter({queue:job.queue,payload:job.payload,exception:jobError.stack||jobError.message},"repeat-failure",2);if(persisted)log.info(`[Queue] Job ${jobId} re-failed after retry - moved to dead_letter_jobs`)}catch{persisted=!1}if(!persisted){log.info(`[Queue] Moving job ${jobId} to failed_jobs`);try{persisted=await moveToFailedJobs(job,jobError,{attempts:currentAttempts,maxAttempts,durationMs:Date.now()-startTime})}catch{persisted=!1}}if(persisted)try{await deleteJob(jobId)}catch{log.info(`[Queue] Failed to delete failed job ${jobId}`)}else log.error(`[Queue] Job ${jobId} exhausted its retries but could NOT be persisted to failed_jobs - leaving it in the queue to avoid data loss (the reservation sweep will retry it). Check that the failed_jobs table exists and is writable.`);try{const{recordFailureForPoison}=await import("./poison"),{recordCircuitFailure}=await import("./circuit-breaker"),jobName=parsedPayload?.jobName??"unknown";await recordFailureForPoison(jobName,parsedPayload?.payload);await recordCircuitFailure(job.queue??"default")}catch{}try{const batchId=parsedPayload.payload?._batchId;if(batchId){const{recordBatchJobFailure}=await import("./batch");await recordBatchJobFailure(batchId,String(jobId),jobError)}}catch{}}else{const backoffDelays=parsedPayload.options?.backoff??declared.backoff;let retryDelay=30;if(Array.isArray(backoffDelays)&&backoffDelays.length>0){const backoffIndex=Math.min(currentAttempts-1,backoffDelays.length-1);retryDelay=backoffDelays[backoffIndex]}else if(typeof backoffDelays==="number"&&backoffDelays>0)retryDelay=backoffDelays;retryDelay=Number(retryDelay);if(!Number.isFinite(retryDelay)||retryDelay<0)retryDelay=30;retryDelay=applyRetryJitter(retryDelay,getRetryJitterRatio());log.info(`[Queue] Job ${jobId} will be retried in ${retryDelay}s (attempt ${currentAttempts}/${maxAttempts})`);try{await releaseJob(jobId,retryDelay);log.info(`[Queue] Job ${jobId} released for retry`)}catch{log.info(`[Queue] Failed to release job ${jobId} for retry`)}}}activeJobCount--;tracker.markIdle(workerId)}async function deleteJob(jobId){const{db}=await import("@stacksjs/database/runtime");await db.deleteFrom("jobs").where("id","=",jobId).execute()}async function releaseJob(jobId,delaySeconds=30){const retryAt=Math.floor(Date.now()/1000)+delaySeconds;log.debug(`Releasing job ${jobId} for retry at ${retryAt}`);try{const{db}=await import("@stacksjs/database/runtime");await db.updateTable("jobs").set({reserved_at:null,available_at:retryAt}).where("id","=",jobId).execute();log.debug(`Job ${jobId} released successfully`)}catch{log.error(`Failed to release job ${jobId}`)}}async function moveToFailedJobs(job,error,metrics){try{const failedAt=new Date().toISOString().slice(0,19).replace("T"," "),uuid=crypto.randomUUID(),exception=error.stack||error.message,{db}=await import("@stacksjs/database/runtime");await db.insertInto("failed_jobs").values({uuid,connection:"database",queue:job.queue,payload:job.payload,exception,attempts:metrics.attempts,max_attempts:metrics.maxAttempts,duration_ms:metrics.durationMs,failed_at:failedAt}).execute();return!0}catch(insertError){log.error("Failed to log failed job:",insertError);return!1}}function readJobTimeoutSec(payload){if(!payload||typeof payload!=="object")return;const t=payload.options?.timeout;if(typeof t!=="number"||!Number.isFinite(t)||t<=0)return;return t}async function raceWithTimeout(task,timeoutMs,message){let timer;const timeout=new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error(message)),timeoutMs)});try{return await Promise.race([task,timeout])}finally{if(timer!==void 0)clearTimeout(timer)}}async function executeJobPayload(payload){const parsed=parseEnvelope(payload);if(!parsed.ok)throw Error(`[queue] Cannot deserialize job envelope: ${parsed.reason}`+(parsed.detail?` (${parsed.detail})`:""));const{runJob}=await import("./job");await runJob(parsed.envelope.jobName,{payload:parsed.envelope.payload,context:parsed.envelope.context,traceId:parsed.envelope.traceId})}async function processJobsFromRedis(queueName,concurrency){const{RedisQueue}=await import("./drivers/redis"),{queue:queueConfig}=await import("@stacksjs/config"),redisConfig=queueConfig?.connections?.redis;if(!redisConfig)throw Error("Redis queue connection is not configured. Check config/queue.ts");const queue=new RedisQueue(queueName,redisConfig),{emitQueueEvent,getWorkerTracker}=await import("./events"),tracker=getWorkerTracker(),handleRedisJob=async(bunJob)=>{activeJobCount++;tracker.markActive(workerId);const startTime=Date.now(),parsed=parseEnvelope(bunJob.data);if(!parsed.ok){activeJobCount--;tracker.markIdle(workerId);log.error(`[Queue] Skipping Redis job ${bunJob.id} - unparseable envelope: ${parsed.reason}${parsed.detail?` (${parsed.detail})`:""}`);return}const data=parsed.envelope,batchId=data.payload?._batchId;if(batchId)try{const{isBatchCancelled}=await import("./batch");if(await isBatchCancelled(batchId)){log.info(`[Queue] Skipping Redis job ${bunJob.id} - batch ${batchId} has been cancelled`);activeJobCount--;tracker.markIdle(workerId);return}}catch{}await emitQueueEvent("job:processing",{jobId:String(bunJob.id),queueName});try{const{runJob}=await import("./job");await runJob(data.jobName,{payload:data.payload,context:data.context,traceId:data.traceId});tracker.recordCompletion(workerId);await emitQueueEvent("job:completed",{jobId:String(bunJob.id),queueName,duration:Date.now()-startTime});if(batchId)try{const{recordBatchJobCompletion}=await import("./batch");await recordBatchJobCompletion(batchId)}catch{}log.info(`[Queue] Redis job ${bunJob.id} completed`)}catch(e){tracker.recordFailure(workerId);await emitQueueEvent("job:failed",{jobId:String(bunJob.id),queueName,error:e instanceof Error?e:Error(String(e)),duration:Date.now()-startTime});if(batchId)try{const{recordBatchJobFailure}=await import("./batch");await recordBatchJobFailure(batchId,String(bunJob.id),e instanceof Error?e:Error(String(e)))}catch{}log.error(`[Queue] Redis job ${bunJob.id} failed: ${e}`);if(isNonRetryableError(e)){log.info(`[Queue] Redis job ${bunJob.id} hit a non-retryable error - skipping retry`);return}throw e}finally{activeJobCount--;tracker.markIdle(workerId)}};queue.process(concurrency,(bunJob)=>trackInFlight(handleRedisJob(bunJob)));log.info(`Listening for Redis jobs on queue "${queueName}" with concurrency ${concurrency}...`);while(workerRunning)await sleep(1000);await queue.close()}export async function stopProcessor(options={}){const graceMs=options.graceMs??1e4;workerRunning=!1;if(inFlightJobs.size>0&&graceMs>0){log.info(`[queue] Draining ${inFlightJobs.size} in-flight job(s) (grace ${graceMs}ms)`);const drain=Promise.allSettled([...inFlightJobs]),timeout=new Promise((resolve)=>setTimeout(()=>resolve("timeout"),graceMs));if(await Promise.race([drain.then(()=>"drained"),timeout])==="timeout"&&inFlightJobs.size>0)log.warn(`[queue] Drain timed out with ${inFlightJobs.size} job(s) still active. Their reservations will be reclaimed by the next worker's sweep (Q-2).`)}if(workerId){const{getWorkerTracker}=await import("./events");getWorkerTracker().unregister(workerId)}if(getQueueDriver()==="redis"){const{closeSharedRedisQueues}=await import("./drivers/redis");await closeSharedRedisQueues()}log.info("Queue processor stopped")}export async function executeFailedJobs(){const{db}=await import("@stacksjs/database/runtime"),failedJobs=await db.selectFrom("failed_jobs").selectAll().execute();for(const failedJob of failedJobs)await retryFailedJob(Number(failedJob.id))}export async function retryFailedJob(id){const now=Math.floor(Date.now()/1000),createdAt=new Date().toISOString().slice(0,19).replace("T"," "),{db}=await import("@stacksjs/database/runtime"),failedJob=(await db.selectFrom("failed_jobs").where("id","=",id).selectAll().execute())[0];if(!failedJob)throw Error(`Failed job ${id} not found`);const storedPayload=typeof failedJob.payload==="string"?failedJob.payload:"";let payloadToRequeue=storedPayload;try{const env=JSON.parse(storedPayload||"{}");env._retriedFromFailed=!0;payloadToRequeue=JSON.stringify(env)}catch{}await db.insertInto("jobs").values({queue:failedJob.queue,payload:payloadToRequeue,attempts:0,reserved_at:null,available_at:now,created_at:createdAt}).execute();await db.deleteFrom("failed_jobs").where("id","=",id).execute();log.info(`Failed job ${id} has been re-queued`)}export function getActiveJobCount(){return activeJobCount}export function isWorkerRunning(){return workerRunning}function sleep(ms){return new Promise((resolve)=>setTimeout(resolve,ms))}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/queue",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.423",
5
+ "version": "0.75.432",
6
6
  "description": "The Stacks Queue system powered by bun-queue.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -63,23 +63,23 @@
63
63
  "prepublishOnly": "bun run build"
64
64
  },
65
65
  "dependencies": {
66
- "@stacksjs/action-runner": "0.75.423",
66
+ "@stacksjs/action-runner": "0.75.432",
67
67
  "@stacksjs/bun-queue": "^0.1.8",
68
- "@stacksjs/cache": "0.75.423",
69
- "@stacksjs/config": "0.75.423",
70
- "@stacksjs/cron": "0.75.423",
71
- "@stacksjs/database": "0.75.423",
72
- "@stacksjs/env": "0.75.423",
73
- "@stacksjs/error-handling": "0.75.423",
74
- "@stacksjs/logging": "0.75.423",
75
- "@stacksjs/path": "0.75.423"
68
+ "@stacksjs/cache": "0.75.432",
69
+ "@stacksjs/config": "0.75.432",
70
+ "@stacksjs/cron": "0.75.432",
71
+ "@stacksjs/database": "0.75.432",
72
+ "@stacksjs/env": "0.75.432",
73
+ "@stacksjs/error-handling": "0.75.432",
74
+ "@stacksjs/logging": "0.75.432",
75
+ "@stacksjs/path": "0.75.432"
76
76
  },
77
77
  "devDependencies": {
78
78
  "better-dx": "^0.2.24"
79
79
  },
80
80
  "peerDependencies": {
81
- "@stacksjs/email": "0.75.423",
82
- "@stacksjs/router": "0.75.423"
81
+ "@stacksjs/email": "0.75.432",
82
+ "@stacksjs/router": "0.75.432"
83
83
  },
84
84
  "peerDependenciesMeta": {
85
85
  "@stacksjs/email": {