@stacksjs/queue 0.74.36 → 0.74.37
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-runner.js +1 -0
- package/dist/action.js +1 -0
- package/dist/batch.js +1 -0
- package/dist/bun-queue.d.ts +84 -0
- package/dist/bun-queue.js +1 -0
- package/dist/circuit-breaker.js +1 -0
- package/dist/dead-letter.js +1 -0
- package/dist/discovery.js +1 -0
- package/dist/drivers/redis.d.ts +92 -0
- package/dist/drivers/redis.js +1 -0
- package/dist/envelope.js +1 -0
- package/dist/events.js +1 -0
- package/dist/health.js +1 -0
- package/dist/idempotency.js +1 -0
- package/dist/index.d.ts +21 -5
- package/dist/index.js +1 -31
- package/dist/job-progress.js +1 -0
- package/dist/job.js +1 -0
- package/dist/missing-table.js +1 -0
- package/dist/notifications.js +30 -0
- package/dist/poison.js +1 -0
- package/dist/scheduler-persistence.js +1 -0
- package/dist/scheduler.js +1 -0
- package/dist/testing.js +1 -0
- package/dist/utils.js +1 -0
- package/dist/worker.js +1 -0
- package/package.json +11 -11
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export{getActionRunner,runNamedAction,setActionRunner}from"@stacksjs/action-runner";
|
package/dist/action.js
ADDED
|
@@ -0,0 +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})}}
|
package/dist/batch.js
ADDED
|
@@ -0,0 +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}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-exports from bun-queue
|
|
3
|
+
*
|
|
4
|
+
* These are separated from the main barrel to avoid blocking the core
|
|
5
|
+
* @stacksjs/queue exports when bun-queue has unresolvable dependencies.
|
|
6
|
+
* Import from '@stacksjs/queue/bun-queue' when you need these features.
|
|
7
|
+
*/
|
|
8
|
+
export {
|
|
9
|
+
// Queue and Job classes
|
|
10
|
+
Queue,
|
|
11
|
+
Job as BunJob,
|
|
12
|
+
JobBase,
|
|
13
|
+
Worker,
|
|
14
|
+
QueueManager,
|
|
15
|
+
getQueueManager,
|
|
16
|
+
setQueueManager,
|
|
17
|
+
closeQueueManager,
|
|
18
|
+
|
|
19
|
+
// Dispatch functions
|
|
20
|
+
dispatch,
|
|
21
|
+
dispatchSync,
|
|
22
|
+
dispatchIf,
|
|
23
|
+
dispatchUnless,
|
|
24
|
+
dispatchAfter,
|
|
25
|
+
dispatchChain,
|
|
26
|
+
dispatchFunction,
|
|
27
|
+
chain,
|
|
28
|
+
batch,
|
|
29
|
+
|
|
30
|
+
// Processing
|
|
31
|
+
JobProcessor,
|
|
32
|
+
createJobProcessor,
|
|
33
|
+
getGlobalJobProcessor,
|
|
34
|
+
setGlobalJobProcessor,
|
|
35
|
+
|
|
36
|
+
// Batch processing
|
|
37
|
+
BatchProcessor,
|
|
38
|
+
|
|
39
|
+
// Priority queue
|
|
40
|
+
PriorityQueue,
|
|
41
|
+
|
|
42
|
+
// Dead letter queue
|
|
43
|
+
DeadLetterQueue,
|
|
44
|
+
|
|
45
|
+
// Rate limiting
|
|
46
|
+
RateLimiter,
|
|
47
|
+
|
|
48
|
+
// Distributed locking
|
|
49
|
+
DistributedLock,
|
|
50
|
+
|
|
51
|
+
// Leader election (horizontal scaling)
|
|
52
|
+
LeaderElection,
|
|
53
|
+
|
|
54
|
+
// Work coordination
|
|
55
|
+
WorkCoordinator,
|
|
56
|
+
|
|
57
|
+
// Queue groups
|
|
58
|
+
QueueGroup,
|
|
59
|
+
|
|
60
|
+
// Observable
|
|
61
|
+
QueueObservable,
|
|
62
|
+
|
|
63
|
+
// Events
|
|
64
|
+
type JobEvents,
|
|
65
|
+
|
|
66
|
+
// Middleware
|
|
67
|
+
middleware,
|
|
68
|
+
RateLimitMiddleware,
|
|
69
|
+
UniqueJobMiddleware,
|
|
70
|
+
ThrottleMiddleware,
|
|
71
|
+
WithoutOverlappingMiddleware,
|
|
72
|
+
SkipIfMiddleware,
|
|
73
|
+
FailureMiddleware,
|
|
74
|
+
|
|
75
|
+
// Worker management
|
|
76
|
+
QueueWorker,
|
|
77
|
+
WorkerManager,
|
|
78
|
+
type WorkerOptions,
|
|
79
|
+
|
|
80
|
+
// Failed job management
|
|
81
|
+
FailedJobManager,
|
|
82
|
+
type FailedJob,
|
|
83
|
+
|
|
84
|
+
} from '@stacksjs/bun-queue';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export{Queue,Job as BunJob,JobBase,Worker,QueueManager,getQueueManager,setQueueManager,closeQueueManager,dispatch,dispatchSync,dispatchIf,dispatchUnless,dispatchAfter,dispatchChain,dispatchFunction,chain,batch,JobProcessor,createJobProcessor,getGlobalJobProcessor,setGlobalJobProcessor,BatchProcessor,PriorityQueue,DeadLetterQueue,RateLimiter,DistributedLock,LeaderElection,WorkCoordinator,QueueGroup,QueueObservable,middleware,RateLimitMiddleware,UniqueJobMiddleware,ThrottleMiddleware,WithoutOverlappingMiddleware,SkipIfMiddleware,FailureMiddleware,QueueWorker,WorkerManager,FailedJobManager}from"@stacksjs/bun-queue";
|
|
@@ -0,0 +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}}
|
|
@@ -0,0 +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/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 Number(result?.numDeletedRows??result?.[0]?.numDeletedRows??result?.affectedRows??0)}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return 0}throw err}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{log}from"@stacksjs/logging";import*as p from"@stacksjs/path";class JobRegistry{jobs=new Map;initialized=!1;register(job){this.jobs.set(job.name,job);log.debug(`Registered job: ${job.name} (${job.type})`)}get(name){return this.jobs.get(name)}all(){return Array.from(this.jobs.values())}byQueue(queue){return this.all().filter((job)=>job.config.queue===queue)}scheduled(){return this.all().filter((job)=>job.config.rate||job.config.schedule)}has(name){return this.jobs.has(name)}clear(){this.jobs.clear();this.initialized=!1}setInitialized(value){this.initialized=value}isInitialized(){return this.initialized}}export const jobRegistry=new JobRegistry;export async function discoverJobs(jobsPath){const basePath=jobsPath||p.userJobsPath(),discovered=[];try{const glob=new Bun.Glob("**/*.{ts,js}"),scanOptions={cwd:basePath,onlyFiles:!0,absolute:!0};for await(const file of glob.scan(scanOptions)){if(file.includes(".test.")||file.includes(".spec.")||file.endsWith("index.ts")||file.endsWith("index.js"))continue;try{const job=await loadJob(file);if(job){discovered.push(job);jobRegistry.register(job)}}catch(error){log.warn(`Failed to load job from ${file}: ${error.message}`)}}jobRegistry.setInitialized(!0);log.info(`Discovered ${discovered.length} jobs from ${basePath}`);return discovered}catch(error){log.error(`Failed to discover jobs: ${error.message}`);return[]}}async function loadJob(filePath){try{const module=await import(filePath),fileName=filePath.split("/").pop()?.replace(/\.(ts|js)$/,"")||"UnknownJob";if(module.default&&typeof module.default==="function"){const JobClass=module.default;if(typeof JobClass.handle==="function"||typeof JobClass.prototype?.handle==="function"){const config=JobClass.config||{};return{name:config.name||fileName,path:filePath,config:{name:config.name||fileName,description:config.description,queue:config.queue||"default",tries:config.retries||3,timeout:config.timeout,withoutOverlapping:config.withoutOverlapping,schedule:config.schedule,retryAfter:config.retryAfter},type:"class",module:JobClass}}}if(module.default&&typeof module.default==="object"){const jobConfig=module.default;if(typeof jobConfig.handle==="function"||typeof jobConfig.action==="string")return{name:jobConfig.name||fileName,path:filePath,config:{name:jobConfig.name||fileName,description:jobConfig.description,queue:jobConfig.queue||"default",tries:jobConfig.tries||3,backoff:jobConfig.backoff,rate:jobConfig.rate,timeout:jobConfig.timeout||jobConfig.timeOut,backoffConfig:jobConfig.backoffConfig},type:"function",module:jobConfig}}return null}catch(error){log.debug(`Could not load job from ${filePath}: ${error.message}`);return null}}export function getJob(name){return jobRegistry.get(name)}export function getAllJobs(){return jobRegistry.all()}export function getScheduledJobs(){return jobRegistry.scheduled()}export async function executeJob(name,payload){const job=jobRegistry.get(name);if(!job)throw Error(`Job "${name}" not found. Did you run discoverJobs()?`);try{if(job.type==="class"){if(typeof job.module.handle==="function")return await job.module.handle(payload);return await new job.module().handle(payload)}else{if(typeof job.module.handle==="function")return await job.module.handle(payload);throw Error(`Job "${name}" does not have a handle method`)}}catch(error){log.error(`Failed to execute job "${name}": ${error.message}`);throw error}}export function toJobOptions(job){const config=job.config;return{name:config.name,queue:config.queue,tries:config.tries,backoff:config.backoff,timeout:config.timeout,backoffConfig:config.backoffConfig,rate:config.rate}}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Queue as BunQueue, type Job as BunJob, batch, chain, dispatch, dispatchAfter, dispatchSync, getQueueManager, QueueManager, setQueueManager } from '@stacksjs/bun-queue';
|
|
2
|
+
import type { Dispatchable, QueueOption, RedisConnectionConfig } from '@stacksjs/types';
|
|
3
|
+
export type { BunJob };
|
|
4
|
+
/**
|
|
5
|
+
* Create a Redis queue dispatcher
|
|
6
|
+
*/
|
|
7
|
+
export declare function createRedisDispatcher<T = any>(queueName: string, config: RedisConnectionConfig): (data: T, options?: QueueOption) => Promise<BunJob<T>>;
|
|
8
|
+
/**
|
|
9
|
+
* Queue events interface
|
|
10
|
+
*/
|
|
11
|
+
declare interface QueueEvents {
|
|
12
|
+
jobAdded: (jobId: string, name: string) => void
|
|
13
|
+
jobCompleted: (jobId: string, result: any) => void
|
|
14
|
+
jobFailed: (jobId: string, error: Error) => void
|
|
15
|
+
jobProgress: (jobId: string, progress: number) => void
|
|
16
|
+
jobActive: (jobId: string) => void
|
|
17
|
+
jobStalled: (jobId: string) => void
|
|
18
|
+
jobDelayed: (jobId: string, delay: number) => void
|
|
19
|
+
ready: () => void
|
|
20
|
+
error: (error: Error) => void
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Redis Queue Driver class
|
|
24
|
+
*/
|
|
25
|
+
export declare class RedisQueue<T = any> {
|
|
26
|
+
constructor(name: string, config: RedisConnectionConfig);
|
|
27
|
+
add(data: T, options?: QueueOption): Promise<BunJob<T>>;
|
|
28
|
+
process(concurrency: number, handler: (job: BunJob<T>) => Promise<any>): void;
|
|
29
|
+
getJob(jobId: string): Promise<BunJob<T> | null>;
|
|
30
|
+
getJobs(status: 'waiting' | 'active' | 'completed' | 'failed' | 'delayed'): Promise<BunJob<T>[]>;
|
|
31
|
+
getJobCounts(): Promise<Record<string, number>>;
|
|
32
|
+
removeJob(jobId: string): Promise<void>;
|
|
33
|
+
pause(): Promise<void>;
|
|
34
|
+
resume(): Promise<void>;
|
|
35
|
+
empty(): Promise<void>;
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
getMetrics(): Promise<any>;
|
|
38
|
+
ping(): Promise<boolean>;
|
|
39
|
+
scheduleCron(options: {
|
|
40
|
+
cron: string
|
|
41
|
+
data: T
|
|
42
|
+
tz?: string
|
|
43
|
+
name?: string
|
|
44
|
+
}): Promise<string>;
|
|
45
|
+
unscheduleCron(jobId: string): Promise<boolean>;
|
|
46
|
+
getDeadLetterJobs(): Promise<BunJob<T>[]>;
|
|
47
|
+
republishDeadLetterJob(jobId: string): Promise<BunJob<T> | null>;
|
|
48
|
+
clearDeadLetterQueue(): Promise<void>;
|
|
49
|
+
bulkRemove(jobIds: string[]): Promise<number>;
|
|
50
|
+
getClusterInfo(): Promise<Record<string, any> | null>;
|
|
51
|
+
isLeader(): boolean;
|
|
52
|
+
getQueue(): BunQueue<T>;
|
|
53
|
+
on<E extends keyof QueueEvents>(event: E, handler: QueueEvents[E]): void;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Queue Manager for multiple queue connections
|
|
57
|
+
*/
|
|
58
|
+
export declare class StacksQueueManager {
|
|
59
|
+
constructor(config: Record<string, RedisConnectionConfig>);
|
|
60
|
+
queue<T = any>(name?: string): RedisQueue<T>;
|
|
61
|
+
setDefaultConnection(name: string): void;
|
|
62
|
+
closeAll(): Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Dispatchable job wrapper for Redis queue
|
|
66
|
+
*/
|
|
67
|
+
export declare class RedisJob<T = any> implements Dispatchable {
|
|
68
|
+
protected options: QueueOption;
|
|
69
|
+
protected data: T;
|
|
70
|
+
constructor(queueName: string, config: RedisConnectionConfig, data: T);
|
|
71
|
+
dispatch(): Promise<void>;
|
|
72
|
+
dispatchNow(): Promise<void>;
|
|
73
|
+
delay(seconds: number): this;
|
|
74
|
+
afterResponse(): this;
|
|
75
|
+
chain(jobs: Dispatchable[]): this;
|
|
76
|
+
onQueue(queue: string): this;
|
|
77
|
+
priority(level: number): this;
|
|
78
|
+
tries(count: number): this;
|
|
79
|
+
timeout(seconds: number): this;
|
|
80
|
+
backoff(attempts: number[]): this;
|
|
81
|
+
}
|
|
82
|
+
// Re-export bun-queue types and utilities
|
|
83
|
+
export {
|
|
84
|
+
batch,
|
|
85
|
+
chain,
|
|
86
|
+
dispatch,
|
|
87
|
+
dispatchAfter,
|
|
88
|
+
dispatchSync,
|
|
89
|
+
getQueueManager,
|
|
90
|
+
QueueManager,
|
|
91
|
+
setQueueManager,
|
|
92
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{Queue as BunQueue,batch,chain,dispatch,dispatchAfter,dispatchSync,getQueueManager,QueueManager,setQueueManager}from"@stacksjs/bun-queue";import{log}from"@stacksjs/logging";export{batch,chain,dispatch,dispatchAfter,dispatchSync,getQueueManager,QueueManager,setQueueManager};export class RedisQueue{queue;config;isProcessing=!1;constructor(name,config){this.config=config;this.queue=new BunQueue(name,{driver:"redis",prefix:config.prefix,redis:config.redis?{url:config.redis.url||this.buildRedisUrl(config.redis)}:void 0,defaultJobOptions:config.defaultJobOptions?{delay:config.defaultJobOptions.delay,attempts:config.defaultJobOptions.attempts,backoff:config.defaultJobOptions.backoff,removeOnComplete:config.defaultJobOptions.removeOnComplete,removeOnFail:config.defaultJobOptions.removeOnFail,priority:config.defaultJobOptions.priority,lifo:config.defaultJobOptions.lifo,timeout:config.defaultJobOptions.timeout,jobId:config.defaultJobOptions.jobId,dependsOn:config.defaultJobOptions.dependsOn,keepJobs:config.defaultJobOptions.keepJobs,deadLetter:config.defaultJobOptions.deadLetter}:void 0,limiter:config.limiter,metrics:config.metrics,stalledJobCheckInterval:config.stalledJobCheckInterval,maxStalledJobRetries:config.maxStalledJobRetries,distributedLock:config.distributedLock,defaultDeadLetterOptions:config.defaultDeadLetterOptions,horizontalScaling:config.horizontalScaling,logLevel:config.logLevel});log.debug(`Redis queue "${name}" initialized`)}buildRedisUrl(redis){const host=redis.host||"localhost",port=redis.port||6379,passwordSegment=redis.password?`:${encodeURIComponent(redis.password)}@`:"",db=Number.isFinite(redis.db)&&redis.db>=0?redis.db:0;return`redis://${passwordSegment}${host}:${port}/${db}`}async add(data,options){const jobOptions={delay:options?.delay?options.delay*1000:void 0,attempts:options?.maxTries,priority:options?.priority,timeout:options?.timeout?options.timeout*1000:void 0,backoff:Array.isArray(options?.backoff)?options.backoff.map((s)=>(Number(s)||1)*1000):typeof options?.backoff==="number"?{type:"fixed",delay:(options.backoff||1)*1000}:void 0};return this.queue.add(data,jobOptions)}process(concurrency,handler){if(this.isProcessing){log.warn("Queue is already processing");return}this.isProcessing=!0;this.queue.process(concurrency,handler);log.info(`Started processing queue with concurrency ${concurrency}`)}async getJob(jobId){return this.queue.getJob(jobId)}async getJobs(status){return this.queue.getJobs(status)}async getJobCounts(){return this.queue.getJobCounts()}async removeJob(jobId){return this.queue.removeJob(jobId)}async pause(){return this.queue.pause()}async resume(){return this.queue.resume()}async empty(){return this.queue.empty()}async close(){this.isProcessing=!1;return this.queue.close()}async getMetrics(){return this.queue.getMetrics()}async ping(){return this.queue.ping()}async scheduleCron(options){return this.queue.scheduleCron({cronExpression:options.cron,data:options.data,timezone:options.tz,jobId:options.name})}async unscheduleCron(jobId){return this.queue.unscheduleCron(jobId)}async getDeadLetterJobs(){return this.queue.getDeadLetterJobs()}async republishDeadLetterJob(jobId){return this.queue.republishDeadLetterJob(jobId)}async clearDeadLetterQueue(){return this.queue.clearDeadLetterQueue()}async bulkRemove(jobIds){return this.queue.bulkRemove(jobIds)}async getClusterInfo(){return this.queue.getClusterInfo()}isLeader(){return this.queue.isLeader()}getQueue(){return this.queue}on(event,handler){this.queue.events.on(event,handler)}}export class StacksQueueManager{config;queues=new Map;defaultConnection="default";constructor(config){this.config=config;for(const[name,connectionConfig]of Object.entries(config))if(connectionConfig.driver==="redis")this.queues.set(name,new RedisQueue(name,connectionConfig))}queue(name){const queueName=name||this.defaultConnection;let queue=this.queues.get(queueName);if(!queue){const config=this.config[queueName];if(!config)throw Error(`Queue "${queueName}" not configured`);queue=new RedisQueue(queueName,config);this.queues.set(queueName,queue)}return queue}setDefaultConnection(name){this.defaultConnection=name}async closeAll(){const closePromises=Array.from(this.queues.values()).map((q)=>q.close());await Promise.all(closePromises);this.queues.clear()}}export function createRedisDispatcher(queueName,config){const queue=new RedisQueue(queueName,config);return async(data,options)=>{return queue.add(data,options)}}export class RedisJob{data;options={};queue;constructor(queueName,config,data){this.data=data;this.queue=new RedisQueue(queueName,config)}async dispatch(){await this.queue.add(this.data,this.options)}async dispatchNow(){await this.queue.add(this.data,{...this.options,immediate:!0})}delay(seconds){this.options.delay=seconds;return this}afterResponse(){this.options.afterResponse=!0;return this}chain(jobs){this.options.chainedJobs=jobs;return this}onQueue(queue){this.options.queue=queue;return this}priority(level){this.options.priority=level;return this}tries(count){this.options.maxTries=count;return this}timeout(seconds){this.options.timeout=seconds;return this}backoff(attempts){this.options.backoff=attempts;return this}}
|
package/dist/envelope.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const JOB_ENVELOPE_VERSION=1;export function createEnvelope(jobName,payload,options,traceId){return{jobName,payload,options,envelopeVersion:JOB_ENVELOPE_VERSION,dispatchedAt:new Date().toISOString(),...traceId?{traceId}:{}}}export function serializeEnvelope(envelope){try{return JSON.stringify(envelope)}catch(err){throw serializationError(envelope,err)}}export function assertEnvelopeSerializable(envelope){serializeEnvelope(envelope)}function serializationError(envelope,cause){const seeContract="See the JSON round-trip contract in @stacksjs/queue's src/envelope.ts.";if(isBigIntFailure(cause)){let offender=null;try{offender=findBigInt(envelope,"",new Set)}catch{}const where=offender?`\`${offender.path}\` is a BigInt (${offender.value}n)`:"it holds a BigInt";return Error(`[queue] Job "${envelope.jobName}" was not dispatched: ${where}, and job envelopes travel as JSON, which cannot represent one. Convert it where you dispatch - \`String(value)\` keeps every digit, \`Number(value)\` is exact below 2^53 - and convert it back inside the job handler. ${seeContract}`,{cause})}if(isCircularFailure(cause))return Error(`[queue] Job "${envelope.jobName}" was not dispatched: its payload contains a circular reference, which JSON cannot represent. Send the ids of the things the payload points at rather than the objects themselves - that also keeps the job replayable once those objects have changed. ${seeContract}`,{cause});const detail=cause instanceof Error?cause.message:String(cause);return Error(`[queue] Job "${envelope.jobName}" was not dispatched: serializing its payload threw (${detail}). JSON rejects only two things on its own and this was neither, so the throw came from the payload - a \`toJSON()\` or a property getter. ${seeContract}`,{cause})}function isBigIntFailure(cause){return cause instanceof TypeError&&/bigint/i.test(cause.message)}function isCircularFailure(cause){return cause instanceof TypeError&&/circular|cyclic/i.test(cause.message)}function findBigInt(value,path,seen){if(typeof value==="bigint")return{path:path||"envelope",value};if(!value||typeof value!=="object")return null;if(seen.has(value))return null;seen.add(value);if(Array.isArray(value)){for(let i=0;i<value.length;i++){const hit=findBigInt(value[i],`${path}[${i}]`,seen);if(hit)return hit}return null}for(const[key,child]of Object.entries(value)){const hit=findBigInt(child,path?`${path}.${key}`:key,seen);if(hit)return hit}return null}const warned=new Set;function warnOnce(source,message){if(warned.has(source))return;warned.add(source);console.warn(message)}export function clearEnvelopeWarnings(){warned.clear()}export function parseEnvelope(raw){let obj;if(typeof raw==="string")try{obj=JSON.parse(raw)}catch{return{ok:!1,reason:"malformed",detail:"not valid JSON"}}else if(raw&&typeof raw==="object")obj=raw;else return{ok:!1,reason:"malformed",detail:`expected string or object, got ${typeof raw}`};if(obj.envelopeVersion===JOB_ENVELOPE_VERSION){if(typeof obj.jobName!=="string")return{ok:!1,reason:"missing-job-name"};return{ok:!0,envelope:{jobName:obj.jobName,payload:obj.payload,options:obj.options??void 0,envelopeVersion:JOB_ENVELOPE_VERSION,dispatchedAt:typeof obj.dispatchedAt==="string"?obj.dispatchedAt:new Date(0).toISOString(),...typeof obj.traceId==="string"&&obj.traceId?{traceId:obj.traceId}:{}},source:"v1"}}if(typeof obj.envelopeVersion==="number"&&obj.envelopeVersion>JOB_ENVELOPE_VERSION)return{ok:!1,reason:"unknown-version",detail:`envelopeVersion=${obj.envelopeVersion}, this worker speaks v${JOB_ENVELOPE_VERSION}`};if(typeof obj.jobName==="string"){warnOnce("v0-implicit","[queue/envelope] Processing pre-#1884 job envelope without envelopeVersion. In-flight jobs from before the upgrade will continue to work; new dispatches use the v1 shape automatically.");return{ok:!0,envelope:{jobName:obj.jobName,payload:obj.payload,options:obj.options??void 0,envelopeVersion:JOB_ENVELOPE_VERSION,dispatchedAt:typeof obj.dispatchedAt==="string"?obj.dispatchedAt:new Date(0).toISOString(),...typeof obj.traceId==="string"&&obj.traceId?{traceId:obj.traceId}:{}},source:"v0-implicit"}}if(typeof obj.job==="string"){warnOnce("laravel-legacy","[queue/envelope] Processing Laravel-legacy job envelope (`{ job, data }` shape). Will continue to process but the queue table contains migration-era rows - consider flushing once they drain.");return{ok:!0,envelope:{jobName:obj.job.replace(/^App\\+Jobs\\+/,"").replace(/^.*\\/,""),payload:obj.data,options:void 0,envelopeVersion:JOB_ENVELOPE_VERSION,dispatchedAt:new Date(0).toISOString()},source:"laravel-legacy"}}return{ok:!1,reason:"missing-job-name"}}
|
package/dist/events.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{log}from"@stacksjs/logging";export class QueueEvents{handlers=new Map;wildcardHandlers=new Set;listenerSubscriptions=new WeakMap;reclaim=new FinalizationRegistry((unsubscribe)=>{unsubscribe()});on(event,handler){if(!this.handlers.has(event))this.handlers.set(event,new Set);this.handlers.get(event).add(handler);return()=>{this.handlers.get(event)?.delete(handler)}}subscribeListener(listener,event,method){const ref=new WeakRef(listener),weakHandler=(payload)=>{const target=ref.deref();if(target===void 0){removeFromHandlers();return}return method.call(target,payload)},removeFromHandlers=this.on(event,weakHandler),unsubscribe=()=>{removeFromHandlers();const target=ref.deref();if(target===void 0)return;const subscriptions=this.listenerSubscriptions.get(target);if(!subscriptions)return;subscriptions.delete(unsubscribe);if(subscriptions.size===0){this.listenerSubscriptions.delete(target);this.reclaim.unregister(target)}};let owned=this.listenerSubscriptions.get(listener);if(!owned){owned=new Set;this.listenerSubscriptions.set(listener,owned)}owned.add(unsubscribe);this.reclaim.register(listener,removeFromHandlers,listener);return unsubscribe}unsubscribeListener(listener){const owned=this.listenerSubscriptions.get(listener);if(!owned)return 0;const all=Array.from(owned);for(const unsubscribe of all)unsubscribe();this.listenerSubscriptions.delete(listener);this.reclaim.unregister(listener);return all.length}listenerCount(event){if(event==="*")return this.wildcardHandlers.size;return this.handlers.get(event)?.size??0}onAny(handler){this.wildcardHandlers.add(handler);return()=>{this.wildcardHandlers.delete(handler)}}once(event,handler){const wrappedHandler=async(payload)=>{this.handlers.get(event)?.delete(wrappedHandler);await handler(payload)};return this.on(event,wrappedHandler)}async emit(event,payload){const fullPayload={...payload,timestamp:Date.now()};this.logEvent(event,fullPayload);const handlers=this.handlers.get(event);if(handlers)for(const handler of handlers)try{await handler(fullPayload)}catch(error){log.error(`Error in queue event handler for ${event}:`,error)}for(const handler of this.wildcardHandlers)try{await handler(event,fullPayload)}catch(error){log.error("Error in wildcard queue event handler:",error)}}logEvent(event,payload){const jobInfo=payload.jobId?`[${payload.jobId}]`:"",queueInfo=payload.queueName?`on ${payload.queueName}`:"";switch(event){case"job:added":log.debug(`Job added ${jobInfo} ${queueInfo}`);break;case"job:processing":log.debug(`Job processing ${jobInfo} ${queueInfo}`);break;case"job:completed":log.info(`Job completed ${jobInfo} ${queueInfo} in ${payload.duration}ms`);break;case"job:failed":log.error(`Job failed ${jobInfo} ${queueInfo}:`,payload.error);break;case"job:retrying":log.warn(`Job retrying ${jobInfo} ${queueInfo} (attempt ${payload.attemptsMade})`);break;case"job:stalled":log.warn(`Job stalled ${jobInfo} ${queueInfo}`);break;case"queue:error":log.error(`Queue error ${queueInfo}:`,payload.error);break}}off(event){this.handlers.delete(event)}removeAllListeners(){this.handlers.clear();this.wildcardHandlers.clear();this.listenerSubscriptions=new WeakMap}}let globalEvents=null;export function getQueueEvents(){if(!globalEvents)globalEvents=new QueueEvents;return globalEvents}export function onQueueEvent(event,handler){const events=getQueueEvents();if(event==="*")return events.onAny(handler);return events.on(event,handler)}export function emitQueueEvent(event,payload){return getQueueEvents().emit(event,payload)}export function withEvents(queueName,handler){return async(...args)=>{const jobId=args[0]?.id||"unknown",startTime=Date.now();await emitQueueEvent("job:processing",{jobId,queueName,data:args[0]?.data});try{const result=await handler(...args);await emitQueueEvent("job:completed",{jobId,queueName,result,duration:Date.now()-startTime});return result}catch(error){await emitQueueEvent("job:failed",{jobId,queueName,error,duration:Date.now()-startTime});throw error}}}export function OnQueueEvent(event){return function(value,context){if(typeof context!=="object"||context===null)throw TypeError(`@OnQueueEvent('${event}') requires standard (TC39) decorators. Legacy decorators ('experimentalDecorators: true') give a method decorator no construction-time hook, so the handler cannot be bound to an instance.`);if(context.kind!=="method")throw TypeError(`@OnQueueEvent('${event}') can only decorate a class method, but it was applied to a ${context.kind}${context.name===void 0?"":` ('${String(context.name)}')`}. Move the handler into a method, or call onQueueEvent('${event}', handler) directly.`);if(context.static)throw TypeError(`@OnQueueEvent('${event}') cannot decorate the static method '${String(context.name)}': a static method has no instance, so it would subscribe at class-definition time and stay subscribed for the life of the process, even if the class is never used. Use an instance method, or subscribe explicitly with onQueueEvent('${event}', MyClass.${String(context.name)}).`);context.addInitializer(function(){getQueueEvents().subscribeListener(this,event,value)});return value}}export class QueueMetrics{jobCounts={added:0,completed:0,failed:0,processing:0};completions=[];errors=[];unsubscribe=[];constructor(){this.setupListeners()}setupListeners(){const events=getQueueEvents();this.unsubscribe.push(events.on("job:added",()=>{this.jobCounts.added++}),events.on("job:processing",()=>{this.jobCounts.processing++}),events.on("job:completed",(payload)=>{this.jobCounts.completed++;this.jobCounts.processing=Math.max(0,this.jobCounts.processing-1);const duration=payload.duration||0;this.completions.push({timestamp:Date.now(),duration});if(this.completions.length>1000)this.completions.shift()}),events.on("job:failed",(payload)=>{this.jobCounts.failed++;this.jobCounts.processing=Math.max(0,this.jobCounts.processing-1);if(payload.error){this.errors.push({error:payload.error,timestamp:Date.now()});if(this.errors.length>100)this.errors.shift()}}))}getThroughputPerMinute(){const oneMinuteAgo=Date.now()-60000;return this.completions.filter((c)=>c.timestamp>=oneMinuteAgo).length}getAverageProcessingTime(){const oneMinuteAgo=Date.now()-60000,recent=this.completions.filter((c)=>c.timestamp>=oneMinuteAgo);if(recent.length===0)return 0;return recent.reduce((sum,c)=>sum+c.duration,0)/recent.length}getMetrics(){return{counts:{...this.jobCounts},averageDuration:this.getAverageProcessingTime(),recentErrors:[...this.errors],throughputPerMinute:this.getThroughputPerMinute()}}reset(){this.jobCounts={added:0,completed:0,failed:0,processing:0};this.completions=[];this.errors=[]}stop(){this.unsubscribe.forEach((fn)=>fn());this.unsubscribe=[]}}let globalMetrics=null;export function getGlobalMetrics(){if(!globalMetrics)globalMetrics=new QueueMetrics;return globalMetrics}class WorkerTracker{workers=new Map;register(id,queue){this.workers.set(id,{id,status:"idle",queue,processedCount:0,failedCount:0,lastActivityAt:new Date().toISOString(),startedAt:new Date().toISOString()})}markActive(id){const w=this.workers.get(id);if(w){w.status="active";w.lastActivityAt=new Date().toISOString()}}markIdle(id){const w=this.workers.get(id);if(w){w.status="idle";w.lastActivityAt=new Date().toISOString()}}recordCompletion(id){const w=this.workers.get(id);if(w){w.processedCount++;w.lastActivityAt=new Date().toISOString()}}recordFailure(id){const w=this.workers.get(id);if(w){w.failedCount++;w.lastActivityAt=new Date().toISOString()}}unregister(id){const w=this.workers.get(id);if(w)w.status="stopped"}getAll(){return Array.from(this.workers.values())}clear(){this.workers.clear()}}const workerTracker=new WorkerTracker;export function getWorkerTracker(){return workerTracker}
|
package/dist/health.js
ADDED
|
@@ -0,0 +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"}
|
|
@@ -0,0 +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{}}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,18 +10,34 @@ export type { JobEnvelope, JobEnvelopeOptions, ParsedEnvelope } from './envelope
|
|
|
10
10
|
// =============================================================================
|
|
11
11
|
// Redis queue driver
|
|
12
12
|
// =============================================================================
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
13
|
+
// Lazily loaded, so an app that does not use Redis does not pay for the driver
|
|
14
|
+
// at import time. Reach it through `getRedisQueue()` rather than a subpath: the
|
|
15
|
+
// package has one public entry, and the driver moving files should not be a
|
|
16
|
+
// breaking change for anyone.
|
|
16
17
|
export declare function getRedisQueue(): Promise<void>;
|
|
17
18
|
/**
|
|
18
19
|
* @stacksjs/queue
|
|
19
20
|
*
|
|
20
21
|
* A thin wrapper around bun-queue that integrates with Stacks conventions.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
22
|
+
*
|
|
23
|
+
* Everything is on this one entry, including `Queue`, `Worker`, `dispatch` and
|
|
24
|
+
* the middleware classes. Those used to sit behind a `@stacksjs/queue/bun-queue`
|
|
25
|
+
* subpath, on the theory that a bun-queue with unresolvable dependencies would
|
|
26
|
+
* otherwise take the whole barrel down with it. It never did - bun-queue is a
|
|
27
|
+
* declared dependency of this package - and the split cost more than it saved:
|
|
28
|
+
* the subpath had no `dist` file for its entire life, so the documented import
|
|
29
|
+
* typechecked and then threw (stacksjs/stacks#2581). One entry cannot drift
|
|
30
|
+
* from itself.
|
|
23
31
|
*/
|
|
24
32
|
// =============================================================================
|
|
33
|
+
// bun-queue: Queue, Worker, dispatch, middleware, batching, rate limiting
|
|
34
|
+
// =============================================================================
|
|
35
|
+
// `Job` is deliberately NOT among these. The name belongs to the Stacks job
|
|
36
|
+
// class below - the one `app/Jobs/*.ts` files construct - and bun-queue's is
|
|
37
|
+
// re-exported as `BunJob`. Two different `Job`s under one name would resolve
|
|
38
|
+
// to whichever export came last.
|
|
39
|
+
export * from './bun-queue';
|
|
40
|
+
// =============================================================================
|
|
25
41
|
// Stacks Job class for file-based jobs (app/Jobs/*.ts)
|
|
26
42
|
// =============================================================================
|
|
27
43
|
export { Job } from './action';
|
package/dist/index.js
CHANGED
|
@@ -1,31 +1 @@
|
|
|
1
|
-
|
|
2
|
-
var Cn=Object.defineProperty;var qn=(e)=>e;function Dn(e,t){this[e]=qn.bind(null,t)}var $n=(e,t)=>{for(var n in t)Cn(e,n,{get:t[n],enumerable:!0,configurable:!0,set:Dn.bind(t,n)})};var q=(e,t,n)=>()=>{if(e)try{t=e(e=0)}catch(r){n=[r]}if(n)throw n[0];return t};function A(e,t,n,r){return{jobName:e,payload:t,options:n,envelopeVersion:1,dispatchedAt:new Date().toISOString(),...r?{traceId:r}:{}}}function I(e){try{return JSON.stringify(e)}catch(t){throw An(e,t)}}function le(e){I(e)}function An(e,t){if(On(t)){let i=null;try{i=Ae(e,"",new Set)}catch{}let o=i?`\`${i.path}\` is a BigInt (${i.value}n)`:"it holds a BigInt";return Error(`[queue] Job "${e.jobName}" was not dispatched: ${o}, and job envelopes travel as JSON, which cannot represent one. Convert it where you dispatch - \`String(value)\` keeps every digit, \`Number(value)\` is exact below 2^53 - and convert it back inside the job handler. See the JSON round-trip contract in @stacksjs/queue's src/envelope.ts.`,{cause:t})}if(Fn(t))return Error(`[queue] Job "${e.jobName}" was not dispatched: its payload contains a circular reference, which JSON cannot represent. Send the ids of the things the payload points at rather than the objects themselves - that also keeps the job replayable once those objects have changed. See the JSON round-trip contract in @stacksjs/queue's src/envelope.ts.`,{cause:t});let r=t instanceof Error?t.message:String(t);return Error(`[queue] Job "${e.jobName}" was not dispatched: serializing its payload threw (${r}). JSON rejects only two things on its own and this was neither, so the throw came from the payload - a \`toJSON()\` or a property getter. See the JSON round-trip contract in @stacksjs/queue's src/envelope.ts.`,{cause:t})}function On(e){return e instanceof TypeError&&/bigint/i.test(e.message)}function Fn(e){return e instanceof TypeError&&/circular|cyclic/i.test(e.message)}function Ae(e,t,n){if(typeof e==="bigint")return{path:t||"envelope",value:e};if(!e||typeof e!=="object")return null;if(n.has(e))return null;if(n.add(e),Array.isArray(e)){for(let r=0;r<e.length;r++){let i=Ae(e[r],`${t}[${r}]`,n);if(i)return i}return null}for(let[r,i]of Object.entries(e)){let o=Ae(i,t?`${t}.${r}`:r,n);if(o)return o}return null}function xt(e,t){if(Oe.has(e))return;Oe.add(e),console.warn(t)}function In(){Oe.clear()}function Je(e){let t;if(typeof e==="string")try{t=JSON.parse(e)}catch{return{ok:!1,reason:"malformed",detail:"not valid JSON"}}else if(e&&typeof e==="object")t=e;else return{ok:!1,reason:"malformed",detail:`expected string or object, got ${typeof e}`};if(t.envelopeVersion===1){if(typeof t.jobName!=="string")return{ok:!1,reason:"missing-job-name"};return{ok:!0,envelope:{jobName:t.jobName,payload:t.payload,options:t.options??void 0,envelopeVersion:1,dispatchedAt:typeof t.dispatchedAt==="string"?t.dispatchedAt:new Date(0).toISOString(),...typeof t.traceId==="string"&&t.traceId?{traceId:t.traceId}:{}},source:"v1"}}if(typeof t.envelopeVersion==="number"&&t.envelopeVersion>1)return{ok:!1,reason:"unknown-version",detail:`envelopeVersion=${t.envelopeVersion}, this worker speaks v1`};if(typeof t.jobName==="string")return xt("v0-implicit","[queue/envelope] Processing pre-#1884 job envelope without envelopeVersion. In-flight jobs from before the upgrade will continue to work; new dispatches use the v1 shape automatically."),{ok:!0,envelope:{jobName:t.jobName,payload:t.payload,options:t.options??void 0,envelopeVersion:1,dispatchedAt:typeof t.dispatchedAt==="string"?t.dispatchedAt:new Date(0).toISOString(),...typeof t.traceId==="string"&&t.traceId?{traceId:t.traceId}:{}},source:"v0-implicit"};if(typeof t.job==="string")return xt("laravel-legacy","[queue/envelope] Processing Laravel-legacy job envelope (`{ job, data }` shape). Will continue to process but the queue table contains migration-era rows - consider flushing once they drain."),{ok:!0,envelope:{jobName:t.job.replace(/^App\\+Jobs\\+/,"").replace(/^.*\\/,""),payload:t.data,options:void 0,envelopeVersion:1,dispatchedAt:new Date(0).toISOString()},source:"laravel-legacy"};return{ok:!1,reason:"missing-job-name"}}var Nn=1,Oe;var ee=q(()=>{Oe=new Set});import{getActionRunner as Qn,runNamedAction as je,setActionRunner as Bn}from"@stacksjs/action-runner";var Pe=()=>{};var Be={};$n(Be,{QueueTester:()=>Se,createQueueTester:()=>St,expectJobToFail:()=>Tt,fake:()=>Ie,getFakeQueue:()=>jt,isFaked:()=>Pt,restore:()=>Qe,runJob:()=>Et});class Jt{dispatchedJobs=[];pushedJobs=[];processedJobs=[];failedJobs=[];dispatch(e,t,n={}){this.dispatchedJobs.push({name:e,data:t,options:n,dispatchedAt:new Date,queue:n.queue||"default"})}push(e,t,n={}){this.pushedJobs.push({name:e,data:t,options:n,dispatchedAt:new Date,queue:n.queue||"default"})}dispatched(e){if(e)return this.dispatchedJobs.filter((t)=>t.name===e);return[...this.dispatchedJobs]}pushed(e){if(e)return this.pushedJobs.filter((t)=>t.name===e);return[...this.pushedJobs]}assertDispatched(e,t){let n=this.dispatched(e);if(n.length===0)throw Error(`Expected job "${e}" to be dispatched, but it was not.`);if(t){if(n.filter(t).length===0)throw Error(`Expected job "${e}" to be dispatched matching the callback, but no matching jobs were found.`)}}assertNotDispatched(e){let t=this.dispatched(e);if(t.length>0)throw Error(`Expected job "${e}" to not be dispatched, but it was dispatched ${t.length} time(s).`)}assertDispatchedTimes(e,t){let n=this.dispatched(e);if(n.length!==t)throw Error(`Expected job "${e}" to be dispatched ${t} time(s), but it was dispatched ${n.length} time(s).`)}assertNothingDispatched(){if(this.dispatchedJobs.length>0){let e=[...new Set(this.dispatchedJobs.map((t)=>t.name))].join(", ");throw Error(`Expected no jobs to be dispatched, but found: ${e}`)}}assertPushed(e,t){let n=this.pushed(e);if(n.length===0)throw Error(`Expected job "${e}" to be pushed, but it was not.`);if(t){if(n.filter(t).length===0)throw Error(`Expected job "${e}" to be pushed matching the callback, but no matching jobs were found.`)}}assertPushedWithDelay(e,t){if(this.pushed(e).filter((i)=>i.options?.delay===t).length===0)throw Error(`Expected job "${e}" to be pushed with delay ${t}ms, but no matching jobs were found.`)}assertPushedOn(e,t){if(this.pushed(t).filter((r)=>r.queue===e).length===0)throw Error(`Expected job "${t}" to be pushed on queue "${e}", but it was not.`)}async processJob(e,t){let n=this.dispatchedJobs.find((r)=>r.name===e);if(!n)throw Error(`No dispatched job found with name "${e}"`);try{await t(n.data),this.processedJobs.push(n)}catch(r){throw this.failedJobs.push({job:n,error:r}),r}}processed(e){if(e)return this.processedJobs.filter((t)=>t.name===e);return[...this.processedJobs]}failed(e){if(e)return this.failedJobs.filter((t)=>t.job.name===e);return[...this.failedJobs]}reset(){this.dispatchedJobs=[],this.pushedJobs=[],this.processedJobs=[],this.failedJobs=[]}}function Ie(){return de=new Jt,de}function jt(){return de}function Pt(){return de!==null}function Qe(){de=null}class Se{queue;constructor(){this.queue=Ie()}dispatch(e,t,n={}){return this.queue.dispatch(e,t,n),this}push(e,t,n={}){return this.queue.push(e,t,n),this}assertDispatched(e,t){return this.queue.assertDispatched(e,t),this}assertNotDispatched(e){return this.queue.assertNotDispatched(e),this}assertDispatchedTimes(e,t){return this.queue.assertDispatchedTimes(e,t),this}assertNothingDispatched(){return this.queue.assertNothingDispatched(),this}dispatched(e){return this.queue.dispatched(e)}reset(){return this.queue.reset(),this}cleanup(){Qe()}}function St(){return new Se}async function Et(e,t){return await e.handle(t)}async function Tt(e,t,n){try{throw await e.handle(t),Error("Expected job to fail, but it succeeded")}catch(r){if(r.message==="Expected job to fail, but it succeeded")throw r;if(n){let i=r.message;if(typeof n==="string"){if(!i.includes(n))throw Error(`Expected error to contain "${n}", got "${i}"`)}else if(!n.test(i))throw Error(`Expected error to match ${n}, got "${i}"`)}return r}}var de=null;import{Queue as Mn,batch as Ii,chain as Qi,dispatch as Bi,dispatchAfter as Mi,dispatchSync as Hi,getQueueManager as Li,QueueManager as Ui,setQueueManager as Wi}from"@stacksjs/bun-queue";import{log as Me}from"@stacksjs/logging";class Q{queue;config;isProcessing=!1;constructor(e,t){this.config=t,this.queue=new Mn(e,{driver:"redis",prefix:t.prefix,redis:t.redis?{url:t.redis.url||this.buildRedisUrl(t.redis)}:void 0,defaultJobOptions:t.defaultJobOptions?{delay:t.defaultJobOptions.delay,attempts:t.defaultJobOptions.attempts,backoff:t.defaultJobOptions.backoff,removeOnComplete:t.defaultJobOptions.removeOnComplete,removeOnFail:t.defaultJobOptions.removeOnFail,priority:t.defaultJobOptions.priority,lifo:t.defaultJobOptions.lifo,timeout:t.defaultJobOptions.timeout,jobId:t.defaultJobOptions.jobId,dependsOn:t.defaultJobOptions.dependsOn,keepJobs:t.defaultJobOptions.keepJobs,deadLetter:t.defaultJobOptions.deadLetter}:void 0,limiter:t.limiter,metrics:t.metrics,stalledJobCheckInterval:t.stalledJobCheckInterval,maxStalledJobRetries:t.maxStalledJobRetries,distributedLock:t.distributedLock,defaultDeadLetterOptions:t.defaultDeadLetterOptions,horizontalScaling:t.horizontalScaling,logLevel:t.logLevel}),Me.debug(`Redis queue "${e}" initialized`)}buildRedisUrl(e){let t=e.host||"localhost",n=e.port||6379,r=e.password?`:${encodeURIComponent(e.password)}@`:"",i=Number.isFinite(e.db)&&e.db>=0?e.db:0;return`redis://${r}${t}:${n}/${i}`}async add(e,t){let n={delay:t?.delay?t.delay*1000:void 0,attempts:t?.maxTries,priority:t?.priority,timeout:t?.timeout?t.timeout*1000:void 0,backoff:Array.isArray(t?.backoff)?t.backoff.map((r)=>(Number(r)||1)*1000):typeof t?.backoff==="number"?{type:"fixed",delay:(t.backoff||1)*1000}:void 0};return this.queue.add(e,n)}process(e,t){if(this.isProcessing){Me.warn("Queue is already processing");return}this.isProcessing=!0,this.queue.process(e,t),Me.info(`Started processing queue with concurrency ${e}`)}async getJob(e){return this.queue.getJob(e)}async getJobs(e){return this.queue.getJobs(e)}async getJobCounts(){return this.queue.getJobCounts()}async removeJob(e){return this.queue.removeJob(e)}async pause(){return this.queue.pause()}async resume(){return this.queue.resume()}async empty(){return this.queue.empty()}async close(){return this.isProcessing=!1,this.queue.close()}async getMetrics(){return this.queue.getMetrics()}async ping(){return this.queue.ping()}async scheduleCron(e){return this.queue.scheduleCron({cronExpression:e.cron,data:e.data,timezone:e.tz,jobId:e.name})}async unscheduleCron(e){return this.queue.unscheduleCron(e)}async getDeadLetterJobs(){return this.queue.getDeadLetterJobs()}async republishDeadLetterJob(e){return this.queue.republishDeadLetterJob(e)}async clearDeadLetterQueue(){return this.queue.clearDeadLetterQueue()}async bulkRemove(e){return this.queue.bulkRemove(e)}async getClusterInfo(){return this.queue.getClusterInfo()}isLeader(){return this.queue.isLeader()}getQueue(){return this.queue}on(e,t){this.queue.events.on(e,t)}}var he=()=>{};function v(e){let t=e,n=t?.errno;if(typeof n==="string"&&n.toUpperCase()==="42P01")return!0;if(typeof n==="number"&&n===1146)return!0;if((typeof t?.code==="string"?t.code.toUpperCase():"")==="42P01")return!0;let i=t?.message??"";return i.includes("no such table")||i.includes("doesn't exist")||i.includes("does not exist")}import{db as te}from"@stacksjs/database";function Ee(){if($t)return;$t=!0,console.warn("[queue/dlq] dead_letter_jobs table missing - DLQ disabled. Run migrations to enable poison-message isolation.")}async function pe(e,t,n=1){let r=new Date().toISOString().slice(0,19).replace("T"," ");try{return await te.insertInto("dead_letter_jobs").values({uuid:e.uuid??crypto.randomUUID(),connection:e.connection??"database",queue:e.queue??"default",payload:e.payload??"{}",exception:e.exception??"unknown",reason:t,total_failures:n,first_failed_at:e.failed_at??r,last_failed_at:r,dead_lettered_at:r}).execute(),!0}catch(i){if(v(i))return Ee(),!1;throw i}}async function Ln(e={}){try{let t=te.selectFrom("dead_letter_jobs").selectAll();if(e.queue)t=t.where("queue","=",e.queue);if(e.reason)t=t.where("reason","=",e.reason);if(e.sinceCutoffMs){let r=new Date(e.sinceCutoffMs).toISOString().slice(0,19).replace("T"," ");t=t.where("dead_lettered_at",">=",r)}if(e.limit&&e.limit>0)t=t.limit(e.limit);return await t.execute()??[]}catch(t){if(v(t))return Ee(),[];throw t}}async function Un(e){try{let t=await te.selectFrom("dead_letter_jobs").where("id","=",e).selectAll().executeTakeFirst();if(!t)return!1;let n=Math.floor(Date.now()/1000);return await te.insertInto("jobs").values({queue:t.queue,payload:t.payload,attempts:0,reserved_at:null,available_at:n,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute(),await te.deleteFrom("dead_letter_jobs").where("id","=",e).execute(),!0}catch(t){if(v(t))return Ee(),!1;throw t}}async function Wn(e=30){try{let t=new Date(Date.now()-e*24*60*60*1000).toISOString().slice(0,19).replace("T"," "),n=await te.deleteFrom("dead_letter_jobs").where("dead_lettered_at","<",t).execute();return Number(n?.numDeletedRows??n?.[0]?.numDeletedRows??n?.affectedRows??0)}catch(t){if(v(t))return Ee(),0;throw t}}var $t=!1;var Te=()=>{};function Re(){return Nt??=import("@stacksjs/database").catch((e)=>{throw Nt=void 0,e})}function He(){if(At)return;At=!0,console.warn("[queue/idempotency] job_idempotency table missing - idempotency keys are accepted but NOT enforced. Run migrations to enable dedup.")}async function zn(e){try{let{db:t}=await Re(),n=await t.selectFrom("job_idempotency").where("idempotency_key","=",e).select(["idempotency_key"]).executeTakeFirst();return Boolean(n)}catch(t){if(v(t))return He(),!1;throw t}}async function Vn(e,t,n){try{let{db:r}=await Re();await r.insertInto("job_idempotency").values({idempotency_key:e,job_name:t,queue:n??"default",dispatched_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute()}catch(r){if(v(r)){He();return}let i=r?.message??"";if(i.includes("UNIQUE constraint")||i.includes("Duplicate entry"))return;throw r}}async function Le(e,t,n){try{let{db:r}=await Re();return await r.insertInto("job_idempotency").values({idempotency_key:e,job_name:t,queue:n??"default",dispatched_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute(),"claimed"}catch(r){if(v(r))return He(),"unenforced";let i=r?.message??"";if(i.includes("UNIQUE constraint")||i.includes("Duplicate entry"))return"duplicate";throw r}}async function Ue(e){try{let{db:t}=await Re();await t.deleteFrom("job_idempotency").where("idempotency_key","=",e).execute()}catch{}}var Nt,At=!1;var We=()=>{};import{hash as Kn}from"crypto";import{db as D}from"@stacksjs/database";function fe(){if(Ot)return;Ot=!0,console.warn("[queue/poison] job_quarantine table missing - poison detection disabled. Run migrations to enable.")}function Ce(e){let t;try{t=typeof e==="string"?e:JSON.stringify(e??null)}catch{t=String(e)}return Kn("sha256",t,"hex").slice(0,32)}async function ze(e,t,n={}){let r=n.maxFailures??5,i=n.windowMinutes??60,o=Ce(t),a=new Date,l=a.toISOString().slice(0,19).replace("T"," ");try{let s=await D.selectFrom("job_quarantine").where("job_name","=",e).where("payload_hash","=",o).selectAll().executeTakeFirst();if(!s)return await D.insertInto("job_quarantine").values({job_name:e,payload_hash:o,failure_count:1,window_start:l,quarantined_at:null}).execute(),!1;if(s.quarantined_at)return!0;let u=Date.parse(s.window_start.replace(" ","T")+"Z"),c=a.getTime()-u,d=i*60*1000;if(Number.isFinite(u)&&c>d)return await D.updateTable("job_quarantine").set({failure_count:1,window_start:l}).where("id","=",s.id).execute(),!1;let m=s.failure_count+1;if(m>=r)return await D.updateTable("job_quarantine").set({failure_count:m,quarantined_at:l}).where("id","=",s.id).execute(),!0;return await D.updateTable("job_quarantine").set({failure_count:m}).where("id","=",s.id).execute(),!1}catch(s){if(v(s))return fe(),!1;throw s}}async function Ve(e,t){let n=Ce(t);try{let r=await D.selectFrom("job_quarantine").where("job_name","=",e).where("payload_hash","in",[n,"*"]).whereNotNull("quarantined_at").select(["id"]).executeTakeFirst();return Boolean(r)}catch(r){if(v(r))return fe(),!1;throw r}}async function Gn(e,t){let n=t===void 0?"*":Ce(t),r=new Date().toISOString().slice(0,19).replace("T"," ");try{let i=await D.selectFrom("job_quarantine").where("job_name","=",e).where("payload_hash","=",n).select(["id"]).executeTakeFirst();if(i)await D.updateTable("job_quarantine").set({quarantined_at:r}).where("id","=",i.id).execute();else await D.insertInto("job_quarantine").values({job_name:e,payload_hash:n,failure_count:0,window_start:r,quarantined_at:r}).execute()}catch(i){if(v(i)){fe();return}throw i}}async function Yn(e){try{await D.deleteFrom("job_quarantine").where("job_name","=",e).execute()}catch(t){if(v(t)){fe();return}throw t}}async function Xn(){try{return await D.selectFrom("job_quarantine").whereNotNull("quarantined_at").selectAll().execute()??[]}catch(e){if(v(e))return fe(),[];throw e}}var Ot=!1;var qe=()=>{};function U(e){let t=e?.numUpdatedRows;if(t===null||t===void 0)return 0;if(typeof t==="object")return Number(t.changes??0);return Number(t)}function Zn(e,t){let n=A(e,t.payload||{},{queue:t.queue,tries:t.maxTries,timeout:t.timeout,backoff:Array.isArray(t.backoff)?t.backoff:void 0});return{queue:t.queue||"default",payload:I(n),attempts:0,available_at:er(t.delay||0),created_at:new Date().toISOString().slice(0,19).replace("T"," ")}}async function Ke(e,t){let n=Zn(e,t),{db:r}=await import("@stacksjs/database");await r.insertInto("jobs").values(n).execute()}function er(e){let t=Date.now();return Math.floor(t/1000+e)}var De=q(()=>{ee()});import{log as B}from"@stacksjs/logging";class Xe{handlers=new Map;wildcardHandlers=new Set;listenerSubscriptions=new WeakMap;reclaim=new FinalizationRegistry((e)=>{e()});on(e,t){if(!this.handlers.has(e))this.handlers.set(e,new Set);return this.handlers.get(e).add(t),()=>{this.handlers.get(e)?.delete(t)}}subscribeListener(e,t,n){let r=new WeakRef(e),i=(s)=>{let u=r.deref();if(u===void 0){o();return}return n.call(u,s)},o=this.on(t,i),a=()=>{o();let s=r.deref();if(s===void 0)return;let u=this.listenerSubscriptions.get(s);if(!u)return;if(u.delete(a),u.size===0)this.listenerSubscriptions.delete(s),this.reclaim.unregister(s)},l=this.listenerSubscriptions.get(e);if(!l)l=new Set,this.listenerSubscriptions.set(e,l);return l.add(a),this.reclaim.register(e,o,e),a}unsubscribeListener(e){let t=this.listenerSubscriptions.get(e);if(!t)return 0;let n=Array.from(t);for(let r of n)r();return this.listenerSubscriptions.delete(e),this.reclaim.unregister(e),n.length}listenerCount(e){if(e==="*")return this.wildcardHandlers.size;return this.handlers.get(e)?.size??0}onAny(e){return this.wildcardHandlers.add(e),()=>{this.wildcardHandlers.delete(e)}}once(e,t){let n=async(r)=>{this.handlers.get(e)?.delete(n),await t(r)};return this.on(e,n)}async emit(e,t){let n={...t,timestamp:Date.now()};this.logEvent(e,n);let r=this.handlers.get(e);if(r)for(let i of r)try{await i(n)}catch(o){B.error(`Error in queue event handler for ${e}:`,o)}for(let i of this.wildcardHandlers)try{await i(e,n)}catch(o){B.error("Error in wildcard queue event handler:",o)}}logEvent(e,t){let n=t.jobId?`[${t.jobId}]`:"",r=t.queueName?`on ${t.queueName}`:"";switch(e){case"job:added":B.debug(`Job added ${n} ${r}`);break;case"job:processing":B.debug(`Job processing ${n} ${r}`);break;case"job:completed":B.info(`Job completed ${n} ${r} in ${t.duration}ms`);break;case"job:failed":B.error(`Job failed ${n} ${r}:`,t.error);break;case"job:retrying":B.warn(`Job retrying ${n} ${r} (attempt ${t.attemptsMade})`);break;case"job:stalled":B.warn(`Job stalled ${n} ${r}`);break;case"queue:error":B.error(`Queue error ${r}:`,t.error);break}}off(e){this.handlers.delete(e)}removeAllListeners(){this.handlers.clear(),this.wildcardHandlers.clear(),this.listenerSubscriptions=new WeakMap}}function me(){if(!Ge)Ge=new Xe;return Ge}function tr(e,t){let n=me();if(e==="*")return n.onAny(t);return n.on(e,t)}function _(e,t){return me().emit(e,t)}function nr(e,t){return async(...n)=>{let r=n[0]?.id||"unknown",i=Date.now();await _("job:processing",{jobId:r,queueName:e,data:n[0]?.data});try{let o=await t(...n);return await _("job:completed",{jobId:r,queueName:e,result:o,duration:Date.now()-i}),o}catch(o){throw await _("job:failed",{jobId:r,queueName:e,error:o,duration:Date.now()-i}),o}}}function rr(e){return function(t,n){if(typeof n!=="object"||n===null)throw TypeError(`@OnQueueEvent('${e}') requires standard (TC39) decorators. Legacy decorators ('experimentalDecorators: true') give a method decorator no construction-time hook, so the handler cannot be bound to an instance.`);if(n.kind!=="method")throw TypeError(`@OnQueueEvent('${e}') can only decorate a class method, but it was applied to a ${n.kind}${n.name===void 0?"":` ('${String(n.name)}')`}. Move the handler into a method, or call onQueueEvent('${e}', handler) directly.`);if(n.static)throw TypeError(`@OnQueueEvent('${e}') cannot decorate the static method '${String(n.name)}': a static method has no instance, so it would subscribe at class-definition time and stay subscribed for the life of the process, even if the class is never used. Use an instance method, or subscribe explicitly with onQueueEvent('${e}', MyClass.${String(n.name)}).`);return n.addInitializer(function(){me().subscribeListener(this,e,t)}),t}}class Ze{jobCounts={added:0,completed:0,failed:0,processing:0};completions=[];errors=[];unsubscribe=[];constructor(){this.setupListeners()}setupListeners(){let e=me();this.unsubscribe.push(e.on("job:added",()=>{this.jobCounts.added++}),e.on("job:processing",()=>{this.jobCounts.processing++}),e.on("job:completed",(t)=>{this.jobCounts.completed++,this.jobCounts.processing=Math.max(0,this.jobCounts.processing-1);let n=t.duration||0;if(this.completions.push({timestamp:Date.now(),duration:n}),this.completions.length>1000)this.completions.shift()}),e.on("job:failed",(t)=>{if(this.jobCounts.failed++,this.jobCounts.processing=Math.max(0,this.jobCounts.processing-1),t.error){if(this.errors.push({error:t.error,timestamp:Date.now()}),this.errors.length>100)this.errors.shift()}}))}getThroughputPerMinute(){let e=Date.now()-60000;return this.completions.filter((n)=>n.timestamp>=e).length}getAverageProcessingTime(){let e=Date.now()-60000,t=this.completions.filter((n)=>n.timestamp>=e);if(t.length===0)return 0;return t.reduce((n,r)=>n+r.duration,0)/t.length}getMetrics(){return{counts:{...this.jobCounts},averageDuration:this.getAverageProcessingTime(),recentErrors:[...this.errors],throughputPerMinute:this.getThroughputPerMinute()}}reset(){this.jobCounts={added:0,completed:0,failed:0,processing:0},this.completions=[],this.errors=[]}stop(){this.unsubscribe.forEach((e)=>e()),this.unsubscribe=[]}}function be(){if(!Ye)Ye=new Ze;return Ye}class Ft{workers=new Map;register(e,t){this.workers.set(e,{id:e,status:"idle",queue:t,processedCount:0,failedCount:0,lastActivityAt:new Date().toISOString(),startedAt:new Date().toISOString()})}markActive(e){let t=this.workers.get(e);if(t)t.status="active",t.lastActivityAt=new Date().toISOString()}markIdle(e){let t=this.workers.get(e);if(t)t.status="idle",t.lastActivityAt=new Date().toISOString()}recordCompletion(e){let t=this.workers.get(e);if(t)t.processedCount++,t.lastActivityAt=new Date().toISOString()}recordFailure(e){let t=this.workers.get(e);if(t)t.failedCount++,t.lastActivityAt=new Date().toISOString()}unregister(e){let t=this.workers.get(e);if(t)t.status="stopped"}getAll(){return Array.from(this.workers.values())}clear(){this.workers.clear()}}function E(){return ir}var Ge=null,Ye=null,ir;var T=q(()=>{ir=new Ft});var{RedisClient:or}=globalThis.Bun;import{log as k}from"@stacksjs/logging";import{env as ar}from"@stacksjs/env";function N(){return ar.QUEUE_DRIVER||"sync"}class ge{jobs;options={thenCallbacks:[],catchCallbacks:[],finallyCallbacks:[],progressCallbacks:[]};constructor(e){this.jobs=e.map((t)=>("job"in t)?t:{job:t})}name(e){return this.options.name=e,this}onQueue(e){return this.options.queue=e,this}allowFailures(){return this.options.allowFailures=!0,this}then(e){return this.options.thenCallbacks.push(e),this}catch(e){return this.options.catchCallbacks.push(e),this}finally(e){return this.options.finallyCallbacks.push(e),this}progress(e){return this.options.progressCallbacks.push(e),this}thenHandler(e){return this.options.thenHandler=e,this}catchHandler(e){return this.options.catchHandler=e,this}finallyHandler(e){return this.options.finallyHandler=e,this}async dispatch(){let e=crypto.randomUUID(),t=this.jobs.length;if(t===0)throw Error("Cannot dispatch an empty batch");let n=N();await cr({id:e,name:this.options.name||"",total_jobs:t,pending_jobs:t,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}),sr(e,this.options);try{await Promise.resolve().then(() => T());await _("batch:added",{jobId:e,data:{name:this.options.name,totalJobs:t}})}catch{}for(let r=0;r<this.jobs.length;r++){let i=this.jobs[r];if(!i)continue;let{job:o,payload:a}=i,l={...a,_batchId:e,_batchIndex:r};if(this.options.queue&&!o.queue)o.queue=this.options.queue;if(n==="sync")try{await o.dispatchNow(l),await ie(e)}catch(s){await oe(e,`${e}:${r}`,s)}else await o.dispatch(l)}return k.info(`[Batch] Dispatched batch "${this.options.name||e}" with ${t} jobs`),new W(e)}getJobs(){return[...this.jobs]}getOptions(){return this.options}}class W{id;constructor(e){this.id=e}async fresh(){return H(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(){let e=await this.fresh();if(!e)return 0;return e.total_jobs-e.pending_jobs}async progress(){let e=await this.fresh();if(!e||e.total_jobs===0)return 0;let t=e.total_jobs-e.pending_jobs;return Math.round(t/e.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(){let e=await this.fresh();if(!e)return[];try{return JSON.parse(e.failed_job_ids||"[]")}catch{return[]}}async cancel(){if(N()==="redis")await _r(this.id);else await br(this.id);k.info(`[Batch] Cancelled batch ${this.id}`);let t=Ne(this.id);if(t)for(let n of t.finallyCallbacks)try{await n(this)}catch(r){k.error(`[Batch] Error in finally callback for batch ${this.id}:`,r)}}async add(e){let t=await this.fresh();if(!t)throw Error(`Batch ${this.id} not found`);if(t.cancelled_at)throw Error(`Batch ${this.id} has been cancelled`);if(t.finished_at)throw Error(`Batch ${this.id} has already finished`);let n=e.map((o)=>("job"in o)?o:{job:o});if(!await dr(this.id,n.length))throw Error(`Batch ${this.id} was cancelled, finished or deleted before the jobs could be added`);let i=JSON.parse(t.options||"{}");for(let o=0;o<n.length;o++){let a=n[o];if(!a)continue;let{job:l,payload:s}=a,u={...s,_batchId:this.id,_batchIndex:t.total_jobs+o};if(i.queue&&!l.queue)l.queue=i.queue;await l.dispatch(u)}k.info(`[Batch] Added ${n.length} jobs to batch ${this.id}`)}async delete(){await pr(this.id),$e(this.id)}}class It{static create(e){return new ge(e)}static async find(e){if(!await H(e))return null;return new W(e)}static async all(){return(await ur()).map((t)=>new W(t.id))}static async prune(e=24){return fr(e)}}function sr(e,t){et.set(e,t)}function Ne(e){return et.get(e)}function $e(e){et.delete(e)}async function cr(e){if(N()==="redis")await yr(e);else await Qt(e)}async function H(e){if(N()==="redis")return vr(e);return Bt(e)}async function ur(){if(N()==="redis")return Wt();return Mt()}async function lr(e,t){if(N()==="redis")await rt(e,t);else await tt(e,t)}async function dr(e,t){if(t===0)return!0;if(N()==="redis")try{let o=await V(),a=`${z}${e}`;return await o.hincrby(a,"total_jobs",t),await o.hincrby(a,"pending_jobs",t),o.close(),!0}catch{}let{db:n,sql:r}=await import("@stacksjs/database"),i=await n.updateTable("job_batches").set(hr(r,t)).where("id","=",e).whereNull("cancelled_at").whereNull("finished_at").executeTakeFirst();return U(i)>0}function hr(e,t){return{total_jobs:e`total_jobs + ${t}`,pending_jobs:e`pending_jobs + ${t}`}}async function pr(e){if(N()==="redis")await Vt(e);else await Ht(e)}async function fr(e){if(N()==="redis")return kr(e);return Lt(e)}function mr(e){return!!(e.then_handler||e.catch_handler||e.finally_handler)}async function Qt(e){let{db:t}=await import("@stacksjs/database"),n={id:e.id,name:e.name,total_jobs:e.total_jobs,pending_jobs:e.pending_jobs,failed_jobs:e.failed_jobs,failed_job_ids:e.failed_job_ids,options:e.options,cancelled_at:e.cancelled_at,created_at:e.created_at,finished_at:e.finished_at};if(!mr(e)){await t.insertInto("job_batches").values(n).execute();return}try{await t.insertInto("job_batches").values({...n,then_handler:e.then_handler??null,catch_handler:e.catch_handler??null,finally_handler:e.finally_handler??null}).execute()}catch(r){k.warn(`[Batch] Could not persist terminal handlers for batch ${e.id}: ${r?.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 t.insertInto("job_batches").values(n).execute()}}async function Bt(e){let{db:t}=await import("@stacksjs/database");return await t.selectFrom("job_batches").where("id","=",e).selectAll().executeTakeFirst()||null}async function Mt(){let{db:e}=await import("@stacksjs/database");return await e.selectFrom("job_batches").selectAll().orderBy("created_at","desc").execute()}async function tt(e,t){let{db:n}=await import("@stacksjs/database");await n.updateTable("job_batches").set(t).where("id","=",e).execute()}async function Ht(e){let{db:t}=await import("@stacksjs/database");await t.deleteFrom("job_batches").where("id","=",e).execute()}async function br(e){await tt(e,{cancelled_at:new Date().toISOString().slice(0,19).replace("T"," "),finished_at:new Date().toISOString().slice(0,19).replace("T"," ")})}async function Lt(e){let{db:t}=await import("@stacksjs/database"),n=new Date(Date.now()-e*60*60*1000).toISOString().slice(0,19).replace("T"," "),r=await t.deleteFrom("job_batches").whereNotNull("finished_at").where("finished_at","<",n).executeTakeFirst();return Number(r?.numDeletedRows??0)}async function gr(){let{queue:e}=await import("@stacksjs/config"),t=e?.connections?.redis?.redis;if(t?.url)return t.url;let n=t?.password?`:${encodeURIComponent(t.password)}@`:"",r=t?.db?`/${t.db}`:"";return`redis://${n}${t?.host||"localhost"}:${t?.port||6379}${r}`}async function V(){let e=new or(await gr());return await e.connect(),e}function wr(e){return{id:e.id,name:e.name,total_jobs:String(e.total_jobs),pending_jobs:String(e.pending_jobs),failed_jobs:String(e.failed_jobs),failed_job_ids:e.failed_job_ids,options:e.options,cancelled_at:e.cancelled_at||"",created_at:e.created_at,finished_at:e.finished_at||"",then_handler:e.then_handler||"",catch_handler:e.catch_handler||"",finally_handler:e.finally_handler||""}}async function yr(e){try{let t=await V();await t.hset(`${z}${e.id}`,wr(e)),await t.sadd(nt,e.id),t.close()}catch{await Qt(e)}}function Ut(e){if(!e?.id)return null;return{id:e.id,name:e.name??"",total_jobs:Number(e.total_jobs),pending_jobs:Number(e.pending_jobs),failed_jobs:Number(e.failed_jobs),failed_job_ids:e.failed_job_ids??"",options:e.options??"",cancelled_at:e.cancelled_at||null,created_at:e.created_at??"",finished_at:e.finished_at||null,then_handler:e.then_handler||null,catch_handler:e.catch_handler||null,finally_handler:e.finally_handler||null}}async function vr(e){try{let t=await V(),n=`${z}${e}`,r=await t.hgetall(n);return t.close(),Ut(r)}catch{return Bt(e)}}async function Wt(){try{let e=await V(),t=await e.smembers(nt),n=[];for(let r of t){let i=Ut(await e.hgetall(`${z}${r}`));if(i)n.push(i)}return e.close(),n}catch{return Mt()}}async function rt(e,t){try{let n=await V(),r=`${z}${e}`,i={};for(let[o,a]of Object.entries(t))i[o]=a===null?"":String(a);await n.hset(r,i),n.close()}catch{await tt(e,t)}}async function zt(e,t,n){let r=await V();try{let i=`${z}${e}`,o=await r.hincrby(i,"pending_jobs",-1),a=Math.max(o,0);if(o<0)await r.hset(i,{pending_jobs:"0"});if(t)await r.hincrby(i,"failed_jobs",1);let s=!(await r.hgetall(i)).finished_at&&(!t||n?a===0:!0),u=new Date().toISOString().slice(0,19).replace("T"," "),c=s&&await r.hsetnx(i,"terminal_claimed","1");if(c)await r.hset(i,{finished_at:u});if(c&&t&&!n)await r.hset(i,{cancelled_at:u});return c}finally{r.close()}}async function Vt(e){try{let t=await V();await t.del(`${z}${e}`),await t.srem(nt,e),t.close()}catch{await Ht(e)}}async function _r(e){let t=new Date().toISOString().slice(0,19).replace("T"," ");await rt(e,{cancelled_at:t,finished_at:t})}async function kr(e){try{let t=await Wt(),n=Date.now()-e*60*60*1000,r=0;for(let i of t)if(i.finished_at){if(new Date(i.finished_at).getTime()<n)await Vt(i.id),r++}return r}catch{return Lt(e)}}function ne(e){if(!e)return null;try{let t=JSON.parse(e);if(t&&(t.kind==="job"||t.kind==="module"))return t;return k.warn(`[Batch] handler JSON has unknown kind '${t?.kind}' - skipping`),null}catch(t){return k.warn(`[Batch] failed to parse persistent handler: ${t.message}`),null}}async function re(e,t){try{if(e.kind==="job"){await Promise.resolve().then(() => ye());await it.dispatch(e.name,{...e.payload??{},_batchId:t});return}let n=await import(e.module).catch((i)=>(k.warn(`[Batch] persistent handler module not found: ${e.module} (${i.message})`),null));if(!n)return;let r=n[e.export];if(typeof r!=="function"){k.warn(`[Batch] persistent handler export '${e.export}' is not a function on ${e.module}`);return}await r(e.payload,t)}catch(n){k.error(`[Batch] persistent handler threw for batch ${t}:`,n)}}async function ie(e){let t=N(),n=!1;if(t==="redis")n=await zt(e,!1,!1);else{let{db:o,sql:a}=await import("@stacksjs/database");await o.updateTable("job_batches").set({pending_jobs:a`GREATEST(pending_jobs - 1, 0)`}).where("id","=",e).where("pending_jobs",">",0).execute();let l=new Date().toISOString().slice(0,19).replace("T"," "),s=await o.updateTable("job_batches").set({finished_at:l}).where("id","=",e).where("pending_jobs","=",0).whereNull("finished_at").executeTakeFirst();n=U(s)>0}let r=Ne(e),i=new W(e);if(r)for(let o of r.progressCallbacks)try{await o(i)}catch(a){k.error(`[Batch] Error in progress callback for batch ${e}:`,a)}if(n){try{await Promise.resolve().then(() => T());await _("batch:completed",{jobId:e})}catch{}let o=await H(e);if(!o){$e(e),k.info(`[Batch] Batch ${e} finished (record vanished)`);return}let a=JSON.parse(o.options||"{}"),s=!((o.failed_jobs||0)>0)||a.allowFailures;if(r){if(s)for(let c of r.thenCallbacks)try{await c(i)}catch(d){k.error(`[Batch] Error in then callback for batch ${e}:`,d)}for(let c of r.finallyCallbacks)try{await c(i)}catch(d){k.error(`[Batch] Error in finally callback for batch ${e}:`,d)}$e(e)}if(s){let c=ne(o.then_handler);if(c)await re(c,e)}else{let c=ne(o.catch_handler);if(c)await re(c,e)}let u=ne(o.finally_handler);if(u)await re(u,e);k.info(`[Batch] Batch ${e} finished`)}}async function oe(e,t,n){let r=await H(e);if(!r)return;let i=JSON.parse(r.options||"{}"),o=new W(e),a=Ne(e),l=!1;if(N()==="redis"){l=await zt(e,!0,!!i.allowFailures);let c=await H(e);if(c){let d=[];try{d=JSON.parse(c.failed_job_ids||"[]")}catch{d=[]}d.push(t),await rt(e,{failed_job_ids:JSON.stringify(d)})}if(a)for(let d of a.catchCallbacks)try{await d(o,n)}catch(m){k.error(`[Batch] Error in catch callback for batch ${e}:`,m)}try{await Promise.resolve().then(() => T());await _("batch:failed",{jobId:e,error:n})}catch{}}else{let{db:c,sql:d}=await import("@stacksjs/database");await c.updateTable("job_batches").set({pending_jobs:d`GREATEST(pending_jobs - 1, 0)`,failed_jobs:d`failed_jobs + 1`}).where("id","=",e).where("pending_jobs",">",0).execute();try{let w=await H(e);if(w){let j=[];try{j=JSON.parse(w.failed_job_ids||"[]")}catch{j=[]}j.push(t),await lr(e,{failed_job_ids:JSON.stringify(j)})}}catch{}if(a)for(let w of a.catchCallbacks)try{await w(o,n)}catch(j){k.error(`[Batch] Error in catch callback for batch ${e}:`,j)}try{await Promise.resolve().then(() => T());await _("batch:failed",{jobId:e,error:n})}catch{}let m=new Date().toISOString().slice(0,19).replace("T"," "),h=c.updateTable("job_batches").set(i.allowFailures?{finished_at:m}:{finished_at:m,cancelled_at:m}).where("id","=",e).whereNull("finished_at");if(i.allowFailures)h=h.where("pending_jobs","=",0);let b=await h.executeTakeFirst();l=U(b)>0}if(!l)return;let s=!!i.allowFailures;if(a){if(s)for(let c of a.thenCallbacks)try{await c(o)}catch(d){k.error(`[Batch] Error in then callback for batch ${e}:`,d)}for(let c of a.finallyCallbacks)try{await c(o)}catch(d){k.error(`[Batch] Error in finally callback for batch ${e}:`,d)}$e(e)}let u=await H(e);if(u){if(s){let d=ne(u.then_handler);if(d)await re(d,e)}else{let d=ne(u.catch_handler);if(d)await re(d,e)}let c=ne(u.finally_handler);if(c)await re(c,e)}k.info(`[Batch] Batch ${e} finished with failure(s)`)}async function we(e){return(await H(e))?.cancelled_at!==null}var et,z="stacks:batch:",nt="stacks:batches";var M=q(()=>{De();et=new Map});import{appPath as xr,frameworkPath as Jr}from"@stacksjs/path";import{env as jr}from"@stacksjs/env";import{enqueueAfterCommit as Pr,isInTransaction as Sr}from"@stacksjs/database";function en(){return Yt??=import("@stacksjs/router").catch((e)=>{throw Yt=void 0,e})}function Er(){return jr.QUEUE_DRIVER||"sync"}function Tr(){if(Xt)return;Xt=!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 O{name;payload;options={};txMode="auto";constructor(e,t){this.name=e;this.payload=t}onQueue(e){return this.options.queue=e,this}delay(e){return this.options.delay=e,this}tries(e){return this.options.tries=e,this}timeout(e){return this.options.timeout=e,this}backoff(e){return this.options.backoff=e,this}withContext(e){return this.options.context=e,this}withIdempotencyKey(e){return this.options.idempotencyKey=e,this}afterCommit(){return this.txMode="after",this}withoutCommit(){return this.txMode="immediate",this}async dispatch(){let{isFaked:e,getFakeQueue:t}=await(Kt??=Promise.resolve().then(() => Be).catch((n)=>{throw Kt=void 0,n}));if(e()){t()?.dispatch(this.name,this.payload,this.options);return}if(this.txMode!=="immediate"){if(Sr()){let r=this.runDispatchPipeline.bind(this);if(Pr(async()=>{await r()}))return}else if(this.txMode==="after")Tr()}await this.runDispatchPipeline()}async runDispatchPipeline(){let e=!1;if(this.options.idempotencyKey){let t=await Le(this.options.idempotencyKey,this.name,this.options.queue);if(t==="duplicate")return;e=t==="claimed"}try{if(await Ve(this.name,this.payload)){let n=A(this.name,this.payload,{queue:this.options.queue,timeout:this.options.timeout,tries:this.options.tries,backoff:this.options.backoff});if(await pe({queue:this.options.queue||"default",payload:I(n),exception:`quarantined: ${this.name}`},"poison-detected"))return}let t=Er();if(t==="database")await this.dispatchToDatabase();else if(t==="redis")await this.dispatchToRedis();else if(t==="sync")await K(this.name,{payload:this.payload,context:this.options.context,traceId:await Zt()});else if(t==="sqs"||t==="memory"||t==="beanstalkd")throw Error(`[queue] Driver "${t}" is not implemented yet. Set QUEUE_DRIVER to one of: redis, database, sync.`);else throw Error(`[queue] Unknown QUEUE_DRIVER "${t}". Allowed values: redis, database, sync.`)}catch(t){if(e&&this.options.idempotencyKey)await Ue(this.options.idempotencyKey);throw t}}async dispatchIf(e){if(e)await this.dispatch()}async dispatchUnless(e){if(!e)await this.dispatch()}async dispatchToDatabase(){let e=Math.floor(Date.now()/1000),t=this.options.delay?e+this.options.delay:e,n=A(this.name,this.payload,{queue:this.options.queue,timeout:this.options.timeout,tries:this.options.tries,backoff:this.options.backoff},await Zt()),r=I(n),{db:i}=await(Gt??=import("@stacksjs/database").catch((o)=>{throw Gt=void 0,o}));await i.insertInto("jobs").values({queue:this.options.queue||"default",payload:r,attempts:0,reserved_at:null,available_at:t,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute()}async dispatchToRedis(){let e=A(this.name,this.payload,{queue:this.options.queue,timeout:this.options.timeout,tries:this.options.tries,backoff:this.options.backoff});le(e);await Promise.resolve().then(() => he());let{queue:n}=await import("@stacksjs/config"),r=n?.connections?.redis;if(!r)throw Error("Redis queue connection is not configured. Check config/queue.ts");await new Q(this.options.queue||"default",r).add(e,{delay:this.options.delay,maxTries:this.options.tries,timeout:this.options.timeout,backoff:this.options.backoff})}async dispatchNow(){await K(this.name,{payload:this.payload,context:this.options.context})}}function Rr(e,t){return new O(e,t)}function Cr(e){M();return new ge(e)}async function qr(e){let t=[xr(`Jobs/${e}.ts`),Jr(`defaults/app/Jobs/${e}.ts`)];for(let r of t)if(await Bun.file(r).exists())return r;let n;try{let r=import.meta.resolve("@stacksjs/defaults/package.json");n=`${new URL(".",r).pathname}app/Jobs/${e}.ts`}catch{}if(n&&await Bun.file(n).exists())return n;return null}async function K(e,t={}){let{withTraceId:n}=await en(),r=t.traceId??`job:${e}:${Math.random().toString(36).slice(2,10)}`;await n(r,async()=>{let i=await qr(e);if(!i)throw Error(`Job ${e} not found. Looked in app/Jobs/${e}.ts and the framework defaults (storage/framework/defaults/app/Jobs, @stacksjs/defaults).`);let a=(await import(i)).default;if(!a)throw Error(`Job ${e} does not export a default`);if(typeof a.handle==="function")await a.handle(t.payload);else if(typeof a.action==="string")await je(a.action);else if(typeof a.action==="function")await a.action();else if(typeof a==="function")await a(t.payload,t.context);else throw Error(`Job ${e} does not have a valid handler`)})}async function Zt(){try{let{getTraceId:e}=await en();return e()}catch{return}}var Kt,Gt,Yt,Xt=!1,it;var ye=q(()=>{Te();ee();We();qe();Pe();it={make(e,t){return new O(e,t)},async dispatch(e,t){await new O(e,t).dispatch()},async dispatchIf(e,t,n){if(e)await new O(t,n).dispatch()},async dispatchUnless(e,t,n){if(!e)await new O(t,n).dispatch()},async dispatchNow(e,t){await new O(e,t).dispatchNow()},dispatchAfter(e,t,n){return new O(t,n).delay(e)},async dispatchOnce(e,t,n){await new O(t,n).withIdempotencyKey(e).dispatch()},async dispatchAfterCommit(e,t){await new O(e,t).afterCommit().dispatch()}}});import{db as R}from"@stacksjs/database";function G(){if(tn)return;tn=!0,console.warn("[queue/circuit-breaker] queue_circuit_state table missing - circuit breaker disabled. Run migrations to enable.")}async function at(e,t){try{let n=await R.selectFrom("queue_circuit_state").where("queue_name","=",e).selectAll().executeTakeFirst();if(n)return n;return await R.insertInto("queue_circuit_state").values({queue_name:e,success_count:0,failure_count:0,window_start:t,paused_at:null,resume_at:null}).execute(),{queue_name:e,success_count:0,failure_count:0,window_start:t,paused_at:null,resume_at:null}}catch(n){if(v(n))return G(),null;let r=n?.message??"";if(r.includes("UNIQUE constraint")||r.includes("Duplicate entry"))return await R.selectFrom("queue_circuit_state").where("queue_name","=",e).selectAll().executeTakeFirst();throw n}}async function st(e){try{let t=await R.selectFrom("queue_circuit_state").where("queue_name","=",e).select(["paused_at","resume_at"]).executeTakeFirst();if(!t||!t.paused_at)return!1;if(t.resume_at){let n=Date.parse(t.resume_at.replace(" ","T")+"Z");if(Number.isFinite(n)&&Date.now()>=n)return await R.updateTable("queue_circuit_state").set({paused_at:null,resume_at:null,success_count:0,failure_count:0}).where("queue_name","=",e).execute(),!1}return!0}catch(t){if(v(t))return G(),!1;throw t}}async function ct(e,t={}){let n=t.windowSeconds??300,r=new Date().toISOString().slice(0,19).replace("T"," "),i=await at(e,r);if(!i)return;try{if(ot(i.window_start,n)){await R.updateTable("queue_circuit_state").set({success_count:1,failure_count:0,window_start:r}).where("queue_name","=",e).execute();return}await R.updateTable("queue_circuit_state").set({success_count:i.success_count+1}).where("queue_name","=",e).execute()}catch(o){if(v(o)){G();return}throw o}}async function ut(e,t={}){let n=t.failureRateThreshold??0.5,r=t.windowSeconds??300,i=t.pauseSeconds??300,o=t.minObservations??10,a=new Date,l=a.toISOString().slice(0,19).replace("T"," "),s=await at(e,l);if(!s)return!1;if(s.paused_at)return!1;try{let{success_count:u,failure_count:c}=s;if(ot(s.window_start,r))u=0,c=0;c+=1;let d=u+c,m=d===0?0:c/d;if(d>=o&&m>=n){let b=new Date(a.getTime()+i*1000).toISOString().slice(0,19).replace("T"," ");return await R.updateTable("queue_circuit_state").set({success_count:u,failure_count:c,window_start:l,paused_at:l,resume_at:b}).where("queue_name","=",e).execute(),!0}return await R.updateTable("queue_circuit_state").set({success_count:u,failure_count:c,window_start:ot(s.window_start,r)?l:s.window_start}).where("queue_name","=",e).execute(),!1}catch(u){if(v(u))return G(),!1;throw u}}function ot(e,t){if(!e)return!0;let n=Date.parse(e.replace(" ","T")+"Z");if(!Number.isFinite(n))return!0;return Date.now()-n>t*1000}async function Dr(e,t=300){let n=new Date,r=n.toISOString().slice(0,19).replace("T"," "),i=new Date(n.getTime()+t*1000).toISOString().slice(0,19).replace("T"," ");await at(e,r);try{await R.updateTable("queue_circuit_state").set({paused_at:r,resume_at:i}).where("queue_name","=",e).execute()}catch(o){if(v(o)){G();return}throw o}}async function $r(e){try{await R.updateTable("queue_circuit_state").set({paused_at:null,resume_at:null,success_count:0,failure_count:0}).where("queue_name","=",e).execute()}catch(t){if(v(t)){G();return}throw t}}async function Nr(){try{return await R.selectFrom("queue_circuit_state").selectAll().execute()??[]}catch(e){if(v(e))return G(),[];throw e}}var tn=!1;var ve=()=>{};ee();Pe();import{env as Hn}from"@stacksjs/env";var Rt,Ct;function qt(){return Hn.QUEUE_DRIVER||"sync"}class Dt{name;description;action;handle;queue;rate;tries;timeout;backoff;backoffConfig;enabled;constructor(e){this.name=e.name,this.description=e.description,this.handle=e.handle,this.queue=e.queue,this.rate=e.rate,this.action=e.action,this.tries=e.tries,this.timeout=e.timeout,this.backoff=e.backoff,this.backoffConfig=e.backoffConfig,this.enabled=e.enabled}async dispatch(...[e]){let{isFaked:t,getFakeQueue:n}=await(Rt??=Promise.resolve().then(() => Be).catch((i)=>{throw Rt=void 0,i}));if(t()){n()?.dispatch(this.name||"UnknownJob",e,{queue:this.queue,tries:this.tries,timeout:this.timeout});return}let r=qt();if(r==="sync")return this.dispatchNow(...[e]);if(r==="redis")return this.dispatchToRedis(e);if(r==="database")return this.dispatchToDatabase(e);if(r==="sqs"||r==="memory"||r==="beanstalkd")throw Error(`[queue] Driver "${r}" is not implemented yet. Set QUEUE_DRIVER to one of: redis, database, sync.`);throw Error(`[queue] Unknown QUEUE_DRIVER "${r}". Allowed values: redis, database, sync.`)}async dispatchIf(e,...[t]){if(e)return this.dispatch(...[t])}async dispatchUnless(e,...[t]){if(!e)return this.dispatch(...[t])}async dispatchAfter(e,...[t]){let n=qt();if(n==="redis")return this.dispatchToRedis(t,{delay:e});if(n==="database")return this.dispatchToDatabase(t,{delay:e});return await new Promise((r)=>setTimeout(r,e*1000)),await this.dispatchNow(...[t])}async dispatchNow(...[e]){if(typeof this.handle==="function")await this.handle(e);else if(typeof this.action==="string")await je(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(e,t){let n=Math.floor(Date.now()/1000),r=t?.delay?n+t.delay:n,i=A(this.name??this.constructor.name,e,{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}),o=I(i),{db:a}=await(Ct??=import("@stacksjs/database").catch((l)=>{throw Ct=void 0,l}));await a.insertInto("jobs").values({queue:this.queue||"default",payload:o,attempts:0,reserved_at:null,available_at:r,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute()}async dispatchToRedis(e,t){let n=A(this.name??this.constructor.name,e,{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});le(n);await Promise.resolve().then(() => he());let{queue:i}=await import("@stacksjs/config"),o=i?.connections?.redis;if(!o)throw Error("Redis queue connection is not configured. Check config/queue.ts");await new Q(this.queue||"default",o).add(n,{delay:t?.delay,maxTries:typeof this.tries==="number"?this.tries:void 0,timeout:this.timeout,backoff:Array.isArray(this.backoff)?this.backoff:void 0})}}Pe();ye();We();Te();qe();ve();ee();var lt=(e)=>`__job_progress__:${e}`,dt=(e)=>`__job_cancel__:${e}`;var _e;function Ar(e){return Number.isFinite(e)?Math.max(0,Math.min(100,e)):0}async function Or(e,t,n){let{cache:r}=_e??=await import("@stacksjs/cache");await r.set(lt(e),{percent:Ar(t),message:n,updatedAt:Date.now()},3600)}async function Fr(e){let{cache:t}=_e??=await import("@stacksjs/cache");return await t.get(lt(e))??null}async function Ir(e){let{cache:t}=_e??=await import("@stacksjs/cache");await t.set(dt(e),1,3600)}async function Qr(e){let{cache:t}=_e??=await import("@stacksjs/cache");return Boolean(await t.get(dt(e)))}async function Br(e){let{cache:t}=_e??=await import("@stacksjs/cache");await Promise.all([t.del(lt(e)),t.del(dt(e))])}import{log as ae}from"@stacksjs/logging";import*as nn from"@stacksjs/path";class rn{jobs=new Map;initialized=!1;register(e){this.jobs.set(e.name,e),ae.debug(`Registered job: ${e.name} (${e.type})`)}get(e){return this.jobs.get(e)}all(){return Array.from(this.jobs.values())}byQueue(e){return this.all().filter((t)=>t.config.queue===e)}scheduled(){return this.all().filter((e)=>e.config.rate||e.config.schedule)}has(e){return this.jobs.has(e)}clear(){this.jobs.clear(),this.initialized=!1}setInitialized(e){this.initialized=e}isInitialized(){return this.initialized}}var Y=new rn;async function ht(e){let t=e||nn.userJobsPath(),n=[];try{let r=new Bun.Glob("**/*.{ts,js}"),i={cwd:t,onlyFiles:!0,absolute:!0};for await(let o of r.scan(i)){if(o.includes(".test.")||o.includes(".spec.")||o.endsWith("index.ts")||o.endsWith("index.js"))continue;try{let a=await Mr(o);if(a)n.push(a),Y.register(a)}catch(a){ae.warn(`Failed to load job from ${o}: ${a.message}`)}}return Y.setInitialized(!0),ae.info(`Discovered ${n.length} jobs from ${t}`),n}catch(r){return ae.error(`Failed to discover jobs: ${r.message}`),[]}}async function Mr(e){try{let t=await import(e),n=e.split("/").pop()?.replace(/\.(ts|js)$/,"")||"UnknownJob";if(t.default&&typeof t.default==="function"){let r=t.default;if(typeof r.handle==="function"||typeof r.prototype?.handle==="function"){let i=r.config||{};return{name:i.name||n,path:e,config:{name:i.name||n,description:i.description,queue:i.queue||"default",tries:i.retries||3,timeout:i.timeout,withoutOverlapping:i.withoutOverlapping,schedule:i.schedule,retryAfter:i.retryAfter},type:"class",module:r}}}if(t.default&&typeof t.default==="object"){let r=t.default;if(typeof r.handle==="function"||typeof r.action==="string")return{name:r.name||n,path:e,config:{name:r.name||n,description:r.description,queue:r.queue||"default",tries:r.tries||3,backoff:r.backoff,rate:r.rate,timeout:r.timeout||r.timeOut,backoffConfig:r.backoffConfig},type:"function",module:r}}return null}catch(t){return ae.debug(`Could not load job from ${e}: ${t.message}`),null}}function Hr(e){return Y.get(e)}function Lr(){return Y.all()}function pt(){return Y.scheduled()}async function Ur(e,t){let n=Y.get(e);if(!n)throw Error(`Job "${e}" not found. Did you run discoverJobs()?`);try{if(n.type==="class"){if(typeof n.module.handle==="function")return await n.module.handle(t);return await new n.module().handle(t)}else{if(typeof n.module.handle==="function")return await n.module.handle(t);throw Error(`Job "${e}" does not have a handle method`)}}catch(r){throw ae.error(`Failed to execute job "${e}": ${r.message}`),r}}function Wr(e){let t=e.config;return{name:t.name,queue:t.queue,tries:t.tries,backoff:t.backoff,timeout:t.timeout,backoffConfig:t.backoffConfig,rate:t.rate}}import{log as x}from"@stacksjs/logging";T();import{log as an}from"@stacksjs/logging";var on=!1;async function sn(){if(on)return!0;try{let{db:e}=await import("@stacksjs/database");return await e.unsafe("CREATE TABLE IF NOT EXISTS scheduled_job_runs (job_name VARCHAR(255) PRIMARY KEY, last_run_at VARCHAR(64) NOT NULL)").execute(),on=!0,!0}catch(e){return an.debug(`[scheduler] run-marker persistence unavailable, using in-memory lastRun: ${e instanceof Error?e.message:String(e)}`),!1}}async function cn(e){if(!await sn())return null;try{let{db:t}=await import("@stacksjs/database"),n=await t.selectFrom("scheduled_job_runs").where("job_name","=",e).select(["last_run_at"]).executeTakeFirst();if(!n?.last_run_at)return null;let r=new Date(n.last_run_at);return Number.isNaN(r.getTime())?null:r}catch{return null}}async function un(e,t){if(!await sn())return;try{let{db:n}=await import("@stacksjs/database"),r=t.toISOString();await n.deleteFrom("scheduled_job_runs").where("job_name","=",e).execute(),await n.insertInto("scheduled_job_runs").values({job_name:e,last_run_at:r}).execute()}catch{}}function zr(e){return`%"jobName":"${e.replace(/[\\%_]/g,(n)=>`\\${n}`)}"%`}async function ln(e){try{let{db:t}=await import("@stacksjs/database"),r=await t.unsafe("SELECT 1 AS present FROM jobs WHERE payload LIKE ? ESCAPE '\\' LIMIT 1",[zr(e)]).execute(),i=Array.isArray(r)?r:r?.rows??[];return Array.isArray(i)&&i.length>0}catch(t){return an.debug(`[scheduler] overlap check unavailable, dispatching anyway: ${t instanceof Error?t.message:String(t)}`),!1}}De();var mn={checkInterval:60000,preventOverlapping:!0},g={isRunning:!1,isShuttingDown:!1,checkInterval:null,jobs:new Map,config:{...mn}},dn=new Set;function Vr(e,t){if(dn.has(e))return;dn.add(e),x.warn(`[scheduler] Cron expression "${e}" specifies seconds="${t}" but the scheduler ticks at minute granularity - the seconds field is being ignored. Use a 5-field expression to avoid this warning, or wait for sub-minute scheduling support.`)}var hn=!1,pn=new Map;function Kr(e){let t=pn.get(e);if(!t)t=new Intl.DateTimeFormat("en-US",{timeZone:e,hour12:!1,month:"numeric",day:"numeric",hour:"numeric",minute:"numeric",weekday:"short"}),pn.set(e,t);return t}function ft(e,t){if(t&&t!=="local"&&t!=="system")try{let n=Kr(t).formatToParts(e),r=(a)=>n.find((l)=>l.type===a)?.value??"",i={Sun:0,Mon:1,Tue:2,Wed:3,Thu:4,Fri:5,Sat:6},o=Number(r("hour"));if(o===24)o=0;return{minute:Number(r("minute")),hour:o,day:Number(r("day")),month:Number(r("month")),dayOfWeek:i[r("weekday")]??e.getDay()}}catch{if(!hn)hn=!0,x.warn(`[scheduler] Invalid timezone "${t}"; falling back to system local time.`)}return{minute:e.getMinutes(),hour:e.getHours(),day:e.getDate(),month:e.getMonth()+1,dayOfWeek:e.getDay()}}function Gr(e,t,n){let r=ft(new Date,n),{minute:i,hour:o,day:a,month:l,dayOfWeek:s}=r;if(t){let w=ft(t,n);if(w.minute===i&&w.hour===o&&w.day===a)return!1}let u=e.trim().split(/\s+/);if(u.length===6){let w=u[0];if(w&&w!=="0"&&w!=="*")Vr(e,w);u=u.slice(1)}if(u.length<5)return x.warn(`Invalid cron expression: ${e}`),!1;let[c,d,m,h,b]=u;return F(c,i,0,59)&&F(d,o,0,23)&&F(m,a,1,31)&&F(h,l,1,12)&&F(b,s,0,6)}function F(e,t,n,r){if(e==="*")return!0;if(e.includes(","))return e.split(",").map((a)=>Number.parseInt(a.trim(),10)).includes(t);if(e.includes("-")){let[o,a]=e.split("-").map((l)=>Number.parseInt(l.trim(),10));return t>=o&&t<=a}if(e.includes("/")){let[o,a]=e.split("/"),l=Number.parseInt(a,10);if(o==="*")return t%l===0;if(o.includes("-")){let[s,u]=o.split("-").map((c)=>Number.parseInt(c.trim(),10));return t>=s&&t<=u&&(t-s)%l===0}}let i=Number.parseInt(e,10);return!Number.isNaN(i)&&t===i}function bn(e){let n={"@yearly":"0 0 1 1 *","@annually":"0 0 1 1 *","@monthly":"0 0 1 * *","@weekly":"0 0 * * 0","@daily":"0 0 * * *","@midnight":"0 0 * * *","@hourly":"0 * * * *"}[e.toLowerCase()];if(n)return n;let r=e.match(/^Every\.(\w+)$/i);if(r&&r[1]!==void 0){let o=r[1].toLowerCase();return{second:"* * * * *",fiveseconds:"* * * * *",tenseconds:"* * * * *",thirtyseconds:"* * * * *",minute:"* * * * *",fiveminutes:"*/5 * * * *",tenminutes:"*/10 * * * *",fifteenminutes:"*/15 * * * *",thirtyminutes:"*/30 * * * *",hour:"0 * * * *",twohours:"0 */2 * * *",sixhours:"0 */6 * * *",twelvehours:"0 */12 * * *",day:"0 0 * * *",week:"0 0 * * 0",month:"0 0 1 * *"}[o]||null}let i=e.split(/\s+/).length;if(i>=5&&i<=6)return e;return null}function gn(e,t){let n=e.trim().split(/\s+/),r=n.length===6?n.slice(1):n;if(r.length<5)return null;let[i,o,a,l,s]=r,u=new Date;u.setSeconds(0,0);let c=527040;for(let d=1;d<=c;d++){let m=new Date(u.getTime()+d*60000),h=ft(m,t);if(F(i,h.minute,0,59)&&F(o,h.hour,0,23)&&F(a,h.day,1,31)&&F(l,h.month,1,12)&&F(s,h.dayOfWeek,0,6))return m}return null}async function Yr(e={}){if(g.isRunning){x.warn("Scheduler is already running");return}g.config={...mn,...e},g.isRunning=!0,g.isShuttingDown=!1,await ht();let t=pt();for(let i of t){let o=i.config.rate||i.config.schedule;if(o){let a=bn(o);if(a){let l=await cn(i.name);g.jobs.set(i.name,{job:i,lastRun:l,nextRun:gn(a,g.config.timezone),isRunning:!1}),x.info(`Registered scheduled job: ${i.name} (${a})`)}else x.warn(`Invalid schedule for job ${i.name}: ${o}`)}}if(g.jobs.size===0){x.info("No scheduled jobs found");return}x.info(`Scheduler started with ${g.jobs.size} job(s)`),process.on("SIGINT",()=>mt()),process.on("SIGTERM",()=>mt());let n=!1,r=()=>{if(g.isShuttingDown)return;let i=g.config.checkInterval,o=i-Date.now()%i;g.checkInterval=setTimeout(()=>{if(!g.isShuttingDown&&!n)n=!0,fn().catch((a)=>x.error("Scheduler check failed:",a)).finally(()=>{n=!1});r()},o),g.checkInterval?.unref?.()};r(),await fn()}async function fn(){for(let[e,t]of g.jobs){let n=t.job.config.rate||t.job.config.schedule;if(!n)continue;let r=bn(n);if(!r)continue;if(Gr(r,t.lastRun,g.config.timezone)){if((g.config.preventOverlapping||t.job.config.withoutOverlapping)&&await ln(e)){x.debug(`Skipping ${e}: previous execution still running`);continue}if(t.isRunning){x.debug(`Skipping ${e}: a dispatch for it is already in flight`);continue}try{t.isRunning=!0,t.lastRun=new Date,t.nextRun=gn(r,g.config.timezone),await un(e,t.lastRun),x.info(`Dispatching scheduled job: ${e}`),await _("job:added",{jobId:`scheduled-${e}-${Date.now()}`,queueName:t.job.config.queue||"default",jobName:e}),await Ke(e,{queue:t.job.config.queue||"default",payload:{},maxTries:t.job.config.tries||3,timeout:t.job.config.timeout||60}),t.isRunning=!1,x.info(`Scheduled job ${e} dispatched to queue`)}catch(o){t.isRunning=!1,x.error(`Failed to dispatch scheduled job ${e}:`,o)}}}}async function mt(){if(!g.isRunning)return;if(x.info("Stopping scheduler..."),g.isShuttingDown=!0,g.checkInterval)clearTimeout(g.checkInterval),g.checkInterval=null;g.isRunning=!1,g.jobs.clear(),x.info("Scheduler stopped")}function Xr(){return{isRunning:g.isRunning,jobCount:g.jobs.size,jobs:Array.from(g.jobs.entries()).map(([e,t])=>({name:e,schedule:t.job.config.rate||t.job.config.schedule,lastRun:t.lastRun,nextRun:t.nextRun,isRunning:t.isRunning}))}}function Zr(){return g.isRunning}function ei(){return new Map(g.jobs)}async function ti(e){let t=g.jobs.get(e);if(!t)throw Error(`Scheduled job "${e}" not found`);x.info(`Manually triggering scheduled job: ${e}`),await Ke(e,{queue:t.job.config.queue||"default",payload:{},maxTries:t.job.config.tries||3,timeout:t.job.config.timeout||60})}T();T();import{log as ni}from"@stacksjs/logging";function wn(e,t=Math.floor(Date.now()/1000)){if(e.reserved_at)return"processing";let n=typeof e.available_at==="number"?e.available_at:typeof e.available_at==="string"&&e.available_at.trim()?Number(e.available_at):Number.NaN;if(Number.isFinite(n)&&n>t)return"delayed";return"pending"}var ri={maxPendingWarning:1000,maxPendingCritical:5000,maxFailedWarning:10,maxFailedCritical:100,maxJobAgeWarning:3600,maxJobAgeCritical:86400,maxErrorRateWarning:0.1,maxErrorRateCritical:0.5};async function bt(e={}){let t={...ri,...e},n=[],r=new Date,i=Math.floor(r.getTime()/1000);try{let{db:o}=await import("@stacksjs/database"),a=await o.selectFrom("jobs").selectAll().execute(),l=await o.selectFrom("failed_jobs").selectAll().execute(),s=new Map;for(let p of a){let y=p.queue||"default";if(!s.has(y))s.set(y,{pending:0,processing:0,delayed:0});let P=s.get(y),S=wn(p,i);if(S==="processing")P.processing++;else if(S==="delayed")P.delayed++;else if(P.pending++,p.created_at){let Rn=typeof p.created_at==="number"?p.created_at:Math.floor(new Date(p.created_at).getTime()/1000),kt=i-Rn;if(!P.oldestAge||kt>P.oldestAge)P.oldestAge=kt}}let u=new Map;for(let p of l){let y=p.queue||"default";u.set(y,(u.get(y)||0)+1)}let c=[],d=new Set([...s.keys(),...u.keys()]),m=t.queues?[...d].filter((p)=>t.queues.includes(p)):[...d],h=0,b=0,w=0,j=0;for(let p of m){let y=s.get(p)||{pending:0,processing:0,delayed:0},P=u.get(p)||0;h+=y.pending,b+=y.processing,w+=y.delayed,j+=P;let S="healthy";if(y.pending>=t.maxPendingCritical)S="unhealthy",n.push({level:"critical",message:`Queue "${p}" has ${y.pending} pending jobs (threshold: ${t.maxPendingCritical})`,queue:p,timestamp:r.toISOString()});else if(y.pending>=t.maxPendingWarning)S="degraded",n.push({level:"warning",message:`Queue "${p}" has ${y.pending} pending jobs (threshold: ${t.maxPendingWarning})`,queue:p,timestamp:r.toISOString()});if(P>=t.maxFailedCritical)S="unhealthy",n.push({level:"critical",message:`Queue "${p}" has ${P} failed jobs (threshold: ${t.maxFailedCritical})`,queue:p,timestamp:r.toISOString()});else if(P>=t.maxFailedWarning){if(S==="healthy")S="degraded";n.push({level:"warning",message:`Queue "${p}" has ${P} failed jobs (threshold: ${t.maxFailedWarning})`,queue:p,timestamp:r.toISOString()})}if(y.oldestAge){if(y.oldestAge>=t.maxJobAgeCritical)S="unhealthy",n.push({level:"critical",message:`Queue "${p}" has a job waiting for ${Math.floor(y.oldestAge/3600)} hours`,queue:p,timestamp:r.toISOString()});else if(y.oldestAge>=t.maxJobAgeWarning){if(S==="healthy")S="degraded";n.push({level:"warning",message:`Queue "${p}" has a job waiting for ${Math.floor(y.oldestAge/60)} minutes`,queue:p,timestamp:r.toISOString()})}}c.push({name:p,status:S,pending:y.pending,processing:y.processing,delayed:y.delayed,failed:P,oldestJobAge:y.oldestAge})}let vt=be().getMetrics(),{throughputPerMinute:Sn,averageDuration:En}=vt,_t=h+b+w+j,ue=_t>0?j/_t:0,Z="healthy";if(c.some((p)=>p.status==="unhealthy"))Z="unhealthy";else if(c.some((p)=>p.status==="degraded"))Z="degraded";if(ue>=t.maxErrorRateCritical)Z="unhealthy",n.push({level:"critical",message:`Overall error rate is ${(ue*100).toFixed(1)}% (threshold: ${t.maxErrorRateCritical*100}%)`,timestamp:r.toISOString()});else if(ue>=t.maxErrorRateWarning){if(Z==="healthy")Z="degraded";n.push({level:"warning",message:`Overall error rate is ${(ue*100).toFixed(1)}% (threshold: ${t.maxErrorRateWarning*100}%)`,timestamp:r.toISOString()})}let Tn=E().getAll().map((p)=>({id:p.id,status:p.status,queue:p.queue,processedCount:p.processedCount,failedCount:p.failedCount,lastActivityAt:p.lastActivityAt}));return{status:Z,timestamp:r.toISOString(),queues:c,workers:Tn,metrics:{totalPending:h,totalProcessing:b,totalDelayed:w,totalFailed:j,throughputPerMinute:Sn,averageProcessingTime:En,errorRate:ue},alerts:n}}catch(o){ni.error("Failed to perform queue health check:",o);let l=E().getAll().map((s)=>({id:s.id,status:s.status,queue:s.queue,processedCount:s.processedCount,failedCount:s.failedCount,lastActivityAt:s.lastActivityAt}));return{status:"unhealthy",timestamp:r.toISOString(),queues:[],workers:l,metrics:{totalPending:0,totalProcessing:0,totalDelayed:0,totalFailed:0,throughputPerMinute:0,averageProcessingTime:0,errorRate:0},alerts:[{level:"critical",message:`Health check failed: ${o.message}`,timestamp:r.toISOString()}]}}}function ii(e={}){return async(t)=>{let n=await bt(e),r=n.status==="healthy"?200:n.status==="degraded"?207:503;return new Response(JSON.stringify(n,null,2),{status:r,headers:{"Content-Type":"application/json","Cache-Control":"no-store"}})}}async function oi(e={}){return(await bt(e)).status==="healthy"}import{log as C}from"@stacksjs/logging";var ai={"&":"&","<":"<",">":">",'"':""","'":"'"};function ke(e){return String(e).replace(/[&<>"']/g,(t)=>ai[t]??t)}class gt{config;notificationCount=0;lastResetTime=Date.now();pendingBatch=[];batchTimeout=null;activeFlush=null;constructor(e){this.config=e}async notify(e){if(this.config.filter&&!this.config.filter(e))return;if(this.config.rateLimit){let t=Date.now();if(t-this.lastResetTime>3600000)this.notificationCount=0,this.lastResetTime=t;if(this.notificationCount>=this.config.rateLimit){C.debug("Rate limit reached for failed job notifications");return}}if(this.config.batch){if(this.pendingBatch.push(e),!this.batchTimeout)this.batchTimeout=setTimeout(()=>{this.batchTimeout=null,this.activeFlush=this.flushBatch().catch((t)=>C.error("Failed to flush notification batch:",t)).finally(()=>{this.activeFlush=null})},this.config.batchInterval||60000),this.batchTimeout.unref?.();return}await this.sendNotifications([e])}async flushBatch(){if(this.pendingBatch.length===0)return;let e=[...this.pendingBatch];if(this.pendingBatch=[],this.batchTimeout)clearTimeout(this.batchTimeout),this.batchTimeout=null;await this.sendNotifications(e)}async shutdown(){if(this.batchTimeout)clearTimeout(this.batchTimeout),this.batchTimeout=null;if(this.activeFlush)await this.activeFlush.catch(()=>{});await this.flushBatch().catch((e)=>C.error("Failed to flush notification batch on shutdown:",e))}async sendNotifications(e){let t=[];for(let n of this.config.channels)switch(n){case"email":if(this.config.email)t.push(this.sendEmail(e));break;case"slack":if(this.config.slack)t.push(this.sendSlack(e));break;case"discord":if(this.config.discord)t.push(this.sendDiscord(e));break;case"webhook":if(this.config.webhook)t.push(this.sendWebhook(e));break;case"log":this.logFailures(e);break}try{await Promise.all(t)}catch(n){C.error("Failed to send job failure notifications:",n)}finally{this.notificationCount+=e.length}}async sendEmail(e){let t=this.config.email,n=t.subject||`[Queue] ${e.length} Job(s) Failed`,r=this.formatEmailBody(e);try{let{mail:i}=await import("@stacksjs/email");await i.send({to:Array.isArray(t.to)?t.to:[t.to],from:t.from?{address:t.from}:void 0,subject:n,html:r})}catch(i){C.error("Failed to send email notification:",i)}}formatEmailBody(e){let t=e.map((n)=>`
|
|
3
|
-
<tr>
|
|
4
|
-
<td style="padding: 8px; border: 1px solid #ddd;">${ke(String(n.id))}</td>
|
|
5
|
-
<td style="padding: 8px; border: 1px solid #ddd;">${ke(n.name)}</td>
|
|
6
|
-
<td style="padding: 8px; border: 1px solid #ddd;">${ke(n.queue)}</td>
|
|
7
|
-
<td style="padding: 8px; border: 1px solid #ddd;">${n.attempts}/${n.maxAttempts}</td>
|
|
8
|
-
<td style="padding: 8px; border: 1px solid #ddd;">${ke(n.failedAt.toISOString())}</td>
|
|
9
|
-
<td style="padding: 8px; border: 1px solid #ddd;"><pre style="max-width: 300px; overflow: auto;">${ke(n.exception)}</pre></td>
|
|
10
|
-
</tr>
|
|
11
|
-
`).join("");return`
|
|
12
|
-
<h2>Failed Jobs Report</h2>
|
|
13
|
-
<p>${e.length} job(s) have failed.</p>
|
|
14
|
-
<table style="border-collapse: collapse; width: 100%;">
|
|
15
|
-
<thead>
|
|
16
|
-
<tr style="background: #f5f5f5;">
|
|
17
|
-
<th style="padding: 8px; border: 1px solid #ddd;">ID</th>
|
|
18
|
-
<th style="padding: 8px; border: 1px solid #ddd;">Name</th>
|
|
19
|
-
<th style="padding: 8px; border: 1px solid #ddd;">Queue</th>
|
|
20
|
-
<th style="padding: 8px; border: 1px solid #ddd;">Attempts</th>
|
|
21
|
-
<th style="padding: 8px; border: 1px solid #ddd;">Failed At</th>
|
|
22
|
-
<th style="padding: 8px; border: 1px solid #ddd;">Exception</th>
|
|
23
|
-
</tr>
|
|
24
|
-
</thead>
|
|
25
|
-
<tbody>
|
|
26
|
-
${t}
|
|
27
|
-
</tbody>
|
|
28
|
-
</table>
|
|
29
|
-
`}async sendSlack(e){let t=this.config.slack,n=[{type:"header",text:{type:"plain_text",text:`\uD83D\uDEA8 ${e.length} Job(s) Failed`,emoji:!0}},...e.slice(0,10).map((r)=>({type:"section",text:{type:"mrkdwn",text:`*${r.name}*
|
|
30
|
-
Queue: ${r.queue} | Attempts: ${r.attempts}/${r.maxAttempts}
|
|
31
|
-
\`\`\`${r.exception.slice(0,200)}\`\`\``}}))];if(e.length>10)n.push({type:"section",text:{type:"mrkdwn",text:`_...and ${e.length-10} more failed jobs_`}});try{let r=await fetch(t.webhookUrl,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({channel:t.channel,username:t.username||"Queue Monitor",icon_emoji:t.iconEmoji||":warning:",blocks:n})});if(!r.ok)C.error(`Slack notification failed with status ${r.status}`)}catch(r){C.error("Failed to send Slack notification:",r)}}async sendDiscord(e){let t=this.config.discord,n=e.slice(0,10).map((r)=>({title:`\u274C ${r.name}`,color:15158332,fields:[{name:"Queue",value:r.queue,inline:!0},{name:"Attempts",value:`${r.attempts}/${r.maxAttempts}`,inline:!0},{name:"Failed At",value:r.failedAt.toISOString(),inline:!0},{name:"Exception",value:`\`\`\`${r.exception.slice(0,500)}\`\`\``}],timestamp:r.failedAt.toISOString()}));try{let r=await fetch(t.webhookUrl,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:t.username||"Queue Monitor",avatar_url:t.avatarUrl,content:`\uD83D\uDEA8 **${e.length} Job(s) Failed**`,embeds:n})});if(!r.ok)C.error(`Discord notification failed with status ${r.status}`)}catch(r){C.error("Failed to send Discord notification:",r)}}async sendWebhook(e){let t=this.config.webhook,n={event:"jobs.failed",timestamp:new Date().toISOString(),count:e.length,jobs:e.map((i)=>({id:i.id,name:i.name,queue:i.queue,attempts:i.attempts,maxAttempts:i.maxAttempts,exception:i.exception,failedAt:i.failedAt.toISOString()}))},r={"Content-Type":"application/json",...t.headers};if(t.secret){let i=JSON.stringify(n),o=await this.generateSignature(i,t.secret);r["X-Signature"]=o}try{let i=await fetch(t.url,{method:"POST",headers:r,body:JSON.stringify(n)});if(!i.ok)C.error(`Webhook notification failed with status ${i.status}`)}catch(i){C.error("Failed to send webhook notification:",i)}}async generateSignature(e,t){let n=new TextEncoder,r=await crypto.subtle.importKey("raw",n.encode(t),{name:"HMAC",hash:"SHA-256"},!1,["sign"]),i=await crypto.subtle.sign("HMAC",r,n.encode(e));return Array.from(new Uint8Array(i)).map((o)=>o.toString(16).padStart(2,"0")).join("")}logFailures(e){for(let t of e)C.error(`[Queue] Job "${t.name}" failed on queue "${t.queue}" after ${t.attempts} attempts: ${t.exception}`)}async cleanup(){if(this.batchTimeout)clearTimeout(this.batchTimeout),this.batchTimeout=null;if(this.pendingBatch.length>0)await this.flushBatch()}}var xe=null;function si(e){return xe=new gt(e),xe}function ci(){return xe}async function ui(e){if(xe)await xe.notify(e)}M();ee();De();import{err as li,ok as yn}from"@stacksjs/error-handling";import{log as f}from"@stacksjs/logging";import se from"process";import{env as hi}from"@stacksjs/env";var vn=!1;function di(){if(vn)return;vn=!0,se.on("unhandledRejection",(e,t)=>{f.error(`Unhandled Rejection: ${e}`)}),se.on("uncaughtException",(e)=>{f.error(`Uncaught Exception: ${e.message}`)})}var L=0,ce=!1,J="",X=new Set,_n=new Map;function Jn(e){return X.add(e),e.finally(()=>X.delete(e)),e}function pi(){let e=se.env.STACKS_QUEUE_RESERVATION_TTL_SEC,t=e===void 0?Number.NaN:Number.parseInt(e,10);return Number.isFinite(t)&&t>0?t:3600}function fi(){let e=se.env.STACKS_QUEUE_RETRY_JITTER;if(e===void 0)return 0.2;let t=Number(e);if(!Number.isFinite(t)||t<0)return 0.2;return Math.min(t,1)}function mi(e,t,n=Math.random){if(!(e>0)||t<=0)return e;return Math.round(e+e*t*n())}function bi(){let e=se.env.STACKS_QUEUE_SWEEP_INTERVAL_SEC,t=e===void 0?Number.NaN:Number.parseInt(e,10);return(Number.isFinite(t)&&t>0?t:60)*1000}async function kn(){let e=pi(),t=Math.floor(Date.now()/1000)-e,n=Math.floor(Date.now()/1000);try{let{db:r}=await import("@stacksjs/database"),i=await r.updateTable("jobs").set({reserved_at:null,available_at:n}).where("reserved_at","<=",t).executeTakeFirst(),o=U(i);if(o>0)f.warn(`[queue] Requeued ${o} job(s) whose reservation exceeded the ${e}s TTL - likely victims of a worker crash. Set STACKS_QUEUE_RESERVATION_TTL_SEC to tune.`);return o}catch(r){return f.error("[queue] Reservation sweep failed",{reason:r instanceof Error?r.message:String(r)}),0}}function gi(e){if(!e||typeof e!=="object")return!1;let t=e.status??e.statusCode;if(typeof t==="number"&&t>=400&&t<500)return!0;let n=e.name;if(typeof n==="string"&&(n==="ValidationError"||n==="ModelNotFoundError"))return!0;return!1}function wi(){return hi.QUEUE_DRIVER||"sync"}async function yi(e,t={}){try{f.info("Starting queue processor..."),di(),ce=!0,J=`worker-${se.pid}-${Date.now()}`;let n=t.concurrency||1,r=wi();await Promise.resolve().then(() => T());if(be(),E().register(J,e||"default"),r==="redis")return f.info("Using Redis queue driver (bun-queue)"),await Si(e||"default",n),yn(void 0);let a;if(e)a=[e];else if(a=await jn(),a.length===0)a=["default"];return f.info(`Processing queues: ${a.join(", ")}`),await vi(a,n),yn(void 0)}catch(n){return ce=!1,li(n)}}async function jn(){try{let{db:e}=await import("@stacksjs/database"),n=(await e.selectFrom("jobs").select("queue").distinct().execute()).map((r)=>r.queue).filter((r)=>Boolean(r));return n.length>0?n:["default"]}catch{return["default"]}}async function vi(e,t){f.info("Listening for jobs...");let n=e,r=Date.now(),i=1e4,o=Date.now(),a=bi();await kn();while(ce)try{let l=Date.now();if(l-r>i){try{let s=await jn();if(s.length>0)n=s}catch{}r=l}if(l-o>a)await kn(),o=l;for(let s of n){try{await Promise.resolve().then(() => ve());if(await st(s))continue}catch{}let u=[];try{u=await _i(s,t)}catch(c){let d=Date.now(),m=_n.get(s)??0;if(d-m>60000)_n.set(s,d),f.error(`[queue] Could not reserve jobs on "${s}" - retrying each cycle:`,c);continue}await Promise.all(u.map(async(c)=>{try{f.info(`Processing job ${c.id} from queue "${s}"`),await Jn(ki(c))}catch{f.error(`Unexpected error processing job ${c.id}`)}}))}await yt(1000)}catch{await yt(3000)}}async function _i(e,t){let n=Math.floor(Date.now()/1000),{db:r}=await import("@stacksjs/database"),i=[];for(let o=0;o<t;o++){let a=await r.selectFrom("jobs").where("queue","=",e).whereNull("reserved_at").where("available_at","<=",n).orderBy("id","asc").limit(1).selectAll().executeTakeFirst();if(!a)break;let l=await r.updateTable("jobs").set({reserved_at:n,attempts:Number(a.attempts||0)+1}).where("id","=",a.id).whereNull("reserved_at").executeTakeFirst();if(U(l)>0)i.push(a)}return i}async function ki(e){let t=e.id,n=e.queue||"default";L++;let r=Date.now();await Promise.resolve().then(() => T());let a=E();a.markActive(J);let l;try{l=JSON.parse(e.payload||"{}").jobName}catch{}await _("job:processing",{jobId:String(t),queueName:n,jobName:l});try{let c=JSON.parse(e.payload||"{}").payload?._batchId;if(c){await Promise.resolve().then(() => M());if(await we(c)){f.info(`[Queue] Skipping job ${t} - batch ${c} has been cancelled`),await wt(t),L--,a.markIdle(J);return}}}catch{}let s=null;try{let u=JSON.parse(e.payload||"{}"),c=ji(u);if(c===void 0)await xn(u);else await Pi(xn(u),c*1000,`Job ${t} exceeded ${c}s timeout`)}catch(u){s=u instanceof Error?u:Error(String(u))}if(!s)try{await wt(t),f.info(`[Queue] Job ${t} completed`),a.recordCompletion(J);try{await Promise.resolve().then(() => ve());await ct(e.queue??"default")}catch{}await _("job:completed",{jobId:String(t),queueName:n,duration:Date.now()-r});try{let c=JSON.parse(e.payload||"{}").payload?._batchId;if(c){await Promise.resolve().then(() => M());await ie(c)}}catch{}}catch{f.info(`[Queue] Failed to delete completed job ${t}`)}else{let u=s.message;f.info(`[Queue] Job ${t} failed: ${u}`),a.recordFailure(J),await _("job:failed",{jobId:String(t),queueName:n,error:s,duration:Date.now()-r,attemptsMade:(e.attempts||0)+1});let c=1,d={};try{d=JSON.parse(e.payload||"{}"),c=d.options?.tries||1}catch{}let m=(e.attempts||0)+1;if(m>=c){f.info(`[Queue] Job ${t} exceeded max attempts (${m}/${c})`);let h=!1;if(d?._retriedFromFailed===!0)try{await Promise.resolve().then(() => Te());if(h=await pe({queue:e.queue,payload:e.payload,exception:s.stack||s.message},"repeat-failure",2),h)f.info(`[Queue] Job ${t} re-failed after retry - moved to dead_letter_jobs`)}catch{h=!1}if(!h){f.info(`[Queue] Moving job ${t} to failed_jobs`);try{h=await Ji(e,s,{attempts:m,maxAttempts:c,durationMs:Date.now()-r})}catch{h=!1}}if(h)try{await wt(t)}catch{f.info(`[Queue] Failed to delete failed job ${t}`)}else f.error(`[Queue] Job ${t} 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{await Promise.resolve().then(() => qe());await Promise.resolve().then(() => ve());let j=d?.jobName??"unknown";await ze(j,d?.payload),await ut(e.queue??"default")}catch{}try{let b=d.payload?._batchId;if(b){await Promise.resolve().then(() => M());await oe(b,String(t),s)}}catch{}}else{let h=d.options?.backoff,b=30;if(Array.isArray(h)&&h.length>0){let w=Math.min(m-1,h.length-1);b=h[w]}else if(typeof h==="number"&&h>0)b=h;if(b=Number(b),!Number.isFinite(b)||b<0)b=30;b=mi(b,fi()),f.info(`[Queue] Job ${t} will be retried in ${b}s (attempt ${m}/${c})`);try{await xi(t,b),f.info(`[Queue] Job ${t} released for retry`)}catch{f.info(`[Queue] Failed to release job ${t} for retry`)}}}L--,a.markIdle(J)}async function wt(e){let{db:t}=await import("@stacksjs/database");await t.deleteFrom("jobs").where("id","=",e).execute()}async function xi(e,t=30){let n=Math.floor(Date.now()/1000)+t;f.debug(`Releasing job ${e} for retry at ${n}`);try{let{db:r}=await import("@stacksjs/database");await r.updateTable("jobs").set({reserved_at:null,available_at:n}).where("id","=",e).execute(),f.debug(`Job ${e} released successfully`)}catch{f.error(`Failed to release job ${e}`)}}async function Ji(e,t,n){try{let r=new Date().toISOString().slice(0,19).replace("T"," "),i=crypto.randomUUID(),o=t.stack||t.message,{db:a}=await import("@stacksjs/database");return await a.insertInto("failed_jobs").values({uuid:i,connection:"database",queue:e.queue,payload:e.payload,exception:o,attempts:n.attempts,max_attempts:n.maxAttempts,duration_ms:n.durationMs,failed_at:r}).execute(),!0}catch(r){return f.error("Failed to log failed job:",r),!1}}function ji(e){if(!e||typeof e!=="object")return;let n=e.options?.timeout;if(typeof n!=="number"||!Number.isFinite(n)||n<=0)return;return n}async function Pi(e,t,n){let r,i=new Promise((o,a)=>{r=setTimeout(()=>a(Error(n)),t)});try{return await Promise.race([e,i])}finally{if(r!==void 0)clearTimeout(r)}}async function xn(e){let t=Je(e);if(!t.ok)throw Error(`[queue] Cannot deserialize job envelope: ${t.reason}`+(t.detail?` (${t.detail})`:""));await Promise.resolve().then(() => ye());await K(t.envelope.jobName,{payload:t.envelope.payload,traceId:t.envelope.traceId})}async function Si(e,t){await Promise.resolve().then(() => he());let{queue:r}=await import("@stacksjs/config"),i=r?.connections?.redis;if(!i)throw Error("Redis queue connection is not configured. Check config/queue.ts");let o=new Q(e,i);await Promise.resolve().then(() => T());let s=E(),u=async(c)=>{L++,s.markActive(J);let d=Date.now(),m=Je(c.data);if(!m.ok){L--,s.markIdle(J),f.error(`[Queue] Skipping Redis job ${c.id} - unparseable envelope: ${m.reason}${m.detail?` (${m.detail})`:""}`);return}let h=m.envelope,b=h.payload?._batchId;if(b)try{await Promise.resolve().then(() => M());if(await we(b)){f.info(`[Queue] Skipping Redis job ${c.id} - batch ${b} has been cancelled`),L--,s.markIdle(J);return}}catch{}await _("job:processing",{jobId:String(c.id),queueName:e});try{await Promise.resolve().then(() => ye());if(await K(h.jobName,{payload:h.payload,traceId:h.traceId}),s.recordCompletion(J),await _("job:completed",{jobId:String(c.id),queueName:e,duration:Date.now()-d}),b)try{await Promise.resolve().then(() => M());await ie(b)}catch{}f.info(`[Queue] Redis job ${c.id} completed`)}catch(w){if(s.recordFailure(J),await _("job:failed",{jobId:String(c.id),queueName:e,error:w instanceof Error?w:Error(String(w)),duration:Date.now()-d}),b)try{await Promise.resolve().then(() => M());await oe(b,String(c.id),w instanceof Error?w:Error(String(w)))}catch{}if(f.error(`[Queue] Redis job ${c.id} failed: ${w}`),gi(w)){f.info(`[Queue] Redis job ${c.id} hit a non-retryable error - skipping retry`);return}throw w}finally{L--,s.markIdle(J)}};o.process(t,(c)=>Jn(u(c))),f.info(`Listening for Redis jobs on queue "${e}" with concurrency ${t}...`);while(ce)await yt(1000);await o.close()}async function Ei(e={}){let t=e.graceMs??1e4;if(ce=!1,X.size>0&&t>0){f.info(`[queue] Draining ${X.size} in-flight job(s) (grace ${t}ms)`);let n=Promise.allSettled([...X]),r=new Promise((o)=>setTimeout(()=>o("timeout"),t));if(await Promise.race([n.then(()=>"drained"),r])==="timeout"&&X.size>0)f.warn(`[queue] Drain timed out with ${X.size} job(s) still active. Their reservations will be reclaimed by the next worker's sweep (Q-2).`)}if(J){await Promise.resolve().then(() => T());E().unregister(J)}f.info("Queue processor stopped")}async function Ti(){let{db:e}=await import("@stacksjs/database"),t=await e.selectFrom("failed_jobs").selectAll().execute();for(let n of t)await Pn(Number(n.id))}async function Pn(e){let t=Math.floor(Date.now()/1000),n=new Date().toISOString().slice(0,19).replace("T"," "),{db:r}=await import("@stacksjs/database"),o=(await r.selectFrom("failed_jobs").where("id","=",e).selectAll().execute())[0];if(!o)throw Error(`Failed job ${e} not found`);let a=typeof o.payload==="string"?o.payload:"",l=a;try{let s=JSON.parse(a||"{}");s._retriedFromFailed=!0,l=JSON.stringify(s)}catch{}await r.insertInto("jobs").values({queue:o.queue,payload:l,attempts:0,reserved_at:null,available_at:t,created_at:n}).execute(),await r.deleteFrom("failed_jobs").where("id","=",e).execute(),f.info(`Failed job ${e} has been re-queued`)}function Ri(){return L}function Ci(){return ce}function yt(e){return new Promise((t)=>setTimeout(t,e))}async function ea(){await Promise.resolve().then(() => he());return Q}export{It as Batch,W as DispatchedBatch,gt as FailedJobNotifier,Nn as JOB_ENVELOPE_VERSION,Dt as Job,it as Jobs,rr as OnQueueEvent,ge as PendingBatch,Xe as QueueEvents,Ze as QueueMetrics,Se as QueueTester,le as assertEnvelopeSerializable,Ir as cancelJob,bt as checkQueueHealth,Le as claimDispatchKey,In as clearEnvelopeWarnings,Br as clearJobState,si as configureFailedJobNotifications,A as createEnvelope,ii as createHealthCheckHandler,St as createQueueTester,ht as discoverJobs,_ as emitQueueEvent,Ti as executeFailedJobs,Ur as executeJob,Tt as expectJobToFail,Ie as fake,Qn as getActionRunner,Ri as getActiveJobCount,Lr as getAllJobs,Ne as getBatchCallbacks,ci as getFailedJobNotifier,jt as getFakeQueue,be as getGlobalMetrics,Hr as getJob,Fr as getJobProgress,me as getQueueEvents,ea as getRedisQueue,ei as getRegisteredJobs,pt as getScheduledJobs,Xr as getSchedulerStatus,E as getWorkerTracker,zn as hasDispatchedKey,Ce as hashPayload,we as isBatchCancelled,st as isCircuitOpen,Pt as isFaked,Qr as isJobCancelled,Ve as isQuarantined,oi as isQueueHealthy,Zr as isSchedulerRunning,Ci as isWorkerRunning,Rr as job,Cr as jobBatch,Y as jobRegistry,Nr as listCircuitState,Ln as listDeadLetterJobs,Xn as listQuarantined,pe as moveToDeadLetter,ui as notifyJobFailed,tr as onQueueEvent,Je as parseEnvelope,Dr as pauseQueue,Wn as purgeDeadLetterJobs,Gn as quarantineJob,wn as queuedJobState,ie as recordBatchJobCompletion,oe as recordBatchJobFailure,ut as recordCircuitFailure,ct as recordCircuitSuccess,Vn as recordDispatchedKey,ze as recordFailureForPoison,Ue as releaseDispatchKey,Qe as restore,$r as resumeQueue,Un as retryDeadLetterJob,Pn as retryFailedJob,K as runJob,Et as runTestJob,I as serializeEnvelope,Bn as setActionRunner,Or as setJobProgress,yi as startProcessor,Yr as startScheduler,Ei as stopProcessor,mt as stopScheduler,Wr as toJobOptions,ti as triggerJob,Yn as unquarantineJob,nr as withEvents};
|
|
1
|
+
export*from"./bun-queue";export{Job}from"./action";export{getActionRunner,setActionRunner}from"./action-runner";export{Jobs,job,jobBatch,runJob}from"./job";export{claimDispatchKey,hasDispatchedKey,recordDispatchedKey,releaseDispatchKey}from"./idempotency";export{listDeadLetterJobs,moveToDeadLetter,purgeDeadLetterJobs,retryDeadLetterJob}from"./dead-letter";export{hashPayload,isQuarantined,listQuarantined,quarantineJob,recordFailureForPoison,unquarantineJob}from"./poison";export{isCircuitOpen,listCircuitState,pauseQueue,recordCircuitFailure,recordCircuitSuccess,resumeQueue}from"./circuit-breaker";export{assertEnvelopeSerializable,clearEnvelopeWarnings,createEnvelope,JOB_ENVELOPE_VERSION,parseEnvelope,serializeEnvelope}from"./envelope";export{setJobProgress,getJobProgress,cancelJob,isJobCancelled,clearJobState}from"./job-progress";export{discoverJobs,executeJob,getAllJobs,getJob,getScheduledJobs,jobRegistry,toJobOptions}from"./discovery";export{getRegisteredJobs,getSchedulerStatus,isSchedulerRunning,startScheduler,stopScheduler,triggerJob}from"./scheduler";export{emitQueueEvent,getGlobalMetrics,getQueueEvents,getWorkerTracker,onQueueEvent,OnQueueEvent,QueueEvents,QueueMetrics,withEvents}from"./events";export{checkQueueHealth,createHealthCheckHandler,isQueueHealthy,queuedJobState}from"./health";export{configureFailedJobNotifications,FailedJobNotifier,getFailedJobNotifier,notifyJobFailed}from"./notifications";export{createQueueTester,expectJobToFail,fake,getFakeQueue,isFaked,QueueTester,restore,runJob as runTestJob}from"./testing";export{Batch,DispatchedBatch,PendingBatch,getBatchCallbacks,isBatchCancelled,recordBatchJobCompletion,recordBatchJobFailure}from"./batch";export{executeFailedJobs,getActiveJobCount,isWorkerRunning,retryFailedJob,startProcessor,stopProcessor}from"./worker";export async function getRedisQueue(){const{RedisQueue}=await import("./drivers/redis");return RedisQueue}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const PROGRESS_KEY=(jobId)=>`__job_progress__:${jobId}`,CANCEL_KEY=(jobId)=>`__job_cancel__:${jobId}`,TTL_SECONDS=3600;let progressCacheModule;export function clampProgressPercent(percent){return Number.isFinite(percent)?Math.max(0,Math.min(100,percent)):0}export async function setJobProgress(jobId,percent,message){const{cache}=progressCacheModule??=await import("@stacksjs/cache");await cache.set(PROGRESS_KEY(jobId),{percent:clampProgressPercent(percent),message,updatedAt:Date.now()},TTL_SECONDS)}export async function getJobProgress(jobId){const{cache}=progressCacheModule??=await import("@stacksjs/cache");return await cache.get(PROGRESS_KEY(jobId))??null}export async function cancelJob(jobId){const{cache}=progressCacheModule??=await import("@stacksjs/cache");await cache.set(CANCEL_KEY(jobId),1,TTL_SECONDS)}export async function isJobCancelled(jobId){const{cache}=progressCacheModule??=await import("@stacksjs/cache");return Boolean(await cache.get(CANCEL_KEY(jobId)))}export async function clearJobState(jobId){const{cache}=progressCacheModule??=await import("@stacksjs/cache");await Promise.all([cache.del(PROGRESS_KEY(jobId)),cache.del(CANCEL_KEY(jobId))])}
|
package/dist/job.js
ADDED
|
@@ -0,0 +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}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const PG_UNDEFINED_TABLE="42P01",MYSQL_NO_SUCH_TABLE=1146;export function isMissingTableError(err){const e=err,errno=e?.errno;if(typeof errno==="string"&&errno.toUpperCase()===PG_UNDEFINED_TABLE)return!0;if(typeof errno==="number"&&errno===MYSQL_NO_SUCH_TABLE)return!0;if((typeof e?.code==="string"?e.code.toUpperCase():"")===PG_UNDEFINED_TABLE)return!0;const msg=e?.message??"";return msg.includes("no such table")||msg.includes("doesn't exist")||msg.includes("does not exist")}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import{log}from"@stacksjs/logging";const HTML_ESCAPE_MAP={"&":"&","<":"<",">":">",'"':""","'":"'"};function escapeHtml(value){return String(value).replace(/[&<>"']/g,(c)=>HTML_ESCAPE_MAP[c]??c)}export class FailedJobNotifier{config;notificationCount=0;lastResetTime=Date.now();pendingBatch=[];batchTimeout=null;activeFlush=null;constructor(config){this.config=config}async notify(job){if(this.config.filter&&!this.config.filter(job))return;if(this.config.rateLimit){const now=Date.now();if(now-this.lastResetTime>3600000){this.notificationCount=0;this.lastResetTime=now}if(this.notificationCount>=this.config.rateLimit){log.debug("Rate limit reached for failed job notifications");return}}if(this.config.batch){this.pendingBatch.push(job);if(!this.batchTimeout){this.batchTimeout=setTimeout(()=>{this.batchTimeout=null;this.activeFlush=this.flushBatch().catch((error)=>log.error("Failed to flush notification batch:",error)).finally(()=>{this.activeFlush=null})},this.config.batchInterval||60000);this.batchTimeout.unref?.()}return}await this.sendNotifications([job])}async flushBatch(){if(this.pendingBatch.length===0)return;const batch=[...this.pendingBatch];this.pendingBatch=[];if(this.batchTimeout){clearTimeout(this.batchTimeout);this.batchTimeout=null}await this.sendNotifications(batch)}async shutdown(){if(this.batchTimeout){clearTimeout(this.batchTimeout);this.batchTimeout=null}if(this.activeFlush)await this.activeFlush.catch(()=>{});await this.flushBatch().catch((error)=>log.error("Failed to flush notification batch on shutdown:",error))}async sendNotifications(jobs){const promises=[];for(const channel of this.config.channels)switch(channel){case"email":if(this.config.email)promises.push(this.sendEmail(jobs));break;case"slack":if(this.config.slack)promises.push(this.sendSlack(jobs));break;case"discord":if(this.config.discord)promises.push(this.sendDiscord(jobs));break;case"webhook":if(this.config.webhook)promises.push(this.sendWebhook(jobs));break;case"log":this.logFailures(jobs);break}try{await Promise.all(promises)}catch(error){log.error("Failed to send job failure notifications:",error)}finally{this.notificationCount+=jobs.length}}async sendEmail(jobs){const config=this.config.email,subject=config.subject||`[Queue] ${jobs.length} Job(s) Failed`,body=this.formatEmailBody(jobs);try{const{mail}=await import("@stacksjs/email");await mail.send({to:Array.isArray(config.to)?config.to:[config.to],from:config.from?{address:config.from}:void 0,subject,html:body})}catch(error){log.error("Failed to send email notification:",error)}}formatEmailBody(jobs){const rows=jobs.map((job)=>`
|
|
2
|
+
<tr>
|
|
3
|
+
<td style="padding: 8px; border: 1px solid #ddd;">${escapeHtml(String(job.id))}</td>
|
|
4
|
+
<td style="padding: 8px; border: 1px solid #ddd;">${escapeHtml(job.name)}</td>
|
|
5
|
+
<td style="padding: 8px; border: 1px solid #ddd;">${escapeHtml(job.queue)}</td>
|
|
6
|
+
<td style="padding: 8px; border: 1px solid #ddd;">${job.attempts}/${job.maxAttempts}</td>
|
|
7
|
+
<td style="padding: 8px; border: 1px solid #ddd;">${escapeHtml(job.failedAt.toISOString())}</td>
|
|
8
|
+
<td style="padding: 8px; border: 1px solid #ddd;"><pre style="max-width: 300px; overflow: auto;">${escapeHtml(job.exception)}</pre></td>
|
|
9
|
+
</tr>
|
|
10
|
+
`).join("");return`
|
|
11
|
+
<h2>Failed Jobs Report</h2>
|
|
12
|
+
<p>${jobs.length} job(s) have failed.</p>
|
|
13
|
+
<table style="border-collapse: collapse; width: 100%;">
|
|
14
|
+
<thead>
|
|
15
|
+
<tr style="background: #f5f5f5;">
|
|
16
|
+
<th style="padding: 8px; border: 1px solid #ddd;">ID</th>
|
|
17
|
+
<th style="padding: 8px; border: 1px solid #ddd;">Name</th>
|
|
18
|
+
<th style="padding: 8px; border: 1px solid #ddd;">Queue</th>
|
|
19
|
+
<th style="padding: 8px; border: 1px solid #ddd;">Attempts</th>
|
|
20
|
+
<th style="padding: 8px; border: 1px solid #ddd;">Failed At</th>
|
|
21
|
+
<th style="padding: 8px; border: 1px solid #ddd;">Exception</th>
|
|
22
|
+
</tr>
|
|
23
|
+
</thead>
|
|
24
|
+
<tbody>
|
|
25
|
+
${rows}
|
|
26
|
+
</tbody>
|
|
27
|
+
</table>
|
|
28
|
+
`}async sendSlack(jobs){const config=this.config.slack,blocks=[{type:"header",text:{type:"plain_text",text:`\uD83D\uDEA8 ${jobs.length} Job(s) Failed`,emoji:!0}},...jobs.slice(0,10).map((job)=>({type:"section",text:{type:"mrkdwn",text:`*${job.name}*
|
|
29
|
+
Queue: ${job.queue} | Attempts: ${job.attempts}/${job.maxAttempts}
|
|
30
|
+
\`\`\`${job.exception.slice(0,200)}\`\`\``}}))];if(jobs.length>10)blocks.push({type:"section",text:{type:"mrkdwn",text:`_...and ${jobs.length-10} more failed jobs_`}});try{const response=await fetch(config.webhookUrl,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({channel:config.channel,username:config.username||"Queue Monitor",icon_emoji:config.iconEmoji||":warning:",blocks})});if(!response.ok)log.error(`Slack notification failed with status ${response.status}`)}catch(error){log.error("Failed to send Slack notification:",error)}}async sendDiscord(jobs){const config=this.config.discord,embeds=jobs.slice(0,10).map((job)=>({title:`\u274C ${job.name}`,color:15158332,fields:[{name:"Queue",value:job.queue,inline:!0},{name:"Attempts",value:`${job.attempts}/${job.maxAttempts}`,inline:!0},{name:"Failed At",value:job.failedAt.toISOString(),inline:!0},{name:"Exception",value:`\`\`\`${job.exception.slice(0,500)}\`\`\``}],timestamp:job.failedAt.toISOString()}));try{const response=await fetch(config.webhookUrl,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:config.username||"Queue Monitor",avatar_url:config.avatarUrl,content:`\uD83D\uDEA8 **${jobs.length} Job(s) Failed**`,embeds})});if(!response.ok)log.error(`Discord notification failed with status ${response.status}`)}catch(error){log.error("Failed to send Discord notification:",error)}}async sendWebhook(jobs){const config=this.config.webhook,payload={event:"jobs.failed",timestamp:new Date().toISOString(),count:jobs.length,jobs:jobs.map((job)=>({id:job.id,name:job.name,queue:job.queue,attempts:job.attempts,maxAttempts:job.maxAttempts,exception:job.exception,failedAt:job.failedAt.toISOString()}))},headers={"Content-Type":"application/json",...config.headers};if(config.secret){const body=JSON.stringify(payload),signature=await this.generateSignature(body,config.secret);headers["X-Signature"]=signature}try{const response=await fetch(config.url,{method:"POST",headers,body:JSON.stringify(payload)});if(!response.ok)log.error(`Webhook notification failed with status ${response.status}`)}catch(error){log.error("Failed to send webhook notification:",error)}}async generateSignature(payload,secret){const encoder=new TextEncoder,key=await crypto.subtle.importKey("raw",encoder.encode(secret),{name:"HMAC",hash:"SHA-256"},!1,["sign"]),signature=await crypto.subtle.sign("HMAC",key,encoder.encode(payload));return Array.from(new Uint8Array(signature)).map((b)=>b.toString(16).padStart(2,"0")).join("")}logFailures(jobs){for(const job of jobs)log.error(`[Queue] Job "${job.name}" failed on queue "${job.queue}" after ${job.attempts} attempts: ${job.exception}`)}async cleanup(){if(this.batchTimeout){clearTimeout(this.batchTimeout);this.batchTimeout=null}if(this.pendingBatch.length>0)await this.flushBatch()}}let globalNotifier=null;export function configureFailedJobNotifications(config){globalNotifier=new FailedJobNotifier(config);return globalNotifier}export function getFailedJobNotifier(){return globalNotifier}export async function notifyJobFailed(job){if(globalNotifier)await globalNotifier.notify(job)}
|
package/dist/poison.js
ADDED
|
@@ -0,0 +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}}
|
|
@@ -0,0 +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}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{log}from"@stacksjs/logging";import{discoverJobs,getScheduledJobs}from"./discovery";import{emitQueueEvent}from"./events";import{hasUnfinishedRun,loadPersistedLastRun,persistLastRun}from"./scheduler-persistence";import{storeJob}from"./utils";const DEFAULT_CONFIG={checkInterval:60000,preventOverlapping:!0},schedulerState={isRunning:!1,isShuttingDown:!1,checkInterval:null,jobs:new Map,config:{...DEFAULT_CONFIG}},_warnedSecondsExprs=new Set;function warnSecondsIgnored(expression,seconds){if(_warnedSecondsExprs.has(expression))return;_warnedSecondsExprs.add(expression);log.warn(`[scheduler] Cron expression "${expression}" specifies seconds="${seconds}" but the scheduler ticks at minute granularity - the seconds field is being ignored. Use a 5-field expression to avoid this warning, or wait for sub-minute scheduling support.`)}let warnedBadTimezone=!1;const tzFormatterCache=new Map;function tzFormatter(timeZone){let f=tzFormatterCache.get(timeZone);if(!f){f=new Intl.DateTimeFormat("en-US",{timeZone,hour12:!1,month:"numeric",day:"numeric",hour:"numeric",minute:"numeric",weekday:"short"});tzFormatterCache.set(timeZone,f)}return f}export function getCronParts(date,timeZone){if(timeZone&&timeZone!=="local"&&timeZone!=="system")try{const parts=tzFormatter(timeZone).formatToParts(date),get=(t)=>parts.find((p)=>p.type===t)?.value??"",weekday={Sun:0,Mon:1,Tue:2,Wed:3,Thu:4,Fri:5,Sat:6};let hour=Number(get("hour"));if(hour===24)hour=0;return{minute:Number(get("minute")),hour,day:Number(get("day")),month:Number(get("month")),dayOfWeek:weekday[get("weekday")]??date.getDay()}}catch{if(!warnedBadTimezone){warnedBadTimezone=!0;log.warn(`[scheduler] Invalid timezone "${timeZone}"; falling back to system local time.`)}}return{minute:date.getMinutes(),hour:date.getHours(),day:date.getDate(),month:date.getMonth()+1,dayOfWeek:date.getDay()}}function shouldRunNow(cronExpression,lastRun,timeZone){const now=getCronParts(new Date,timeZone),{minute:currentMinute,hour:currentHour,day:currentDay,month:currentMonth,dayOfWeek:currentDayOfWeek}=now;if(lastRun){const last=getCronParts(lastRun,timeZone);if(last.minute===currentMinute&&last.hour===currentHour&&last.day===currentDay)return!1}let parts=cronExpression.trim().split(/\s+/);if(parts.length===6){const seconds=parts[0];if(seconds&&seconds!=="0"&&seconds!=="*")warnSecondsIgnored(cronExpression,seconds);parts=parts.slice(1)}if(parts.length<5){log.warn(`Invalid cron expression: ${cronExpression}`);return!1}const[minute,hour,day,month,dayOfWeek]=parts;return matchesCronPart(minute,currentMinute,0,59)&&matchesCronPart(hour,currentHour,0,23)&&matchesCronPart(day,currentDay,1,31)&&matchesCronPart(month,currentMonth,1,12)&&matchesCronPart(dayOfWeek,currentDayOfWeek,0,6)}function matchesCronPart(part,current,_min,_max){if(part==="*")return!0;if(part.includes(","))return part.split(",").map((v)=>Number.parseInt(v.trim(),10)).includes(current);if(part.includes("-")){const[start,end]=part.split("-").map((v)=>Number.parseInt(v.trim(),10));return current>=start&¤t<=end}if(part.includes("/")){const[range,step]=part.split("/"),stepNum=Number.parseInt(step,10);if(range==="*")return current%stepNum===0;if(range.includes("-")){const[start,end]=range.split("-").map((v)=>Number.parseInt(v.trim(),10));return current>=start&¤t<=end&&(current-start)%stepNum===0}}const exact=Number.parseInt(part,10);return!Number.isNaN(exact)&¤t===exact}function parseScheduleString(schedule){const mapped={"@yearly":"0 0 1 1 *","@annually":"0 0 1 1 *","@monthly":"0 0 1 * *","@weekly":"0 0 * * 0","@daily":"0 0 * * *","@midnight":"0 0 * * *","@hourly":"0 * * * *"}[schedule.toLowerCase()];if(mapped)return mapped;const everyMatch=schedule.match(/^Every\.(\w+)$/i);if(everyMatch&&everyMatch[1]!==void 0){const interval=everyMatch[1].toLowerCase();return{second:"* * * * *",fiveseconds:"* * * * *",tenseconds:"* * * * *",thirtyseconds:"* * * * *",minute:"* * * * *",fiveminutes:"*/5 * * * *",tenminutes:"*/10 * * * *",fifteenminutes:"*/15 * * * *",thirtyminutes:"*/30 * * * *",hour:"0 * * * *",twohours:"0 */2 * * *",sixhours:"0 */6 * * *",twelvehours:"0 */12 * * *",day:"0 0 * * *",week:"0 0 * * 0",month:"0 0 1 * *"}[interval]||null}const partCount=schedule.split(/\s+/).length;if(partCount>=5&&partCount<=6)return schedule;return null}export function calculateNextRun(cronExpression,timeZone){const parts=cronExpression.trim().split(/\s+/),fields=parts.length===6?parts.slice(1):parts;if(fields.length<5)return null;const[minute,hour,day,month,dayOfWeek]=fields,start=new Date;start.setSeconds(0,0);const MAX_MINUTES=527040;for(let i=1;i<=MAX_MINUTES;i++){const candidate=new Date(start.getTime()+i*60000),p=getCronParts(candidate,timeZone);if(matchesCronPart(minute,p.minute,0,59)&&matchesCronPart(hour,p.hour,0,23)&&matchesCronPart(day,p.day,1,31)&&matchesCronPart(month,p.month,1,12)&&matchesCronPart(dayOfWeek,p.dayOfWeek,0,6))return candidate}return null}export async function startScheduler(config={}){if(schedulerState.isRunning){log.warn("Scheduler is already running");return}schedulerState.config={...DEFAULT_CONFIG,...config};schedulerState.isRunning=!0;schedulerState.isShuttingDown=!1;await discoverJobs();const scheduledJobs=getScheduledJobs();for(const job of scheduledJobs){const schedule=job.config.rate||job.config.schedule;if(schedule){const cronExpression=parseScheduleString(schedule);if(cronExpression){const lastRun=await loadPersistedLastRun(job.name);schedulerState.jobs.set(job.name,{job,lastRun,nextRun:calculateNextRun(cronExpression,schedulerState.config.timezone),isRunning:!1});log.info(`Registered scheduled job: ${job.name} (${cronExpression})`)}else log.warn(`Invalid schedule for job ${job.name}: ${schedule}`)}}if(schedulerState.jobs.size===0){log.info("No scheduled jobs found");return}log.info(`Scheduler started with ${schedulerState.jobs.size} job(s)`);process.on("SIGINT",()=>stopScheduler());process.on("SIGTERM",()=>stopScheduler());let isChecking=!1;const scheduleNextTick=()=>{if(schedulerState.isShuttingDown)return;const interval=schedulerState.config.checkInterval,delay=interval-Date.now()%interval;schedulerState.checkInterval=setTimeout(()=>{if(!schedulerState.isShuttingDown&&!isChecking){isChecking=!0;checkScheduledJobs().catch((err)=>log.error("Scheduler check failed:",err)).finally(()=>{isChecking=!1})}scheduleNextTick()},delay);schedulerState.checkInterval?.unref?.()};scheduleNextTick();await checkScheduledJobs()}async function checkScheduledJobs(){for(const[name,state]of schedulerState.jobs){const schedule=state.job.config.rate||state.job.config.schedule;if(!schedule)continue;const cronExpression=parseScheduleString(schedule);if(!cronExpression)continue;if(shouldRunNow(cronExpression,state.lastRun,schedulerState.config.timezone)){if((schedulerState.config.preventOverlapping||state.job.config.withoutOverlapping)&&await hasUnfinishedRun(name)){log.debug(`Skipping ${name}: previous execution still running`);continue}if(state.isRunning){log.debug(`Skipping ${name}: a dispatch for it is already in flight`);continue}try{state.isRunning=!0;state.lastRun=new Date;state.nextRun=calculateNextRun(cronExpression,schedulerState.config.timezone);await persistLastRun(name,state.lastRun);log.info(`Dispatching scheduled job: ${name}`);await emitQueueEvent("job:added",{jobId:`scheduled-${name}-${Date.now()}`,queueName:state.job.config.queue||"default",jobName:name});await storeJob(name,{queue:state.job.config.queue||"default",payload:{},maxTries:state.job.config.tries||3,timeout:state.job.config.timeout||60});state.isRunning=!1;log.info(`Scheduled job ${name} dispatched to queue`)}catch(error){state.isRunning=!1;log.error(`Failed to dispatch scheduled job ${name}:`,error)}}}}export async function stopScheduler(){if(!schedulerState.isRunning)return;log.info("Stopping scheduler...");schedulerState.isShuttingDown=!0;if(schedulerState.checkInterval){clearTimeout(schedulerState.checkInterval);schedulerState.checkInterval=null}schedulerState.isRunning=!1;schedulerState.jobs.clear();log.info("Scheduler stopped")}export function getSchedulerStatus(){return{isRunning:schedulerState.isRunning,jobCount:schedulerState.jobs.size,jobs:Array.from(schedulerState.jobs.entries()).map(([name,state])=>({name,schedule:state.job.config.rate||state.job.config.schedule,lastRun:state.lastRun,nextRun:state.nextRun,isRunning:state.isRunning}))}}export function isSchedulerRunning(){return schedulerState.isRunning}export function getRegisteredJobs(){return new Map(schedulerState.jobs)}export async function triggerJob(name){const state=schedulerState.jobs.get(name);if(!state)throw Error(`Scheduled job "${name}" not found`);log.info(`Manually triggering scheduled job: ${name}`);await storeJob(name,{queue:state.job.config.queue||"default",payload:{},maxTries:state.job.config.tries||3,timeout:state.job.config.timeout||60})}
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
class FakeQueue{dispatchedJobs=[];pushedJobs=[];processedJobs=[];failedJobs=[];dispatch(name,data,options={}){this.dispatchedJobs.push({name,data,options,dispatchedAt:new Date,queue:options.queue||"default"})}push(name,data,options={}){this.pushedJobs.push({name,data,options,dispatchedAt:new Date,queue:options.queue||"default"})}dispatched(name){if(name)return this.dispatchedJobs.filter((j)=>j.name===name);return[...this.dispatchedJobs]}pushed(name){if(name)return this.pushedJobs.filter((j)=>j.name===name);return[...this.pushedJobs]}assertDispatched(name,callback){const jobs=this.dispatched(name);if(jobs.length===0)throw Error(`Expected job "${name}" to be dispatched, but it was not.`);if(callback){if(jobs.filter(callback).length===0)throw Error(`Expected job "${name}" to be dispatched matching the callback, but no matching jobs were found.`)}}assertNotDispatched(name){const jobs=this.dispatched(name);if(jobs.length>0)throw Error(`Expected job "${name}" to not be dispatched, but it was dispatched ${jobs.length} time(s).`)}assertDispatchedTimes(name,times){const jobs=this.dispatched(name);if(jobs.length!==times)throw Error(`Expected job "${name}" to be dispatched ${times} time(s), but it was dispatched ${jobs.length} time(s).`)}assertNothingDispatched(){if(this.dispatchedJobs.length>0){const names=[...new Set(this.dispatchedJobs.map((j)=>j.name))].join(", ");throw Error(`Expected no jobs to be dispatched, but found: ${names}`)}}assertPushed(name,callback){const jobs=this.pushed(name);if(jobs.length===0)throw Error(`Expected job "${name}" to be pushed, but it was not.`);if(callback){if(jobs.filter(callback).length===0)throw Error(`Expected job "${name}" to be pushed matching the callback, but no matching jobs were found.`)}}assertPushedWithDelay(name,delay){if(this.pushed(name).filter((j)=>j.options?.delay===delay).length===0)throw Error(`Expected job "${name}" to be pushed with delay ${delay}ms, but no matching jobs were found.`)}assertPushedOn(queue,name){if(this.pushed(name).filter((j)=>j.queue===queue).length===0)throw Error(`Expected job "${name}" to be pushed on queue "${queue}", but it was not.`)}async processJob(name,handler){const job=this.dispatchedJobs.find((j)=>j.name===name);if(!job)throw Error(`No dispatched job found with name "${name}"`);try{await handler(job.data);this.processedJobs.push(job)}catch(error){this.failedJobs.push({job,error});throw error}}processed(name){if(name)return this.processedJobs.filter((j)=>j.name===name);return[...this.processedJobs]}failed(name){if(name)return this.failedJobs.filter((f)=>f.job.name===name);return[...this.failedJobs]}reset(){this.dispatchedJobs=[];this.pushedJobs=[];this.processedJobs=[];this.failedJobs=[]}}let fakeQueue=null;export function fake(){fakeQueue=new FakeQueue;return fakeQueue}export function getFakeQueue(){return fakeQueue}export function isFaked(){return fakeQueue!==null}export function restore(){fakeQueue=null}export class QueueTester{queue;constructor(){this.queue=fake()}dispatch(name,data,options={}){this.queue.dispatch(name,data,options);return this}push(name,data,options={}){this.queue.push(name,data,options);return this}assertDispatched(name,callback){this.queue.assertDispatched(name,callback);return this}assertNotDispatched(name){this.queue.assertNotDispatched(name);return this}assertDispatchedTimes(name,times){this.queue.assertDispatchedTimes(name,times);return this}assertNothingDispatched(){this.queue.assertNothingDispatched();return this}dispatched(name){return this.queue.dispatched(name)}reset(){this.queue.reset();return this}cleanup(){restore()}}export function createQueueTester(){return new QueueTester}export async function runJob(jobModule,data){return await jobModule.handle(data)}export async function expectJobToFail(jobModule,data,expectedError){try{await jobModule.handle(data);throw Error("Expected job to fail, but it succeeded")}catch(error){if(error.message==="Expected job to fail, but it succeeded")throw error;if(expectedError){const message=error.message;if(typeof expectedError==="string"){if(!message.includes(expectedError))throw Error(`Expected error to contain "${expectedError}", got "${message}"`)}else if(!expectedError.test(message))throw Error(`Expected error to match ${expectedError}, got "${message}"`)}return error}}
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createEnvelope,serializeEnvelope}from"./envelope";export function updatedRowCount(result){const raw=result?.numUpdatedRows;if(raw===null||raw===void 0)return 0;if(typeof raw==="object")return Number(raw.changes??0);return Number(raw)}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)}
|
package/dist/worker.js
ADDED
|
@@ -0,0 +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))}
|
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.37",
|
|
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.37",
|
|
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.37",
|
|
69
|
+
"@stacksjs/config": "0.74.37",
|
|
70
|
+
"@stacksjs/database": "0.74.37",
|
|
71
|
+
"@stacksjs/env": "0.74.37",
|
|
72
|
+
"@stacksjs/error-handling": "0.74.37",
|
|
73
|
+
"@stacksjs/logging": "0.74.37",
|
|
74
|
+
"@stacksjs/path": "0.74.37"
|
|
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.37",
|
|
81
|
+
"@stacksjs/router": "0.74.37"
|
|
82
82
|
},
|
|
83
83
|
"peerDependenciesMeta": {
|
|
84
84
|
"@stacksjs/email": {
|