Add PostgreSQL-backed background worker
This commit is contained in:
@@ -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");
|
||||
Reference in New Issue
Block a user