@stacksjs/queue 0.75.501 → 0.75.504

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/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export type { ProcessLeaseOwner } from './process-owner';
1
2
  // The generated name union and its payload type. Exported because a caller
2
3
  // building a name-to-job map wants a rename to be a compile error rather than a
3
4
  // dispatch that fails at runtime (stacksjs/stacks#2578).
@@ -42,6 +43,7 @@ export * from './bun-queue';
42
43
  // Stacks Job class for file-based jobs (app/Jobs/*.ts)
43
44
  // =============================================================================
44
45
  export { Job } from './action';
46
+ export { captureProcessLeaseOwner, processLeaseOwnerStatus } from './process-owner';
45
47
  // How a job runs an action it names by string. `@stacksjs/actions` registers
46
48
  // itself here on import; see ./action-runner for why the queue asks rather
47
49
  // than imports.
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export*from"./bun-queue";export{Job}from"./action";export{getActionRunner,setActionRunner}from"./action-runner";export{Jobs,job,jobBatch,runJob}from"./job";export{claimDispatchKey,hasDispatchedKey,recordDispatchedKey,releaseDispatchKey}from"./idempotency";export{listDeadLetterJobs,moveToDeadLetter,purgeDeadLetterJobs,retryDeadLetterJob}from"./dead-letter";export{hashPayload,isQuarantined,listQuarantined,quarantineJob,recordFailureForPoison,unquarantineJob}from"./poison";export{isCircuitOpen,listCircuitState,pauseQueue,recordCircuitFailure,recordCircuitSuccess,resumeQueue}from"./circuit-breaker";export{assertEnvelopeSerializable,clearEnvelopeWarnings,createEnvelope,JOB_ENVELOPE_VERSION,parseEnvelope,serializeEnvelope}from"./envelope";export{setJobProgress,getJobProgress,cancelJob,isJobCancelled,clearJobState}from"./job-progress";export{discoverJobs,executeJob,getAllJobs,getJob,getScheduledJobs,jobRegistry,toJobOptions}from"./discovery";export{getRegisteredJobs,getSchedulerStatus,isSchedulerRunning,startScheduler,stopScheduler,triggerJob}from"./scheduler";export{emitQueueEvent,getGlobalMetrics,getQueueEvents,getWorkerTracker,onQueueEvent,OnQueueEvent,QueueEvents,QueueMetrics,withEvents}from"./events";export{checkQueueHealth,createHealthCheckHandler,isQueueHealthy,queuedJobState}from"./health";export{configureFailedJobNotifications,FailedJobNotifier,getFailedJobNotifier,notifyJobFailed}from"./notifications";export{createQueueTester,expectJobToFail,fake,getFakeQueue,isFaked,QueueTester,restore,runJob as runTestJob}from"./testing";export{Batch,DispatchedBatch,PendingBatch,getBatchCallbacks,isBatchCancelled,recordBatchJobCompletion,recordBatchJobFailure}from"./batch";export{executeFailedJobs,getActiveJobCount,isWorkerRunning,retryFailedJob,startProcessor,stopProcessor}from"./worker";export async function getRedisQueue(){const{RedisQueue}=await import("./drivers/redis");return RedisQueue}export{resolveQueueDriver}from"./driver";export{aggregateWorkerMetrics,readWorkerSnapshots}from"./worker-monitor";
1
+ export*from"./bun-queue";export{Job}from"./action";export{captureProcessLeaseOwner,processLeaseOwnerStatus}from"./process-owner";export{getActionRunner,setActionRunner}from"./action-runner";export{Jobs,job,jobBatch,runJob}from"./job";export{claimDispatchKey,hasDispatchedKey,recordDispatchedKey,releaseDispatchKey}from"./idempotency";export{listDeadLetterJobs,moveToDeadLetter,purgeDeadLetterJobs,retryDeadLetterJob}from"./dead-letter";export{hashPayload,isQuarantined,listQuarantined,quarantineJob,recordFailureForPoison,unquarantineJob}from"./poison";export{isCircuitOpen,listCircuitState,pauseQueue,recordCircuitFailure,recordCircuitSuccess,resumeQueue}from"./circuit-breaker";export{assertEnvelopeSerializable,clearEnvelopeWarnings,createEnvelope,JOB_ENVELOPE_VERSION,parseEnvelope,serializeEnvelope}from"./envelope";export{setJobProgress,getJobProgress,cancelJob,isJobCancelled,clearJobState}from"./job-progress";export{discoverJobs,executeJob,getAllJobs,getJob,getScheduledJobs,jobRegistry,toJobOptions}from"./discovery";export{getRegisteredJobs,getSchedulerStatus,isSchedulerRunning,startScheduler,stopScheduler,triggerJob}from"./scheduler";export{emitQueueEvent,getGlobalMetrics,getQueueEvents,getWorkerTracker,onQueueEvent,OnQueueEvent,QueueEvents,QueueMetrics,withEvents}from"./events";export{checkQueueHealth,createHealthCheckHandler,isQueueHealthy,queuedJobState}from"./health";export{configureFailedJobNotifications,FailedJobNotifier,getFailedJobNotifier,notifyJobFailed}from"./notifications";export{createQueueTester,expectJobToFail,fake,getFakeQueue,isFaked,QueueTester,restore,runJob as runTestJob}from"./testing";export{Batch,DispatchedBatch,PendingBatch,getBatchCallbacks,isBatchCancelled,recordBatchJobCompletion,recordBatchJobFailure}from"./batch";export{executeFailedJobs,getActiveJobCount,isWorkerRunning,retryFailedJob,startProcessor,stopProcessor}from"./worker";export async function getRedisQueue(){const{RedisQueue}=await import("./drivers/redis");return RedisQueue}export{resolveQueueDriver}from"./driver";export{aggregateWorkerMetrics,readWorkerSnapshots}from"./worker-monitor";
@@ -0,0 +1,13 @@
1
+ declare function birth(pid: number): string;
2
+ /** Unsupported hosts fail closed and continue using the ordinary lease TTL. */
3
+ export declare function captureProcessLeaseOwner(pid?: unknown): ProcessLeaseOwner | undefined;
4
+ /** Only a positively dead local incarnation may be reclaimed before its TTL. */
5
+ export declare function processLeaseOwnerStatus(value: unknown): 'alive' | 'dead' | 'unknown';
6
+ /** A Linux process incarnation, not a PID that can be reused after exit. */
7
+ export declare interface ProcessLeaseOwner {
8
+ host: string
9
+ boot: string
10
+ namespace: string
11
+ pid: number
12
+ birth: string
13
+ }
@@ -0,0 +1 @@
1
+ import{createHash}from"node:crypto";import{readFileSync,readlinkSync}from"node:fs";import{hostname}from"node:os";import process from"node:process";function machine(){if(process.platform!=="linux")return;try{const id=readFileSync("/etc/machine-id","utf8").trim(),boot=readFileSync("/proc/sys/kernel/random/boot_id","utf8").trim(),namespace=readlinkSync("/proc/self/ns/pid");if(!id||!boot)return;return{host:createHash("sha256").update(`${hostname()}\x00${id}`).digest("hex"),boot,namespace}}catch{return}}function birth(pid){const stat=readFileSync(`/proc/${pid}/stat`,"utf8"),value=stat.slice(stat.lastIndexOf(")")+2).split(/\s+/)[19];if(!value||!/^\d+$/.test(value))throw Error("Process birth identity unavailable");return value}export function captureProcessLeaseOwner(pid=process.pid){if(!Number.isSafeInteger(pid)||pid<1)return;const local=machine();if(!local)return;try{return{...local,pid,birth:birth(pid)}}catch{return}}export function processLeaseOwnerStatus(value){if(!value||typeof value!=="object")return"unknown";const owner=value;if(typeof owner.host!=="string"||typeof owner.boot!=="string"||typeof owner.birth!=="string"||typeof owner.namespace!=="string"||!/^pid:\[\d+\]$/.test(owner.namespace)||!/^\d+$/.test(owner.birth)||!Number.isSafeInteger(owner.pid)||Number(owner.pid)<1)return"unknown";const local=machine();if(!local||local.host!==owner.host)return"unknown";if(local.boot!==owner.boot)return"dead";if(local.namespace!==owner.namespace)return"unknown";try{return birth(owner.pid)===owner.birth?"alive":"dead"}catch(error){return error.code==="ENOENT"?"dead":"unknown"}}
package/dist/worker.js CHANGED
@@ -1 +1 @@
1
- import{persistWorkerSnapshot,pruneWorkerSnapshots}from"./worker-monitor";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,inFlightDatabaseBatches=new Set,reserveErrorLoggedAt=new Map;function trackInFlight(promise,pending=inFlightJobs){pending.add(promise);promise.then(()=>pending.delete(promise),()=>pending.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()}async function monitorWorker(id){const{getWorkerTracker,getGlobalMetrics}=await import("./events");let writing,warned=!1;const persist=()=>{if(writing)return writing;const worker=getWorkerTracker().getAll().find((worker)=>worker.id===id);if(!worker)return Promise.resolve();const metrics=getGlobalMetrics().getMetrics();writing=persistWorkerSnapshot({worker:{...worker},heartbeatAt:Date.now(),activeJobs:getActiveJobCount(),metrics:{completed:worker.processedCount,failed:worker.failedCount,throughputPerMinute:metrics.throughputPerMinute,averageDuration:metrics.averageDuration}}).catch((error)=>{if(warned)return;warned=!0;log.warn(`Queue worker telemetry unavailable. Run migrations for QueueWorker: ${error instanceof Error?error.message:String(error)}`)}).finally(()=>{writing=void 0});return writing};pruneWorkerSnapshots().catch(()=>{});persist();const timer=setInterval(()=>{persist()},1e4);timer.unref();return async()=>{clearInterval(timer);await writing;await persist()}}export async function startProcessor(queueName,options={}){let stopMonitor,monitoredId;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||"*");monitoredId=workerId;stopMonitor=await monitorWorker(monitoredId);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)}finally{const{getWorkerTracker}=await import("./events");if(monitoredId)getWorkerTracker().unregister(monitoredId);await stopMonitor?.()}}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{}if(!workerRunning)break;const processed=await trackInFlight(processDatabaseBatch(queueName,concurrency),inFlightDatabaseBatches);processedJobs||=processed}if(workerRunning&&!processedJobs)await sleep(1000)}catch{await sleep(3000)}}async function processDatabaseBatch(queueName,concurrency){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)}return!1}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}`)}}));return jobs.length>0}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&&workerRunning;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||!workerRunning)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;const pending=new Set([...inFlightDatabaseBatches,...inFlightJobs]);if(pending.size>0&&graceMs>0){log.info(`[queue] Draining database reservations and in-flight jobs (grace ${graceMs}ms)`);const drain=Promise.allSettled([...pending]),timeout=new Promise((resolve)=>setTimeout(()=>resolve("timeout"),graceMs));if(await Promise.race([drain.then(()=>"drained"),timeout])==="timeout"&&(inFlightJobs.size>0||inFlightDatabaseBatches.size>0))log.warn("[queue] Drain timed out with reservations or jobs 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{persistWorkerSnapshot,pruneWorkerSnapshots}from"./worker-monitor";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";import{captureProcessLeaseOwner,processLeaseOwnerStatus}from"./process-owner";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,inFlightDatabaseBatches=new Set,reserveErrorLoggedAt=new Map;let reservationScanAfter=0;function ownedReservationPayload(payload){const owner=captureProcessLeaseOwner();if(!owner)return payload;try{const envelope=JSON.parse(payload);if(!envelope||typeof envelope!=="object"||Array.isArray(envelope))return payload;return JSON.stringify({...envelope,_queueProcessOwner:owner})}catch{return payload}}function trackInFlight(promise,pending=inFlightJobs){pending.add(promise);promise.then(()=>pending.delete(promise),()=>pending.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();let requeued=updatedRowCount(result);const owned=await db.selectFrom("jobs").select(["id","payload","reserved_at"]).where("reserved_at",">",cutoff).where("id",">",reservationScanAfter).orderBy("id","asc").limit(128).execute();reservationScanAfter=owned.length===128?Number(owned.at(-1).id):0;for(const row of owned){let owner;try{owner=JSON.parse(String(row.payload))._queueProcessOwner}catch{continue}if(processLeaseOwnerStatus(owner)!=="dead")continue;const released=await db.updateTable("jobs").set({reserved_at:null,available_at:now}).where("id","=",row.id).where("reserved_at","=",row.reserved_at).where("payload","=",row.payload).executeTakeFirst();requeued+=updatedRowCount(released)}if(requeued>0)log.warn(`[queue] Requeued ${requeued} job(s) after their owner exited or their reservation exceeded the ${ttlSec}s TTL. 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()}async function monitorWorker(id){const{getWorkerTracker,getGlobalMetrics}=await import("./events");let writing,warned=!1;const persist=()=>{if(writing)return writing;const worker=getWorkerTracker().getAll().find((worker)=>worker.id===id);if(!worker)return Promise.resolve();const metrics=getGlobalMetrics().getMetrics();writing=persistWorkerSnapshot({worker:{...worker},heartbeatAt:Date.now(),activeJobs:getActiveJobCount(),metrics:{completed:worker.processedCount,failed:worker.failedCount,throughputPerMinute:metrics.throughputPerMinute,averageDuration:metrics.averageDuration}}).catch((error)=>{if(warned)return;warned=!0;log.warn(`Queue worker telemetry unavailable. Run migrations for QueueWorker: ${error instanceof Error?error.message:String(error)}`)}).finally(()=>{writing=void 0});return writing};pruneWorkerSnapshots().catch(()=>{});persist();const timer=setInterval(()=>{persist()},1e4);timer.unref();return async()=>{clearInterval(timer);await writing;await persist()}}export async function startProcessor(queueName,options={}){let stopMonitor,monitoredId;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||"*");monitoredId=workerId;stopMonitor=await monitorWorker(monitoredId);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)}finally{const{getWorkerTracker}=await import("./events");if(monitoredId)getWorkerTracker().unregister(monitoredId);await stopMonitor?.()}}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{}if(!workerRunning)break;const processed=await trackInFlight(processDatabaseBatch(queueName,concurrency),inFlightDatabaseBatches);processedJobs||=processed}if(workerRunning&&!processedJobs)await sleep(1000)}catch{await sleep(3000)}}async function processDatabaseBatch(queueName,concurrency){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)}return!1}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}`)}}));return jobs.length>0}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&&workerRunning;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||!workerRunning)break;const payload=ownedReservationPayload(String(job.payload)),result=await db.updateTable("jobs").set({reserved_at:now,attempts:Number(job.attempts||0)+1,payload}).where("id","=",job.id).whereNull("reserved_at").where("payload","=",job.payload).executeTakeFirst();if(updatedRowCount(result)>0)claimed.push({...job,payload})}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;const pending=new Set([...inFlightDatabaseBatches,...inFlightJobs]);if(pending.size>0&&graceMs>0){log.info(`[queue] Draining database reservations and in-flight jobs (grace ${graceMs}ms)`);const drain=Promise.allSettled([...pending]),timeout=new Promise((resolve)=>setTimeout(()=>resolve("timeout"),graceMs));if(await Promise.race([drain.then(()=>"drained"),timeout])==="timeout"&&(inFlightJobs.size>0||inFlightDatabaseBatches.size>0))log.warn("[queue] Drain timed out with reservations or jobs 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.501",
5
+ "version": "0.75.504",
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.501",
66
+ "@stacksjs/action-runner": "0.75.504",
67
67
  "@stacksjs/bun-queue": "^0.1.8",
68
- "@stacksjs/cache": "0.75.501",
69
- "@stacksjs/config": "0.75.501",
70
- "@stacksjs/cron": "0.75.501",
71
- "@stacksjs/database": "0.75.501",
72
- "@stacksjs/env": "0.75.501",
73
- "@stacksjs/error-handling": "0.75.501",
74
- "@stacksjs/logging": "0.75.501",
75
- "@stacksjs/path": "0.75.501"
68
+ "@stacksjs/cache": "0.75.504",
69
+ "@stacksjs/config": "0.75.504",
70
+ "@stacksjs/cron": "0.75.504",
71
+ "@stacksjs/database": "0.75.504",
72
+ "@stacksjs/env": "0.75.504",
73
+ "@stacksjs/error-handling": "0.75.504",
74
+ "@stacksjs/logging": "0.75.504",
75
+ "@stacksjs/path": "0.75.504"
76
76
  },
77
77
  "devDependencies": {
78
78
  "better-dx": "^0.2.24"
79
79
  },
80
80
  "peerDependencies": {
81
- "@stacksjs/email": "0.75.501",
82
- "@stacksjs/router": "0.75.501"
81
+ "@stacksjs/email": "0.75.504",
82
+ "@stacksjs/router": "0.75.504"
83
83
  },
84
84
  "peerDependenciesMeta": {
85
85
  "@stacksjs/email": {