@stacksjs/queue 0.75.452 → 0.75.457
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/batch-payload.d.ts +6 -0
- package/dist/batch-payload.js +1 -0
- package/dist/batch.js +1 -1
- package/dist/job.d.ts +2 -1
- package/dist/job.js +1 -1
- package/dist/worker.js +1 -1
- package/package.json +12 -12
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** A terminal handler carries batch context, rather than membership in its lifecycle. */
|
|
2
|
+
export declare function batchMembershipId(payload: unknown): string | undefined;
|
|
3
|
+
/** Reserved membership metadata always wins over the caller's payload. */
|
|
4
|
+
export declare function batchMemberPayload(payload: unknown, batchId: string, index: number): {};
|
|
5
|
+
/** Keep the existing identifier available to the handler without enrolling it again. */
|
|
6
|
+
export declare function batchTerminalPayload(payload: unknown, batchId: string): {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function batchMembershipId(payload){if(!payload||typeof payload!=="object")return;const data=payload;if(data._batchTerminalHandler===!0)return;return typeof data._batchId==="string"&&data._batchId?data._batchId:void 0}export function batchMemberPayload(payload,batchId,index){return{...payload??{},_batchId:batchId,_batchIndex:index,_batchTerminalHandler:!1}}export function batchTerminalPayload(payload,batchId){return{...payload??{},_batchId:batchId,_batchTerminalHandler:!0}}
|
package/dist/batch.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{RedisClient}from"bun";import{log}from"@stacksjs/logging";import{env as envVars}from"@stacksjs/env";import{updatedRowCount}from"./utils";function getQueueDriver(){return envVars.QUEUE_DRIVER||"sync"}export class PendingBatch{jobs;options={thenCallbacks:[],catchCallbacks:[],finallyCallbacks:[],progressCallbacks:[]};constructor(jobs){this.jobs=jobs.map((j)=>("job"in j)?j:{job:j})}name(name){this.options.name=name;return this}onQueue(queue){this.options.queue=queue;return this}allowFailures(){this.options.allowFailures=!0;return this}then(callback){this.options.thenCallbacks.push(callback);return this}catch(callback){this.options.catchCallbacks.push(callback);return this}finally(callback){this.options.finallyCallbacks.push(callback);return this}progress(callback){this.options.progressCallbacks.push(callback);return this}thenHandler(handler){this.options.thenHandler=handler;return this}catchHandler(handler){this.options.catchHandler=handler;return this}finallyHandler(handler){this.options.finallyHandler=handler;return this}async dispatch(){const batchId=crypto.randomUUID(),totalJobs=this.jobs.length;if(totalJobs===0)throw Error("Cannot dispatch an empty batch");const driver=getQueueDriver();await storeBatchRecord({id:batchId,name:this.options.name||"",total_jobs:totalJobs,pending_jobs:totalJobs,failed_jobs:0,failed_job_ids:"[]",options:JSON.stringify({queue:this.options.queue,allowFailures:this.options.allowFailures||!1}),cancelled_at:null,created_at:new Date().toISOString().slice(0,19).replace("T"," "),finished_at:null,then_handler:this.options.thenHandler?JSON.stringify(this.options.thenHandler):null,catch_handler:this.options.catchHandler?JSON.stringify(this.options.catchHandler):null,finally_handler:this.options.finallyHandler?JSON.stringify(this.options.finallyHandler):null});registerBatchCallbacks(batchId,this.options);try{const{emitQueueEvent}=await import("./events");await emitQueueEvent("batch:added",{jobId:batchId,data:{name:this.options.name,totalJobs}})}catch{}for(let i=0;i<this.jobs.length;i++){const entry=this.jobs[i];if(!entry)continue;const{job,payload}=entry,jobPayload={...payload,_batchId:batchId,_batchIndex:i};if(this.options.queue&&!job.queue)job.queue=this.options.queue;if(driver==="sync")try{await job.dispatchNow(jobPayload);await recordBatchJobCompletion(batchId)}catch(error){await recordBatchJobFailure(batchId,`${batchId}:${i}`,error)}else await job.dispatch(jobPayload)}log.info(`[Batch] Dispatched batch "${this.options.name||batchId}" with ${totalJobs} jobs`);return new DispatchedBatch(batchId)}getJobs(){return[...this.jobs]}getOptions(){return this.options}}export class DispatchedBatch{id;constructor(id){this.id=id}async fresh(){return getBatchRecord(this.id)}async getName(){return(await this.fresh())?.name||""}async totalJobs(){return(await this.fresh())?.total_jobs||0}async pendingJobs(){return(await this.fresh())?.pending_jobs||0}async failedJobs(){return(await this.fresh())?.failed_jobs||0}async completedJobs(){const record=await this.fresh();if(!record)return 0;return record.total_jobs-record.pending_jobs}async progress(){const record=await this.fresh();if(!record||record.total_jobs===0)return 0;const completed=record.total_jobs-record.pending_jobs;return Math.round(completed/record.total_jobs*100)}async finished(){return(await this.fresh())?.finished_at!==null}async cancelled(){return(await this.fresh())?.cancelled_at!==null}async hasFailures(){return((await this.fresh())?.failed_jobs||0)>0}async failedJobIds(){const record=await this.fresh();if(!record)return[];try{return JSON.parse(record.failed_job_ids||"[]")}catch{return[]}}async cancel(){if(getQueueDriver()==="redis")await cancelBatchInRedis(this.id);else await cancelBatchInDatabase(this.id);log.info(`[Batch] Cancelled batch ${this.id}`);const callbacks=getBatchCallbacks(this.id);if(callbacks)for(const cb of callbacks.finallyCallbacks)try{await cb(this)}catch(e){log.error(`[Batch] Error in finally callback for batch ${this.id}:`,e)}}async add(jobs){const record=await this.fresh();if(!record)throw Error(`Batch ${this.id} not found`);if(record.cancelled_at)throw Error(`Batch ${this.id} has been cancelled`);if(record.finished_at)throw Error(`Batch ${this.id} has already finished`);const batchableJobs=jobs.map((j)=>("job"in j)?j:{job:j});if(!await incrementBatchCounters(this.id,batchableJobs.length))throw Error(`Batch ${this.id} was cancelled, finished or deleted before the jobs could be added`);const options=JSON.parse(record.options||"{}");for(let i=0;i<batchableJobs.length;i++){const entry=batchableJobs[i];if(!entry)continue;const{job,payload}=entry,jobPayload={...payload,_batchId:this.id,_batchIndex:record.total_jobs+i};if(options.queue&&!job.queue)job.queue=options.queue;await job.dispatch(jobPayload)}log.info(`[Batch] Added ${batchableJobs.length} jobs to batch ${this.id}`)}async delete(){await deleteBatchRecord(this.id);removeBatchCallbacks(this.id)}}export class Batch{static create(jobs){return new PendingBatch(jobs)}static async find(id){if(!await getBatchRecord(id))return null;return new DispatchedBatch(id)}static async all(){return(await getAllBatchRecords()).map((r)=>new DispatchedBatch(r.id))}static async prune(olderThanHours=24){return pruneBatchRecords(olderThanHours)}}const batchCallbackRegistry=new Map;function registerBatchCallbacks(batchId,options){batchCallbackRegistry.set(batchId,options)}export function getBatchCallbacks(batchId){return batchCallbackRegistry.get(batchId)}function removeBatchCallbacks(batchId){batchCallbackRegistry.delete(batchId)}async function storeBatchRecord(record){if(getQueueDriver()==="redis")await storeBatchInRedis(record);else await storeBatchInDatabase(record)}async function getBatchRecord(id){if(getQueueDriver()==="redis")return getBatchFromRedis(id);return getBatchFromDatabase(id)}async function getAllBatchRecords(){if(getQueueDriver()==="redis")return getAllBatchesFromRedis();return getAllBatchesFromDatabase()}async function updateBatchRecord(id,updates){if(getQueueDriver()==="redis")await updateBatchInRedis(id,updates);else await updateBatchInDatabase(id,updates)}async function incrementBatchCounters(id,delta){if(delta===0)return!0;if(getQueueDriver()==="redis")try{const client=await connectBatchRedis(),key=`${REDIS_BATCH_PREFIX}${id}`;await client.hincrby(key,"total_jobs",delta);await client.hincrby(key,"pending_jobs",delta);client.close();return!0}catch{}const{db,sql}=await import("@stacksjs/database/runtime"),result=await db.updateTable("job_batches").set(batchCounterIncrements(sql,delta)).where("id","=",id).whereNull("cancelled_at").whereNull("finished_at").executeTakeFirst();return updatedRowCount(result)>0}export function batchCounterIncrements(sql,delta){return{total_jobs:sql`total_jobs + ${delta}`,pending_jobs:sql`pending_jobs + ${delta}`}}async function deleteBatchRecord(id){if(getQueueDriver()==="redis")await deleteBatchFromRedis(id);else await deleteBatchFromDatabase(id)}async function pruneBatchRecords(olderThanHours){if(getQueueDriver()==="redis")return pruneBatchesFromRedis(olderThanHours);return pruneBatchesFromDatabase(olderThanHours)}function hasPersistentHandlers(record){return!!(record.then_handler||record.catch_handler||record.finally_handler)}async function storeBatchInDatabase(record){const{db}=await import("@stacksjs/database/runtime"),columns={id:record.id,name:record.name,total_jobs:record.total_jobs,pending_jobs:record.pending_jobs,failed_jobs:record.failed_jobs,failed_job_ids:record.failed_job_ids,options:record.options,cancelled_at:record.cancelled_at,created_at:record.created_at,finished_at:record.finished_at};if(!hasPersistentHandlers(record)){await db.insertInto("job_batches").values(columns).execute();return}try{await db.insertInto("job_batches").values({...columns,then_handler:record.then_handler??null,catch_handler:record.catch_handler??null,finally_handler:record.finally_handler??null}).execute()}catch(error){log.warn(`[Batch] Could not persist terminal handlers for batch ${record.id}: ${error?.message}. The job_batches table predates the then_handler/catch_handler/finally_handler columns; recreate it to enable handlers that survive a worker restart.`);await db.insertInto("job_batches").values(columns).execute()}}async function getBatchFromDatabase(id){const{db}=await import("@stacksjs/database/runtime");return await db.selectFrom("job_batches").where("id","=",id).selectAll().executeTakeFirst()||null}async function getAllBatchesFromDatabase(){const{db}=await import("@stacksjs/database/runtime");return await db.selectFrom("job_batches").selectAll().orderBy("created_at","desc").execute()}async function updateBatchInDatabase(id,updates){const{db}=await import("@stacksjs/database/runtime");await db.updateTable("job_batches").set(updates).where("id","=",id).execute()}async function deleteBatchFromDatabase(id){const{db}=await import("@stacksjs/database/runtime");await db.deleteFrom("job_batches").where("id","=",id).execute()}async function cancelBatchInDatabase(id){await updateBatchInDatabase(id,{cancelled_at:new Date().toISOString().slice(0,19).replace("T"," "),finished_at:new Date().toISOString().slice(0,19).replace("T"," ")})}async function pruneBatchesFromDatabase(olderThanHours){const{db}=await import("@stacksjs/database/runtime"),cutoff=new Date(Date.now()-olderThanHours*60*60*1000).toISOString().slice(0,19).replace("T"," "),result=await db.deleteFrom("job_batches").whereNotNull("finished_at").where("finished_at","<",cutoff).executeTakeFirst();return Number(result?.numDeletedRows??0)}const REDIS_BATCH_PREFIX="stacks:batch:",REDIS_BATCH_INDEX="stacks:batches";async function getRedisClient(){const{sharedRedisQueue}=await import("./drivers/redis"),{queue:queueConfig}=await import("@stacksjs/config"),redisConfig=queueConfig?.connections?.redis;if(!redisConfig)throw Error("Redis queue connection is not configured");return sharedRedisQueue("__batches__",redisConfig)}async function batchRedisUrl(){const{queue:queueConfig}=await import("@stacksjs/config"),redisConfig=queueConfig?.connections?.redis?.redis;if(redisConfig?.url)return redisConfig.url;const auth=redisConfig?.password?`:${encodeURIComponent(redisConfig.password)}@`:"",db=redisConfig?.db?`/${redisConfig.db}`:"";return`redis://${auth}${redisConfig?.host||"localhost"}:${redisConfig?.port||6379}${db}`}async function connectBatchRedis(){const client=new RedisClient(await batchRedisUrl());await client.connect();return client}export function batchRecordToHash(record){return{id:record.id,name:record.name,total_jobs:String(record.total_jobs),pending_jobs:String(record.pending_jobs),failed_jobs:String(record.failed_jobs),failed_job_ids:record.failed_job_ids,options:record.options,cancelled_at:record.cancelled_at||"",created_at:record.created_at,finished_at:record.finished_at||"",then_handler:record.then_handler||"",catch_handler:record.catch_handler||"",finally_handler:record.finally_handler||""}}async function storeBatchInRedis(record){try{const client=await connectBatchRedis();await client.hset(`${REDIS_BATCH_PREFIX}${record.id}`,batchRecordToHash(record));await client.sadd(REDIS_BATCH_INDEX,record.id);client.close()}catch{await storeBatchInDatabase(record)}}export function batchRecordFromHash(data){if(!data?.id)return null;return{id:data.id,name:data.name??"",total_jobs:Number(data.total_jobs),pending_jobs:Number(data.pending_jobs),failed_jobs:Number(data.failed_jobs),failed_job_ids:data.failed_job_ids??"",options:data.options??"",cancelled_at:data.cancelled_at||null,created_at:data.created_at??"",finished_at:data.finished_at||null,then_handler:data.then_handler||null,catch_handler:data.catch_handler||null,finally_handler:data.finally_handler||null}}async function getBatchFromRedis(id){try{const client=await connectBatchRedis(),key=`${REDIS_BATCH_PREFIX}${id}`,data=await client.hgetall(key);client.close();return batchRecordFromHash(data)}catch{return getBatchFromDatabase(id)}}async function getAllBatchesFromRedis(){try{const client=await connectBatchRedis(),ids=await client.smembers(REDIS_BATCH_INDEX),batches=[];for(const id of ids){const record=batchRecordFromHash(await client.hgetall(`${REDIS_BATCH_PREFIX}${id}`));if(record)batches.push(record)}client.close();return batches}catch{return getAllBatchesFromDatabase()}}async function updateBatchInRedis(id,updates){try{const client=await connectBatchRedis(),key=`${REDIS_BATCH_PREFIX}${id}`,hashUpdates={};for(const[k,v]of Object.entries(updates))hashUpdates[k]=v===null?"":String(v);await client.hset(key,hashUpdates);client.close()}catch{await updateBatchInDatabase(id,updates)}}async function decrementBatchInRedis(id,failed,allowFailures){const client=await connectBatchRedis();try{const key=`${REDIS_BATCH_PREFIX}${id}`,decrementedPending=await client.hincrby(key,"pending_jobs",-1),pending=Math.max(decrementedPending,0);if(decrementedPending<0)await client.hset(key,{pending_jobs:"0"});if(failed)await client.hincrby(key,"failed_jobs",1);const shouldFinish=!(await client.hgetall(key)).finished_at&&(!failed||allowFailures?pending===0:!0),finishedAt=new Date().toISOString().slice(0,19).replace("T"," "),completed=shouldFinish&&await client.hsetnx(key,"terminal_claimed","1");if(completed)await client.hset(key,{finished_at:finishedAt});if(completed&&failed&&!allowFailures)await client.hset(key,{cancelled_at:finishedAt});return completed}finally{client.close()}}async function deleteBatchFromRedis(id){try{const client=await connectBatchRedis();await client.del(`${REDIS_BATCH_PREFIX}${id}`);await client.srem(REDIS_BATCH_INDEX,id);client.close()}catch{await deleteBatchFromDatabase(id)}}async function cancelBatchInRedis(id){const now=new Date().toISOString().slice(0,19).replace("T"," ");await updateBatchInRedis(id,{cancelled_at:now,finished_at:now})}async function pruneBatchesFromRedis(olderThanHours){try{const batches=await getAllBatchesFromRedis(),cutoff=Date.now()-olderThanHours*60*60*1000;let pruned=0;for(const batch of batches)if(batch.finished_at){if(new Date(batch.finished_at).getTime()<cutoff){await deleteBatchFromRedis(batch.id);pruned++}}return pruned}catch{return pruneBatchesFromDatabase(olderThanHours)}}function parsePersistentHandler(raw){if(!raw)return null;try{const parsed=JSON.parse(raw);if(parsed&&(parsed.kind==="job"||parsed.kind==="module"))return parsed;log.warn(`[Batch] handler JSON has unknown kind '${parsed?.kind}' - skipping`);return null}catch(err){log.warn(`[Batch] failed to parse persistent handler: ${err.message}`);return null}}async function firePersistentHandler(handler,batchId){try{if(handler.kind==="job"){const{Jobs}=await import("./job");await Jobs.dispatch(handler.name,{...handler.payload??{},_batchId:batchId});return}const mod=await import(handler.module).catch((err)=>{log.warn(`[Batch] persistent handler module not found: ${handler.module} (${err.message})`);return null});if(!mod)return;const fn=mod[handler.export];if(typeof fn!=="function"){log.warn(`[Batch] persistent handler export '${handler.export}' is not a function on ${handler.module}`);return}await fn(handler.payload,batchId)}catch(err){log.error(`[Batch] persistent handler threw for batch ${batchId}:`,err)}}export async function recordBatchJobCompletion(batchId){const driver=getQueueDriver();let completed=!1;if(driver==="redis")completed=await decrementBatchInRedis(batchId,!1,!1);else{const{db,sql}=await import("@stacksjs/database/runtime");await db.updateTable("job_batches").set({pending_jobs:sql`GREATEST(pending_jobs - 1, 0)`}).where("id","=",batchId).where("pending_jobs",">",0).execute();const finishedAt=new Date().toISOString().slice(0,19).replace("T"," "),completeResult=await db.updateTable("job_batches").set({finished_at:finishedAt}).where("id","=",batchId).where("pending_jobs","=",0).whereNull("finished_at").executeTakeFirst();completed=updatedRowCount(completeResult)>0}const callbacks=getBatchCallbacks(batchId),dispatched=new DispatchedBatch(batchId);if(callbacks)for(const cb of callbacks.progressCallbacks)try{await cb(dispatched)}catch(e){log.error(`[Batch] Error in progress callback for batch ${batchId}:`,e)}if(completed){try{const{emitQueueEvent}=await import("./events");await emitQueueEvent("batch:completed",{jobId:batchId})}catch{}const freshRecord=await getBatchRecord(batchId);if(!freshRecord){removeBatchCallbacks(batchId);log.info(`[Batch] Batch ${batchId} finished (record vanished)`);return}const opts=JSON.parse(freshRecord.options||"{}"),succeeded=!((freshRecord.failed_jobs||0)>0)||opts.allowFailures;if(callbacks){if(succeeded)for(const cb of callbacks.thenCallbacks)try{await cb(dispatched)}catch(e){log.error(`[Batch] Error in then callback for batch ${batchId}:`,e)}for(const cb of callbacks.finallyCallbacks)try{await cb(dispatched)}catch(e){log.error(`[Batch] Error in finally callback for batch ${batchId}:`,e)}removeBatchCallbacks(batchId)}if(succeeded){const thenHandler=parsePersistentHandler(freshRecord.then_handler);if(thenHandler)await firePersistentHandler(thenHandler,batchId)}else{const catchHandler=parsePersistentHandler(freshRecord.catch_handler);if(catchHandler)await firePersistentHandler(catchHandler,batchId)}const finallyHandler=parsePersistentHandler(freshRecord.finally_handler);if(finallyHandler)await firePersistentHandler(finallyHandler,batchId);log.info(`[Batch] Batch ${batchId} finished`)}}export async function recordBatchJobFailure(batchId,jobId,error){const record=await getBatchRecord(batchId);if(!record)return;const opts=JSON.parse(record.options||"{}"),dispatched=new DispatchedBatch(batchId),callbacks=getBatchCallbacks(batchId);let completed=!1;if(getQueueDriver()==="redis"){completed=await decrementBatchInRedis(batchId,!0,!!opts.allowFailures);const fresh=await getBatchRecord(batchId);if(fresh){let failedIds=[];try{failedIds=JSON.parse(fresh.failed_job_ids||"[]")}catch{failedIds=[]}failedIds.push(jobId);await updateBatchInRedis(batchId,{failed_job_ids:JSON.stringify(failedIds)})}if(callbacks)for(const cb of callbacks.catchCallbacks)try{await cb(dispatched,error)}catch(e){log.error(`[Batch] Error in catch callback for batch ${batchId}:`,e)}try{const{emitQueueEvent}=await import("./events");await emitQueueEvent("batch:failed",{jobId:batchId,error})}catch{}}else{const{db,sql}=await import("@stacksjs/database/runtime");await db.updateTable("job_batches").set({pending_jobs:sql`GREATEST(pending_jobs - 1, 0)`,failed_jobs:sql`failed_jobs + 1`}).where("id","=",batchId).where("pending_jobs",">",0).execute();try{const fresh=await getBatchRecord(batchId);if(fresh){let failedIds=[];try{failedIds=JSON.parse(fresh.failed_job_ids||"[]")}catch{failedIds=[]}failedIds.push(jobId);await updateBatchRecord(batchId,{failed_job_ids:JSON.stringify(failedIds)})}}catch{}if(callbacks)for(const cb of callbacks.catchCallbacks)try{await cb(dispatched,error)}catch(e){log.error(`[Batch] Error in catch callback for batch ${batchId}:`,e)}try{const{emitQueueEvent}=await import("./events");await emitQueueEvent("batch:failed",{jobId:batchId,error})}catch{}const finishedAt=new Date().toISOString().slice(0,19).replace("T"," ");let finalize=db.updateTable("job_batches").set(opts.allowFailures?{finished_at:finishedAt}:{finished_at:finishedAt,cancelled_at:finishedAt}).where("id","=",batchId).whereNull("finished_at");if(opts.allowFailures)finalize=finalize.where("pending_jobs","=",0);const finalizeResult=await finalize.executeTakeFirst();completed=updatedRowCount(finalizeResult)>0}if(!completed)return;const succeeded=!!opts.allowFailures;if(callbacks){if(succeeded)for(const cb of callbacks.thenCallbacks)try{await cb(dispatched)}catch(e){log.error(`[Batch] Error in then callback for batch ${batchId}:`,e)}for(const cb of callbacks.finallyCallbacks)try{await cb(dispatched)}catch(e){log.error(`[Batch] Error in finally callback for batch ${batchId}:`,e)}removeBatchCallbacks(batchId)}const freshRecord=await getBatchRecord(batchId);if(freshRecord){if(succeeded){const thenHandler=parsePersistentHandler(freshRecord.then_handler);if(thenHandler)await firePersistentHandler(thenHandler,batchId)}else{const catchHandler=parsePersistentHandler(freshRecord.catch_handler);if(catchHandler)await firePersistentHandler(catchHandler,batchId)}const finallyHandler=parsePersistentHandler(freshRecord.finally_handler);if(finallyHandler)await firePersistentHandler(finallyHandler,batchId)}log.info(`[Batch] Batch ${batchId} finished with failure(s)`)}export async function isBatchCancelled(batchId){return(await getBatchRecord(batchId))?.cancelled_at!==null}
|
|
1
|
+
import{batchMemberPayload,batchTerminalPayload}from"./batch-payload";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=batchMemberPayload(payload,batchId,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=batchMemberPayload(payload,this.id,record.total_jobs+i);if(options.queue&&!job.queue)job.queue=options.queue;await job.dispatch(jobPayload)}log.info(`[Batch] Added ${batchableJobs.length} jobs to batch ${this.id}`)}async delete(){await deleteBatchRecord(this.id);removeBatchCallbacks(this.id)}}export class Batch{static create(jobs){return new PendingBatch(jobs)}static async find(id){if(!await getBatchRecord(id))return null;return new DispatchedBatch(id)}static async all(){return(await getAllBatchRecords()).map((r)=>new DispatchedBatch(r.id))}static async prune(olderThanHours=24){return pruneBatchRecords(olderThanHours)}}const batchCallbackRegistry=new Map;function registerBatchCallbacks(batchId,options){batchCallbackRegistry.set(batchId,options)}export function getBatchCallbacks(batchId){return batchCallbackRegistry.get(batchId)}function removeBatchCallbacks(batchId){batchCallbackRegistry.delete(batchId)}async function storeBatchRecord(record){if(getQueueDriver()==="redis")await storeBatchInRedis(record);else await storeBatchInDatabase(record)}async function getBatchRecord(id){if(getQueueDriver()==="redis")return getBatchFromRedis(id);return getBatchFromDatabase(id)}async function getAllBatchRecords(){if(getQueueDriver()==="redis")return getAllBatchesFromRedis();return getAllBatchesFromDatabase()}async function updateBatchRecord(id,updates){if(getQueueDriver()==="redis")await updateBatchInRedis(id,updates);else await updateBatchInDatabase(id,updates)}async function incrementBatchCounters(id,delta){if(delta===0)return!0;if(getQueueDriver()==="redis")try{const client=await connectBatchRedis(),key=`${REDIS_BATCH_PREFIX}${id}`;await client.hincrby(key,"total_jobs",delta);await client.hincrby(key,"pending_jobs",delta);client.close();return!0}catch{}const{db,sql}=await import("@stacksjs/database/runtime"),result=await db.updateTable("job_batches").set(batchCounterIncrements(sql,delta)).where("id","=",id).whereNull("cancelled_at").whereNull("finished_at").executeTakeFirst();return updatedRowCount(result)>0}export function batchCounterIncrements(sql,delta){return{total_jobs:sql`total_jobs + ${delta}`,pending_jobs:sql`pending_jobs + ${delta}`}}async function deleteBatchRecord(id){if(getQueueDriver()==="redis")await deleteBatchFromRedis(id);else await deleteBatchFromDatabase(id)}async function pruneBatchRecords(olderThanHours){if(getQueueDriver()==="redis")return pruneBatchesFromRedis(olderThanHours);return pruneBatchesFromDatabase(olderThanHours)}function hasPersistentHandlers(record){return!!(record.then_handler||record.catch_handler||record.finally_handler)}async function storeBatchInDatabase(record){const{db}=await import("@stacksjs/database/runtime"),columns={id:record.id,name:record.name,total_jobs:record.total_jobs,pending_jobs:record.pending_jobs,failed_jobs:record.failed_jobs,failed_job_ids:record.failed_job_ids,options:record.options,cancelled_at:record.cancelled_at,created_at:record.created_at,finished_at:record.finished_at};if(!hasPersistentHandlers(record)){await db.insertInto("job_batches").values(columns).execute();return}try{await db.insertInto("job_batches").values({...columns,then_handler:record.then_handler??null,catch_handler:record.catch_handler??null,finally_handler:record.finally_handler??null}).execute()}catch(error){log.warn(`[Batch] Could not persist terminal handlers for batch ${record.id}: ${error?.message}. The job_batches table predates the then_handler/catch_handler/finally_handler columns; recreate it to enable handlers that survive a worker restart.`);await db.insertInto("job_batches").values(columns).execute()}}async function getBatchFromDatabase(id){const{db}=await import("@stacksjs/database/runtime");return await db.selectFrom("job_batches").where("id","=",id).selectAll().executeTakeFirst()||null}async function getAllBatchesFromDatabase(){const{db}=await import("@stacksjs/database/runtime");return await db.selectFrom("job_batches").selectAll().orderBy("created_at","desc").execute()}async function updateBatchInDatabase(id,updates){const{db}=await import("@stacksjs/database/runtime");await db.updateTable("job_batches").set(updates).where("id","=",id).execute()}async function deleteBatchFromDatabase(id){const{db}=await import("@stacksjs/database/runtime");await db.deleteFrom("job_batches").where("id","=",id).execute()}async function cancelBatchInDatabase(id){await updateBatchInDatabase(id,{cancelled_at:new Date().toISOString().slice(0,19).replace("T"," "),finished_at:new Date().toISOString().slice(0,19).replace("T"," ")})}async function pruneBatchesFromDatabase(olderThanHours){const{db}=await import("@stacksjs/database/runtime"),cutoff=new Date(Date.now()-olderThanHours*60*60*1000).toISOString().slice(0,19).replace("T"," "),result=await db.deleteFrom("job_batches").whereNotNull("finished_at").where("finished_at","<",cutoff).executeTakeFirst();return Number(result?.numDeletedRows??0)}const REDIS_BATCH_PREFIX="stacks:batch:",REDIS_BATCH_INDEX="stacks:batches";async function getRedisClient(){const{sharedRedisQueue}=await import("./drivers/redis"),{queue:queueConfig}=await import("@stacksjs/config"),redisConfig=queueConfig?.connections?.redis;if(!redisConfig)throw Error("Redis queue connection is not configured");return sharedRedisQueue("__batches__",redisConfig)}async function batchRedisUrl(){const{queue:queueConfig}=await import("@stacksjs/config"),redisConfig=queueConfig?.connections?.redis?.redis;if(redisConfig?.url)return redisConfig.url;const auth=redisConfig?.password?`:${encodeURIComponent(redisConfig.password)}@`:"",db=redisConfig?.db?`/${redisConfig.db}`:"";return`redis://${auth}${redisConfig?.host||"localhost"}:${redisConfig?.port||6379}${db}`}async function connectBatchRedis(){const client=new RedisClient(await batchRedisUrl());await client.connect();return client}export function batchRecordToHash(record){return{id:record.id,name:record.name,total_jobs:String(record.total_jobs),pending_jobs:String(record.pending_jobs),failed_jobs:String(record.failed_jobs),failed_job_ids:record.failed_job_ids,options:record.options,cancelled_at:record.cancelled_at||"",created_at:record.created_at,finished_at:record.finished_at||"",then_handler:record.then_handler||"",catch_handler:record.catch_handler||"",finally_handler:record.finally_handler||""}}async function storeBatchInRedis(record){try{const client=await connectBatchRedis();await client.hset(`${REDIS_BATCH_PREFIX}${record.id}`,batchRecordToHash(record));await client.sadd(REDIS_BATCH_INDEX,record.id);client.close()}catch{await storeBatchInDatabase(record)}}export function batchRecordFromHash(data){if(!data?.id)return null;return{id:data.id,name:data.name??"",total_jobs:Number(data.total_jobs),pending_jobs:Number(data.pending_jobs),failed_jobs:Number(data.failed_jobs),failed_job_ids:data.failed_job_ids??"",options:data.options??"",cancelled_at:data.cancelled_at||null,created_at:data.created_at??"",finished_at:data.finished_at||null,then_handler:data.then_handler||null,catch_handler:data.catch_handler||null,finally_handler:data.finally_handler||null}}async function getBatchFromRedis(id){try{const client=await connectBatchRedis(),key=`${REDIS_BATCH_PREFIX}${id}`,data=await client.hgetall(key);client.close();return batchRecordFromHash(data)}catch{return getBatchFromDatabase(id)}}async function getAllBatchesFromRedis(){try{const client=await connectBatchRedis(),ids=await client.smembers(REDIS_BATCH_INDEX),batches=[];for(const id of ids){const record=batchRecordFromHash(await client.hgetall(`${REDIS_BATCH_PREFIX}${id}`));if(record)batches.push(record)}client.close();return batches}catch{return getAllBatchesFromDatabase()}}async function updateBatchInRedis(id,updates){try{const client=await connectBatchRedis(),key=`${REDIS_BATCH_PREFIX}${id}`,hashUpdates={};for(const[k,v]of Object.entries(updates))hashUpdates[k]=v===null?"":String(v);await client.hset(key,hashUpdates);client.close()}catch{await updateBatchInDatabase(id,updates)}}async function decrementBatchInRedis(id,failed,allowFailures){const client=await connectBatchRedis();try{const key=`${REDIS_BATCH_PREFIX}${id}`,decrementedPending=await client.hincrby(key,"pending_jobs",-1),pending=Math.max(decrementedPending,0);if(decrementedPending<0)await client.hset(key,{pending_jobs:"0"});if(failed)await client.hincrby(key,"failed_jobs",1);const shouldFinish=!(await client.hgetall(key)).finished_at&&(!failed||allowFailures?pending===0:!0),finishedAt=new Date().toISOString().slice(0,19).replace("T"," "),completed=shouldFinish&&await client.hsetnx(key,"terminal_claimed","1");if(completed)await client.hset(key,{finished_at:finishedAt});if(completed&&failed&&!allowFailures)await client.hset(key,{cancelled_at:finishedAt});return completed}finally{client.close()}}async function deleteBatchFromRedis(id){try{const client=await connectBatchRedis();await client.del(`${REDIS_BATCH_PREFIX}${id}`);await client.srem(REDIS_BATCH_INDEX,id);client.close()}catch{await deleteBatchFromDatabase(id)}}async function cancelBatchInRedis(id){const now=new Date().toISOString().slice(0,19).replace("T"," ");await updateBatchInRedis(id,{cancelled_at:now,finished_at:now})}async function pruneBatchesFromRedis(olderThanHours){try{const batches=await getAllBatchesFromRedis(),cutoff=Date.now()-olderThanHours*60*60*1000;let pruned=0;for(const batch of batches)if(batch.finished_at){if(new Date(batch.finished_at).getTime()<cutoff){await deleteBatchFromRedis(batch.id);pruned++}}return pruned}catch{return pruneBatchesFromDatabase(olderThanHours)}}function parsePersistentHandler(raw){if(!raw)return null;try{const parsed=JSON.parse(raw);if(parsed&&(parsed.kind==="job"||parsed.kind==="module"))return parsed;log.warn(`[Batch] handler JSON has unknown kind '${parsed?.kind}' - skipping`);return null}catch(err){log.warn(`[Batch] failed to parse persistent handler: ${err.message}`);return null}}async function firePersistentHandler(handler,batchId){try{if(handler.kind==="job"){const{Jobs}=await import("./job");await Jobs.dispatch(handler.name,batchTerminalPayload(handler.payload,batchId));return}const mod=await import(handler.module).catch((err)=>{log.warn(`[Batch] persistent handler module not found: ${handler.module} (${err.message})`);return null});if(!mod)return;const fn=mod[handler.export];if(typeof fn!=="function"){log.warn(`[Batch] persistent handler export '${handler.export}' is not a function on ${handler.module}`);return}await fn(handler.payload,batchId)}catch(err){log.error(`[Batch] persistent handler threw for batch ${batchId}:`,err)}}export async function recordBatchJobCompletion(batchId){const driver=getQueueDriver();let completed=!1;if(driver==="redis")completed=await decrementBatchInRedis(batchId,!1,!1);else{const{db,sql}=await import("@stacksjs/database/runtime");await db.updateTable("job_batches").set({pending_jobs:sql`GREATEST(pending_jobs - 1, 0)`}).where("id","=",batchId).where("pending_jobs",">",0).execute();const finishedAt=new Date().toISOString().slice(0,19).replace("T"," "),completeResult=await db.updateTable("job_batches").set({finished_at:finishedAt}).where("id","=",batchId).where("pending_jobs","=",0).whereNull("finished_at").executeTakeFirst();completed=updatedRowCount(completeResult)>0}const callbacks=getBatchCallbacks(batchId),dispatched=new DispatchedBatch(batchId);if(callbacks)for(const cb of callbacks.progressCallbacks)try{await cb(dispatched)}catch(e){log.error(`[Batch] Error in progress callback for batch ${batchId}:`,e)}if(completed){try{const{emitQueueEvent}=await import("./events");await emitQueueEvent("batch:completed",{jobId:batchId})}catch{}const freshRecord=await getBatchRecord(batchId);if(!freshRecord){removeBatchCallbacks(batchId);log.info(`[Batch] Batch ${batchId} finished (record vanished)`);return}const opts=JSON.parse(freshRecord.options||"{}"),succeeded=!((freshRecord.failed_jobs||0)>0)||opts.allowFailures;if(callbacks){if(succeeded)for(const cb of callbacks.thenCallbacks)try{await cb(dispatched)}catch(e){log.error(`[Batch] Error in then callback for batch ${batchId}:`,e)}for(const cb of callbacks.finallyCallbacks)try{await cb(dispatched)}catch(e){log.error(`[Batch] Error in finally callback for batch ${batchId}:`,e)}removeBatchCallbacks(batchId)}if(succeeded){const thenHandler=parsePersistentHandler(freshRecord.then_handler);if(thenHandler)await firePersistentHandler(thenHandler,batchId)}else{const catchHandler=parsePersistentHandler(freshRecord.catch_handler);if(catchHandler)await firePersistentHandler(catchHandler,batchId)}const finallyHandler=parsePersistentHandler(freshRecord.finally_handler);if(finallyHandler)await firePersistentHandler(finallyHandler,batchId);log.info(`[Batch] Batch ${batchId} finished`)}}export async function recordBatchJobFailure(batchId,jobId,error){const record=await getBatchRecord(batchId);if(!record)return;const opts=JSON.parse(record.options||"{}"),dispatched=new DispatchedBatch(batchId),callbacks=getBatchCallbacks(batchId);let completed=!1;if(getQueueDriver()==="redis"){completed=await decrementBatchInRedis(batchId,!0,!!opts.allowFailures);const fresh=await getBatchRecord(batchId);if(fresh){let failedIds=[];try{failedIds=JSON.parse(fresh.failed_job_ids||"[]")}catch{failedIds=[]}failedIds.push(jobId);await updateBatchInRedis(batchId,{failed_job_ids:JSON.stringify(failedIds)})}if(callbacks)for(const cb of callbacks.catchCallbacks)try{await cb(dispatched,error)}catch(e){log.error(`[Batch] Error in catch callback for batch ${batchId}:`,e)}try{const{emitQueueEvent}=await import("./events");await emitQueueEvent("batch:failed",{jobId:batchId,error})}catch{}}else{const{db,sql}=await import("@stacksjs/database/runtime");await db.updateTable("job_batches").set({pending_jobs:sql`GREATEST(pending_jobs - 1, 0)`,failed_jobs:sql`failed_jobs + 1`}).where("id","=",batchId).where("pending_jobs",">",0).execute();try{const fresh=await getBatchRecord(batchId);if(fresh){let failedIds=[];try{failedIds=JSON.parse(fresh.failed_job_ids||"[]")}catch{failedIds=[]}failedIds.push(jobId);await updateBatchRecord(batchId,{failed_job_ids:JSON.stringify(failedIds)})}}catch{}if(callbacks)for(const cb of callbacks.catchCallbacks)try{await cb(dispatched,error)}catch(e){log.error(`[Batch] Error in catch callback for batch ${batchId}:`,e)}try{const{emitQueueEvent}=await import("./events");await emitQueueEvent("batch:failed",{jobId:batchId,error})}catch{}const finishedAt=new Date().toISOString().slice(0,19).replace("T"," ");let finalize=db.updateTable("job_batches").set(opts.allowFailures?{finished_at:finishedAt}:{finished_at:finishedAt,cancelled_at:finishedAt}).where("id","=",batchId).whereNull("finished_at");if(opts.allowFailures)finalize=finalize.where("pending_jobs","=",0);const finalizeResult=await finalize.executeTakeFirst();completed=updatedRowCount(finalizeResult)>0}if(!completed)return;const succeeded=!!opts.allowFailures;if(callbacks){if(succeeded)for(const cb of callbacks.thenCallbacks)try{await cb(dispatched)}catch(e){log.error(`[Batch] Error in then callback for batch ${batchId}:`,e)}for(const cb of callbacks.finallyCallbacks)try{await cb(dispatched)}catch(e){log.error(`[Batch] Error in finally callback for batch ${batchId}:`,e)}removeBatchCallbacks(batchId)}const freshRecord=await getBatchRecord(batchId);if(freshRecord){if(succeeded){const thenHandler=parsePersistentHandler(freshRecord.then_handler);if(thenHandler)await firePersistentHandler(thenHandler,batchId)}else{const catchHandler=parsePersistentHandler(freshRecord.catch_handler);if(catchHandler)await firePersistentHandler(catchHandler,batchId)}const finallyHandler=parsePersistentHandler(freshRecord.finally_handler);if(finallyHandler)await firePersistentHandler(finallyHandler,batchId)}log.info(`[Batch] Batch ${batchId} finished with failure(s)`)}export async function isBatchCancelled(batchId){return(await getBatchRecord(batchId))?.cancelled_at!==null}
|
package/dist/job.d.ts
CHANGED
|
@@ -37,6 +37,7 @@ export declare function clearJobNameIndex(): void;
|
|
|
37
37
|
* What a job's own definition says about retrying it, for a dispatch that
|
|
38
38
|
* did not.
|
|
39
39
|
*
|
|
40
|
+
* A job dispatched by name inherits its declared queue unless explicitly routed.
|
|
40
41
|
* A job dispatched by name - `mail.queue()` sending `SendEmailJob`, a
|
|
41
42
|
* `Queue.dispatch('X')` - carried none of the `tries`, `backoff` or
|
|
42
43
|
* `timeout` the job declares, and the worker defaulted to one attempt. A
|
|
@@ -44,7 +45,7 @@ export declare function clearJobNameIndex(): void;
|
|
|
44
45
|
* job asks for three tries with 10/30/60s backoff. The worker reads these
|
|
45
46
|
* when the envelope is silent; anything set on the dispatch still wins.
|
|
46
47
|
*/
|
|
47
|
-
export declare function jobDefaults(name: string): Promise<{ tries?: number, backoff?: number | number[], timeout?: number }>;
|
|
48
|
+
export declare function jobDefaults(name: string): Promise<{ queue?: string, tries?: number, backoff?: number | number[], timeout?: number }>;
|
|
48
49
|
/**
|
|
49
50
|
* Run a job immediately by name
|
|
50
51
|
*
|
package/dist/job.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var {require}=import.meta;import{resolveQueueDriver}from"./driver";import{basename}from"node:path";import{appPath,frameworkPath}from"@stacksjs/path";import{enqueueAfterCommit,isInTransaction}from"@stacksjs/database/runtime";import{moveToDeadLetter}from"./dead-letter";import{assertEnvelopeSerializable,createEnvelope,serializeEnvelope}from"./envelope";import{claimDispatchKey,releaseDispatchKey}from"./idempotency";import{isQuarantined}from"./poison";import{runNamedAction}from"./action-runner";let testingModule,databaseModule,traceModule;function loadTraceModule(){return traceModule??=import("@stacksjs/router").catch((error)=>{traceModule=void 0;throw error})}function getQueueDriver(){return resolveQueueDriver()}let _warnedAfterCommitNoTx=!1;function warnAfterCommitOutsideTransaction(){if(_warnedAfterCommitNoTx)return;_warnedAfterCommitNoTx=!0;console.warn("[queue] .afterCommit() was called outside of `db.transaction(...)`. Dispatching immediately. Wrap the call in a transaction or drop .afterCommit() to silence this message.")}class JobBuilder{name;payload;options={};txMode="auto";constructor(name,payload){this.name=name;this.payload=payload}onQueue(queue){this.options.queue=queue;return this}delay(seconds){this.options.delay=seconds;return this}tries(count){this.options.tries=count;return this}timeout(seconds){this.options.timeout=seconds;return this}backoff(delays){this.options.backoff=delays;return this}withContext(context){this.options.context=context;return this}withIdempotencyKey(key){this.options.idempotencyKey=key;return this}afterCommit(){this.txMode="after";return this}withoutCommit(){this.txMode="immediate";return this}async dispatch(){const{isFaked,getFakeQueue}=await(testingModule??=import("./testing").catch((error)=>{testingModule=void 0;throw error}));if(isFaked()){getFakeQueue()?.dispatch(this.name,this.payload,this.options);return}if(this.txMode!=="immediate"){if(isInTransaction()){const buffered=this.runDispatchPipeline.bind(this);if(enqueueAfterCommit(async()=>{await buffered()}))return}else if(this.txMode==="after")warnAfterCommitOutsideTransaction()}await this.runDispatchPipeline()}async runDispatchPipeline(){let claimedKey=!1;if(this.options.idempotencyKey){const claim=await claimDispatchKey(this.options.idempotencyKey,this.name,this.options.queue);if(claim==="duplicate")return;claimedKey=claim==="claimed"}try{if(await isQuarantined(this.name,this.payload)){const envelope=createEnvelope(this.name,this.payload,{queue:this.options.queue,timeout:this.options.timeout,tries:this.options.tries,backoff:this.options.backoff},await currentTraceId(),this.options.context);if(await moveToDeadLetter({queue:this.options.queue||"default",payload:serializeEnvelope(envelope),exception:`quarantined: ${this.name}`},"poison-detected"))return}const driver=getQueueDriver();if(driver==="database")await this.dispatchToDatabase();else if(driver==="redis")await this.dispatchToRedis();else if(driver==="sync")await runJob(this.name,{payload:this.payload,context:this.options.context,traceId:await currentTraceId()});else if(driver==="sqs"||driver==="memory"||driver==="beanstalkd")throw Error(`[queue] Driver "${driver}" is not implemented yet. Set QUEUE_DRIVER to one of: redis, database, sync.`);else throw Error(`[queue] Unknown QUEUE_DRIVER "${driver}". Allowed values: redis, database, sync.`)}catch(err){if(claimedKey&&this.options.idempotencyKey)await releaseDispatchKey(this.options.idempotencyKey);throw err}}async dispatchIf(condition){if(condition)await this.dispatch()}async dispatchUnless(condition){if(!condition)await this.dispatch()}async dispatchToDatabase(){const now=Math.floor(Date.now()/1000),availableAt=this.options.delay?now+this.options.delay:now,envelope=createEnvelope(this.name,this.payload,{queue:this.options.queue,timeout:this.options.timeout,tries:this.options.tries,backoff:this.options.backoff},await currentTraceId(),this.options.context),payloadJson=serializeEnvelope(envelope),{db}=await(databaseModule??=import("@stacksjs/database/runtime").catch((error)=>{databaseModule=void 0;throw error}));await db.insertInto("jobs").values({queue:this.options.queue||"default",payload:payloadJson,attempts:0,reserved_at:null,available_at:availableAt,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute()}async dispatchToRedis(){const envelope=createEnvelope(this.name,this.payload,{queue:this.options.queue,timeout:this.options.timeout,tries:this.options.tries,backoff:this.options.backoff},await currentTraceId(),this.options.context);assertEnvelopeSerializable(envelope);const{sharedRedisQueue}=await import("./drivers/redis"),{queue:queueConfig}=await import("@stacksjs/config"),redisConfig=queueConfig?.connections?.redis;if(!redisConfig)throw Error("Redis queue connection is not configured. Check config/queue.ts");await sharedRedisQueue(this.options.queue||"default",redisConfig).add(envelope,{delay:this.options.delay,maxTries:this.options.tries,timeout:this.options.timeout,backoff:this.options.backoff})}async dispatchNow(){await runJob(this.name,{payload:this.payload,context:this.options.context})}}export function job(name,payload){return new JobBuilder(name,payload)}export const Jobs={make(name,payload){return new JobBuilder(name,payload)},async dispatch(name,payload){await new JobBuilder(name,payload).dispatch()},async dispatchIf(condition,name,payload){if(condition)await new JobBuilder(name,payload).dispatch()},async dispatchUnless(condition,name,payload){if(!condition)await new JobBuilder(name,payload).dispatch()},async dispatchNow(name,payload){await new JobBuilder(name,payload).dispatchNow()},dispatchAfter(seconds,name,payload){return new JobBuilder(name,payload).delay(seconds)},async dispatchOnce(key,name,payload){await new JobBuilder(name,payload).withIdempotencyKey(key).dispatch()},async dispatchAfterCommit(name,payload){await new JobBuilder(name,payload).afterCommit().dispatch()}};export function jobBatch(jobs){const{PendingBatch}=require("./batch");return new PendingBatch(jobs)}export async function resolveJobFile(name){const files=name.endsWith("Job")?[`${name}.ts`]:[`${name}.ts`,`${name}Job.ts`],candidates=[...files.map((file)=>appPath(`Jobs/${file}`)),...files.map((file)=>frameworkPath(`defaults/app/Jobs/${file}`))];for(const candidate of candidates)if(await Bun.file(candidate).exists())return candidate;let packageRoot;try{const pkgUrl=import.meta.resolve("@stacksjs/defaults/package.json");packageRoot=new URL(".",pkgUrl).pathname}catch{}if(packageRoot)for(const file of files){const packageCandidate=`${packageRoot}app/Jobs/${file}`;if(await Bun.file(packageCandidate).exists())return packageCandidate}const directories=[appPath("Jobs"),frameworkPath("defaults/app/Jobs"),...packageRoot?[`${packageRoot}app/Jobs`]:[]];for(const directory of directories){const declared=(await declaredJobNames(directory)).get(name);if(declared)return declared}return null}const declaredNamesByDirectory=new Map;function declaredJobNames(directory){let index=declaredNamesByDirectory.get(directory);if(!index){index=(async()=>{const byName=new Map,{existsSync}=await import("node:fs");if(!existsSync(directory))return byName;const glob=new Bun.Glob("*.ts");for await(const file of glob.scan({cwd:directory,absolute:!0,onlyFiles:!0})){if(file.endsWith(".d.ts")||file.includes(".test."))continue;const fileName=basename(file,".ts");let declared;try{declared=(await import(file)).default?.name}catch{continue}if(typeof declared!=="string"||declared===""||declared===fileName)continue;const existing=byName.get(declared);if(existing)throw Error(`Jobs ${existing} and ${file} both declare the name "${declared}"; rename one so a dispatch under it has one job to run`);byName.set(declared,file)}return byName})();index.catch(()=>declaredNamesByDirectory.delete(directory));declaredNamesByDirectory.set(directory,index)}return index}export function clearJobNameIndex(){declaredNamesByDirectory.clear()}export async function jobDefaults(name){try{const jobPath=await resolveJobFile(name);if(!jobPath)return{};const definition=(await import(jobPath)).default;if(!definition||typeof definition!=="object")return{};const tries=Number(definition.tries),timeout=Number(definition.timeout),backoff=definition.backoff;return{tries:Number.isInteger(tries)&&tries>0?tries:void 0,timeout:Number.isFinite(timeout)&&timeout>0?timeout:void 0,backoff:typeof backoff==="number"||Array.isArray(backoff)&&backoff.every((delay)=>typeof delay==="number")?backoff:void 0}}catch{return{}}}export async function runJob(name,options={}){const{withTraceId}=await loadTraceModule(),traceId=options.traceId??`job:${name}:${Math.random().toString(36).slice(2,10)}`;await withTraceId(traceId,async()=>{const jobPath=await resolveJobFile(name);if(!jobPath)throw Error(`Job ${name} not found. Looked in app/Jobs/${name}.ts${name.endsWith("Job")?"":` (and ${name}Job.ts)`} and the framework defaults (storage/framework/defaults/app/Jobs, @stacksjs/defaults).`);const jobConfig=(await import(jobPath)).default;if(!jobConfig)throw Error(`Job ${name} does not export a default`);if(typeof jobConfig.handle==="function")await jobConfig.handle(options.payload,options.context);else if(typeof jobConfig.action==="string")await runNamedAction(jobConfig.action);else if(typeof jobConfig.action==="function")await jobConfig.action();else if(typeof jobConfig==="function")await jobConfig(options.payload,options.context);else throw Error(`Job ${name} does not have a valid handler`)})}async function currentTraceId(){try{const{getTraceId}=await loadTraceModule();return getTraceId()}catch{return}}
|
|
1
|
+
var {require}=import.meta;import{resolveQueueDriver}from"./driver";import{basename}from"node:path";import{appPath,frameworkPath}from"@stacksjs/path";import{enqueueAfterCommit,isInTransaction}from"@stacksjs/database/runtime";import{moveToDeadLetter}from"./dead-letter";import{assertEnvelopeSerializable,createEnvelope,serializeEnvelope}from"./envelope";import{claimDispatchKey,releaseDispatchKey}from"./idempotency";import{isQuarantined}from"./poison";import{runNamedAction}from"./action-runner";let testingModule,databaseModule,traceModule;function loadTraceModule(){return traceModule??=import("@stacksjs/router").catch((error)=>{traceModule=void 0;throw error})}function getQueueDriver(){return resolveQueueDriver()}let _warnedAfterCommitNoTx=!1;function warnAfterCommitOutsideTransaction(){if(_warnedAfterCommitNoTx)return;_warnedAfterCommitNoTx=!0;console.warn("[queue] .afterCommit() was called outside of `db.transaction(...)`. Dispatching immediately. Wrap the call in a transaction or drop .afterCommit() to silence this message.")}class JobBuilder{name;payload;options={};txMode="auto";constructor(name,payload){this.name=name;this.payload=payload}onQueue(queue){this.options.queue=queue;return this}delay(seconds){this.options.delay=seconds;return this}tries(count){this.options.tries=count;return this}timeout(seconds){this.options.timeout=seconds;return this}backoff(delays){this.options.backoff=delays;return this}withContext(context){this.options.context=context;return this}withIdempotencyKey(key){this.options.idempotencyKey=key;return this}afterCommit(){this.txMode="after";return this}withoutCommit(){this.txMode="immediate";return this}async dispatch(){const{isFaked,getFakeQueue}=await(testingModule??=import("./testing").catch((error)=>{testingModule=void 0;throw error}));if(isFaked()){getFakeQueue()?.dispatch(this.name,this.payload,this.options);return}if(this.txMode!=="immediate"){if(isInTransaction()){const buffered=this.runDispatchPipeline.bind(this);if(enqueueAfterCommit(async()=>{await buffered()}))return}else if(this.txMode==="after")warnAfterCommitOutsideTransaction()}await this.runDispatchPipeline()}async runDispatchPipeline(){if(this.options.queue===void 0)this.options.queue=(await jobDefaults(this.name)).queue;let claimedKey=!1;if(this.options.idempotencyKey){const claim=await claimDispatchKey(this.options.idempotencyKey,this.name,this.options.queue);if(claim==="duplicate")return;claimedKey=claim==="claimed"}try{if(await isQuarantined(this.name,this.payload)){const envelope=createEnvelope(this.name,this.payload,{queue:this.options.queue,timeout:this.options.timeout,tries:this.options.tries,backoff:this.options.backoff},await currentTraceId(),this.options.context);if(await moveToDeadLetter({queue:this.options.queue||"default",payload:serializeEnvelope(envelope),exception:`quarantined: ${this.name}`},"poison-detected"))return}const driver=getQueueDriver();if(driver==="database")await this.dispatchToDatabase();else if(driver==="redis")await this.dispatchToRedis();else if(driver==="sync")await runJob(this.name,{payload:this.payload,context:this.options.context,traceId:await currentTraceId()});else if(driver==="sqs"||driver==="memory"||driver==="beanstalkd")throw Error(`[queue] Driver "${driver}" is not implemented yet. Set QUEUE_DRIVER to one of: redis, database, sync.`);else throw Error(`[queue] Unknown QUEUE_DRIVER "${driver}". Allowed values: redis, database, sync.`)}catch(err){if(claimedKey&&this.options.idempotencyKey)await releaseDispatchKey(this.options.idempotencyKey);throw err}}async dispatchIf(condition){if(condition)await this.dispatch()}async dispatchUnless(condition){if(!condition)await this.dispatch()}async dispatchToDatabase(){const now=Math.floor(Date.now()/1000),availableAt=this.options.delay?now+this.options.delay:now,envelope=createEnvelope(this.name,this.payload,{queue:this.options.queue,timeout:this.options.timeout,tries:this.options.tries,backoff:this.options.backoff},await currentTraceId(),this.options.context),payloadJson=serializeEnvelope(envelope),{db}=await(databaseModule??=import("@stacksjs/database/runtime").catch((error)=>{databaseModule=void 0;throw error}));await db.insertInto("jobs").values({queue:this.options.queue||"default",payload:payloadJson,attempts:0,reserved_at:null,available_at:availableAt,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute()}async dispatchToRedis(){const envelope=createEnvelope(this.name,this.payload,{queue:this.options.queue,timeout:this.options.timeout,tries:this.options.tries,backoff:this.options.backoff},await currentTraceId(),this.options.context);assertEnvelopeSerializable(envelope);const{sharedRedisQueue}=await import("./drivers/redis"),{queue:queueConfig}=await import("@stacksjs/config"),redisConfig=queueConfig?.connections?.redis;if(!redisConfig)throw Error("Redis queue connection is not configured. Check config/queue.ts");await sharedRedisQueue(this.options.queue||"default",redisConfig).add(envelope,{delay:this.options.delay,maxTries:this.options.tries,timeout:this.options.timeout,backoff:this.options.backoff})}async dispatchNow(){await runJob(this.name,{payload:this.payload,context:this.options.context})}}export function job(name,payload){return new JobBuilder(name,payload)}export const Jobs={make(name,payload){return new JobBuilder(name,payload)},async dispatch(name,payload){await new JobBuilder(name,payload).dispatch()},async dispatchIf(condition,name,payload){if(condition)await new JobBuilder(name,payload).dispatch()},async dispatchUnless(condition,name,payload){if(!condition)await new JobBuilder(name,payload).dispatch()},async dispatchNow(name,payload){await new JobBuilder(name,payload).dispatchNow()},dispatchAfter(seconds,name,payload){return new JobBuilder(name,payload).delay(seconds)},async dispatchOnce(key,name,payload){await new JobBuilder(name,payload).withIdempotencyKey(key).dispatch()},async dispatchAfterCommit(name,payload){await new JobBuilder(name,payload).afterCommit().dispatch()}};export function jobBatch(jobs){const{PendingBatch}=require("./batch");return new PendingBatch(jobs)}export async function resolveJobFile(name){const files=name.endsWith("Job")?[`${name}.ts`]:[`${name}.ts`,`${name}Job.ts`],candidates=[...files.map((file)=>appPath(`Jobs/${file}`)),...files.map((file)=>frameworkPath(`defaults/app/Jobs/${file}`))];for(const candidate of candidates)if(await Bun.file(candidate).exists())return candidate;let packageRoot;try{const pkgUrl=import.meta.resolve("@stacksjs/defaults/package.json");packageRoot=new URL(".",pkgUrl).pathname}catch{}if(packageRoot)for(const file of files){const packageCandidate=`${packageRoot}app/Jobs/${file}`;if(await Bun.file(packageCandidate).exists())return packageCandidate}const directories=[appPath("Jobs"),frameworkPath("defaults/app/Jobs"),...packageRoot?[`${packageRoot}app/Jobs`]:[]];for(const directory of directories){const declared=(await declaredJobNames(directory)).get(name);if(declared)return declared}return null}const declaredNamesByDirectory=new Map;function declaredJobNames(directory){let index=declaredNamesByDirectory.get(directory);if(!index){index=(async()=>{const byName=new Map,{existsSync}=await import("node:fs");if(!existsSync(directory))return byName;const glob=new Bun.Glob("*.ts");for await(const file of glob.scan({cwd:directory,absolute:!0,onlyFiles:!0})){if(file.endsWith(".d.ts")||file.includes(".test."))continue;const fileName=basename(file,".ts");let declared;try{declared=(await import(file)).default?.name}catch{continue}if(typeof declared!=="string"||declared===""||declared===fileName)continue;const existing=byName.get(declared);if(existing)throw Error(`Jobs ${existing} and ${file} both declare the name "${declared}"; rename one so a dispatch under it has one job to run`);byName.set(declared,file)}return byName})();index.catch(()=>declaredNamesByDirectory.delete(directory));declaredNamesByDirectory.set(directory,index)}return index}export function clearJobNameIndex(){declaredNamesByDirectory.clear()}export async function jobDefaults(name){try{const jobPath=await resolveJobFile(name);if(!jobPath)return{};const definition=(await import(jobPath)).default;if(!definition||typeof definition!=="object")return{};const tries=Number(definition.tries),timeout=Number(definition.timeout),backoff=definition.backoff;return{queue:typeof definition.queue==="string"&&definition.queue.trim()?definition.queue:void 0,tries:Number.isInteger(tries)&&tries>0?tries:void 0,timeout:Number.isFinite(timeout)&&timeout>0?timeout:void 0,backoff:typeof backoff==="number"||Array.isArray(backoff)&&backoff.every((delay)=>typeof delay==="number")?backoff:void 0}}catch{return{}}}export async function runJob(name,options={}){const{withTraceId}=await loadTraceModule(),traceId=options.traceId??`job:${name}:${Math.random().toString(36).slice(2,10)}`;await withTraceId(traceId,async()=>{const jobPath=await resolveJobFile(name);if(!jobPath)throw Error(`Job ${name} not found. Looked in app/Jobs/${name}.ts${name.endsWith("Job")?"":` (and ${name}Job.ts)`} and the framework defaults (storage/framework/defaults/app/Jobs, @stacksjs/defaults).`);const jobConfig=(await import(jobPath)).default;if(!jobConfig)throw Error(`Job ${name} does not export a default`);if(typeof jobConfig.handle==="function")await jobConfig.handle(options.payload,options.context);else if(typeof jobConfig.action==="string")await runNamedAction(jobConfig.action);else if(typeof jobConfig.action==="function")await jobConfig.action();else if(typeof jobConfig==="function")await jobConfig(options.payload,options.context);else throw Error(`Job ${name} does not have a valid handler`)})}async function currentTraceId(){try{const{getTraceId}=await loadTraceModule();return getTraceId()}catch{return}}
|
package/dist/worker.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{resolveQueueDriver}from"./driver";import{err,ok}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import process from"node:process";import{parseEnvelope}from"./envelope";import{updatedRowCount}from"./utils";let workerCrashHandlersInstalled=!1;function installWorkerCrashHandlers(){if(workerCrashHandlersInstalled)return;workerCrashHandlersInstalled=!0;process.on("unhandledRejection",(reason,_promise)=>{log.error(`Unhandled Rejection: ${reason}`)});process.on("uncaughtException",(error)=>{log.error(`Uncaught Exception: ${error.message}`)})}let activeJobCount=0,workerRunning=!1,workerId="";const inFlightJobs=new Set,reserveErrorLoggedAt=new Map;function trackInFlight(promise){inFlightJobs.add(promise);promise.finally(()=>inFlightJobs.delete(promise));return promise}function getReservationTtlSec(){const raw=process.env.STACKS_QUEUE_RESERVATION_TTL_SEC,n=raw===void 0?Number.NaN:Number.parseInt(raw,10);return Number.isFinite(n)&&n>0?n:3600}function getRetryJitterRatio(){const raw=process.env.STACKS_QUEUE_RETRY_JITTER;if(raw===void 0)return 0.2;const n=Number(raw);if(!Number.isFinite(n)||n<0)return 0.2;return Math.min(n,1)}export function applyRetryJitter(delaySeconds,ratio,random=Math.random){if(!(delaySeconds>0)||ratio<=0)return delaySeconds;return Math.round(delaySeconds+delaySeconds*ratio*random())}function getSweepIntervalMs(){const raw=process.env.STACKS_QUEUE_SWEEP_INTERVAL_SEC,n=raw===void 0?Number.NaN:Number.parseInt(raw,10);return(Number.isFinite(n)&&n>0?n:60)*1000}async function sweepStaleReservations(){const ttlSec=getReservationTtlSec(),cutoff=Math.floor(Date.now()/1000)-ttlSec,now=Math.floor(Date.now()/1000);try{const{db}=await import("@stacksjs/database/runtime"),result=await db.updateTable("jobs").set({reserved_at:null,available_at:now}).where("reserved_at","<=",cutoff).executeTakeFirst(),requeued=updatedRowCount(result);if(requeued>0)log.warn(`[queue] Requeued ${requeued} job(s) whose reservation exceeded the ${ttlSec}s TTL - likely victims of a worker crash. Set STACKS_QUEUE_RESERVATION_TTL_SEC to tune.`);return requeued}catch(error){log.error("[queue] Reservation sweep failed",{reason:error instanceof Error?error.message:String(error)});return 0}}function isNonRetryableError(e){if(!e||typeof e!=="object")return!1;const status=e.status??e.statusCode;if(typeof status==="number"&&status>=400&&status<500)return!0;const name=e.name;if(typeof name==="string"&&(name==="ValidationError"||name==="ModelNotFoundError"))return!0;return!1}function getQueueDriver(){return resolveQueueDriver()}export async function startProcessor(queueName,options={}){try{log.info("Starting queue processor...");installWorkerCrashHandlers();workerRunning=!0;workerId=`worker-${process.pid}-${Date.now()}`;const concurrency=options.concurrency||1,queueDriver=getQueueDriver(),{getWorkerTracker,getGlobalMetrics}=await import("./events");getGlobalMetrics();getWorkerTracker().register(workerId,queueName||"default");if(queueDriver==="redis"){log.info("Using Redis queue driver (bun-queue)");await processJobsFromRedis(queueName||"default",concurrency);return ok(void 0)}const named=namedWorkerQueues(queueName);let queues;if(named.length>0)queues=named;else{queues=await getAllQueues();if(queues.length===0)queues=["default"]}log.info(`Processing queues: ${queues.join(", ")}`);await processJobsFromDatabase(queues,concurrency,{followNewQueues:named.length===0});return ok(void 0)}catch(error){workerRunning=!1;return err(error)}}async function getAllQueues(){try{const{db}=await import("@stacksjs/database/runtime"),queues=(await db.selectFrom("jobs").select("queue").distinct().execute()).map((r)=>r.queue).filter((q)=>Boolean(q));return queues.length>0?queues:["default"]}catch{return["default"]}}export function namedWorkerQueues(queueName){if(typeof queueName!=="string")return[];return[...new Set(queueName.split(",").map((name)=>name.trim()).filter(Boolean))]}async function processJobsFromDatabase(initialQueues,concurrency,options={followNewQueues:!0}){log.info("Listening for jobs...");let queues=initialQueues,lastQueueRefresh=Date.now();const queueRefreshInterval=1e4;let lastSweep=Date.now();const sweepIntervalMs=getSweepIntervalMs();await sweepStaleReservations();while(workerRunning)try{const now=Date.now();if(options.followNewQueues&&now-lastQueueRefresh>queueRefreshInterval){try{const refreshedQueues=await getAllQueues();if(refreshedQueues.length>0)queues=refreshedQueues}catch{}lastQueueRefresh=now}if(now-lastSweep>sweepIntervalMs){await sweepStaleReservations();lastSweep=now}for(const queueName of queues){try{const{isCircuitOpen}=await import("./circuit-breaker");if(await isCircuitOpen(queueName))continue}catch{}let jobs=[];try{jobs=await fetchPendingJobs(queueName,concurrency)}catch(error){const now=Date.now(),last=reserveErrorLoggedAt.get(queueName)??0;if(now-last>60000){reserveErrorLoggedAt.set(queueName,now);log.error(`[queue] Could not reserve jobs on "${queueName}" - retrying each cycle:`,error)}continue}await Promise.all(jobs.map(async(job)=>{try{log.info(`Processing job ${job.id} from queue "${queueName}"`);await trackInFlight(processJob(job))}catch{log.error(`Unexpected error processing job ${job.id}`)}}))}await sleep(1000)}catch{await sleep(3000)}}async function fetchPendingJobs(queueName,limit){const now=Math.floor(Date.now()/1000),{db}=await import("@stacksjs/database/runtime"),claimed=[];for(let i=0;i<limit;i++){const job=await db.selectFrom("jobs").where("queue","=",queueName).whereNull("reserved_at").where("available_at","<=",now).orderBy("id","asc").limit(1).selectAll().executeTakeFirst();if(!job)break;const result=await db.updateTable("jobs").set({reserved_at:now,attempts:Number(job.attempts||0)+1}).where("id","=",job.id).whereNull("reserved_at").executeTakeFirst();if(updatedRowCount(result)>0)claimed.push(job)}return claimed}async function processJob(job){const jobId=job.id,queueName=job.queue||"default";activeJobCount++;const startTime=Date.now(),{emitQueueEvent,getWorkerTracker}=await import("./events"),tracker=getWorkerTracker();tracker.markActive(workerId);let parsedJobName;try{parsedJobName=JSON.parse(job.payload||"{}").jobName}catch{}await emitQueueEvent("job:processing",{jobId:String(jobId),queueName,jobName:parsedJobName});try{const batchId=JSON.parse(job.payload||"{}").payload?._batchId;if(batchId){const{isBatchCancelled}=await import("./batch");if(await isBatchCancelled(batchId)){log.info(`[Queue] Skipping job ${jobId} - batch ${batchId} has been cancelled`);await deleteJob(jobId);activeJobCount--;tracker.markIdle(workerId);return}}}catch{}let jobError=null;const declared=parsedJobName?await(await import("./job")).jobDefaults(parsedJobName):{};try{const payload=JSON.parse(job.payload||"{}"),timeoutSec=readJobTimeoutSec(payload)??declared.timeout;if(timeoutSec===void 0)await executeJobPayload(payload);else await raceWithTimeout(executeJobPayload(payload),timeoutSec*1000,`Job ${jobId} exceeded ${timeoutSec}s timeout`)}catch(e){jobError=e instanceof Error?e:Error(String(e))}if(!jobError)try{await deleteJob(jobId);log.info(`[Queue] Job ${jobId} completed`);tracker.recordCompletion(workerId);try{const{recordCircuitSuccess}=await import("./circuit-breaker");await recordCircuitSuccess(job.queue??"default")}catch{}await emitQueueEvent("job:completed",{jobId:String(jobId),queueName,duration:Date.now()-startTime});try{const batchId=JSON.parse(job.payload||"{}").payload?._batchId;if(batchId){const{recordBatchJobCompletion}=await import("./batch");await recordBatchJobCompletion(batchId)}}catch{}}catch{log.info(`[Queue] Failed to delete completed job ${jobId}`)}else{const errorMessage=jobError.message;log.info(`[Queue] Job ${jobId} failed: ${errorMessage}`);tracker.recordFailure(workerId);await emitQueueEvent("job:failed",{jobId:String(jobId),queueName,error:jobError,duration:Date.now()-startTime,attemptsMade:(job.attempts||0)+1});let maxAttempts=1,parsedPayload={};try{parsedPayload=JSON.parse(job.payload||"{}");maxAttempts=parsedPayload.options?.tries||declared.tries||1}catch{}const currentAttempts=(job.attempts||0)+1;if(currentAttempts>=maxAttempts){log.info(`[Queue] Job ${jobId} exceeded max attempts (${currentAttempts}/${maxAttempts})`);let persisted=!1;if(parsedPayload?._retriedFromFailed===!0)try{const{moveToDeadLetter}=await import("./dead-letter");persisted=await moveToDeadLetter({queue:job.queue,payload:job.payload,exception:jobError.stack||jobError.message},"repeat-failure",2);if(persisted)log.info(`[Queue] Job ${jobId} re-failed after retry - moved to dead_letter_jobs`)}catch{persisted=!1}if(!persisted){log.info(`[Queue] Moving job ${jobId} to failed_jobs`);try{persisted=await moveToFailedJobs(job,jobError,{attempts:currentAttempts,maxAttempts,durationMs:Date.now()-startTime})}catch{persisted=!1}}if(persisted)try{await deleteJob(jobId)}catch{log.info(`[Queue] Failed to delete failed job ${jobId}`)}else log.error(`[Queue] Job ${jobId} exhausted its retries but could NOT be persisted to failed_jobs - leaving it in the queue to avoid data loss (the reservation sweep will retry it). Check that the failed_jobs table exists and is writable.`);try{const{recordFailureForPoison}=await import("./poison"),{recordCircuitFailure}=await import("./circuit-breaker"),jobName=parsedPayload?.jobName??"unknown";await recordFailureForPoison(jobName,parsedPayload?.payload);await recordCircuitFailure(job.queue??"default")}catch{}try{const batchId=parsedPayload.payload?._batchId;if(batchId){const{recordBatchJobFailure}=await import("./batch");await recordBatchJobFailure(batchId,String(jobId),jobError)}}catch{}}else{const backoffDelays=parsedPayload.options?.backoff??declared.backoff;let retryDelay=30;if(Array.isArray(backoffDelays)&&backoffDelays.length>0){const backoffIndex=Math.min(currentAttempts-1,backoffDelays.length-1);retryDelay=backoffDelays[backoffIndex]}else if(typeof backoffDelays==="number"&&backoffDelays>0)retryDelay=backoffDelays;retryDelay=Number(retryDelay);if(!Number.isFinite(retryDelay)||retryDelay<0)retryDelay=30;retryDelay=applyRetryJitter(retryDelay,getRetryJitterRatio());log.info(`[Queue] Job ${jobId} will be retried in ${retryDelay}s (attempt ${currentAttempts}/${maxAttempts})`);try{await releaseJob(jobId,retryDelay);log.info(`[Queue] Job ${jobId} released for retry`)}catch{log.info(`[Queue] Failed to release job ${jobId} for retry`)}}}activeJobCount--;tracker.markIdle(workerId)}async function deleteJob(jobId){const{db}=await import("@stacksjs/database/runtime");await db.deleteFrom("jobs").where("id","=",jobId).execute()}async function releaseJob(jobId,delaySeconds=30){const retryAt=Math.floor(Date.now()/1000)+delaySeconds;log.debug(`Releasing job ${jobId} for retry at ${retryAt}`);try{const{db}=await import("@stacksjs/database/runtime");await db.updateTable("jobs").set({reserved_at:null,available_at:retryAt}).where("id","=",jobId).execute();log.debug(`Job ${jobId} released successfully`)}catch{log.error(`Failed to release job ${jobId}`)}}async function moveToFailedJobs(job,error,metrics){try{const failedAt=new Date().toISOString().slice(0,19).replace("T"," "),uuid=crypto.randomUUID(),exception=error.stack||error.message,{db}=await import("@stacksjs/database/runtime");await db.insertInto("failed_jobs").values({uuid,connection:"database",queue:job.queue,payload:job.payload,exception,attempts:metrics.attempts,max_attempts:metrics.maxAttempts,duration_ms:metrics.durationMs,failed_at:failedAt}).execute();return!0}catch(insertError){log.error("Failed to log failed job:",insertError);return!1}}function readJobTimeoutSec(payload){if(!payload||typeof payload!=="object")return;const t=payload.options?.timeout;if(typeof t!=="number"||!Number.isFinite(t)||t<=0)return;return t}async function raceWithTimeout(task,timeoutMs,message){let timer;const timeout=new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error(message)),timeoutMs)});try{return await Promise.race([task,timeout])}finally{if(timer!==void 0)clearTimeout(timer)}}async function executeJobPayload(payload){const parsed=parseEnvelope(payload);if(!parsed.ok)throw Error(`[queue] Cannot deserialize job envelope: ${parsed.reason}`+(parsed.detail?` (${parsed.detail})`:""));const{runJob}=await import("./job");await runJob(parsed.envelope.jobName,{payload:parsed.envelope.payload,context:parsed.envelope.context,traceId:parsed.envelope.traceId})}async function processJobsFromRedis(queueName,concurrency){const{RedisQueue}=await import("./drivers/redis"),{queue:queueConfig}=await import("@stacksjs/config"),redisConfig=queueConfig?.connections?.redis;if(!redisConfig)throw Error("Redis queue connection is not configured. Check config/queue.ts");const queue=new RedisQueue(queueName,redisConfig),{emitQueueEvent,getWorkerTracker}=await import("./events"),tracker=getWorkerTracker(),handleRedisJob=async(bunJob)=>{activeJobCount++;tracker.markActive(workerId);const startTime=Date.now(),parsed=parseEnvelope(bunJob.data);if(!parsed.ok){activeJobCount--;tracker.markIdle(workerId);log.error(`[Queue] Skipping Redis job ${bunJob.id} - unparseable envelope: ${parsed.reason}${parsed.detail?` (${parsed.detail})`:""}`);return}const data=parsed.envelope,batchId=data.payload?._batchId;if(batchId)try{const{isBatchCancelled}=await import("./batch");if(await isBatchCancelled(batchId)){log.info(`[Queue] Skipping Redis job ${bunJob.id} - batch ${batchId} has been cancelled`);activeJobCount--;tracker.markIdle(workerId);return}}catch{}await emitQueueEvent("job:processing",{jobId:String(bunJob.id),queueName});try{const{runJob}=await import("./job");await runJob(data.jobName,{payload:data.payload,context:data.context,traceId:data.traceId});tracker.recordCompletion(workerId);await emitQueueEvent("job:completed",{jobId:String(bunJob.id),queueName,duration:Date.now()-startTime});if(batchId)try{const{recordBatchJobCompletion}=await import("./batch");await recordBatchJobCompletion(batchId)}catch{}log.info(`[Queue] Redis job ${bunJob.id} completed`)}catch(e){tracker.recordFailure(workerId);await emitQueueEvent("job:failed",{jobId:String(bunJob.id),queueName,error:e instanceof Error?e:Error(String(e)),duration:Date.now()-startTime});if(batchId)try{const{recordBatchJobFailure}=await import("./batch");await recordBatchJobFailure(batchId,String(bunJob.id),e instanceof Error?e:Error(String(e)))}catch{}log.error(`[Queue] Redis job ${bunJob.id} failed: ${e}`);if(isNonRetryableError(e)){log.info(`[Queue] Redis job ${bunJob.id} hit a non-retryable error - skipping retry`);return}throw e}finally{activeJobCount--;tracker.markIdle(workerId)}};queue.process(concurrency,(bunJob)=>trackInFlight(handleRedisJob(bunJob)));log.info(`Listening for Redis jobs on queue "${queueName}" with concurrency ${concurrency}...`);while(workerRunning)await sleep(1000);await queue.close()}export async function stopProcessor(options={}){const graceMs=options.graceMs??1e4;workerRunning=!1;if(inFlightJobs.size>0&&graceMs>0){log.info(`[queue] Draining ${inFlightJobs.size} in-flight job(s) (grace ${graceMs}ms)`);const drain=Promise.allSettled([...inFlightJobs]),timeout=new Promise((resolve)=>setTimeout(()=>resolve("timeout"),graceMs));if(await Promise.race([drain.then(()=>"drained"),timeout])==="timeout"&&inFlightJobs.size>0)log.warn(`[queue] Drain timed out with ${inFlightJobs.size} job(s) still active. Their reservations will be reclaimed by the next worker's sweep (Q-2).`)}if(workerId){const{getWorkerTracker}=await import("./events");getWorkerTracker().unregister(workerId)}if(getQueueDriver()==="redis"){const{closeSharedRedisQueues}=await import("./drivers/redis");await closeSharedRedisQueues()}log.info("Queue processor stopped")}export async function executeFailedJobs(){const{db}=await import("@stacksjs/database/runtime"),failedJobs=await db.selectFrom("failed_jobs").selectAll().execute();for(const failedJob of failedJobs)await retryFailedJob(Number(failedJob.id))}export async function retryFailedJob(id){const now=Math.floor(Date.now()/1000),createdAt=new Date().toISOString().slice(0,19).replace("T"," "),{db}=await import("@stacksjs/database/runtime"),failedJob=(await db.selectFrom("failed_jobs").where("id","=",id).selectAll().execute())[0];if(!failedJob)throw Error(`Failed job ${id} not found`);const storedPayload=typeof failedJob.payload==="string"?failedJob.payload:"";let payloadToRequeue=storedPayload;try{const env=JSON.parse(storedPayload||"{}");env._retriedFromFailed=!0;payloadToRequeue=JSON.stringify(env)}catch{}await db.insertInto("jobs").values({queue:failedJob.queue,payload:payloadToRequeue,attempts:0,reserved_at:null,available_at:now,created_at:createdAt}).execute();await db.deleteFrom("failed_jobs").where("id","=",id).execute();log.info(`Failed job ${id} has been re-queued`)}export function getActiveJobCount(){return activeJobCount}export function isWorkerRunning(){return workerRunning}function sleep(ms){return new Promise((resolve)=>setTimeout(resolve,ms))}
|
|
1
|
+
import{batchMembershipId}from"./batch-payload";import{resolveQueueDriver}from"./driver";import{err,ok}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import process from"node:process";import{parseEnvelope}from"./envelope";import{updatedRowCount}from"./utils";let workerCrashHandlersInstalled=!1;function installWorkerCrashHandlers(){if(workerCrashHandlersInstalled)return;workerCrashHandlersInstalled=!0;process.on("unhandledRejection",(reason,_promise)=>{log.error(`Unhandled Rejection: ${reason}`)});process.on("uncaughtException",(error)=>{log.error(`Uncaught Exception: ${error.message}`)})}let activeJobCount=0,workerRunning=!1,workerId="";const inFlightJobs=new Set,reserveErrorLoggedAt=new Map;function trackInFlight(promise){inFlightJobs.add(promise);promise.finally(()=>inFlightJobs.delete(promise));return promise}function getReservationTtlSec(){const raw=process.env.STACKS_QUEUE_RESERVATION_TTL_SEC,n=raw===void 0?Number.NaN:Number.parseInt(raw,10);return Number.isFinite(n)&&n>0?n:3600}function getRetryJitterRatio(){const raw=process.env.STACKS_QUEUE_RETRY_JITTER;if(raw===void 0)return 0.2;const n=Number(raw);if(!Number.isFinite(n)||n<0)return 0.2;return Math.min(n,1)}export function applyRetryJitter(delaySeconds,ratio,random=Math.random){if(!(delaySeconds>0)||ratio<=0)return delaySeconds;return Math.round(delaySeconds+delaySeconds*ratio*random())}function getSweepIntervalMs(){const raw=process.env.STACKS_QUEUE_SWEEP_INTERVAL_SEC,n=raw===void 0?Number.NaN:Number.parseInt(raw,10);return(Number.isFinite(n)&&n>0?n:60)*1000}async function sweepStaleReservations(){const ttlSec=getReservationTtlSec(),cutoff=Math.floor(Date.now()/1000)-ttlSec,now=Math.floor(Date.now()/1000);try{const{db}=await import("@stacksjs/database/runtime"),result=await db.updateTable("jobs").set({reserved_at:null,available_at:now}).where("reserved_at","<=",cutoff).executeTakeFirst(),requeued=updatedRowCount(result);if(requeued>0)log.warn(`[queue] Requeued ${requeued} job(s) whose reservation exceeded the ${ttlSec}s TTL - likely victims of a worker crash. Set STACKS_QUEUE_RESERVATION_TTL_SEC to tune.`);return requeued}catch(error){log.error("[queue] Reservation sweep failed",{reason:error instanceof Error?error.message:String(error)});return 0}}function isNonRetryableError(e){if(!e||typeof e!=="object")return!1;const status=e.status??e.statusCode;if(typeof status==="number"&&status>=400&&status<500)return!0;const name=e.name;if(typeof name==="string"&&(name==="ValidationError"||name==="ModelNotFoundError"))return!0;return!1}function getQueueDriver(){return resolveQueueDriver()}export async function startProcessor(queueName,options={}){try{log.info("Starting queue processor...");installWorkerCrashHandlers();workerRunning=!0;workerId=`worker-${process.pid}-${Date.now()}`;const concurrency=options.concurrency||1,queueDriver=getQueueDriver(),{getWorkerTracker,getGlobalMetrics}=await import("./events");getGlobalMetrics();getWorkerTracker().register(workerId,queueName||"default");if(queueDriver==="redis"){log.info("Using Redis queue driver (bun-queue)");await processJobsFromRedis(queueName||"default",concurrency);return ok(void 0)}const named=namedWorkerQueues(queueName);let queues;if(named.length>0)queues=named;else{queues=await getAllQueues();if(queues.length===0)queues=["default"]}log.info(`Processing queues: ${queues.join(", ")}`);await processJobsFromDatabase(queues,concurrency,{followNewQueues:named.length===0});return ok(void 0)}catch(error){workerRunning=!1;return err(error)}}async function getAllQueues(){try{const{db}=await import("@stacksjs/database/runtime"),queues=(await db.selectFrom("jobs").select("queue").distinct().execute()).map((r)=>r.queue).filter((q)=>Boolean(q));return queues.length>0?queues:["default"]}catch{return["default"]}}export function namedWorkerQueues(queueName){if(typeof queueName!=="string")return[];return[...new Set(queueName.split(",").map((name)=>name.trim()).filter(Boolean))]}async function processJobsFromDatabase(initialQueues,concurrency,options={followNewQueues:!0}){log.info("Listening for jobs...");let queues=initialQueues,lastQueueRefresh=Date.now();const queueRefreshInterval=1e4;let lastSweep=Date.now();const sweepIntervalMs=getSweepIntervalMs();await sweepStaleReservations();while(workerRunning)try{let processedJobs=!1;const now=Date.now();if(options.followNewQueues&&now-lastQueueRefresh>queueRefreshInterval){try{const refreshedQueues=await getAllQueues();if(refreshedQueues.length>0)queues=refreshedQueues}catch{}lastQueueRefresh=now}if(now-lastSweep>sweepIntervalMs){await sweepStaleReservations();lastSweep=now}for(const queueName of queues){if(!workerRunning)break;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}processedJobs||=jobs.length>0;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}`)}}))}if(workerRunning&&!processedJobs)await sleep(1000)}catch{await sleep(3000)}}async function fetchPendingJobs(queueName,limit){const now=Math.floor(Date.now()/1000),{db}=await import("@stacksjs/database/runtime"),claimed=[];for(let i=0;i<limit;i++){const job=await db.selectFrom("jobs").where("queue","=",queueName).whereNull("reserved_at").where("available_at","<=",now).orderBy("id","asc").limit(1).selectAll().executeTakeFirst();if(!job)break;const result=await db.updateTable("jobs").set({reserved_at:now,attempts:Number(job.attempts||0)+1}).where("id","=",job.id).whereNull("reserved_at").executeTakeFirst();if(updatedRowCount(result)>0)claimed.push(job)}return claimed}async function processJob(job){const jobId=job.id,queueName=job.queue||"default";activeJobCount++;const startTime=Date.now(),{emitQueueEvent,getWorkerTracker}=await import("./events"),tracker=getWorkerTracker();tracker.markActive(workerId);let parsedJobName;try{parsedJobName=JSON.parse(job.payload||"{}").jobName}catch{}await emitQueueEvent("job:processing",{jobId:String(jobId),queueName,jobName:parsedJobName});try{const parsedForBatch=JSON.parse(job.payload||"{}"),batchId=batchMembershipId(parsedForBatch.payload);if(batchId){const{isBatchCancelled}=await import("./batch");if(await isBatchCancelled(batchId)){log.info(`[Queue] Skipping job ${jobId} - batch ${batchId} has been cancelled`);await deleteJob(jobId);activeJobCount--;tracker.markIdle(workerId);return}}}catch{}let jobError=null;const declared=parsedJobName?await(await import("./job")).jobDefaults(parsedJobName):{};try{const payload=JSON.parse(job.payload||"{}"),timeoutSec=readJobTimeoutSec(payload)??declared.timeout;if(timeoutSec===void 0)await executeJobPayload(payload);else await raceWithTimeout(executeJobPayload(payload),timeoutSec*1000,`Job ${jobId} exceeded ${timeoutSec}s timeout`)}catch(e){jobError=e instanceof Error?e:Error(String(e))}if(!jobError)try{await deleteJob(jobId);log.info(`[Queue] Job ${jobId} completed`);tracker.recordCompletion(workerId);try{const{recordCircuitSuccess}=await import("./circuit-breaker");await recordCircuitSuccess(job.queue??"default")}catch{}await emitQueueEvent("job:completed",{jobId:String(jobId),queueName,duration:Date.now()-startTime});try{const payload=JSON.parse(job.payload||"{}"),batchId=batchMembershipId(payload.payload);if(batchId){const{recordBatchJobCompletion}=await import("./batch");await recordBatchJobCompletion(batchId)}}catch{}}catch{log.info(`[Queue] Failed to delete completed job ${jobId}`)}else{const errorMessage=jobError.message;log.info(`[Queue] Job ${jobId} failed: ${errorMessage}`);tracker.recordFailure(workerId);await emitQueueEvent("job:failed",{jobId:String(jobId),queueName,error:jobError,duration:Date.now()-startTime,attemptsMade:(job.attempts||0)+1});let maxAttempts=1,parsedPayload={};try{parsedPayload=JSON.parse(job.payload||"{}");maxAttempts=parsedPayload.options?.tries||declared.tries||1}catch{}const currentAttempts=(job.attempts||0)+1;if(currentAttempts>=maxAttempts){log.info(`[Queue] Job ${jobId} exceeded max attempts (${currentAttempts}/${maxAttempts})`);let persisted=!1;if(parsedPayload?._retriedFromFailed===!0)try{const{moveToDeadLetter}=await import("./dead-letter");persisted=await moveToDeadLetter({queue:job.queue,payload:job.payload,exception:jobError.stack||jobError.message},"repeat-failure",2);if(persisted)log.info(`[Queue] Job ${jobId} re-failed after retry - moved to dead_letter_jobs`)}catch{persisted=!1}if(!persisted){log.info(`[Queue] Moving job ${jobId} to failed_jobs`);try{persisted=await moveToFailedJobs(job,jobError,{attempts:currentAttempts,maxAttempts,durationMs:Date.now()-startTime})}catch{persisted=!1}}if(persisted)try{await deleteJob(jobId)}catch{log.info(`[Queue] Failed to delete failed job ${jobId}`)}else log.error(`[Queue] Job ${jobId} exhausted its retries but could NOT be persisted to failed_jobs - leaving it in the queue to avoid data loss (the reservation sweep will retry it). Check that the failed_jobs table exists and is writable.`);try{const{recordFailureForPoison}=await import("./poison"),{recordCircuitFailure}=await import("./circuit-breaker"),jobName=parsedPayload?.jobName??"unknown";await recordFailureForPoison(jobName,parsedPayload?.payload);await recordCircuitFailure(job.queue??"default")}catch{}try{const batchId=batchMembershipId(parsedPayload.payload);if(batchId){const{recordBatchJobFailure}=await import("./batch");await recordBatchJobFailure(batchId,String(jobId),jobError)}}catch{}}else{const backoffDelays=parsedPayload.options?.backoff??declared.backoff;let retryDelay=30;if(Array.isArray(backoffDelays)&&backoffDelays.length>0){const backoffIndex=Math.min(currentAttempts-1,backoffDelays.length-1);retryDelay=backoffDelays[backoffIndex]}else if(typeof backoffDelays==="number"&&backoffDelays>0)retryDelay=backoffDelays;retryDelay=Number(retryDelay);if(!Number.isFinite(retryDelay)||retryDelay<0)retryDelay=30;retryDelay=applyRetryJitter(retryDelay,getRetryJitterRatio());log.info(`[Queue] Job ${jobId} will be retried in ${retryDelay}s (attempt ${currentAttempts}/${maxAttempts})`);try{await releaseJob(jobId,retryDelay);log.info(`[Queue] Job ${jobId} released for retry`)}catch{log.info(`[Queue] Failed to release job ${jobId} for retry`)}}}activeJobCount--;tracker.markIdle(workerId)}async function deleteJob(jobId){const{db}=await import("@stacksjs/database/runtime");await db.deleteFrom("jobs").where("id","=",jobId).execute()}async function releaseJob(jobId,delaySeconds=30){const retryAt=Math.floor(Date.now()/1000)+delaySeconds;log.debug(`Releasing job ${jobId} for retry at ${retryAt}`);try{const{db}=await import("@stacksjs/database/runtime");await db.updateTable("jobs").set({reserved_at:null,available_at:retryAt}).where("id","=",jobId).execute();log.debug(`Job ${jobId} released successfully`)}catch{log.error(`Failed to release job ${jobId}`)}}async function moveToFailedJobs(job,error,metrics){try{const failedAt=new Date().toISOString().slice(0,19).replace("T"," "),uuid=crypto.randomUUID(),exception=error.stack||error.message,{db}=await import("@stacksjs/database/runtime");await db.insertInto("failed_jobs").values({uuid,connection:"database",queue:job.queue,payload:job.payload,exception,attempts:metrics.attempts,max_attempts:metrics.maxAttempts,duration_ms:metrics.durationMs,failed_at:failedAt}).execute();return!0}catch(insertError){log.error("Failed to log failed job:",insertError);return!1}}function readJobTimeoutSec(payload){if(!payload||typeof payload!=="object")return;const t=payload.options?.timeout;if(typeof t!=="number"||!Number.isFinite(t)||t<=0)return;return t}async function raceWithTimeout(task,timeoutMs,message){let timer;const timeout=new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error(message)),timeoutMs)});try{return await Promise.race([task,timeout])}finally{if(timer!==void 0)clearTimeout(timer)}}async function executeJobPayload(payload){const parsed=parseEnvelope(payload);if(!parsed.ok)throw Error(`[queue] Cannot deserialize job envelope: ${parsed.reason}`+(parsed.detail?` (${parsed.detail})`:""));const{runJob}=await import("./job");await runJob(parsed.envelope.jobName,{payload:parsed.envelope.payload,context:parsed.envelope.context,traceId:parsed.envelope.traceId})}async function processJobsFromRedis(queueName,concurrency){const{RedisQueue}=await import("./drivers/redis"),{queue:queueConfig}=await import("@stacksjs/config"),redisConfig=queueConfig?.connections?.redis;if(!redisConfig)throw Error("Redis queue connection is not configured. Check config/queue.ts");const queue=new RedisQueue(queueName,redisConfig),{emitQueueEvent,getWorkerTracker}=await import("./events"),tracker=getWorkerTracker(),handleRedisJob=async(bunJob)=>{activeJobCount++;tracker.markActive(workerId);const startTime=Date.now(),parsed=parseEnvelope(bunJob.data);if(!parsed.ok){activeJobCount--;tracker.markIdle(workerId);log.error(`[Queue] Skipping Redis job ${bunJob.id} - unparseable envelope: ${parsed.reason}${parsed.detail?` (${parsed.detail})`:""}`);return}const data=parsed.envelope,batchId=batchMembershipId(data.payload);if(batchId)try{const{isBatchCancelled}=await import("./batch");if(await isBatchCancelled(batchId)){log.info(`[Queue] Skipping Redis job ${bunJob.id} - batch ${batchId} has been cancelled`);activeJobCount--;tracker.markIdle(workerId);return}}catch{}await emitQueueEvent("job:processing",{jobId:String(bunJob.id),queueName});try{const{runJob}=await import("./job");await runJob(data.jobName,{payload:data.payload,context:data.context,traceId:data.traceId});tracker.recordCompletion(workerId);await emitQueueEvent("job:completed",{jobId:String(bunJob.id),queueName,duration:Date.now()-startTime});if(batchId)try{const{recordBatchJobCompletion}=await import("./batch");await recordBatchJobCompletion(batchId)}catch{}log.info(`[Queue] Redis job ${bunJob.id} completed`)}catch(e){tracker.recordFailure(workerId);await emitQueueEvent("job:failed",{jobId:String(bunJob.id),queueName,error:e instanceof Error?e:Error(String(e)),duration:Date.now()-startTime});if(batchId)try{const{recordBatchJobFailure}=await import("./batch");await recordBatchJobFailure(batchId,String(bunJob.id),e instanceof Error?e:Error(String(e)))}catch{}log.error(`[Queue] Redis job ${bunJob.id} failed: ${e}`);if(isNonRetryableError(e)){log.info(`[Queue] Redis job ${bunJob.id} hit a non-retryable error - skipping retry`);return}throw e}finally{activeJobCount--;tracker.markIdle(workerId)}};queue.process(concurrency,(bunJob)=>trackInFlight(handleRedisJob(bunJob)));log.info(`Listening for Redis jobs on queue "${queueName}" with concurrency ${concurrency}...`);while(workerRunning)await sleep(1000);await queue.close()}export async function stopProcessor(options={}){const graceMs=options.graceMs??1e4;workerRunning=!1;if(inFlightJobs.size>0&&graceMs>0){log.info(`[queue] Draining ${inFlightJobs.size} in-flight job(s) (grace ${graceMs}ms)`);const drain=Promise.allSettled([...inFlightJobs]),timeout=new Promise((resolve)=>setTimeout(()=>resolve("timeout"),graceMs));if(await Promise.race([drain.then(()=>"drained"),timeout])==="timeout"&&inFlightJobs.size>0)log.warn(`[queue] Drain timed out with ${inFlightJobs.size} job(s) still active. Their reservations will be reclaimed by the next worker's sweep (Q-2).`)}if(workerId){const{getWorkerTracker}=await import("./events");getWorkerTracker().unregister(workerId)}if(getQueueDriver()==="redis"){const{closeSharedRedisQueues}=await import("./drivers/redis");await closeSharedRedisQueues()}log.info("Queue processor stopped")}export async function executeFailedJobs(){const{db}=await import("@stacksjs/database/runtime"),failedJobs=await db.selectFrom("failed_jobs").selectAll().execute();for(const failedJob of failedJobs)await retryFailedJob(Number(failedJob.id))}export async function retryFailedJob(id){const now=Math.floor(Date.now()/1000),createdAt=new Date().toISOString().slice(0,19).replace("T"," "),{db}=await import("@stacksjs/database/runtime"),failedJob=(await db.selectFrom("failed_jobs").where("id","=",id).selectAll().execute())[0];if(!failedJob)throw Error(`Failed job ${id} not found`);const storedPayload=typeof failedJob.payload==="string"?failedJob.payload:"";let payloadToRequeue=storedPayload;try{const env=JSON.parse(storedPayload||"{}");env._retriedFromFailed=!0;payloadToRequeue=JSON.stringify(env)}catch{}await db.insertInto("jobs").values({queue:failedJob.queue,payload:payloadToRequeue,attempts:0,reserved_at:null,available_at:now,created_at:createdAt}).execute();await db.deleteFrom("failed_jobs").where("id","=",id).execute();log.info(`Failed job ${id} has been re-queued`)}export function getActiveJobCount(){return activeJobCount}export function isWorkerRunning(){return workerRunning}function sleep(ms){return new Promise((resolve)=>setTimeout(resolve,ms))}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/queue",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.457",
|
|
6
6
|
"description": "The Stacks Queue system powered by bun-queue.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -63,23 +63,23 @@
|
|
|
63
63
|
"prepublishOnly": "bun run build"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@stacksjs/action-runner": "0.75.
|
|
66
|
+
"@stacksjs/action-runner": "0.75.457",
|
|
67
67
|
"@stacksjs/bun-queue": "^0.1.8",
|
|
68
|
-
"@stacksjs/cache": "0.75.
|
|
69
|
-
"@stacksjs/config": "0.75.
|
|
70
|
-
"@stacksjs/cron": "0.75.
|
|
71
|
-
"@stacksjs/database": "0.75.
|
|
72
|
-
"@stacksjs/env": "0.75.
|
|
73
|
-
"@stacksjs/error-handling": "0.75.
|
|
74
|
-
"@stacksjs/logging": "0.75.
|
|
75
|
-
"@stacksjs/path": "0.75.
|
|
68
|
+
"@stacksjs/cache": "0.75.457",
|
|
69
|
+
"@stacksjs/config": "0.75.457",
|
|
70
|
+
"@stacksjs/cron": "0.75.457",
|
|
71
|
+
"@stacksjs/database": "0.75.457",
|
|
72
|
+
"@stacksjs/env": "0.75.457",
|
|
73
|
+
"@stacksjs/error-handling": "0.75.457",
|
|
74
|
+
"@stacksjs/logging": "0.75.457",
|
|
75
|
+
"@stacksjs/path": "0.75.457"
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
78
|
"better-dx": "^0.2.24"
|
|
79
79
|
},
|
|
80
80
|
"peerDependencies": {
|
|
81
|
-
"@stacksjs/email": "0.75.
|
|
82
|
-
"@stacksjs/router": "0.75.
|
|
81
|
+
"@stacksjs/email": "0.75.457",
|
|
82
|
+
"@stacksjs/router": "0.75.457"
|
|
83
83
|
},
|
|
84
84
|
"peerDependenciesMeta": {
|
|
85
85
|
"@stacksjs/email": {
|