@stacksjs/queue 0.74.47 → 0.74.48
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/dead-letter.js +1 -1
- package/dist/health.js +1 -1
- package/dist/idempotency.js +1 -1
- package/dist/job.js +1 -1
- package/dist/poison.js +1 -1
- package/dist/scheduler-persistence.js +1 -1
- package/dist/utils.js +1 -1
- package/dist/worker.js +1 -1
- package/package.json +11 -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").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{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");await new RedisQueue(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)?this.backoff:void 0})}}
|
|
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{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");await new RedisQueue(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)?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"),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"),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");return await db.selectFrom("job_batches").where("id","=",id).selectAll().executeTakeFirst()||null}async function getAllBatchesFromDatabase(){const{db}=await import("@stacksjs/database");return await db.selectFrom("job_batches").selectAll().orderBy("created_at","desc").execute()}async function updateBatchInDatabase(id,updates){const{db}=await import("@stacksjs/database");await db.updateTable("job_batches").set(updates).where("id","=",id).execute()}async function deleteBatchFromDatabase(id){const{db}=await import("@stacksjs/database");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"),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");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");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{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}
|
package/dist/circuit-breaker.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{db}from"@stacksjs/database";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}const msg=err?.message??"";if(msg.includes("UNIQUE constraint")||msg.includes("Duplicate entry"))return await db.selectFrom("queue_circuit_state").where("queue_name","=",queue).selectAll().executeTakeFirst();throw err}}export async function isCircuitOpen(queue){try{const row=await db.selectFrom("queue_circuit_state").where("queue_name","=",queue).select(["paused_at","resume_at"]).executeTakeFirst();if(!row||!row.paused_at)return!1;if(row.resume_at){const resumeMs=Date.parse(row.resume_at.replace(" ","T")+"Z");if(Number.isFinite(resumeMs)&&Date.now()>=resumeMs){await db.updateTable("queue_circuit_state").set({paused_at:null,resume_at:null,success_count:0,failure_count:0}).where("queue_name","=",queue).execute();return!1}}return!0}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function recordCircuitSuccess(queue,config={}){const windowSeconds=config.windowSeconds??300,nowStr=new Date().toISOString().slice(0,19).replace("T"," "),row=await getOrCreateRow(queue,nowStr);if(!row)return;try{if(shouldResetWindow(row.window_start,windowSeconds)){await db.updateTable("queue_circuit_state").set({success_count:1,failure_count:0,window_start:nowStr}).where("queue_name","=",queue).execute();return}await db.updateTable("queue_circuit_state").set({success_count:row.success_count+1}).where("queue_name","=",queue).execute()}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return}throw err}}export async function recordCircuitFailure(queue,config={}){const threshold=config.failureRateThreshold??0.5,windowSeconds=config.windowSeconds??300,pauseSeconds=config.pauseSeconds??300,minObservations=config.minObservations??10,now=new Date,nowStr=now.toISOString().slice(0,19).replace("T"," "),row=await getOrCreateRow(queue,nowStr);if(!row)return!1;if(row.paused_at)return!1;try{let{success_count:successCount,failure_count:failureCount}=row;if(shouldResetWindow(row.window_start,windowSeconds)){successCount=0;failureCount=0}failureCount+=1;const observed=successCount+failureCount,rate=observed===0?0:failureCount/observed;if(observed>=minObservations&&rate>=threshold){const resumeAt=new Date(now.getTime()+pauseSeconds*1000).toISOString().slice(0,19).replace("T"," ");await db.updateTable("queue_circuit_state").set({success_count:successCount,failure_count:failureCount,window_start:nowStr,paused_at:nowStr,resume_at:resumeAt}).where("queue_name","=",queue).execute();return!0}await db.updateTable("queue_circuit_state").set({success_count:successCount,failure_count:failureCount,window_start:shouldResetWindow(row.window_start,windowSeconds)?nowStr:row.window_start}).where("queue_name","=",queue).execute();return!1}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}function shouldResetWindow(windowStart,windowSeconds){if(!windowStart)return!0;const startMs=Date.parse(windowStart.replace(" ","T")+"Z");if(!Number.isFinite(startMs))return!0;return Date.now()-startMs>windowSeconds*1000}export async function pauseQueue(queue,pauseSeconds=300){const now=new Date,nowStr=now.toISOString().slice(0,19).replace("T"," "),resumeAt=new Date(now.getTime()+pauseSeconds*1000).toISOString().slice(0,19).replace("T"," ");await getOrCreateRow(queue,nowStr);try{await db.updateTable("queue_circuit_state").set({paused_at:nowStr,resume_at:resumeAt}).where("queue_name","=",queue).execute()}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return}throw err}}export async function resumeQueue(queue){try{await db.updateTable("queue_circuit_state").set({paused_at:null,resume_at:null,success_count:0,failure_count:0}).where("queue_name","=",queue).execute()}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return}throw err}}export async function listCircuitState(){try{return await db.selectFrom("queue_circuit_state").selectAll().execute()??[]}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return[]}throw err}}
|
|
1
|
+
import{db}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}const msg=err?.message??"";if(msg.includes("UNIQUE constraint")||msg.includes("Duplicate entry"))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/dead-letter.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{db,mutationCount}from"@stacksjs/database";import{isMissingTableError}from"./missing-table";let warnedAboutMissingTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingTable)return;warnedAboutMissingTable=!0;console.warn("[queue/dlq] dead_letter_jobs table missing - DLQ disabled. Run migrations to enable poison-message isolation.")}export async function moveToDeadLetter(failedJob,reason,totalFailures=1){const now=new Date().toISOString().slice(0,19).replace("T"," ");try{await db.insertInto("dead_letter_jobs").values({uuid:failedJob.uuid??crypto.randomUUID(),connection:failedJob.connection??"database",queue:failedJob.queue??"default",payload:failedJob.payload??"{}",exception:failedJob.exception??"unknown",reason,total_failures:totalFailures,first_failed_at:failedJob.failed_at??now,last_failed_at:now,dead_lettered_at:now}).execute();return!0}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function listDeadLetterJobs(filter={}){try{let q=db.selectFrom("dead_letter_jobs").selectAll();if(filter.queue)q=q.where("queue","=",filter.queue);if(filter.reason)q=q.where("reason","=",filter.reason);if(filter.sinceCutoffMs){const cutoff=new Date(filter.sinceCutoffMs).toISOString().slice(0,19).replace("T"," ");q=q.where("dead_lettered_at",">=",cutoff)}if(filter.limit&&filter.limit>0)q=q.limit(filter.limit);return await q.execute()??[]}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return[]}throw err}}export async function retryDeadLetterJob(id){try{const row=await db.selectFrom("dead_letter_jobs").where("id","=",id).selectAll().executeTakeFirst();if(!row)return!1;const nowSec=Math.floor(Date.now()/1000);await db.insertInto("jobs").values({queue:row.queue,payload:row.payload,attempts:0,reserved_at:null,available_at:nowSec,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute();await db.deleteFrom("dead_letter_jobs").where("id","=",id).execute();return!0}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function purgeDeadLetterJobs(olderThanDays=30){try{const cutoff=new Date(Date.now()-olderThanDays*24*60*60*1000).toISOString().slice(0,19).replace("T"," "),result=await db.deleteFrom("dead_letter_jobs").where("dead_lettered_at","<",cutoff).execute();return mutationCount(result)}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return 0}throw err}}
|
|
1
|
+
import{db,mutationCount}from"@stacksjs/database/runtime";import{isMissingTableError}from"./missing-table";let warnedAboutMissingTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingTable)return;warnedAboutMissingTable=!0;console.warn("[queue/dlq] dead_letter_jobs table missing - DLQ disabled. Run migrations to enable poison-message isolation.")}export async function moveToDeadLetter(failedJob,reason,totalFailures=1){const now=new Date().toISOString().slice(0,19).replace("T"," ");try{await db.insertInto("dead_letter_jobs").values({uuid:failedJob.uuid??crypto.randomUUID(),connection:failedJob.connection??"database",queue:failedJob.queue??"default",payload:failedJob.payload??"{}",exception:failedJob.exception??"unknown",reason,total_failures:totalFailures,first_failed_at:failedJob.failed_at??now,last_failed_at:now,dead_lettered_at:now}).execute();return!0}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function listDeadLetterJobs(filter={}){try{let q=db.selectFrom("dead_letter_jobs").selectAll();if(filter.queue)q=q.where("queue","=",filter.queue);if(filter.reason)q=q.where("reason","=",filter.reason);if(filter.sinceCutoffMs){const cutoff=new Date(filter.sinceCutoffMs).toISOString().slice(0,19).replace("T"," ");q=q.where("dead_lettered_at",">=",cutoff)}if(filter.limit&&filter.limit>0)q=q.limit(filter.limit);return await q.execute()??[]}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return[]}throw err}}export async function retryDeadLetterJob(id){try{const row=await db.selectFrom("dead_letter_jobs").where("id","=",id).selectAll().executeTakeFirst();if(!row)return!1;const nowSec=Math.floor(Date.now()/1000);await db.insertInto("jobs").values({queue:row.queue,payload:row.payload,attempts:0,reserved_at:null,available_at:nowSec,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute();await db.deleteFrom("dead_letter_jobs").where("id","=",id).execute();return!0}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function purgeDeadLetterJobs(olderThanDays=30){try{const cutoff=new Date(Date.now()-olderThanDays*24*60*60*1000).toISOString().slice(0,19).replace("T"," "),result=await db.deleteFrom("dead_letter_jobs").where("dead_lettered_at","<",cutoff).execute();return mutationCount(result)}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return 0}throw err}}
|
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"),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:Math.floor(new Date(job.created_at).getTime()/1000),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"}
|
|
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:Math.floor(new Date(job.created_at).getTime()/1000),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"}
|
package/dist/idempotency.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{isMissingTableError}from"./missing-table";let databaseModule;function loadDatabaseModule(){return databaseModule??=import("@stacksjs/database").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}const msg=err?.message??"";if(msg.includes("UNIQUE constraint")||msg.includes("Duplicate entry"))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"}const msg=err?.message??"";if(msg.includes("UNIQUE constraint")||msg.includes("Duplicate entry"))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{}}
|
|
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}const msg=err?.message??"";if(msg.includes("UNIQUE constraint")||msg.includes("Duplicate entry"))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"}const msg=err?.message??"";if(msg.includes("UNIQUE constraint")||msg.includes("Duplicate entry"))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.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";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").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{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");await new RedisQueue(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 candidates=[appPath(`Jobs/${name}.ts`),frameworkPath(`defaults/app/Jobs/${name}.ts`)];for(const candidate of candidates)if(await Bun.file(candidate).exists())return candidate;let packageCandidate;try{const pkgUrl=import.meta.resolve("@stacksjs/defaults/package.json");packageCandidate=`${new URL(".",pkgUrl).pathname}app/Jobs/${name}.ts`}catch{}if(packageCandidate&&await Bun.file(packageCandidate).exists())return packageCandidate;return null}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 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);else if(typeof jobConfig.action==="string")await runNamedAction(jobConfig.action);else if(typeof jobConfig.action==="function")await jobConfig.action();else if(typeof jobConfig==="function")await jobConfig(options.payload,options.context);else throw Error(`Job ${name} does not have a valid handler`)})}async function currentTraceId(){try{const{getTraceId}=await loadTraceModule();return getTraceId()}catch{return}}
|
|
1
|
+
var {require}=import.meta;import{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{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");await new RedisQueue(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 candidates=[appPath(`Jobs/${name}.ts`),frameworkPath(`defaults/app/Jobs/${name}.ts`)];for(const candidate of candidates)if(await Bun.file(candidate).exists())return candidate;let packageCandidate;try{const pkgUrl=import.meta.resolve("@stacksjs/defaults/package.json");packageCandidate=`${new URL(".",pkgUrl).pathname}app/Jobs/${name}.ts`}catch{}if(packageCandidate&&await Bun.file(packageCandidate).exists())return packageCandidate;return null}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 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);else if(typeof jobConfig.action==="string")await runNamedAction(jobConfig.action);else if(typeof jobConfig.action==="function")await jobConfig.action();else if(typeof jobConfig==="function")await jobConfig(options.payload,options.context);else throw Error(`Job ${name} does not have a valid handler`)})}async function currentTraceId(){try{const{getTraceId}=await loadTraceModule();return getTraceId()}catch{return}}
|
package/dist/poison.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{hash}from"node:crypto";import{db}from"@stacksjs/database";import{isMissingTableError}from"./missing-table";let warnedAboutMissingTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingTable)return;warnedAboutMissingTable=!0;console.warn("[queue/poison] job_quarantine table missing - poison detection disabled. Run migrations to enable.")}export function hashPayload(payload){let serialized;try{serialized=typeof payload==="string"?payload:JSON.stringify(payload??null)}catch{serialized=String(payload)}return hash("sha256",serialized,"hex").slice(0,32)}export async function recordFailureForPoison(jobName,payload,config={}){const maxFailures=config.maxFailures??5,windowMinutes=config.windowMinutes??60,payloadHash=hashPayload(payload),now=new Date,nowStr=now.toISOString().slice(0,19).replace("T"," ");try{const existing=await db.selectFrom("job_quarantine").where("job_name","=",jobName).where("payload_hash","=",payloadHash).selectAll().executeTakeFirst();if(!existing){await db.insertInto("job_quarantine").values({job_name:jobName,payload_hash:payloadHash,failure_count:1,window_start:nowStr,quarantined_at:null}).execute();return!1}if(existing.quarantined_at)return!0;const windowStartMs=Date.parse(existing.window_start.replace(" ","T")+"Z"),ageMs=now.getTime()-windowStartMs,windowMs=windowMinutes*60*1000;if(Number.isFinite(windowStartMs)&&ageMs>windowMs){await db.updateTable("job_quarantine").set({failure_count:1,window_start:nowStr}).where("id","=",existing.id).execute();return!1}const newCount=existing.failure_count+1;if(newCount>=maxFailures){await db.updateTable("job_quarantine").set({failure_count:newCount,quarantined_at:nowStr}).where("id","=",existing.id).execute();return!0}await db.updateTable("job_quarantine").set({failure_count:newCount}).where("id","=",existing.id).execute();return!1}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function isQuarantined(jobName,payload){const payloadHash=hashPayload(payload);try{const row=await db.selectFrom("job_quarantine").where("job_name","=",jobName).where("payload_hash","in",[payloadHash,"*"]).whereNotNull("quarantined_at").select(["id"]).executeTakeFirst();return Boolean(row)}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function quarantineJob(jobName,payload){const payloadHash=payload===void 0?"*":hashPayload(payload),nowStr=new Date().toISOString().slice(0,19).replace("T"," ");try{const existing=await db.selectFrom("job_quarantine").where("job_name","=",jobName).where("payload_hash","=",payloadHash).select(["id"]).executeTakeFirst();if(existing)await db.updateTable("job_quarantine").set({quarantined_at:nowStr}).where("id","=",existing.id).execute();else await db.insertInto("job_quarantine").values({job_name:jobName,payload_hash:payloadHash,failure_count:0,window_start:nowStr,quarantined_at:nowStr}).execute()}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return}throw err}}export async function unquarantineJob(jobName){try{await db.deleteFrom("job_quarantine").where("job_name","=",jobName).execute()}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return}throw err}}export async function listQuarantined(){try{return await db.selectFrom("job_quarantine").whereNotNull("quarantined_at").selectAll().execute()??[]}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return[]}throw err}}
|
|
1
|
+
import{hash}from"node:crypto";import{db}from"@stacksjs/database/runtime";import{isMissingTableError}from"./missing-table";let warnedAboutMissingTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingTable)return;warnedAboutMissingTable=!0;console.warn("[queue/poison] job_quarantine table missing - poison detection disabled. Run migrations to enable.")}export function hashPayload(payload){let serialized;try{serialized=typeof payload==="string"?payload:JSON.stringify(payload??null)}catch{serialized=String(payload)}return hash("sha256",serialized,"hex").slice(0,32)}export async function recordFailureForPoison(jobName,payload,config={}){const maxFailures=config.maxFailures??5,windowMinutes=config.windowMinutes??60,payloadHash=hashPayload(payload),now=new Date,nowStr=now.toISOString().slice(0,19).replace("T"," ");try{const existing=await db.selectFrom("job_quarantine").where("job_name","=",jobName).where("payload_hash","=",payloadHash).selectAll().executeTakeFirst();if(!existing){await db.insertInto("job_quarantine").values({job_name:jobName,payload_hash:payloadHash,failure_count:1,window_start:nowStr,quarantined_at:null}).execute();return!1}if(existing.quarantined_at)return!0;const windowStartMs=Date.parse(existing.window_start.replace(" ","T")+"Z"),ageMs=now.getTime()-windowStartMs,windowMs=windowMinutes*60*1000;if(Number.isFinite(windowStartMs)&&ageMs>windowMs){await db.updateTable("job_quarantine").set({failure_count:1,window_start:nowStr}).where("id","=",existing.id).execute();return!1}const newCount=existing.failure_count+1;if(newCount>=maxFailures){await db.updateTable("job_quarantine").set({failure_count:newCount,quarantined_at:nowStr}).where("id","=",existing.id).execute();return!0}await db.updateTable("job_quarantine").set({failure_count:newCount}).where("id","=",existing.id).execute();return!1}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function isQuarantined(jobName,payload){const payloadHash=hashPayload(payload);try{const row=await db.selectFrom("job_quarantine").where("job_name","=",jobName).where("payload_hash","in",[payloadHash,"*"]).whereNotNull("quarantined_at").select(["id"]).executeTakeFirst();return Boolean(row)}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function quarantineJob(jobName,payload){const payloadHash=payload===void 0?"*":hashPayload(payload),nowStr=new Date().toISOString().slice(0,19).replace("T"," ");try{const existing=await db.selectFrom("job_quarantine").where("job_name","=",jobName).where("payload_hash","=",payloadHash).select(["id"]).executeTakeFirst();if(existing)await db.updateTable("job_quarantine").set({quarantined_at:nowStr}).where("id","=",existing.id).execute();else await db.insertInto("job_quarantine").values({job_name:jobName,payload_hash:payloadHash,failure_count:0,window_start:nowStr,quarantined_at:nowStr}).execute()}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return}throw err}}export async function unquarantineJob(jobName){try{await db.deleteFrom("job_quarantine").where("job_name","=",jobName).execute()}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return}throw err}}export async function listQuarantined(){try{return await db.selectFrom("job_quarantine").whereNotNull("quarantined_at").selectAll().execute()??[]}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return[]}throw err}}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{log}from"@stacksjs/logging";let ensured=!1;async function ensureTable(){if(ensured)return!0;try{const{db}=await import("@stacksjs/database");await db.unsafe("CREATE TABLE IF NOT EXISTS scheduled_job_runs (job_name VARCHAR(255) PRIMARY KEY, last_run_at VARCHAR(64) NOT NULL)").execute();ensured=!0;return!0}catch(err){log.debug(`[scheduler] run-marker persistence unavailable, using in-memory lastRun: ${err instanceof Error?err.message:String(err)}`);return!1}}export async function loadPersistedLastRun(jobName){if(!await ensureTable())return null;try{const{db}=await import("@stacksjs/database"),row=await db.selectFrom("scheduled_job_runs").where("job_name","=",jobName).select(["last_run_at"]).executeTakeFirst();if(!row?.last_run_at)return null;const d=new Date(row.last_run_at);return Number.isNaN(d.getTime())?null:d}catch{return null}}export async function persistLastRun(jobName,when){if(!await ensureTable())return;try{const{db}=await import("@stacksjs/database"),iso=when.toISOString();await db.deleteFrom("scheduled_job_runs").where("job_name","=",jobName).execute();await db.insertInto("scheduled_job_runs").values({job_name:jobName,last_run_at:iso}).execute()}catch{}}export function overlapPayloadPattern(jobName){return`%"jobName":"${jobName.replace(/[\\%_]/g,(character)=>`\\${character}`)}"%`}export async function hasUnfinishedRun(jobName){try{const{db}=await import("@stacksjs/database"),raw=await db.unsafe("SELECT 1 AS present FROM jobs WHERE payload LIKE ? ESCAPE '\\' LIMIT 1",[overlapPayloadPattern(jobName)]).execute(),list=Array.isArray(raw)?raw:raw?.rows??[];return Array.isArray(list)&&list.length>0}catch(err){log.debug(`[scheduler] overlap check unavailable, dispatching anyway: ${err instanceof Error?err.message:String(err)}`);return!1}}export function __resetSchedulerPersistenceForTests(){ensured=!1}
|
|
1
|
+
import{log}from"@stacksjs/logging";let ensured=!1;async function ensureTable(){if(ensured)return!0;try{const{db}=await import("@stacksjs/database/runtime");await db.unsafe("CREATE TABLE IF NOT EXISTS scheduled_job_runs (job_name VARCHAR(255) PRIMARY KEY, last_run_at VARCHAR(64) NOT NULL)").execute();ensured=!0;return!0}catch(err){log.debug(`[scheduler] run-marker persistence unavailable, using in-memory lastRun: ${err instanceof Error?err.message:String(err)}`);return!1}}export async function loadPersistedLastRun(jobName){if(!await ensureTable())return null;try{const{db}=await import("@stacksjs/database/runtime"),row=await db.selectFrom("scheduled_job_runs").where("job_name","=",jobName).select(["last_run_at"]).executeTakeFirst();if(!row?.last_run_at)return null;const d=new Date(row.last_run_at);return Number.isNaN(d.getTime())?null:d}catch{return null}}export async function persistLastRun(jobName,when){if(!await ensureTable())return;try{const{db}=await import("@stacksjs/database/runtime"),iso=when.toISOString();await db.deleteFrom("scheduled_job_runs").where("job_name","=",jobName).execute();await db.insertInto("scheduled_job_runs").values({job_name:jobName,last_run_at:iso}).execute()}catch{}}export function overlapPayloadPattern(jobName){return`%"jobName":"${jobName.replace(/[\\%_]/g,(character)=>`\\${character}`)}"%`}export async function hasUnfinishedRun(jobName){try{const{db}=await import("@stacksjs/database/runtime"),raw=await db.unsafe("SELECT 1 AS present FROM jobs WHERE payload LIKE ? ESCAPE '\\' LIMIT 1",[overlapPayloadPattern(jobName)]).execute(),list=Array.isArray(raw)?raw:raw?.rows??[];return Array.isArray(list)&&list.length>0}catch(err){log.debug(`[scheduler] overlap check unavailable, dispatching anyway: ${err instanceof Error?err.message:String(err)}`);return!1}}export function __resetSchedulerPersistenceForTests(){ensured=!1}
|
package/dist/utils.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{mutationCount}from"@stacksjs/database";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");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)?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.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"),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"),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"),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");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");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");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"),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"),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)}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))}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/queue",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.74.
|
|
5
|
+
"version": "0.74.48",
|
|
6
6
|
"description": "The Stacks Queue system powered by bun-queue.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -63,22 +63,22 @@
|
|
|
63
63
|
"prepublishOnly": "bun run build"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@stacksjs/action-runner": "0.74.
|
|
66
|
+
"@stacksjs/action-runner": "0.74.48",
|
|
67
67
|
"@stacksjs/bun-queue": "^0.1.8",
|
|
68
|
-
"@stacksjs/cache": "0.74.
|
|
69
|
-
"@stacksjs/config": "0.74.
|
|
70
|
-
"@stacksjs/database": "0.74.
|
|
71
|
-
"@stacksjs/env": "0.74.
|
|
72
|
-
"@stacksjs/error-handling": "0.74.
|
|
73
|
-
"@stacksjs/logging": "0.74.
|
|
74
|
-
"@stacksjs/path": "0.74.
|
|
68
|
+
"@stacksjs/cache": "0.74.48",
|
|
69
|
+
"@stacksjs/config": "0.74.48",
|
|
70
|
+
"@stacksjs/database": "0.74.48",
|
|
71
|
+
"@stacksjs/env": "0.74.48",
|
|
72
|
+
"@stacksjs/error-handling": "0.74.48",
|
|
73
|
+
"@stacksjs/logging": "0.74.48",
|
|
74
|
+
"@stacksjs/path": "0.74.48"
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
77
|
"better-dx": "^0.2.24"
|
|
78
78
|
},
|
|
79
79
|
"peerDependencies": {
|
|
80
|
-
"@stacksjs/email": "0.74.
|
|
81
|
-
"@stacksjs/router": "0.74.
|
|
80
|
+
"@stacksjs/email": "0.74.48",
|
|
81
|
+
"@stacksjs/router": "0.74.48"
|
|
82
82
|
},
|
|
83
83
|
"peerDependenciesMeta": {
|
|
84
84
|
"@stacksjs/email": {
|