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
+17
View File
@@ -0,0 +1,17 @@
# Beenvoice worker
The worker is a separate Bun process backed by the same PostgreSQL database as the web app. It follows the Racetix worker model: a durable database outbox, a lightweight scheduler, and horizontally safe polling with `FOR UPDATE SKIP LOCKED`.
Currently it schedules and generates due recurring invoices. New asynchronous workflows should enqueue a typed job through `apps/web/src/server/jobs/queue.ts` and add a handler in `src/index.ts`.
Jobs have an idempotency key, scheduled run time, bounded exponential retries, and stale-lock recovery. Multiple worker replicas can run safely. Timer elapsed time is still derived from `startedAt`; the worker should only send time-clock reminders, never increment a counter every second.
```bash
# Uses the web app's .env/.env.local files
bun run dev
# Production environment is injected by Docker Compose/Coolify
bun run start
```
`WORKER_POLL_MS` defaults to 2000 and `WORKER_SCHEDULE_MS` defaults to 60000.
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@beenvoice/worker",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --env-file=../web/.env --env-file=../web/.env.local --watch src/index.ts",
"start": "bun src/index.ts",
"build": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"test": "bun test"
},
"dependencies": {
"@t3-oss/env-nextjs": "0.12.0",
"drizzle-orm": "0.45.2",
"pg": "8.21.0",
"zod": "3.25.76"
},
"devDependencies": {
"@types/bun": "1.3.14",
"@types/node": "20.19.39",
"@types/pg": "8.18.0",
"typescript": "5.9.3"
}
}
+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");
@@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test";
import { nextDueDate } from "../../web/src/server/services/recurring-invoices";
describe("recurring invoice scheduling", () => {
test("advances weekly schedules from their scheduled occurrence", () => {
const scheduledFor = new Date("2026-08-17T12:00:00.000Z");
expect(nextDueDate("weekly", scheduledFor).toISOString()).toBe(
"2026-08-24T12:00:00.000Z",
);
});
test("does not mutate the source date", () => {
const scheduledFor = new Date("2026-08-17T12:00:00.000Z");
nextDueDate("monthly", scheduledFor);
expect(scheduledFor.toISOString()).toBe("2026-08-17T12:00:00.000Z");
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../web/tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["../web/src/*"],
"src/*": ["../web/src/*"]
},
"incremental": false,
"jsx": "preserve",
"types": ["node", "bun"]
},
"include": ["src/**/*.ts", "tests/**/*.ts", "../web/src/server/**/*.ts", "../web/src/env.js"]
}