@stacksjs/queue 0.75.71 → 0.75.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/action.js +1 -1
- package/dist/batch.js +1 -1
- package/dist/circuit-breaker.js +1 -1
- package/dist/drivers/redis.d.ts +6 -0
- package/dist/drivers/redis.js +1 -1
- package/dist/envelope.d.ts +2 -1
- package/dist/envelope.js +1 -1
- package/dist/health.d.ts +9 -0
- package/dist/health.js +1 -1
- package/dist/idempotency.js +1 -1
- package/dist/job.d.ts +22 -0
- package/dist/job.js +1 -1
- package/dist/scheduler.d.ts +11 -3
- package/dist/scheduler.js +1 -1
- package/dist/utils.js +1 -1
- package/dist/worker.d.ts +8 -0
- package/dist/worker.js +1 -1
- package/package.json +12 -11
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)?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)?this.backoff:void 0});assertEnvelopeSerializable(envelope);const{
|
|
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})}}
|
package/dist/batch.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{RedisClient}from"bun";import{log}from"@stacksjs/logging";import{env as envVars}from"@stacksjs/env";import{updatedRowCount}from"./utils";function getQueueDriver(){return envVars.QUEUE_DRIVER||"sync"}export class PendingBatch{jobs;options={thenCallbacks:[],catchCallbacks:[],finallyCallbacks:[],progressCallbacks:[]};constructor(jobs){this.jobs=jobs.map((j)=>("job"in j)?j:{job:j})}name(name){this.options.name=name;return this}onQueue(queue){this.options.queue=queue;return this}allowFailures(){this.options.allowFailures=!0;return this}then(callback){this.options.thenCallbacks.push(callback);return this}catch(callback){this.options.catchCallbacks.push(callback);return this}finally(callback){this.options.finallyCallbacks.push(callback);return this}progress(callback){this.options.progressCallbacks.push(callback);return this}thenHandler(handler){this.options.thenHandler=handler;return this}catchHandler(handler){this.options.catchHandler=handler;return this}finallyHandler(handler){this.options.finallyHandler=handler;return this}async dispatch(){const batchId=crypto.randomUUID(),totalJobs=this.jobs.length;if(totalJobs===0)throw Error("Cannot dispatch an empty batch");const driver=getQueueDriver();await storeBatchRecord({id:batchId,name:this.options.name||"",total_jobs:totalJobs,pending_jobs:totalJobs,failed_jobs:0,failed_job_ids:"[]",options:JSON.stringify({queue:this.options.queue,allowFailures:this.options.allowFailures||!1}),cancelled_at:null,created_at:new Date().toISOString().slice(0,19).replace("T"," "),finished_at:null,then_handler:this.options.thenHandler?JSON.stringify(this.options.thenHandler):null,catch_handler:this.options.catchHandler?JSON.stringify(this.options.catchHandler):null,finally_handler:this.options.finallyHandler?JSON.stringify(this.options.finallyHandler):null});registerBatchCallbacks(batchId,this.options);try{const{emitQueueEvent}=await import("./events");await emitQueueEvent("batch:added",{jobId:batchId,data:{name:this.options.name,totalJobs}})}catch{}for(let i=0;i<this.jobs.length;i++){const entry=this.jobs[i];if(!entry)continue;const{job,payload}=entry,jobPayload={...payload,_batchId:batchId,_batchIndex:i};if(this.options.queue&&!job.queue)job.queue=this.options.queue;if(driver==="sync")try{await job.dispatchNow(jobPayload);await recordBatchJobCompletion(batchId)}catch(error){await recordBatchJobFailure(batchId,`${batchId}:${i}`,error)}else await job.dispatch(jobPayload)}log.info(`[Batch] Dispatched batch "${this.options.name||batchId}" with ${totalJobs} jobs`);return new DispatchedBatch(batchId)}getJobs(){return[...this.jobs]}getOptions(){return this.options}}export class DispatchedBatch{id;constructor(id){this.id=id}async fresh(){return getBatchRecord(this.id)}async getName(){return(await this.fresh())?.name||""}async totalJobs(){return(await this.fresh())?.total_jobs||0}async pendingJobs(){return(await this.fresh())?.pending_jobs||0}async failedJobs(){return(await this.fresh())?.failed_jobs||0}async completedJobs(){const record=await this.fresh();if(!record)return 0;return record.total_jobs-record.pending_jobs}async progress(){const record=await this.fresh();if(!record||record.total_jobs===0)return 0;const completed=record.total_jobs-record.pending_jobs;return Math.round(completed/record.total_jobs*100)}async finished(){return(await this.fresh())?.finished_at!==null}async cancelled(){return(await this.fresh())?.cancelled_at!==null}async hasFailures(){return((await this.fresh())?.failed_jobs||0)>0}async failedJobIds(){const record=await this.fresh();if(!record)return[];try{return JSON.parse(record.failed_job_ids||"[]")}catch{return[]}}async cancel(){if(getQueueDriver()==="redis")await cancelBatchInRedis(this.id);else await cancelBatchInDatabase(this.id);log.info(`[Batch] Cancelled batch ${this.id}`);const callbacks=getBatchCallbacks(this.id);if(callbacks)for(const cb of callbacks.finallyCallbacks)try{await cb(this)}catch(e){log.error(`[Batch] Error in finally callback for batch ${this.id}:`,e)}}async add(jobs){const record=await this.fresh();if(!record)throw Error(`Batch ${this.id} not found`);if(record.cancelled_at)throw Error(`Batch ${this.id} has been cancelled`);if(record.finished_at)throw Error(`Batch ${this.id} has already finished`);const batchableJobs=jobs.map((j)=>("job"in j)?j:{job:j});if(!await incrementBatchCounters(this.id,batchableJobs.length))throw Error(`Batch ${this.id} was cancelled, finished or deleted before the jobs could be added`);const options=JSON.parse(record.options||"{}");for(let i=0;i<batchableJobs.length;i++){const entry=batchableJobs[i];if(!entry)continue;const{job,payload}=entry,jobPayload={...payload,_batchId:this.id,_batchIndex:record.total_jobs+i};if(options.queue&&!job.queue)job.queue=options.queue;await job.dispatch(jobPayload)}log.info(`[Batch] Added ${batchableJobs.length} jobs to batch ${this.id}`)}async delete(){await deleteBatchRecord(this.id);removeBatchCallbacks(this.id)}}export class Batch{static create(jobs){return new PendingBatch(jobs)}static async find(id){if(!await getBatchRecord(id))return null;return new DispatchedBatch(id)}static async all(){return(await getAllBatchRecords()).map((r)=>new DispatchedBatch(r.id))}static async prune(olderThanHours=24){return pruneBatchRecords(olderThanHours)}}const batchCallbackRegistry=new Map;function registerBatchCallbacks(batchId,options){batchCallbackRegistry.set(batchId,options)}export function getBatchCallbacks(batchId){return batchCallbackRegistry.get(batchId)}function removeBatchCallbacks(batchId){batchCallbackRegistry.delete(batchId)}async function storeBatchRecord(record){if(getQueueDriver()==="redis")await storeBatchInRedis(record);else await storeBatchInDatabase(record)}async function getBatchRecord(id){if(getQueueDriver()==="redis")return getBatchFromRedis(id);return getBatchFromDatabase(id)}async function getAllBatchRecords(){if(getQueueDriver()==="redis")return getAllBatchesFromRedis();return getAllBatchesFromDatabase()}async function updateBatchRecord(id,updates){if(getQueueDriver()==="redis")await updateBatchInRedis(id,updates);else await updateBatchInDatabase(id,updates)}async function incrementBatchCounters(id,delta){if(delta===0)return!0;if(getQueueDriver()==="redis")try{const client=await connectBatchRedis(),key=`${REDIS_BATCH_PREFIX}${id}`;await client.hincrby(key,"total_jobs",delta);await client.hincrby(key,"pending_jobs",delta);client.close();return!0}catch{}const{db,sql}=await import("@stacksjs/database/runtime"),result=await db.updateTable("job_batches").set(batchCounterIncrements(sql,delta)).where("id","=",id).whereNull("cancelled_at").whereNull("finished_at").executeTakeFirst();return updatedRowCount(result)>0}export function batchCounterIncrements(sql,delta){return{total_jobs:sql`total_jobs + ${delta}`,pending_jobs:sql`pending_jobs + ${delta}`}}async function deleteBatchRecord(id){if(getQueueDriver()==="redis")await deleteBatchFromRedis(id);else await deleteBatchFromDatabase(id)}async function pruneBatchRecords(olderThanHours){if(getQueueDriver()==="redis")return pruneBatchesFromRedis(olderThanHours);return pruneBatchesFromDatabase(olderThanHours)}function hasPersistentHandlers(record){return!!(record.then_handler||record.catch_handler||record.finally_handler)}async function storeBatchInDatabase(record){const{db}=await import("@stacksjs/database/runtime"),columns={id:record.id,name:record.name,total_jobs:record.total_jobs,pending_jobs:record.pending_jobs,failed_jobs:record.failed_jobs,failed_job_ids:record.failed_job_ids,options:record.options,cancelled_at:record.cancelled_at,created_at:record.created_at,finished_at:record.finished_at};if(!hasPersistentHandlers(record)){await db.insertInto("job_batches").values(columns).execute();return}try{await db.insertInto("job_batches").values({...columns,then_handler:record.then_handler??null,catch_handler:record.catch_handler??null,finally_handler:record.finally_handler??null}).execute()}catch(error){log.warn(`[Batch] Could not persist terminal handlers for batch ${record.id}: ${error?.message}. The job_batches table predates the then_handler/catch_handler/finally_handler columns; recreate it to enable handlers that survive a worker restart.`);await db.insertInto("job_batches").values(columns).execute()}}async function getBatchFromDatabase(id){const{db}=await import("@stacksjs/database/runtime");return await db.selectFrom("job_batches").where("id","=",id).selectAll().executeTakeFirst()||null}async function getAllBatchesFromDatabase(){const{db}=await import("@stacksjs/database/runtime");return await db.selectFrom("job_batches").selectAll().orderBy("created_at","desc").execute()}async function updateBatchInDatabase(id,updates){const{db}=await import("@stacksjs/database/runtime");await db.updateTable("job_batches").set(updates).where("id","=",id).execute()}async function deleteBatchFromDatabase(id){const{db}=await import("@stacksjs/database/runtime");await db.deleteFrom("job_batches").where("id","=",id).execute()}async function cancelBatchInDatabase(id){await updateBatchInDatabase(id,{cancelled_at:new Date().toISOString().slice(0,19).replace("T"," "),finished_at:new Date().toISOString().slice(0,19).replace("T"," ")})}async function pruneBatchesFromDatabase(olderThanHours){const{db}=await import("@stacksjs/database/runtime"),cutoff=new Date(Date.now()-olderThanHours*60*60*1000).toISOString().slice(0,19).replace("T"," "),result=await db.deleteFrom("job_batches").whereNotNull("finished_at").where("finished_at","<",cutoff).executeTakeFirst();return Number(result?.numDeletedRows??0)}const REDIS_BATCH_PREFIX="stacks:batch:",REDIS_BATCH_INDEX="stacks:batches";async function getRedisClient(){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");return new RedisQueue("__batches__",redisConfig)}async function batchRedisUrl(){const{queue:queueConfig}=await import("@stacksjs/config"),redisConfig=queueConfig?.connections?.redis?.redis;if(redisConfig?.url)return redisConfig.url;const auth=redisConfig?.password?`:${encodeURIComponent(redisConfig.password)}@`:"",db=redisConfig?.db?`/${redisConfig.db}`:"";return`redis://${auth}${redisConfig?.host||"localhost"}:${redisConfig?.port||6379}${db}`}async function connectBatchRedis(){const client=new RedisClient(await batchRedisUrl());await client.connect();return client}export function batchRecordToHash(record){return{id:record.id,name:record.name,total_jobs:String(record.total_jobs),pending_jobs:String(record.pending_jobs),failed_jobs:String(record.failed_jobs),failed_job_ids:record.failed_job_ids,options:record.options,cancelled_at:record.cancelled_at||"",created_at:record.created_at,finished_at:record.finished_at||"",then_handler:record.then_handler||"",catch_handler:record.catch_handler||"",finally_handler:record.finally_handler||""}}async function storeBatchInRedis(record){try{const client=await connectBatchRedis();await client.hset(`${REDIS_BATCH_PREFIX}${record.id}`,batchRecordToHash(record));await client.sadd(REDIS_BATCH_INDEX,record.id);client.close()}catch{await storeBatchInDatabase(record)}}export function batchRecordFromHash(data){if(!data?.id)return null;return{id:data.id,name:data.name??"",total_jobs:Number(data.total_jobs),pending_jobs:Number(data.pending_jobs),failed_jobs:Number(data.failed_jobs),failed_job_ids:data.failed_job_ids??"",options:data.options??"",cancelled_at:data.cancelled_at||null,created_at:data.created_at??"",finished_at:data.finished_at||null,then_handler:data.then_handler||null,catch_handler:data.catch_handler||null,finally_handler:data.finally_handler||null}}async function getBatchFromRedis(id){try{const client=await connectBatchRedis(),key=`${REDIS_BATCH_PREFIX}${id}`,data=await client.hgetall(key);client.close();return batchRecordFromHash(data)}catch{return getBatchFromDatabase(id)}}async function getAllBatchesFromRedis(){try{const client=await connectBatchRedis(),ids=await client.smembers(REDIS_BATCH_INDEX),batches=[];for(const id of ids){const record=batchRecordFromHash(await client.hgetall(`${REDIS_BATCH_PREFIX}${id}`));if(record)batches.push(record)}client.close();return batches}catch{return getAllBatchesFromDatabase()}}async function updateBatchInRedis(id,updates){try{const client=await connectBatchRedis(),key=`${REDIS_BATCH_PREFIX}${id}`,hashUpdates={};for(const[k,v]of Object.entries(updates))hashUpdates[k]=v===null?"":String(v);await client.hset(key,hashUpdates);client.close()}catch{await updateBatchInDatabase(id,updates)}}async function decrementBatchInRedis(id,failed,allowFailures){const client=await connectBatchRedis();try{const key=`${REDIS_BATCH_PREFIX}${id}`,decrementedPending=await client.hincrby(key,"pending_jobs",-1),pending=Math.max(decrementedPending,0);if(decrementedPending<0)await client.hset(key,{pending_jobs:"0"});if(failed)await client.hincrby(key,"failed_jobs",1);const shouldFinish=!(await client.hgetall(key)).finished_at&&(!failed||allowFailures?pending===0:!0),finishedAt=new Date().toISOString().slice(0,19).replace("T"," "),completed=shouldFinish&&await client.hsetnx(key,"terminal_claimed","1");if(completed)await client.hset(key,{finished_at:finishedAt});if(completed&&failed&&!allowFailures)await client.hset(key,{cancelled_at:finishedAt});return completed}finally{client.close()}}async function deleteBatchFromRedis(id){try{const client=await connectBatchRedis();await client.del(`${REDIS_BATCH_PREFIX}${id}`);await client.srem(REDIS_BATCH_INDEX,id);client.close()}catch{await deleteBatchFromDatabase(id)}}async function cancelBatchInRedis(id){const now=new Date().toISOString().slice(0,19).replace("T"," ");await updateBatchInRedis(id,{cancelled_at:now,finished_at:now})}async function pruneBatchesFromRedis(olderThanHours){try{const batches=await getAllBatchesFromRedis(),cutoff=Date.now()-olderThanHours*60*60*1000;let pruned=0;for(const batch of batches)if(batch.finished_at){if(new Date(batch.finished_at).getTime()<cutoff){await deleteBatchFromRedis(batch.id);pruned++}}return pruned}catch{return pruneBatchesFromDatabase(olderThanHours)}}function parsePersistentHandler(raw){if(!raw)return null;try{const parsed=JSON.parse(raw);if(parsed&&(parsed.kind==="job"||parsed.kind==="module"))return parsed;log.warn(`[Batch] handler JSON has unknown kind '${parsed?.kind}' - skipping`);return null}catch(err){log.warn(`[Batch] failed to parse persistent handler: ${err.message}`);return null}}async function firePersistentHandler(handler,batchId){try{if(handler.kind==="job"){const{Jobs}=await import("./job");await Jobs.dispatch(handler.name,{...handler.payload??{},_batchId:batchId});return}const mod=await import(handler.module).catch((err)=>{log.warn(`[Batch] persistent handler module not found: ${handler.module} (${err.message})`);return null});if(!mod)return;const fn=mod[handler.export];if(typeof fn!=="function"){log.warn(`[Batch] persistent handler export '${handler.export}' is not a function on ${handler.module}`);return}await fn(handler.payload,batchId)}catch(err){log.error(`[Batch] persistent handler threw for batch ${batchId}:`,err)}}export async function recordBatchJobCompletion(batchId){const driver=getQueueDriver();let completed=!1;if(driver==="redis")completed=await decrementBatchInRedis(batchId,!1,!1);else{const{db,sql}=await import("@stacksjs/database/runtime");await db.updateTable("job_batches").set({pending_jobs:sql`GREATEST(pending_jobs - 1, 0)`}).where("id","=",batchId).where("pending_jobs",">",0).execute();const finishedAt=new Date().toISOString().slice(0,19).replace("T"," "),completeResult=await db.updateTable("job_batches").set({finished_at:finishedAt}).where("id","=",batchId).where("pending_jobs","=",0).whereNull("finished_at").executeTakeFirst();completed=updatedRowCount(completeResult)>0}const callbacks=getBatchCallbacks(batchId),dispatched=new DispatchedBatch(batchId);if(callbacks)for(const cb of callbacks.progressCallbacks)try{await cb(dispatched)}catch(e){log.error(`[Batch] Error in progress callback for batch ${batchId}:`,e)}if(completed){try{const{emitQueueEvent}=await import("./events");await emitQueueEvent("batch:completed",{jobId:batchId})}catch{}const freshRecord=await getBatchRecord(batchId);if(!freshRecord){removeBatchCallbacks(batchId);log.info(`[Batch] Batch ${batchId} finished (record vanished)`);return}const opts=JSON.parse(freshRecord.options||"{}"),succeeded=!((freshRecord.failed_jobs||0)>0)||opts.allowFailures;if(callbacks){if(succeeded)for(const cb of callbacks.thenCallbacks)try{await cb(dispatched)}catch(e){log.error(`[Batch] Error in then callback for batch ${batchId}:`,e)}for(const cb of callbacks.finallyCallbacks)try{await cb(dispatched)}catch(e){log.error(`[Batch] Error in finally callback for batch ${batchId}:`,e)}removeBatchCallbacks(batchId)}if(succeeded){const thenHandler=parsePersistentHandler(freshRecord.then_handler);if(thenHandler)await firePersistentHandler(thenHandler,batchId)}else{const catchHandler=parsePersistentHandler(freshRecord.catch_handler);if(catchHandler)await firePersistentHandler(catchHandler,batchId)}const finallyHandler=parsePersistentHandler(freshRecord.finally_handler);if(finallyHandler)await firePersistentHandler(finallyHandler,batchId);log.info(`[Batch] Batch ${batchId} finished`)}}export async function recordBatchJobFailure(batchId,jobId,error){const record=await getBatchRecord(batchId);if(!record)return;const opts=JSON.parse(record.options||"{}"),dispatched=new DispatchedBatch(batchId),callbacks=getBatchCallbacks(batchId);let completed=!1;if(getQueueDriver()==="redis"){completed=await decrementBatchInRedis(batchId,!0,!!opts.allowFailures);const fresh=await getBatchRecord(batchId);if(fresh){let failedIds=[];try{failedIds=JSON.parse(fresh.failed_job_ids||"[]")}catch{failedIds=[]}failedIds.push(jobId);await updateBatchInRedis(batchId,{failed_job_ids:JSON.stringify(failedIds)})}if(callbacks)for(const cb of callbacks.catchCallbacks)try{await cb(dispatched,error)}catch(e){log.error(`[Batch] Error in catch callback for batch ${batchId}:`,e)}try{const{emitQueueEvent}=await import("./events");await emitQueueEvent("batch:failed",{jobId:batchId,error})}catch{}}else{const{db,sql}=await import("@stacksjs/database/runtime");await db.updateTable("job_batches").set({pending_jobs:sql`GREATEST(pending_jobs - 1, 0)`,failed_jobs:sql`failed_jobs + 1`}).where("id","=",batchId).where("pending_jobs",">",0).execute();try{const fresh=await getBatchRecord(batchId);if(fresh){let failedIds=[];try{failedIds=JSON.parse(fresh.failed_job_ids||"[]")}catch{failedIds=[]}failedIds.push(jobId);await updateBatchRecord(batchId,{failed_job_ids:JSON.stringify(failedIds)})}}catch{}if(callbacks)for(const cb of callbacks.catchCallbacks)try{await cb(dispatched,error)}catch(e){log.error(`[Batch] Error in catch callback for batch ${batchId}:`,e)}try{const{emitQueueEvent}=await import("./events");await emitQueueEvent("batch:failed",{jobId:batchId,error})}catch{}const finishedAt=new Date().toISOString().slice(0,19).replace("T"," ");let finalize=db.updateTable("job_batches").set(opts.allowFailures?{finished_at:finishedAt}:{finished_at:finishedAt,cancelled_at:finishedAt}).where("id","=",batchId).whereNull("finished_at");if(opts.allowFailures)finalize=finalize.where("pending_jobs","=",0);const finalizeResult=await finalize.executeTakeFirst();completed=updatedRowCount(finalizeResult)>0}if(!completed)return;const succeeded=!!opts.allowFailures;if(callbacks){if(succeeded)for(const cb of callbacks.thenCallbacks)try{await cb(dispatched)}catch(e){log.error(`[Batch] Error in then callback for batch ${batchId}:`,e)}for(const cb of callbacks.finallyCallbacks)try{await cb(dispatched)}catch(e){log.error(`[Batch] Error in finally callback for batch ${batchId}:`,e)}removeBatchCallbacks(batchId)}const freshRecord=await getBatchRecord(batchId);if(freshRecord){if(succeeded){const thenHandler=parsePersistentHandler(freshRecord.then_handler);if(thenHandler)await firePersistentHandler(thenHandler,batchId)}else{const catchHandler=parsePersistentHandler(freshRecord.catch_handler);if(catchHandler)await firePersistentHandler(catchHandler,batchId)}const finallyHandler=parsePersistentHandler(freshRecord.finally_handler);if(finallyHandler)await firePersistentHandler(finallyHandler,batchId)}log.info(`[Batch] Batch ${batchId} finished with failure(s)`)}export async function isBatchCancelled(batchId){return(await getBatchRecord(batchId))?.cancelled_at!==null}
|
|
1
|
+
import{RedisClient}from"bun";import{log}from"@stacksjs/logging";import{env as envVars}from"@stacksjs/env";import{updatedRowCount}from"./utils";function getQueueDriver(){return envVars.QUEUE_DRIVER||"sync"}export class PendingBatch{jobs;options={thenCallbacks:[],catchCallbacks:[],finallyCallbacks:[],progressCallbacks:[]};constructor(jobs){this.jobs=jobs.map((j)=>("job"in j)?j:{job:j})}name(name){this.options.name=name;return this}onQueue(queue){this.options.queue=queue;return this}allowFailures(){this.options.allowFailures=!0;return this}then(callback){this.options.thenCallbacks.push(callback);return this}catch(callback){this.options.catchCallbacks.push(callback);return this}finally(callback){this.options.finallyCallbacks.push(callback);return this}progress(callback){this.options.progressCallbacks.push(callback);return this}thenHandler(handler){this.options.thenHandler=handler;return this}catchHandler(handler){this.options.catchHandler=handler;return this}finallyHandler(handler){this.options.finallyHandler=handler;return this}async dispatch(){const batchId=crypto.randomUUID(),totalJobs=this.jobs.length;if(totalJobs===0)throw Error("Cannot dispatch an empty batch");const driver=getQueueDriver();await storeBatchRecord({id:batchId,name:this.options.name||"",total_jobs:totalJobs,pending_jobs:totalJobs,failed_jobs:0,failed_job_ids:"[]",options:JSON.stringify({queue:this.options.queue,allowFailures:this.options.allowFailures||!1}),cancelled_at:null,created_at:new Date().toISOString().slice(0,19).replace("T"," "),finished_at:null,then_handler:this.options.thenHandler?JSON.stringify(this.options.thenHandler):null,catch_handler:this.options.catchHandler?JSON.stringify(this.options.catchHandler):null,finally_handler:this.options.finallyHandler?JSON.stringify(this.options.finallyHandler):null});registerBatchCallbacks(batchId,this.options);try{const{emitQueueEvent}=await import("./events");await emitQueueEvent("batch:added",{jobId:batchId,data:{name:this.options.name,totalJobs}})}catch{}for(let i=0;i<this.jobs.length;i++){const entry=this.jobs[i];if(!entry)continue;const{job,payload}=entry,jobPayload={...payload,_batchId:batchId,_batchIndex:i};if(this.options.queue&&!job.queue)job.queue=this.options.queue;if(driver==="sync")try{await job.dispatchNow(jobPayload);await recordBatchJobCompletion(batchId)}catch(error){await recordBatchJobFailure(batchId,`${batchId}:${i}`,error)}else await job.dispatch(jobPayload)}log.info(`[Batch] Dispatched batch "${this.options.name||batchId}" with ${totalJobs} jobs`);return new DispatchedBatch(batchId)}getJobs(){return[...this.jobs]}getOptions(){return this.options}}export class DispatchedBatch{id;constructor(id){this.id=id}async fresh(){return getBatchRecord(this.id)}async getName(){return(await this.fresh())?.name||""}async totalJobs(){return(await this.fresh())?.total_jobs||0}async pendingJobs(){return(await this.fresh())?.pending_jobs||0}async failedJobs(){return(await this.fresh())?.failed_jobs||0}async completedJobs(){const record=await this.fresh();if(!record)return 0;return record.total_jobs-record.pending_jobs}async progress(){const record=await this.fresh();if(!record||record.total_jobs===0)return 0;const completed=record.total_jobs-record.pending_jobs;return Math.round(completed/record.total_jobs*100)}async finished(){return(await this.fresh())?.finished_at!==null}async cancelled(){return(await this.fresh())?.cancelled_at!==null}async hasFailures(){return((await this.fresh())?.failed_jobs||0)>0}async failedJobIds(){const record=await this.fresh();if(!record)return[];try{return JSON.parse(record.failed_job_ids||"[]")}catch{return[]}}async cancel(){if(getQueueDriver()==="redis")await cancelBatchInRedis(this.id);else await cancelBatchInDatabase(this.id);log.info(`[Batch] Cancelled batch ${this.id}`);const callbacks=getBatchCallbacks(this.id);if(callbacks)for(const cb of callbacks.finallyCallbacks)try{await cb(this)}catch(e){log.error(`[Batch] Error in finally callback for batch ${this.id}:`,e)}}async add(jobs){const record=await this.fresh();if(!record)throw Error(`Batch ${this.id} not found`);if(record.cancelled_at)throw Error(`Batch ${this.id} has been cancelled`);if(record.finished_at)throw Error(`Batch ${this.id} has already finished`);const batchableJobs=jobs.map((j)=>("job"in j)?j:{job:j});if(!await incrementBatchCounters(this.id,batchableJobs.length))throw Error(`Batch ${this.id} was cancelled, finished or deleted before the jobs could be added`);const options=JSON.parse(record.options||"{}");for(let i=0;i<batchableJobs.length;i++){const entry=batchableJobs[i];if(!entry)continue;const{job,payload}=entry,jobPayload={...payload,_batchId:this.id,_batchIndex:record.total_jobs+i};if(options.queue&&!job.queue)job.queue=options.queue;await job.dispatch(jobPayload)}log.info(`[Batch] Added ${batchableJobs.length} jobs to batch ${this.id}`)}async delete(){await deleteBatchRecord(this.id);removeBatchCallbacks(this.id)}}export class Batch{static create(jobs){return new PendingBatch(jobs)}static async find(id){if(!await getBatchRecord(id))return null;return new DispatchedBatch(id)}static async all(){return(await getAllBatchRecords()).map((r)=>new DispatchedBatch(r.id))}static async prune(olderThanHours=24){return pruneBatchRecords(olderThanHours)}}const batchCallbackRegistry=new Map;function registerBatchCallbacks(batchId,options){batchCallbackRegistry.set(batchId,options)}export function getBatchCallbacks(batchId){return batchCallbackRegistry.get(batchId)}function removeBatchCallbacks(batchId){batchCallbackRegistry.delete(batchId)}async function storeBatchRecord(record){if(getQueueDriver()==="redis")await storeBatchInRedis(record);else await storeBatchInDatabase(record)}async function getBatchRecord(id){if(getQueueDriver()==="redis")return getBatchFromRedis(id);return getBatchFromDatabase(id)}async function getAllBatchRecords(){if(getQueueDriver()==="redis")return getAllBatchesFromRedis();return getAllBatchesFromDatabase()}async function updateBatchRecord(id,updates){if(getQueueDriver()==="redis")await updateBatchInRedis(id,updates);else await updateBatchInDatabase(id,updates)}async function incrementBatchCounters(id,delta){if(delta===0)return!0;if(getQueueDriver()==="redis")try{const client=await connectBatchRedis(),key=`${REDIS_BATCH_PREFIX}${id}`;await client.hincrby(key,"total_jobs",delta);await client.hincrby(key,"pending_jobs",delta);client.close();return!0}catch{}const{db,sql}=await import("@stacksjs/database/runtime"),result=await db.updateTable("job_batches").set(batchCounterIncrements(sql,delta)).where("id","=",id).whereNull("cancelled_at").whereNull("finished_at").executeTakeFirst();return updatedRowCount(result)>0}export function batchCounterIncrements(sql,delta){return{total_jobs:sql`total_jobs + ${delta}`,pending_jobs:sql`pending_jobs + ${delta}`}}async function deleteBatchRecord(id){if(getQueueDriver()==="redis")await deleteBatchFromRedis(id);else await deleteBatchFromDatabase(id)}async function pruneBatchRecords(olderThanHours){if(getQueueDriver()==="redis")return pruneBatchesFromRedis(olderThanHours);return pruneBatchesFromDatabase(olderThanHours)}function hasPersistentHandlers(record){return!!(record.then_handler||record.catch_handler||record.finally_handler)}async function storeBatchInDatabase(record){const{db}=await import("@stacksjs/database/runtime"),columns={id:record.id,name:record.name,total_jobs:record.total_jobs,pending_jobs:record.pending_jobs,failed_jobs:record.failed_jobs,failed_job_ids:record.failed_job_ids,options:record.options,cancelled_at:record.cancelled_at,created_at:record.created_at,finished_at:record.finished_at};if(!hasPersistentHandlers(record)){await db.insertInto("job_batches").values(columns).execute();return}try{await db.insertInto("job_batches").values({...columns,then_handler:record.then_handler??null,catch_handler:record.catch_handler??null,finally_handler:record.finally_handler??null}).execute()}catch(error){log.warn(`[Batch] Could not persist terminal handlers for batch ${record.id}: ${error?.message}. The job_batches table predates the then_handler/catch_handler/finally_handler columns; recreate it to enable handlers that survive a worker restart.`);await db.insertInto("job_batches").values(columns).execute()}}async function getBatchFromDatabase(id){const{db}=await import("@stacksjs/database/runtime");return await db.selectFrom("job_batches").where("id","=",id).selectAll().executeTakeFirst()||null}async function getAllBatchesFromDatabase(){const{db}=await import("@stacksjs/database/runtime");return await db.selectFrom("job_batches").selectAll().orderBy("created_at","desc").execute()}async function updateBatchInDatabase(id,updates){const{db}=await import("@stacksjs/database/runtime");await db.updateTable("job_batches").set(updates).where("id","=",id).execute()}async function deleteBatchFromDatabase(id){const{db}=await import("@stacksjs/database/runtime");await db.deleteFrom("job_batches").where("id","=",id).execute()}async function cancelBatchInDatabase(id){await updateBatchInDatabase(id,{cancelled_at:new Date().toISOString().slice(0,19).replace("T"," "),finished_at:new Date().toISOString().slice(0,19).replace("T"," ")})}async function pruneBatchesFromDatabase(olderThanHours){const{db}=await import("@stacksjs/database/runtime"),cutoff=new Date(Date.now()-olderThanHours*60*60*1000).toISOString().slice(0,19).replace("T"," "),result=await db.deleteFrom("job_batches").whereNotNull("finished_at").where("finished_at","<",cutoff).executeTakeFirst();return Number(result?.numDeletedRows??0)}const REDIS_BATCH_PREFIX="stacks:batch:",REDIS_BATCH_INDEX="stacks:batches";async function getRedisClient(){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");return sharedRedisQueue("__batches__",redisConfig)}async function batchRedisUrl(){const{queue:queueConfig}=await import("@stacksjs/config"),redisConfig=queueConfig?.connections?.redis?.redis;if(redisConfig?.url)return redisConfig.url;const auth=redisConfig?.password?`:${encodeURIComponent(redisConfig.password)}@`:"",db=redisConfig?.db?`/${redisConfig.db}`:"";return`redis://${auth}${redisConfig?.host||"localhost"}:${redisConfig?.port||6379}${db}`}async function connectBatchRedis(){const client=new RedisClient(await batchRedisUrl());await client.connect();return client}export function batchRecordToHash(record){return{id:record.id,name:record.name,total_jobs:String(record.total_jobs),pending_jobs:String(record.pending_jobs),failed_jobs:String(record.failed_jobs),failed_job_ids:record.failed_job_ids,options:record.options,cancelled_at:record.cancelled_at||"",created_at:record.created_at,finished_at:record.finished_at||"",then_handler:record.then_handler||"",catch_handler:record.catch_handler||"",finally_handler:record.finally_handler||""}}async function storeBatchInRedis(record){try{const client=await connectBatchRedis();await client.hset(`${REDIS_BATCH_PREFIX}${record.id}`,batchRecordToHash(record));await client.sadd(REDIS_BATCH_INDEX,record.id);client.close()}catch{await storeBatchInDatabase(record)}}export function batchRecordFromHash(data){if(!data?.id)return null;return{id:data.id,name:data.name??"",total_jobs:Number(data.total_jobs),pending_jobs:Number(data.pending_jobs),failed_jobs:Number(data.failed_jobs),failed_job_ids:data.failed_job_ids??"",options:data.options??"",cancelled_at:data.cancelled_at||null,created_at:data.created_at??"",finished_at:data.finished_at||null,then_handler:data.then_handler||null,catch_handler:data.catch_handler||null,finally_handler:data.finally_handler||null}}async function getBatchFromRedis(id){try{const client=await connectBatchRedis(),key=`${REDIS_BATCH_PREFIX}${id}`,data=await client.hgetall(key);client.close();return batchRecordFromHash(data)}catch{return getBatchFromDatabase(id)}}async function getAllBatchesFromRedis(){try{const client=await connectBatchRedis(),ids=await client.smembers(REDIS_BATCH_INDEX),batches=[];for(const id of ids){const record=batchRecordFromHash(await client.hgetall(`${REDIS_BATCH_PREFIX}${id}`));if(record)batches.push(record)}client.close();return batches}catch{return getAllBatchesFromDatabase()}}async function updateBatchInRedis(id,updates){try{const client=await connectBatchRedis(),key=`${REDIS_BATCH_PREFIX}${id}`,hashUpdates={};for(const[k,v]of Object.entries(updates))hashUpdates[k]=v===null?"":String(v);await client.hset(key,hashUpdates);client.close()}catch{await updateBatchInDatabase(id,updates)}}async function decrementBatchInRedis(id,failed,allowFailures){const client=await connectBatchRedis();try{const key=`${REDIS_BATCH_PREFIX}${id}`,decrementedPending=await client.hincrby(key,"pending_jobs",-1),pending=Math.max(decrementedPending,0);if(decrementedPending<0)await client.hset(key,{pending_jobs:"0"});if(failed)await client.hincrby(key,"failed_jobs",1);const shouldFinish=!(await client.hgetall(key)).finished_at&&(!failed||allowFailures?pending===0:!0),finishedAt=new Date().toISOString().slice(0,19).replace("T"," "),completed=shouldFinish&&await client.hsetnx(key,"terminal_claimed","1");if(completed)await client.hset(key,{finished_at:finishedAt});if(completed&&failed&&!allowFailures)await client.hset(key,{cancelled_at:finishedAt});return completed}finally{client.close()}}async function deleteBatchFromRedis(id){try{const client=await connectBatchRedis();await client.del(`${REDIS_BATCH_PREFIX}${id}`);await client.srem(REDIS_BATCH_INDEX,id);client.close()}catch{await deleteBatchFromDatabase(id)}}async function cancelBatchInRedis(id){const now=new Date().toISOString().slice(0,19).replace("T"," ");await updateBatchInRedis(id,{cancelled_at:now,finished_at:now})}async function pruneBatchesFromRedis(olderThanHours){try{const batches=await getAllBatchesFromRedis(),cutoff=Date.now()-olderThanHours*60*60*1000;let pruned=0;for(const batch of batches)if(batch.finished_at){if(new Date(batch.finished_at).getTime()<cutoff){await deleteBatchFromRedis(batch.id);pruned++}}return pruned}catch{return pruneBatchesFromDatabase(olderThanHours)}}function parsePersistentHandler(raw){if(!raw)return null;try{const parsed=JSON.parse(raw);if(parsed&&(parsed.kind==="job"||parsed.kind==="module"))return parsed;log.warn(`[Batch] handler JSON has unknown kind '${parsed?.kind}' - skipping`);return null}catch(err){log.warn(`[Batch] failed to parse persistent handler: ${err.message}`);return null}}async function firePersistentHandler(handler,batchId){try{if(handler.kind==="job"){const{Jobs}=await import("./job");await Jobs.dispatch(handler.name,{...handler.payload??{},_batchId:batchId});return}const mod=await import(handler.module).catch((err)=>{log.warn(`[Batch] persistent handler module not found: ${handler.module} (${err.message})`);return null});if(!mod)return;const fn=mod[handler.export];if(typeof fn!=="function"){log.warn(`[Batch] persistent handler export '${handler.export}' is not a function on ${handler.module}`);return}await fn(handler.payload,batchId)}catch(err){log.error(`[Batch] persistent handler threw for batch ${batchId}:`,err)}}export async function recordBatchJobCompletion(batchId){const driver=getQueueDriver();let completed=!1;if(driver==="redis")completed=await decrementBatchInRedis(batchId,!1,!1);else{const{db,sql}=await import("@stacksjs/database/runtime");await db.updateTable("job_batches").set({pending_jobs:sql`GREATEST(pending_jobs - 1, 0)`}).where("id","=",batchId).where("pending_jobs",">",0).execute();const finishedAt=new Date().toISOString().slice(0,19).replace("T"," "),completeResult=await db.updateTable("job_batches").set({finished_at:finishedAt}).where("id","=",batchId).where("pending_jobs","=",0).whereNull("finished_at").executeTakeFirst();completed=updatedRowCount(completeResult)>0}const callbacks=getBatchCallbacks(batchId),dispatched=new DispatchedBatch(batchId);if(callbacks)for(const cb of callbacks.progressCallbacks)try{await cb(dispatched)}catch(e){log.error(`[Batch] Error in progress callback for batch ${batchId}:`,e)}if(completed){try{const{emitQueueEvent}=await import("./events");await emitQueueEvent("batch:completed",{jobId:batchId})}catch{}const freshRecord=await getBatchRecord(batchId);if(!freshRecord){removeBatchCallbacks(batchId);log.info(`[Batch] Batch ${batchId} finished (record vanished)`);return}const opts=JSON.parse(freshRecord.options||"{}"),succeeded=!((freshRecord.failed_jobs||0)>0)||opts.allowFailures;if(callbacks){if(succeeded)for(const cb of callbacks.thenCallbacks)try{await cb(dispatched)}catch(e){log.error(`[Batch] Error in then callback for batch ${batchId}:`,e)}for(const cb of callbacks.finallyCallbacks)try{await cb(dispatched)}catch(e){log.error(`[Batch] Error in finally callback for batch ${batchId}:`,e)}removeBatchCallbacks(batchId)}if(succeeded){const thenHandler=parsePersistentHandler(freshRecord.then_handler);if(thenHandler)await firePersistentHandler(thenHandler,batchId)}else{const catchHandler=parsePersistentHandler(freshRecord.catch_handler);if(catchHandler)await firePersistentHandler(catchHandler,batchId)}const finallyHandler=parsePersistentHandler(freshRecord.finally_handler);if(finallyHandler)await firePersistentHandler(finallyHandler,batchId);log.info(`[Batch] Batch ${batchId} finished`)}}export async function recordBatchJobFailure(batchId,jobId,error){const record=await getBatchRecord(batchId);if(!record)return;const opts=JSON.parse(record.options||"{}"),dispatched=new DispatchedBatch(batchId),callbacks=getBatchCallbacks(batchId);let completed=!1;if(getQueueDriver()==="redis"){completed=await decrementBatchInRedis(batchId,!0,!!opts.allowFailures);const fresh=await getBatchRecord(batchId);if(fresh){let failedIds=[];try{failedIds=JSON.parse(fresh.failed_job_ids||"[]")}catch{failedIds=[]}failedIds.push(jobId);await updateBatchInRedis(batchId,{failed_job_ids:JSON.stringify(failedIds)})}if(callbacks)for(const cb of callbacks.catchCallbacks)try{await cb(dispatched,error)}catch(e){log.error(`[Batch] Error in catch callback for batch ${batchId}:`,e)}try{const{emitQueueEvent}=await import("./events");await emitQueueEvent("batch:failed",{jobId:batchId,error})}catch{}}else{const{db,sql}=await import("@stacksjs/database/runtime");await db.updateTable("job_batches").set({pending_jobs:sql`GREATEST(pending_jobs - 1, 0)`,failed_jobs:sql`failed_jobs + 1`}).where("id","=",batchId).where("pending_jobs",">",0).execute();try{const fresh=await getBatchRecord(batchId);if(fresh){let failedIds=[];try{failedIds=JSON.parse(fresh.failed_job_ids||"[]")}catch{failedIds=[]}failedIds.push(jobId);await updateBatchRecord(batchId,{failed_job_ids:JSON.stringify(failedIds)})}}catch{}if(callbacks)for(const cb of callbacks.catchCallbacks)try{await cb(dispatched,error)}catch(e){log.error(`[Batch] Error in catch callback for batch ${batchId}:`,e)}try{const{emitQueueEvent}=await import("./events");await emitQueueEvent("batch:failed",{jobId:batchId,error})}catch{}const finishedAt=new Date().toISOString().slice(0,19).replace("T"," ");let finalize=db.updateTable("job_batches").set(opts.allowFailures?{finished_at:finishedAt}:{finished_at:finishedAt,cancelled_at:finishedAt}).where("id","=",batchId).whereNull("finished_at");if(opts.allowFailures)finalize=finalize.where("pending_jobs","=",0);const finalizeResult=await finalize.executeTakeFirst();completed=updatedRowCount(finalizeResult)>0}if(!completed)return;const succeeded=!!opts.allowFailures;if(callbacks){if(succeeded)for(const cb of callbacks.thenCallbacks)try{await cb(dispatched)}catch(e){log.error(`[Batch] Error in then callback for batch ${batchId}:`,e)}for(const cb of callbacks.finallyCallbacks)try{await cb(dispatched)}catch(e){log.error(`[Batch] Error in finally callback for batch ${batchId}:`,e)}removeBatchCallbacks(batchId)}const freshRecord=await getBatchRecord(batchId);if(freshRecord){if(succeeded){const thenHandler=parsePersistentHandler(freshRecord.then_handler);if(thenHandler)await firePersistentHandler(thenHandler,batchId)}else{const catchHandler=parsePersistentHandler(freshRecord.catch_handler);if(catchHandler)await firePersistentHandler(catchHandler,batchId)}const finallyHandler=parsePersistentHandler(freshRecord.finally_handler);if(finallyHandler)await firePersistentHandler(finallyHandler,batchId)}log.info(`[Batch] Batch ${batchId} finished with failure(s)`)}export async function isBatchCancelled(batchId){return(await getBatchRecord(batchId))?.cancelled_at!==null}
|
package/dist/circuit-breaker.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{db}from"@stacksjs/database/runtime";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}
|
|
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}}
|
package/dist/drivers/redis.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { Queue as BunQueue, type Job as BunJob, batch, chain, dispatch, dispatchAfter, dispatchSync, getQueueManager, QueueManager, setQueueManager } from '@stacksjs/bun-queue';
|
|
2
2
|
import type { Dispatchable, QueueOption, RedisConnectionConfig } from '@stacksjs/types';
|
|
3
3
|
export type { BunJob };
|
|
4
|
+
/** A backoff delay in seconds: the value when it is a usable number, 1 otherwise. */
|
|
5
|
+
export declare function backoffSeconds(value: unknown): number;
|
|
6
|
+
/** The RedisQueue for `name` on this connection, opened once and reused. */
|
|
7
|
+
export declare function sharedRedisQueue<T = any>(name: string, config: RedisConnectionConfig): RedisQueue<T>;
|
|
8
|
+
/** Close every shared queue: at shutdown, or in a test. */
|
|
9
|
+
export declare function closeSharedRedisQueues(): Promise<void>;
|
|
4
10
|
/**
|
|
5
11
|
* Create a Redis queue dispatcher
|
|
6
12
|
*/
|
package/dist/drivers/redis.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{Queue as BunQueue,batch,chain,dispatch,dispatchAfter,dispatchSync,getQueueManager,QueueManager,setQueueManager}from"@stacksjs/bun-queue";import{log}from"@stacksjs/logging";export{batch,chain,dispatch,dispatchAfter,dispatchSync,getQueueManager,QueueManager,setQueueManager};export class RedisQueue{queue;config;isProcessing=!1;constructor(name,config){this.config=config;this.queue=new BunQueue(name,{driver:"redis",prefix:config.prefix,redis:config.redis?{url:config.redis.url||this.buildRedisUrl(config.redis)}:void 0,defaultJobOptions:config.defaultJobOptions?{delay:config.defaultJobOptions.delay,attempts:config.defaultJobOptions.attempts,backoff:config.defaultJobOptions.backoff,removeOnComplete:config.defaultJobOptions.removeOnComplete,removeOnFail:config.defaultJobOptions.removeOnFail,priority:config.defaultJobOptions.priority,lifo:config.defaultJobOptions.lifo,timeout:config.defaultJobOptions.timeout,jobId:config.defaultJobOptions.jobId,dependsOn:config.defaultJobOptions.dependsOn,keepJobs:config.defaultJobOptions.keepJobs,deadLetter:config.defaultJobOptions.deadLetter}:void 0,limiter:config.limiter,metrics:config.metrics,stalledJobCheckInterval:config.stalledJobCheckInterval,maxStalledJobRetries:config.maxStalledJobRetries,distributedLock:config.distributedLock,defaultDeadLetterOptions:config.defaultDeadLetterOptions,horizontalScaling:config.horizontalScaling,logLevel:config.logLevel});log.debug(`Redis queue "${name}" initialized`)}buildRedisUrl(redis){const host=redis.host||"localhost",port=redis.port||6379,passwordSegment=redis.password?`:${encodeURIComponent(redis.password)}@`:"",db=Number.isFinite(redis.db)&&redis.db>=0?redis.db:0;return`redis://${passwordSegment}${host}:${port}/${db}`}async add(data,options){const jobOptions={delay:options?.delay?options.delay*1000:void 0,attempts:options?.maxTries,priority:options?.priority,timeout:options?.timeout?options.timeout*1000:void 0,backoff:Array.isArray(options?.backoff)?options.backoff.map((s)=>(
|
|
1
|
+
import{Queue as BunQueue,batch,chain,dispatch,dispatchAfter,dispatchSync,getQueueManager,QueueManager,setQueueManager}from"@stacksjs/bun-queue";import{log}from"@stacksjs/logging";export{batch,chain,dispatch,dispatchAfter,dispatchSync,getQueueManager,QueueManager,setQueueManager};export function backoffSeconds(value){const seconds=Number(value);return Number.isFinite(seconds)&&seconds>=0?seconds:1}export class RedisQueue{queue;config;isProcessing=!1;constructor(name,config){this.config=config;this.queue=new BunQueue(name,{driver:"redis",prefix:config.prefix,redis:config.redis?{url:config.redis.url||this.buildRedisUrl(config.redis)}:void 0,defaultJobOptions:config.defaultJobOptions?{delay:config.defaultJobOptions.delay,attempts:config.defaultJobOptions.attempts,backoff:config.defaultJobOptions.backoff,removeOnComplete:config.defaultJobOptions.removeOnComplete,removeOnFail:config.defaultJobOptions.removeOnFail,priority:config.defaultJobOptions.priority,lifo:config.defaultJobOptions.lifo,timeout:config.defaultJobOptions.timeout,jobId:config.defaultJobOptions.jobId,dependsOn:config.defaultJobOptions.dependsOn,keepJobs:config.defaultJobOptions.keepJobs,deadLetter:config.defaultJobOptions.deadLetter}:void 0,limiter:config.limiter,metrics:config.metrics,stalledJobCheckInterval:config.stalledJobCheckInterval,maxStalledJobRetries:config.maxStalledJobRetries,distributedLock:config.distributedLock,defaultDeadLetterOptions:config.defaultDeadLetterOptions,horizontalScaling:config.horizontalScaling,logLevel:config.logLevel});log.debug(`Redis queue "${name}" initialized`)}buildRedisUrl(redis){const host=redis.host||"localhost",port=redis.port||6379,passwordSegment=redis.password?`:${encodeURIComponent(redis.password)}@`:"",db=Number.isFinite(redis.db)&&redis.db>=0?redis.db:0;return`redis://${passwordSegment}${host}:${port}/${db}`}async add(data,options){const jobOptions={delay:options?.delay?options.delay*1000:void 0,attempts:options?.maxTries,priority:options?.priority,timeout:options?.timeout?options.timeout*1000:void 0,backoff:Array.isArray(options?.backoff)?options.backoff.map((s)=>backoffSeconds(s)*1000):typeof options?.backoff==="number"?{type:"fixed",delay:backoffSeconds(options.backoff)*1000}:void 0};return this.queue.add(data,jobOptions)}process(concurrency,handler){if(this.isProcessing){log.warn("Queue is already processing");return}this.isProcessing=!0;this.queue.process(concurrency,handler);log.info(`Started processing queue with concurrency ${concurrency}`)}async getJob(jobId){return this.queue.getJob(jobId)}async getJobs(status){return this.queue.getJobs(status)}async getJobCounts(){return this.queue.getJobCounts()}async removeJob(jobId){return this.queue.removeJob(jobId)}async pause(){return this.queue.pause()}async resume(){return this.queue.resume()}async empty(){return this.queue.empty()}async close(){this.isProcessing=!1;return this.queue.close()}async getMetrics(){return this.queue.getMetrics()}async ping(){return this.queue.ping()}async scheduleCron(options){return this.queue.scheduleCron({cronExpression:options.cron,data:options.data,timezone:options.tz,jobId:options.name})}async unscheduleCron(jobId){return this.queue.unscheduleCron(jobId)}async getDeadLetterJobs(){return this.queue.getDeadLetterJobs()}async republishDeadLetterJob(jobId){return this.queue.republishDeadLetterJob(jobId)}async clearDeadLetterQueue(){return this.queue.clearDeadLetterQueue()}async bulkRemove(jobIds){return this.queue.bulkRemove(jobIds)}async getClusterInfo(){return this.queue.getClusterInfo()}isLeader(){return this.queue.isLeader()}getQueue(){return this.queue}on(event,handler){this.queue.events.on(event,handler)}}const sharedQueues=new Map;export function sharedRedisQueue(name,config){const key=`${name}\x00${JSON.stringify(config)}`;let queue=sharedQueues.get(key);if(!queue){queue=new RedisQueue(name,config);sharedQueues.set(key,queue)}return queue}export async function closeSharedRedisQueues(){const queues=[...sharedQueues.values()];sharedQueues.clear();await Promise.all(queues.map((queue)=>queue.close()))}export class StacksQueueManager{config;queues=new Map;defaultConnection="default";constructor(config){this.config=config;for(const[name,connectionConfig]of Object.entries(config))if(connectionConfig.driver==="redis")this.queues.set(name,new RedisQueue(name,connectionConfig))}queue(name){const queueName=name||this.defaultConnection;let queue=this.queues.get(queueName);if(!queue){const config=this.config[queueName];if(!config)throw Error(`Queue "${queueName}" not configured`);queue=new RedisQueue(queueName,config);this.queues.set(queueName,queue)}return queue}setDefaultConnection(name){this.defaultConnection=name}async closeAll(){const closePromises=Array.from(this.queues.values()).map((q)=>q.close());await Promise.all(closePromises);this.queues.clear()}}export function createRedisDispatcher(queueName,config){const queue=new RedisQueue(queueName,config);return async(data,options)=>{return queue.add(data,options)}}export class RedisJob{data;options={};queue;constructor(queueName,config,data){this.data=data;this.queue=new RedisQueue(queueName,config)}async dispatch(){await this.queue.add(this.data,this.options)}async dispatchNow(){await this.queue.add(this.data,{...this.options,immediate:!0})}delay(seconds){this.options.delay=seconds;return this}afterResponse(){this.options.afterResponse=!0;return this}chain(jobs){this.options.chainedJobs=jobs;return this}onQueue(queue){this.options.queue=queue;return this}priority(level){this.options.priority=level;return this}tries(count){this.options.maxTries=count;return this}timeout(seconds){this.options.timeout=seconds;return this}backoff(attempts){this.options.backoff=attempts;return this}}
|
package/dist/envelope.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Construct a fresh envelope for an outgoing dispatch. Both drivers
|
|
3
3
|
* call this — single source of truth for the on-the-wire shape.
|
|
4
4
|
*/
|
|
5
|
-
export declare function createEnvelope(jobName: string, payload: unknown, options?: JobEnvelopeOptions, traceId?: string): JobEnvelope;
|
|
5
|
+
export declare function createEnvelope(jobName: string, payload: unknown, options?: JobEnvelopeOptions, traceId?: string, context?: unknown): JobEnvelope;
|
|
6
6
|
/**
|
|
7
7
|
* JSON round-trip contract — what a payload is worth on the other side
|
|
8
8
|
* (stacksjs/stacks#2282, item 6).
|
|
@@ -164,6 +164,7 @@ export declare interface JobEnvelope {
|
|
|
164
164
|
envelopeVersion: number
|
|
165
165
|
dispatchedAt: string
|
|
166
166
|
traceId?: string
|
|
167
|
+
context?: unknown
|
|
167
168
|
}
|
|
168
169
|
/**
|
|
169
170
|
* Result of attempting to parse an arbitrary input into a
|
package/dist/envelope.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const JOB_ENVELOPE_VERSION=1;export function createEnvelope(jobName,payload,options,traceId){return{jobName,payload,options,envelopeVersion:JOB_ENVELOPE_VERSION,dispatchedAt:new Date().toISOString(),...traceId?{traceId}:{}}}export function serializeEnvelope(envelope){try{return JSON.stringify(envelope)}catch(err){throw serializationError(envelope,err)}}export function assertEnvelopeSerializable(envelope){serializeEnvelope(envelope)}function serializationError(envelope,cause){const seeContract="See the JSON round-trip contract in @stacksjs/queue's src/envelope.ts.";if(isBigIntFailure(cause)){let offender=null;try{offender=findBigInt(envelope,"",new Set)}catch{}const where=offender?`\`${offender.path}\` is a BigInt (${offender.value}n)`:"it holds a BigInt";return Error(`[queue] Job "${envelope.jobName}" was not dispatched: ${where}, and job envelopes travel as JSON, which cannot represent one. Convert it where you dispatch - \`String(value)\` keeps every digit, \`Number(value)\` is exact below 2^53 - and convert it back inside the job handler. ${seeContract}`,{cause})}if(isCircularFailure(cause))return Error(`[queue] Job "${envelope.jobName}" was not dispatched: its payload contains a circular reference, which JSON cannot represent. Send the ids of the things the payload points at rather than the objects themselves - that also keeps the job replayable once those objects have changed. ${seeContract}`,{cause});const detail=cause instanceof Error?cause.message:String(cause);return Error(`[queue] Job "${envelope.jobName}" was not dispatched: serializing its payload threw (${detail}). JSON rejects only two things on its own and this was neither, so the throw came from the payload - a \`toJSON()\` or a property getter. ${seeContract}`,{cause})}function isBigIntFailure(cause){return cause instanceof TypeError&&/bigint/i.test(cause.message)}function isCircularFailure(cause){return cause instanceof TypeError&&/circular|cyclic/i.test(cause.message)}function findBigInt(value,path,seen){if(typeof value==="bigint")return{path:path||"envelope",value};if(!value||typeof value!=="object")return null;if(seen.has(value))return null;seen.add(value);if(Array.isArray(value)){for(let i=0;i<value.length;i++){const hit=findBigInt(value[i],`${path}[${i}]`,seen);if(hit)return hit}return null}for(const[key,child]of Object.entries(value)){const hit=findBigInt(child,path?`${path}.${key}`:key,seen);if(hit)return hit}return null}const warned=new Set;function warnOnce(source,message){if(warned.has(source))return;warned.add(source);console.warn(message)}export function clearEnvelopeWarnings(){warned.clear()}export function parseEnvelope(raw){let obj;if(typeof raw==="string")try{obj=JSON.parse(raw)}catch{return{ok:!1,reason:"malformed",detail:"not valid JSON"}}else if(raw&&typeof raw==="object")obj=raw;else return{ok:!1,reason:"malformed",detail:`expected string or object, got ${typeof raw}`};if(obj.envelopeVersion===JOB_ENVELOPE_VERSION){if(typeof obj.jobName!=="string")return{ok:!1,reason:"missing-job-name"};return{ok:!0,envelope:{jobName:obj.jobName,payload:obj.payload,options:obj.options??void 0,envelopeVersion:JOB_ENVELOPE_VERSION,dispatchedAt:typeof obj.dispatchedAt==="string"?obj.dispatchedAt:new Date(0).toISOString(),...typeof obj.traceId==="string"&&obj.traceId?{traceId:obj.traceId}:{}},source:"v1"}}if(typeof obj.envelopeVersion==="number"&&obj.envelopeVersion>JOB_ENVELOPE_VERSION)return{ok:!1,reason:"unknown-version",detail:`envelopeVersion=${obj.envelopeVersion}, this worker speaks v${JOB_ENVELOPE_VERSION}`};if(typeof obj.jobName==="string"){warnOnce("v0-implicit","[queue/envelope] Processing pre-#1884 job envelope without envelopeVersion. In-flight jobs from before the upgrade will continue to work; new dispatches use the v1 shape automatically.");return{ok:!0,envelope:{jobName:obj.jobName,payload:obj.payload,options:obj.options??void 0,envelopeVersion:JOB_ENVELOPE_VERSION,dispatchedAt:typeof obj.dispatchedAt==="string"?obj.dispatchedAt:new Date(0).toISOString(),...typeof obj.traceId==="string"&&obj.traceId?{traceId:obj.traceId}:{}},source:"v0-implicit"}}if(typeof obj.job==="string"){warnOnce("laravel-legacy","[queue/envelope] Processing Laravel-legacy job envelope (`{ job, data }` shape). Will continue to process but the queue table contains migration-era rows - consider flushing once they drain.");return{ok:!0,envelope:{jobName:obj.job.replace(/^App\\+Jobs\\+/,"").replace(/^.*\\/,""),payload:obj.data,options:void 0,envelopeVersion:JOB_ENVELOPE_VERSION,dispatchedAt:new Date(0).toISOString()},source:"laravel-legacy"}}return{ok:!1,reason:"missing-job-name"}}
|
|
1
|
+
export const JOB_ENVELOPE_VERSION=1;export function createEnvelope(jobName,payload,options,traceId,context){return{jobName,payload,options,envelopeVersion:JOB_ENVELOPE_VERSION,dispatchedAt:new Date().toISOString(),...traceId?{traceId}:{},...context!==void 0?{context}:{}}}export function serializeEnvelope(envelope){try{return JSON.stringify(envelope)}catch(err){throw serializationError(envelope,err)}}export function assertEnvelopeSerializable(envelope){serializeEnvelope(envelope)}function serializationError(envelope,cause){const seeContract="See the JSON round-trip contract in @stacksjs/queue's src/envelope.ts.";if(isBigIntFailure(cause)){let offender=null;try{offender=findBigInt(envelope,"",new Set)}catch{}const where=offender?`\`${offender.path}\` is a BigInt (${offender.value}n)`:"it holds a BigInt";return Error(`[queue] Job "${envelope.jobName}" was not dispatched: ${where}, and job envelopes travel as JSON, which cannot represent one. Convert it where you dispatch - \`String(value)\` keeps every digit, \`Number(value)\` is exact below 2^53 - and convert it back inside the job handler. ${seeContract}`,{cause})}if(isCircularFailure(cause))return Error(`[queue] Job "${envelope.jobName}" was not dispatched: its payload contains a circular reference, which JSON cannot represent. Send the ids of the things the payload points at rather than the objects themselves - that also keeps the job replayable once those objects have changed. ${seeContract}`,{cause});const detail=cause instanceof Error?cause.message:String(cause);return Error(`[queue] Job "${envelope.jobName}" was not dispatched: serializing its payload threw (${detail}). JSON rejects only two things on its own and this was neither, so the throw came from the payload - a \`toJSON()\` or a property getter. ${seeContract}`,{cause})}function isBigIntFailure(cause){return cause instanceof TypeError&&/bigint/i.test(cause.message)}function isCircularFailure(cause){return cause instanceof TypeError&&/circular|cyclic/i.test(cause.message)}function findBigInt(value,path,seen){if(typeof value==="bigint")return{path:path||"envelope",value};if(!value||typeof value!=="object")return null;if(seen.has(value))return null;seen.add(value);if(Array.isArray(value)){for(let i=0;i<value.length;i++){const hit=findBigInt(value[i],`${path}[${i}]`,seen);if(hit)return hit}return null}for(const[key,child]of Object.entries(value)){const hit=findBigInt(child,path?`${path}.${key}`:key,seen);if(hit)return hit}return null}const warned=new Set;function warnOnce(source,message){if(warned.has(source))return;warned.add(source);console.warn(message)}export function clearEnvelopeWarnings(){warned.clear()}export function parseEnvelope(raw){let obj;if(typeof raw==="string")try{obj=JSON.parse(raw)}catch{return{ok:!1,reason:"malformed",detail:"not valid JSON"}}else if(raw&&typeof raw==="object")obj=raw;else return{ok:!1,reason:"malformed",detail:`expected string or object, got ${typeof raw}`};if(obj.envelopeVersion===JOB_ENVELOPE_VERSION){if(typeof obj.jobName!=="string")return{ok:!1,reason:"missing-job-name"};return{ok:!0,envelope:{jobName:obj.jobName,payload:obj.payload,options:obj.options??void 0,envelopeVersion:JOB_ENVELOPE_VERSION,dispatchedAt:typeof obj.dispatchedAt==="string"?obj.dispatchedAt:new Date(0).toISOString(),...typeof obj.traceId==="string"&&obj.traceId?{traceId:obj.traceId}:{},...obj.context!==void 0?{context:obj.context}:{}},source:"v1"}}if(typeof obj.envelopeVersion==="number"&&obj.envelopeVersion>JOB_ENVELOPE_VERSION)return{ok:!1,reason:"unknown-version",detail:`envelopeVersion=${obj.envelopeVersion}, this worker speaks v${JOB_ENVELOPE_VERSION}`};if(typeof obj.jobName==="string"){warnOnce("v0-implicit","[queue/envelope] Processing pre-#1884 job envelope without envelopeVersion. In-flight jobs from before the upgrade will continue to work; new dispatches use the v1 shape automatically.");return{ok:!0,envelope:{jobName:obj.jobName,payload:obj.payload,options:obj.options??void 0,envelopeVersion:JOB_ENVELOPE_VERSION,dispatchedAt:typeof obj.dispatchedAt==="string"?obj.dispatchedAt:new Date(0).toISOString(),...typeof obj.traceId==="string"&&obj.traceId?{traceId:obj.traceId}:{}},source:"v0-implicit"}}if(typeof obj.job==="string"){warnOnce("laravel-legacy","[queue/envelope] Processing Laravel-legacy job envelope (`{ job, data }` shape). Will continue to process but the queue table contains migration-era rows - consider flushing once they drain.");return{ok:!0,envelope:{jobName:obj.job.replace(/^App\\+Jobs\\+/,"").replace(/^.*\\/,""),payload:obj.data,options:void 0,envelopeVersion:JOB_ENVELOPE_VERSION,dispatchedAt:new Date(0).toISOString()},source:"laravel-legacy"}}return{ok:!1,reason:"missing-job-name"}}
|
package/dist/health.d.ts
CHANGED
|
@@ -11,6 +11,15 @@ export declare function createHealthCheckHandler(config?: HealthCheckConfig): (r
|
|
|
11
11
|
* Quick health check - returns just the status
|
|
12
12
|
*/
|
|
13
13
|
export declare function isQueueHealthy(config?: HealthCheckConfig): Promise<boolean>;
|
|
14
|
+
/**
|
|
15
|
+
* Seconds since the epoch for a stored `created_at`.
|
|
16
|
+
*
|
|
17
|
+
* Jobs are written with a UTC time and no zone - `2026-10-05 10:00:00` - and
|
|
18
|
+
* `new Date()` reads that as LOCAL time. East of UTC every job looked hours
|
|
19
|
+
* old and the health check reported a stalled queue; west of UTC ages went
|
|
20
|
+
* negative and it never alerted. A value with its own zone is read as is.
|
|
21
|
+
*/
|
|
22
|
+
export declare function utcSeconds(value: string): number;
|
|
14
23
|
/**
|
|
15
24
|
* Queue health check result
|
|
16
25
|
*/
|
package/dist/health.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{log}from"@stacksjs/logging";import{getGlobalMetrics,getWorkerTracker}from"./events";export function queuedJobState(job,nowTimestamp=Math.floor(Date.now()/1000)){if(job.reserved_at)return"processing";const availableAt=typeof job.available_at==="number"?job.available_at:typeof job.available_at==="string"&&job.available_at.trim()?Number(job.available_at):Number.NaN;if(Number.isFinite(availableAt)&&availableAt>nowTimestamp)return"delayed";return"pending"}const defaultConfig={maxPendingWarning:1000,maxPendingCritical:5000,maxFailedWarning:10,maxFailedCritical:100,maxJobAgeWarning:3600,maxJobAgeCritical:86400,maxErrorRateWarning:0.1,maxErrorRateCritical:0.5};export async function checkQueueHealth(config={}){const cfg={...defaultConfig,...config},alerts=[],now=new Date,nowTimestamp=Math.floor(now.getTime()/1000);try{const{db}=await import("@stacksjs/database/runtime"),jobs=await db.selectFrom("jobs").selectAll().execute(),failedJobs=await db.selectFrom("failed_jobs").selectAll().execute(),queueMap=new Map;for(const job of jobs){const queueName=job.queue||"default";if(!queueMap.has(queueName))queueMap.set(queueName,{pending:0,processing:0,delayed:0});const stats=queueMap.get(queueName),state=queuedJobState(job,nowTimestamp);if(state==="processing")stats.processing++;else if(state==="delayed")stats.delayed++;else{stats.pending++;if(job.created_at){const createdAt=typeof job.created_at==="number"?job.created_at:
|
|
1
|
+
import{log}from"@stacksjs/logging";import{getGlobalMetrics,getWorkerTracker}from"./events";export function queuedJobState(job,nowTimestamp=Math.floor(Date.now()/1000)){if(job.reserved_at)return"processing";const availableAt=typeof job.available_at==="number"?job.available_at:typeof job.available_at==="string"&&job.available_at.trim()?Number(job.available_at):Number.NaN;if(Number.isFinite(availableAt)&&availableAt>nowTimestamp)return"delayed";return"pending"}const defaultConfig={maxPendingWarning:1000,maxPendingCritical:5000,maxFailedWarning:10,maxFailedCritical:100,maxJobAgeWarning:3600,maxJobAgeCritical:86400,maxErrorRateWarning:0.1,maxErrorRateCritical:0.5};export async function checkQueueHealth(config={}){const cfg={...defaultConfig,...config},alerts=[],now=new Date,nowTimestamp=Math.floor(now.getTime()/1000);try{const{db}=await import("@stacksjs/database/runtime"),jobs=await db.selectFrom("jobs").selectAll().execute(),failedJobs=await db.selectFrom("failed_jobs").selectAll().execute(),queueMap=new Map;for(const job of jobs){const queueName=job.queue||"default";if(!queueMap.has(queueName))queueMap.set(queueName,{pending:0,processing:0,delayed:0});const stats=queueMap.get(queueName),state=queuedJobState(job,nowTimestamp);if(state==="processing")stats.processing++;else if(state==="delayed")stats.delayed++;else{stats.pending++;if(job.created_at){const createdAt=typeof job.created_at==="number"?job.created_at:utcSeconds(job.created_at),age=nowTimestamp-createdAt;if(!stats.oldestAge||age>stats.oldestAge)stats.oldestAge=age}}}const failedByQueue=new Map;for(const fj of failedJobs){const queueName=fj.queue||"default";failedByQueue.set(queueName,(failedByQueue.get(queueName)||0)+1)}const queueStatuses=[],allQueues=new Set([...queueMap.keys(),...failedByQueue.keys()]),queuesToCheck=cfg.queues?[...allQueues].filter((q)=>cfg.queues.includes(q)):[...allQueues];let totalPending=0,totalProcessing=0,totalDelayed=0,totalFailed=0;for(const queueName of queuesToCheck){const stats=queueMap.get(queueName)||{pending:0,processing:0,delayed:0},failed=failedByQueue.get(queueName)||0;totalPending+=stats.pending;totalProcessing+=stats.processing;totalDelayed+=stats.delayed;totalFailed+=failed;let status="healthy";if(stats.pending>=cfg.maxPendingCritical){status="unhealthy";alerts.push({level:"critical",message:`Queue "${queueName}" has ${stats.pending} pending jobs (threshold: ${cfg.maxPendingCritical})`,queue:queueName,timestamp:now.toISOString()})}else if(stats.pending>=cfg.maxPendingWarning){status="degraded";alerts.push({level:"warning",message:`Queue "${queueName}" has ${stats.pending} pending jobs (threshold: ${cfg.maxPendingWarning})`,queue:queueName,timestamp:now.toISOString()})}if(failed>=cfg.maxFailedCritical){status="unhealthy";alerts.push({level:"critical",message:`Queue "${queueName}" has ${failed} failed jobs (threshold: ${cfg.maxFailedCritical})`,queue:queueName,timestamp:now.toISOString()})}else if(failed>=cfg.maxFailedWarning){if(status==="healthy")status="degraded";alerts.push({level:"warning",message:`Queue "${queueName}" has ${failed} failed jobs (threshold: ${cfg.maxFailedWarning})`,queue:queueName,timestamp:now.toISOString()})}if(stats.oldestAge){if(stats.oldestAge>=cfg.maxJobAgeCritical){status="unhealthy";alerts.push({level:"critical",message:`Queue "${queueName}" has a job waiting for ${Math.floor(stats.oldestAge/3600)} hours`,queue:queueName,timestamp:now.toISOString()})}else if(stats.oldestAge>=cfg.maxJobAgeWarning){if(status==="healthy")status="degraded";alerts.push({level:"warning",message:`Queue "${queueName}" has a job waiting for ${Math.floor(stats.oldestAge/60)} minutes`,queue:queueName,timestamp:now.toISOString()})}}queueStatuses.push({name:queueName,status,pending:stats.pending,processing:stats.processing,delayed:stats.delayed,failed,oldestJobAge:stats.oldestAge})}const metricsData=getGlobalMetrics().getMetrics(),{throughputPerMinute,averageDuration:averageProcessingTime}=metricsData,totalJobs=totalPending+totalProcessing+totalDelayed+totalFailed,errorRate=totalJobs>0?totalFailed/totalJobs:0;let overallStatus="healthy";if(queueStatuses.some((q)=>q.status==="unhealthy"))overallStatus="unhealthy";else if(queueStatuses.some((q)=>q.status==="degraded"))overallStatus="degraded";if(errorRate>=cfg.maxErrorRateCritical){overallStatus="unhealthy";alerts.push({level:"critical",message:`Overall error rate is ${(errorRate*100).toFixed(1)}% (threshold: ${cfg.maxErrorRateCritical*100}%)`,timestamp:now.toISOString()})}else if(errorRate>=cfg.maxErrorRateWarning){if(overallStatus==="healthy")overallStatus="degraded";alerts.push({level:"warning",message:`Overall error rate is ${(errorRate*100).toFixed(1)}% (threshold: ${cfg.maxErrorRateWarning*100}%)`,timestamp:now.toISOString()})}const workerStatuses=getWorkerTracker().getAll().map((w)=>({id:w.id,status:w.status,queue:w.queue,processedCount:w.processedCount,failedCount:w.failedCount,lastActivityAt:w.lastActivityAt}));return{status:overallStatus,timestamp:now.toISOString(),queues:queueStatuses,workers:workerStatuses,metrics:{totalPending,totalProcessing,totalDelayed,totalFailed,throughputPerMinute,averageProcessingTime,errorRate},alerts}}catch(error){log.error("Failed to perform queue health check:",error);const workerStatuses=getWorkerTracker().getAll().map((w)=>({id:w.id,status:w.status,queue:w.queue,processedCount:w.processedCount,failedCount:w.failedCount,lastActivityAt:w.lastActivityAt}));return{status:"unhealthy",timestamp:now.toISOString(),queues:[],workers:workerStatuses,metrics:{totalPending:0,totalProcessing:0,totalDelayed:0,totalFailed:0,throughputPerMinute:0,averageProcessingTime:0,errorRate:0},alerts:[{level:"critical",message:`Health check failed: ${error.message}`,timestamp:now.toISOString()}]}}}export function createHealthCheckHandler(config={}){return async(_req)=>{const result=await checkQueueHealth(config),statusCode=result.status==="healthy"?200:result.status==="degraded"?207:503;return new Response(JSON.stringify(result,null,2),{status:statusCode,headers:{"Content-Type":"application/json","Cache-Control":"no-store"}})}}export async function isQueueHealthy(config={}){return(await checkQueueHealth(config)).status==="healthy"}export function utcSeconds(value){const text=String(value).trim(),zoned=/(?:Z|[+-]\d{2}:?\d{2})$/i.test(text);return Math.floor(new Date(zoned?text:`${text.replace(" ","T")}Z`).getTime()/1000)}
|
package/dist/idempotency.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{isMissingTableError}from"./missing-table";let databaseModule;function loadDatabaseModule(){return databaseModule??=import("@stacksjs/database/runtime").catch((error)=>{databaseModule=void 0;throw error})}let warnedAboutMissingJobIdempotencyTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingJobIdempotencyTable)return;warnedAboutMissingJobIdempotencyTable=!0;console.warn("[queue/idempotency] job_idempotency table missing - idempotency keys are accepted but NOT enforced. Run migrations to enable dedup.")}export async function hasDispatchedKey(key){try{const{db}=await loadDatabaseModule(),row=await db.selectFrom("job_idempotency").where("idempotency_key","=",key).select(["idempotency_key"]).executeTakeFirst();return Boolean(row)}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function recordDispatchedKey(key,jobName,queue){try{const{db}=await loadDatabaseModule();await db.insertInto("job_idempotency").values({idempotency_key:key,job_name:jobName,queue:queue??"default",dispatched_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute()}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return}
|
|
1
|
+
import{isUniqueViolation}from"@stacksjs/error-handling";import{isMissingTableError}from"./missing-table";let databaseModule;function loadDatabaseModule(){return databaseModule??=import("@stacksjs/database/runtime").catch((error)=>{databaseModule=void 0;throw error})}let warnedAboutMissingJobIdempotencyTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingJobIdempotencyTable)return;warnedAboutMissingJobIdempotencyTable=!0;console.warn("[queue/idempotency] job_idempotency table missing - idempotency keys are accepted but NOT enforced. Run migrations to enable dedup.")}export async function hasDispatchedKey(key){try{const{db}=await loadDatabaseModule(),row=await db.selectFrom("job_idempotency").where("idempotency_key","=",key).select(["idempotency_key"]).executeTakeFirst();return Boolean(row)}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function recordDispatchedKey(key,jobName,queue){try{const{db}=await loadDatabaseModule();await db.insertInto("job_idempotency").values({idempotency_key:key,job_name:jobName,queue:queue??"default",dispatched_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute()}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return}if(isUniqueViolation(err))return;throw err}}export async function claimDispatchKey(key,jobName,queue){try{const{db}=await loadDatabaseModule();await db.insertInto("job_idempotency").values({idempotency_key:key,job_name:jobName,queue:queue??"default",dispatched_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute();return"claimed"}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return"unenforced"}if(isUniqueViolation(err))return"duplicate";throw err}}export async function releaseDispatchKey(key){try{const{db}=await loadDatabaseModule();await db.deleteFrom("job_idempotency").where("idempotency_key","=",key).execute()}catch{}}
|
package/dist/job.d.ts
CHANGED
|
@@ -21,8 +21,30 @@ export declare function job<const N extends JobName>(name: N, payload?: JobPaylo
|
|
|
21
21
|
export declare function jobBatch(jobs: Array<import('./action').Job | import('./batch').BatchableJob>): import('./batch').PendingBatch;
|
|
22
22
|
/*` exports map rewrites the
|
|
23
23
|
* subpath under the `bun` condition and does not land on the file.
|
|
24
|
+
*
|
|
25
|
+
* A job is also found by the `name` it declares, in the same three places,
|
|
26
|
+
* when no file is called that. The name is optional and defaults to the file
|
|
27
|
+
* name, but a job that sets one - the scaffold's `ExampleJob.ts` says
|
|
28
|
+
* `name: 'Example Job'` - is dispatched under it: `new Job({...}).dispatch()`
|
|
29
|
+
* writes it into the envelope, and the scheduler's `rate` runner scheduled it
|
|
30
|
+
* by it. The worker then looked for `app/Jobs/Example Job.ts` and failed every
|
|
31
|
+
* run of a job that was sitting right there.
|
|
24
32
|
*/
|
|
25
33
|
export declare function resolveJobFile(name: string): Promise<string | null>;
|
|
34
|
+
/** Forget the declared-name index, after job files change. */
|
|
35
|
+
export declare function clearJobNameIndex(): void;
|
|
36
|
+
/**
|
|
37
|
+
* What a job's own definition says about retrying it, for a dispatch that
|
|
38
|
+
* did not.
|
|
39
|
+
*
|
|
40
|
+
* A job dispatched by name - `mail.queue()` sending `SendEmailJob`, a
|
|
41
|
+
* `Queue.dispatch('X')` - carried none of the `tries`, `backoff` or
|
|
42
|
+
* `timeout` the job declares, and the worker defaulted to one attempt. A
|
|
43
|
+
* transient SMTP failure sent the email straight to failed_jobs although the
|
|
44
|
+
* job asks for three tries with 10/30/60s backoff. The worker reads these
|
|
45
|
+
* when the envelope is silent; anything set on the dispatch still wins.
|
|
46
|
+
*/
|
|
47
|
+
export declare function jobDefaults(name: string): Promise<{ tries?: number, backoff?: number | number[], timeout?: number }>;
|
|
26
48
|
/**
|
|
27
49
|
* Run a job immediately by name
|
|
28
50
|
*
|
package/dist/job.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var {require}=import.meta;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});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()),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});assertEnvelopeSerializable(envelope);const{
|
|
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}}
|
package/dist/scheduler.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { storeJob } from './utils';
|
|
1
2
|
import { type DiscoveredJob } from './discovery';
|
|
2
3
|
/**
|
|
3
4
|
* Extract the wall-clock fields a cron expression matches against, in the
|
|
@@ -8,6 +9,7 @@ import { type DiscoveredJob } from './discovery';
|
|
|
8
9
|
* local-time semantics; an invalid zone warns once and falls back to local.
|
|
9
10
|
*/
|
|
10
11
|
export declare function getCronParts(date: Date, timeZone?: string): CronParts;
|
|
12
|
+
export declare function shouldRunNow(cronExpression: string, lastRun: Date | null, timeZone?: string): boolean;
|
|
11
13
|
/**
|
|
12
14
|
* Calculate next run time for a cron expression
|
|
13
15
|
*/
|
|
@@ -46,6 +48,15 @@ export declare function getRegisteredJobs(): Map<string, ScheduledJobState>;
|
|
|
46
48
|
* Manually trigger a scheduled job
|
|
47
49
|
*/
|
|
48
50
|
export declare function triggerJob(name: string): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* What a scheduled run is queued with: the job's own settings.
|
|
53
|
+
*
|
|
54
|
+
* `timeout: config.timeout || 60` gave every scheduled job that declared no
|
|
55
|
+
* timeout a 60-second one - a nightly export that takes five minutes was
|
|
56
|
+
* failed at one and retried while the first run was still going. And the
|
|
57
|
+
* job's `backoff` was never passed at all.
|
|
58
|
+
*/
|
|
59
|
+
export declare function scheduledDispatchOptions(config: { queue?: string, tries?: number, timeout?: number, backoff?: number | number[] | unknown }): Parameters<typeof storeJob>[1];
|
|
49
60
|
/**
|
|
50
61
|
* Scheduler configuration
|
|
51
62
|
*/
|
|
@@ -63,7 +74,4 @@ declare interface ScheduledJobState {
|
|
|
63
74
|
nextRun: Date | null
|
|
64
75
|
isRunning: boolean
|
|
65
76
|
}
|
|
66
|
-
/**
|
|
67
|
-
* Parse a cron expression and check if it should run now
|
|
68
|
-
*/
|
|
69
77
|
declare interface CronParts { minute: number, hour: number, day: number, month: number, dayOfWeek: number }
|
package/dist/scheduler.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
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}}
|
|
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}}
|
package/dist/utils.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{mutationCount}from"@stacksjs/database/runtime";import{createEnvelope,serializeEnvelope}from"./envelope";export function updatedRowCount(result){return mutationCount(result)}export function buildScheduledJobRow(name,options){const envelope=createEnvelope(name,options.payload||{},{queue:options.queue,tries:options.maxTries,timeout:options.timeout,backoff:Array.isArray(options.backoff)?options.backoff:void 0});return{queue:options.queue||"default",payload:serializeEnvelope(envelope),attempts:0,available_at:generateUnixTimestamp(options.delay||0),created_at:new Date().toISOString().slice(0,19).replace("T"," ")}}export async function storeJob(name,options){const row=buildScheduledJobRow(name,options),{db}=await import("@stacksjs/database/runtime");await db.insertInto("jobs").values(row).execute()}function generateUnixTimestamp(secondsToAdd){const now=Date.now();return Math.floor(now/1000+secondsToAdd)}
|
|
1
|
+
import{mutationCount}from"@stacksjs/database/runtime";import{createEnvelope,serializeEnvelope}from"./envelope";export function updatedRowCount(result){return mutationCount(result)}export function buildScheduledJobRow(name,options){const envelope=createEnvelope(name,options.payload||{},{queue:options.queue,tries:options.maxTries,timeout:options.timeout,backoff:Array.isArray(options.backoff)||typeof options.backoff==="number"?options.backoff:void 0});return{queue:options.queue||"default",payload:serializeEnvelope(envelope),attempts:0,available_at:generateUnixTimestamp(options.delay||0),created_at:new Date().toISOString().slice(0,19).replace("T"," ")}}export async function storeJob(name,options){const row=buildScheduledJobRow(name,options),{db}=await import("@stacksjs/database/runtime");await db.insertInto("jobs").values(row).execute()}function generateUnixTimestamp(secondsToAdd){const now=Date.now();return Math.floor(now/1000+secondsToAdd)}
|
package/dist/worker.d.ts
CHANGED
|
@@ -27,6 +27,14 @@ export declare function applyRetryJitter(delaySeconds: number, ratio: number, ra
|
|
|
27
27
|
* This is the main function called by `buddy queue:work`
|
|
28
28
|
*/
|
|
29
29
|
export declare function startProcessor(queueName?: string, options?: { concurrency?: number }): Promise<Result<undefined, Error>>;
|
|
30
|
+
/**
|
|
31
|
+
* The queues a worker was told to take, in priority order, or none.
|
|
32
|
+
*
|
|
33
|
+
* `--queue=high,default` is a list. It was taken as one queue literally named
|
|
34
|
+
* "high,default", which never holds a job. A worker given a list keeps to it;
|
|
35
|
+
* one given nothing follows every queue, picking up new ones as they appear.
|
|
36
|
+
*/
|
|
37
|
+
export declare function namedWorkerQueues(queueName?: string | boolean | null): string[];
|
|
30
38
|
/**
|
|
31
39
|
* Stop the queue processor, draining in-flight jobs within a grace
|
|
32
40
|
* window before returning. The `signalled to stop` flag stops the
|
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)}let queues;if(queueName)queues=[queueName];else{queues=await getAllQueues();if(queues.length===0)queues=["default"]}log.info(`Processing queues: ${queues.join(", ")}`);await processJobsFromDatabase(queues,concurrency);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"]}}async function processJobsFromDatabase(initialQueues,concurrency){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(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;try{const payload=JSON.parse(job.payload||"{}"),timeoutSec=readJobTimeoutSec(payload);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||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;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,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,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)}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{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))}
|
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.
|
|
5
|
+
"version": "0.75.73",
|
|
6
6
|
"description": "The Stacks Queue system powered by bun-queue.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -63,22 +63,23 @@
|
|
|
63
63
|
"prepublishOnly": "bun run build"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@stacksjs/action-runner": "0.75.
|
|
66
|
+
"@stacksjs/action-runner": "0.75.73",
|
|
67
67
|
"@stacksjs/bun-queue": "^0.1.8",
|
|
68
|
-
"@stacksjs/cache": "0.75.
|
|
69
|
-
"@stacksjs/config": "0.75.
|
|
70
|
-
"@stacksjs/
|
|
71
|
-
"@stacksjs/
|
|
72
|
-
"@stacksjs/
|
|
73
|
-
"@stacksjs/
|
|
74
|
-
"@stacksjs/
|
|
68
|
+
"@stacksjs/cache": "0.75.73",
|
|
69
|
+
"@stacksjs/config": "0.75.73",
|
|
70
|
+
"@stacksjs/cron": "0.75.73",
|
|
71
|
+
"@stacksjs/database": "0.75.73",
|
|
72
|
+
"@stacksjs/env": "0.75.73",
|
|
73
|
+
"@stacksjs/error-handling": "0.75.73",
|
|
74
|
+
"@stacksjs/logging": "0.75.73",
|
|
75
|
+
"@stacksjs/path": "0.75.73"
|
|
75
76
|
},
|
|
76
77
|
"devDependencies": {
|
|
77
78
|
"better-dx": "^0.2.24"
|
|
78
79
|
},
|
|
79
80
|
"peerDependencies": {
|
|
80
|
-
"@stacksjs/email": "0.75.
|
|
81
|
-
"@stacksjs/router": "0.75.
|
|
81
|
+
"@stacksjs/email": "0.75.73",
|
|
82
|
+
"@stacksjs/router": "0.75.73"
|
|
82
83
|
},
|
|
83
84
|
"peerDependenciesMeta": {
|
|
84
85
|
"@stacksjs/email": {
|