Add pg-boss background job queue with session expiry

Add @trails-cool/jobs package wrapping pg-boss for durable background job
execution using the existing PostgreSQL database. Wire up planner session
expiry as the first scheduled job (hourly, 7-day TTL) — this was previously
dead code that never ran. Add placeholder worker to journal for future
Komoot import and federation jobs.

Infrastructure: grant grafana_reader access to pgboss schema, add job queue
health panels to Service Health dashboard, add failed job alert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-13 21:17:24 +02:00
parent fc80e66ad4
commit 32c5fbde8f
25 changed files with 614 additions and 6 deletions

View file

@ -0,0 +1,21 @@
{
"name": "@trails-cool/jobs",
"version": "0.0.1",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"test": "vitest run",
"lint": "eslint .",
"typecheck": "tsc"
},
"dependencies": {
"pg-boss": "^10.3.1"
},
"devDependencies": {
"@types/node": "catalog:"
}
}

View file

@ -0,0 +1,5 @@
import PgBoss from "pg-boss";
export function createBoss(connectionString: string): PgBoss {
return new PgBoss({ connectionString });
}

View file

@ -0,0 +1,3 @@
export { createBoss } from "./boss.ts";
export { startWorker } from "./worker.ts";
export type { JobDefinition } from "./types.ts";

View file

@ -0,0 +1,9 @@
import type PgBoss from "pg-boss";
export interface JobDefinition<T = unknown> {
name: string;
handler: (jobs: PgBoss.Job<T>[]) => Promise<unknown>;
cron?: string;
retryLimit?: number;
expireInSeconds?: number;
}

View file

@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { startWorker } from "./worker.ts";
import type { JobDefinition } from "./types.ts";
function createMockBoss() {
return {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
schedule: vi.fn().mockResolvedValue(undefined),
work: vi.fn().mockResolvedValue("worker-id"),
};
}
beforeEach(() => {
vi.restoreAllMocks();
});
describe("startWorker", () => {
it("starts the boss instance", async () => {
const boss = createMockBoss();
await startWorker(boss as never, []);
expect(boss.start).toHaveBeenCalled();
});
it("registers job handlers", async () => {
const boss = createMockBoss();
const handler = vi.fn();
const jobs: JobDefinition[] = [{ name: "test-job", handler }];
await startWorker(boss as never, jobs);
expect(boss.work).toHaveBeenCalledWith("test-job", handler);
});
it("schedules cron jobs with options", async () => {
const boss = createMockBoss();
const handler = vi.fn();
const jobs: JobDefinition[] = [
{
name: "cron-job",
handler,
cron: "0 * * * *",
retryLimit: 3,
expireInSeconds: 120,
},
];
await startWorker(boss as never, jobs);
expect(boss.schedule).toHaveBeenCalledWith("cron-job", "0 * * * *", undefined, {
retryLimit: 3,
expireInSeconds: 120,
});
expect(boss.work).toHaveBeenCalledWith("cron-job", handler);
});
it("does not schedule non-cron jobs", async () => {
const boss = createMockBoss();
const jobs: JobDefinition[] = [{ name: "one-shot", handler: vi.fn() }];
await startWorker(boss as never, jobs);
expect(boss.schedule).not.toHaveBeenCalled();
expect(boss.work).toHaveBeenCalledWith("one-shot", jobs[0]!.handler);
});
it("registers multiple jobs", async () => {
const boss = createMockBoss();
const jobs: JobDefinition[] = [
{ name: "job-a", handler: vi.fn() },
{ name: "job-b", handler: vi.fn(), cron: "*/5 * * * *" },
];
await startWorker(boss as never, jobs);
expect(boss.work).toHaveBeenCalledTimes(2);
expect(boss.schedule).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,28 @@
import type PgBoss from "pg-boss";
import type { JobDefinition } from "./types.ts";
export async function startWorker(
boss: PgBoss,
jobs: JobDefinition[],
): Promise<void> {
await boss.start();
for (const job of jobs) {
if (job.cron) {
await boss.schedule(job.name, job.cron, undefined, {
retryLimit: job.retryLimit,
expireInSeconds: job.expireInSeconds,
});
}
await boss.work(job.name, job.handler);
}
const onShutdown = async () => {
await boss.stop({ graceful: true, timeout: 10_000 });
process.exit(0);
};
process.on("SIGTERM", onShutdown);
process.on("SIGINT", onShutdown);
}

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}

View file

@ -0,0 +1 @@
export { default } from "../../vitest.shared.ts";