import { randomUUID } from 'node:crypto'; import type { QueueWorkerOptions, WorkerContext } from './types.js'; export class QueueWorker { private readonly queueName: string; private readonly store: QueueWorkerOptions['store']; private readonly handler: QueueWorkerOptions['handler']; private readonly workerId: string; private readonly pollIntervalMs: number; private readonly leaseMs: number; private readonly backoffMs: number; private timer?: ReturnType; private running = false; private inflight?: Promise; constructor(options: QueueWorkerOptions) { this.queueName = options.queueName; this.store = options.store; this.handler = options.handler; this.workerId = options.workerId || `worker_${randomUUID()}`; this.pollIntervalMs = options.pollIntervalMs ?? 200; this.leaseMs = options.leaseMs ?? 30_000; this.backoffMs = options.backoffMs ?? 1_000; } start(): void { if (this.running) return; this.running = true; this.schedule(0); } async stop(): Promise { this.running = false; if (this.timer) { clearTimeout(this.timer); this.timer = undefined; } await this.inflight; } private schedule(delayMs: number): void { if (!this.running) return; this.timer = globalThis.setTimeout(() => { this.inflight = this.tick().finally(() => { this.inflight = undefined; }); }, delayMs); } private async tick(): Promise { const job = await this.store.claimNext( this.queueName, this.workerId, this.leaseMs ); if (!job) { this.schedule(this.pollIntervalMs); return; } const context: WorkerContext = { patch: async patch => { await this.store.patch(this.queueName, job.id, patch); }, heartbeat: async () => { await this.store.patch(this.queueName, job.id, { leaseOwner: this.workerId, leaseExpiresAt: new Date(Date.now() + this.leaseMs).toISOString(), }); }, }; try { const result = await this.handler(job, context); await this.store.patch(this.queueName, job.id, { status: 'succeeded', result, completedAt: new Date().toISOString(), leaseOwner: undefined, leaseExpiresAt: undefined, }); } catch (err: unknown) { const lastError = err instanceof Error ? err.message : String(err); const finalStatus = job.attempts >= job.maxAttempts ? 'dead_letter' : 'queued'; await this.store.patch(this.queueName, job.id, { status: finalStatus, lastError, scheduledAt: finalStatus === 'queued' ? new Date(Date.now() + this.backoffMs * job.attempts).toISOString() : job.scheduledAt, completedAt: finalStatus === 'dead_letter' ? new Date().toISOString() : undefined, leaseOwner: undefined, leaseExpiresAt: undefined, }); } this.schedule(0); } }