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