Add PostgreSQL-backed background worker

This commit is contained in:
2026-08-17 01:02:44 -04:00
parent 9929d7321d
commit 24a1307a96
22 changed files with 629 additions and 120 deletions
+128
View File
@@ -0,0 +1,128 @@
import { randomUUID } from "node:crypto";
import { generateRecurringInvoice } from "../../web/src/server/jobs/handlers/recurring-invoice";
import {
claimNextJob,
completeJob,
failJob,
jobTypes,
scheduleDueRecurringInvoiceJobs,
type BackgroundJob,
} from "../../web/src/server/jobs/queue";
const workerId = `beenvoice-worker:${randomUUID()}`;
const pollMs = Number(process.env.WORKER_POLL_MS ?? 2_000);
const scheduleMs = Number(process.env.WORKER_SCHEDULE_MS ?? 60_000);
let stopping = false;
let working = false;
let scheduling = false;
function log(level: "info" | "error", event: string, fields: Record<string, unknown> = {}) {
const record = JSON.stringify({
timestamp: new Date().toISOString(),
level,
service: "beenvoice-worker",
event,
workerId,
...fields,
});
if (level === "error") console.error(record);
else console.info(record);
}
function errorFields(error: unknown) {
if (!(error instanceof Error)) return { error: String(error) };
const cause = error.cause;
return {
error: error.message || error.name,
...(cause instanceof Error
? { errorCause: cause.message || cause.name }
: cause
? { errorCause: String(cause) }
: {}),
};
}
async function scheduleRecurringInvoices() {
if (scheduling) return;
scheduling = true;
try {
const result = await scheduleDueRecurringInvoiceJobs();
if (result.due) log("info", "scheduler.recurring_invoices", result);
} finally {
scheduling = false;
}
}
async function handleJob(job: BackgroundJob) {
if (job.type === jobTypes.generateRecurringInvoice) {
await generateRecurringInvoice(job);
return;
}
throw new Error(`No handler registered for ${job.type}`);
}
async function drainJobs() {
if (working) return;
working = true;
try {
while (!stopping) {
const job = await claimNextJob(workerId);
if (!job) break;
try {
await handleJob(job);
await completeJob(job.id);
log("info", "job.completed", { jobId: job.id, jobType: job.type, attempts: job.attempts });
} catch (error) {
const terminal = await failJob(job, error);
log("error", "job.failed", {
jobId: job.id,
jobType: job.type,
attempts: job.attempts,
terminal,
...errorFields(error),
});
}
}
} finally {
working = false;
}
}
async function runSchedulerTick() {
try {
await scheduleRecurringInvoices();
} catch (error) {
log("error", "scheduler.failed", errorFields(error));
}
}
async function runWorkerTick() {
try {
await drainJobs();
} catch (error) {
log("error", "worker.tick_failed", errorFields(error));
}
}
function shutdown(signal: string) {
if (stopping) return;
stopping = true;
log("info", "worker.stopping", { signal });
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
log("info", "worker.started", { pollMs, scheduleMs });
await runSchedulerTick();
await runWorkerTick();
const scheduleTimer = setInterval(() => void runSchedulerTick(), scheduleMs);
const workTimer = setInterval(() => void runWorkerTick(), pollMs);
while (!stopping) await Bun.sleep(250);
clearInterval(scheduleTimer);
clearInterval(workTimer);
while (working || scheduling) await Bun.sleep(100);
log("info", "worker.stopped");