cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

333 lines
11 KiB
TypeScript

import {
type BackgroundJob,
type BackgroundJobHandlers,
dispatchBackgroundJob,
type JobPublisher,
type JobWorkerHost,
} from "@rakazo/adapter-kit";
import { isTooManyDatabaseConnections } from "@rakazo/db";
import { runCorrelatedJob, unwrapJobPayload, wrapJobPayload } from "@rakazo/logging";
import { makeWorkerUtils, type Runner, run, type WorkerUtils } from "graphile-worker";
import type { Pool } from "pg";
// Share the caller's pg.Pool instead of opening a separate connectionString-based
// pool per graphile-worker component: three independent pools per worker process
// (Prisma + publisher + runner) triples the connection footprint against Postgres'
// max_connections for no benefit, since they never need isolation from each other.
export class GraphileJobPublisher implements JobPublisher {
private utils: Promise<WorkerUtils> | undefined;
private closed = false;
constructor(private readonly pgPool: Pool) {}
async enqueue(job: BackgroundJob): Promise<void> {
const utils = await this.getUtils();
await utils.addJob(job.name, wrapJobPayload(job.payload), {
runAt: job.availableAt,
jobKey: job.replaceKey,
});
}
async cancel(key: string): Promise<void> {
const utils = await this.getUtils();
await utils.withPgClient(async (client) => {
await client.query("select graphile_worker.remove_job($1::text)", [key]);
});
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
if (this.utils) await (await this.utils).release();
}
private getUtils(): Promise<WorkerUtils> {
if (this.closed) throw new Error("Background job publisher is closed");
this.utils ??= makeWorkerUtils({ pgPool: this.pgPool });
return this.utils;
}
}
/** Same bounded backoff the worker uses around jobHost.start for 53300. */
export function databaseCapacityBackoffMs(attempt: number): number {
return Math.min(30_000, 200 * 2 ** Math.min(attempt, 8));
}
export class GraphileJobWorkerHost implements JobWorkerHost {
private runner: Runner | undefined;
private handlers: BackgroundJobHandlers | undefined;
private stopping = false;
private superviseTask: Promise<void> | undefined;
private wakeSleep: (() => void) | undefined;
constructor(
private readonly pgPool: Pool,
private readonly options: {
concurrency?: number;
pollInterval?: number;
noHandleSignals?: boolean;
/** Test seam: delay between 53300 restart attempts. */
sleep?: (ms: number) => Promise<void>;
} = {},
) {}
async start(handlers: BackgroundJobHandlers): Promise<void> {
if (this.runner || this.superviseTask) return;
this.stopping = false;
this.handlers = handlers;
await this.launchRunner();
// run() resolves once the runner is up; runner.promise can still reject later
// (e.g. Postgres 53300). Observe it so a dead runner cannot leave the worker
// process idle forever while unhandledRejection swallows that same error.
this.superviseTask = this.supervise();
}
async stop(): Promise<void> {
this.stopping = true;
this.wakeSleep?.();
try {
await this.runner?.stop();
} finally {
await this.superviseTask?.catch(() => undefined);
this.runner = undefined;
this.superviseTask = undefined;
this.handlers = undefined;
}
}
private async launchRunner(): Promise<void> {
const handlers = this.handlers;
if (!handlers) throw new Error("Background job worker has no handlers");
const taskList = Object.fromEntries(
Object.keys(handlers).map((name) => [
name,
async (payload: unknown) => {
const unpacked = unwrapJobPayload(payload);
await runCorrelatedJob({
name,
payload: unpacked.payload,
correlation: unpacked.correlation,
run: () => dispatchBackgroundJob(handlers, name, unpacked.payload),
});
},
]),
);
const runner = await run({
pgPool: this.pgPool,
concurrency: this.options.concurrency ?? 4,
pollInterval: this.options.pollInterval ?? 500,
noHandleSignals: this.options.noHandleSignals,
taskList,
});
if (this.stopping) {
await runner.stop().catch(() => undefined);
return;
}
this.runner = runner;
}
private delay(ms: number): Promise<void> {
if (this.stopping) return Promise.resolve();
const sleep =
this.options.sleep ??
((wait: number) => new Promise<void>((resolve) => setTimeout(resolve, wait)));
return new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
this.wakeSleep = undefined;
resolve();
};
this.wakeSleep = finish;
void Promise.resolve(sleep(ms)).then(finish, finish);
});
}
private async supervise(): Promise<void> {
for (;;) {
const runner = this.runner;
if (!runner || this.stopping) return;
try {
await runner.promise;
return;
} catch (error) {
if (this.stopping) return;
if (this.runner === runner) this.runner = undefined;
if (!isTooManyDatabaseConnections(error)) throw error;
// Back off after every lifecycle 53300 (runner death or failed relaunch),
// not only when launchRunner rejects — otherwise a runner that starts then
// dies again under saturation reconnects with no delay.
for (let attempt = 0; ; attempt += 1) {
if (this.stopping) return;
await this.delay(databaseCapacityBackoffMs(attempt));
if (this.stopping) return;
try {
await this.launchRunner();
if (this.stopping || !this.runner) return;
break;
} catch (startError) {
if (!isTooManyDatabaseConnections(startError)) throw startError;
}
}
}
}
}
}
interface QueuedJob {
name: BackgroundJob["name"];
payload: unknown;
availableAt?: Date;
replaceKey?: string;
}
function toQueuedJob(job: BackgroundJob): QueuedJob {
return {
name: job.name,
payload: wrapJobPayload(job.payload),
availableAt: job.availableAt,
replaceKey: job.replaceKey,
};
}
export class InMemoryJobQueue implements JobPublisher, JobWorkerHost {
private handlers: BackgroundJobHandlers | undefined;
private readonly timers = new Set<ReturnType<typeof setTimeout>>();
private readonly scheduled = new Map<ReturnType<typeof setTimeout>, QueuedJob>();
private readonly keyed = new Map<string, ReturnType<typeof setTimeout>>();
private readonly active = new Set<Promise<void>>();
private readonly closingJobs: QueuedJob[] = [];
private draining: Promise<void> | undefined;
private closed = false;
private stopped = false;
private closing = false;
private acceptingClosingJobs = false;
private closeRequested = false;
async enqueue(job: BackgroundJob): Promise<void> {
const stored = toQueuedJob(job);
if (this.closed) throw new Error("Background job publisher is closed");
if (this.stopped) throw new Error("Background job publisher is stopped");
if (this.closing) {
this.enqueueWhileClosing(stored);
return;
}
if (stored.replaceKey) {
await this.cancel(stored.replaceKey);
if (this.closed) throw new Error("Background job publisher is closed");
if (this.stopped) throw new Error("Background job publisher is stopped");
if (this.closing) {
this.enqueueWhileClosing(stored);
return;
}
}
const delay = stored.availableAt ? Math.max(0, stored.availableAt.getTime() - Date.now()) : 0;
const timer = setTimeout(() => {
this.timers.delete(timer);
this.scheduled.delete(timer);
if (stored.replaceKey && this.keyed.get(stored.replaceKey) === timer) {
this.keyed.delete(stored.replaceKey);
}
const handlers = this.handlers;
if (!handlers) return;
void this.dispatch(handlers, stored);
}, delay);
this.timers.add(timer);
this.scheduled.set(timer, stored);
if (stored.replaceKey) this.keyed.set(stored.replaceKey, timer);
}
async cancel(replaceKey: string): Promise<void> {
const closing = this.closingJobs.findIndex((job) => job.replaceKey === replaceKey);
if (closing >= 0) this.closingJobs.splice(closing, 1);
const timer = this.keyed.get(replaceKey);
if (!timer) return;
clearTimeout(timer);
this.keyed.delete(replaceKey);
this.timers.delete(timer);
this.scheduled.delete(timer);
}
async start(handlers: BackgroundJobHandlers): Promise<void> {
this.handlers = handlers;
this.stopped = false;
}
async stop(): Promise<void> {
await this.drain();
}
private dispatch(handlers: BackgroundJobHandlers, job: QueuedJob): Promise<void> {
const unpacked = unwrapJobPayload(job.payload);
const active = runCorrelatedJob({
name: job.name,
payload: unpacked.payload,
correlation: unpacked.correlation,
run: () => dispatchBackgroundJob(handlers, job.name, unpacked.payload),
}).catch(() => undefined);
this.active.add(active);
void active.finally(() => this.active.delete(active));
return active;
}
async close(): Promise<void> {
if (this.closed) return;
this.closeRequested = true;
await this.drain();
}
private enqueueWhileClosing(job: QueuedJob): void {
if (job.replaceKey) {
const existing = this.closingJobs.findIndex((queued) => queued.replaceKey === job.replaceKey);
if (existing >= 0) this.closingJobs.splice(existing, 1);
}
if (job.availableAt && job.availableAt.getTime() > Date.now()) {
// Delayed in-memory jobs are intentionally discarded on shutdown; durable
// schedulers reconcile them after restart. Never run them early.
return;
}
if (!this.acceptingClosingJobs) throw new Error("Background job publisher is closing");
this.closingJobs.push(job);
}
private async drain(): Promise<void> {
if (this.draining) return this.draining;
const draining = this.performDrain();
this.draining = draining;
try {
await draining;
} finally {
if (this.draining === draining) this.draining = undefined;
}
}
private async performDrain(): Promise<void> {
this.closing = true;
this.acceptingClosingJobs = true;
for (const timer of this.timers) {
clearTimeout(timer);
const job = this.scheduled.get(timer);
if (job) this.enqueueWhileClosing(job);
}
this.timers.clear();
this.scheduled.clear();
this.keyed.clear();
await Promise.all(this.active);
this.acceptingClosingJobs = false;
const handlers = this.handlers;
const closingJobs = this.closingJobs.splice(0);
if (!handlers && closingJobs.length > 0) {
throw new Error("Background job publisher is closing");
}
if (handlers) {
for (const job of closingJobs) void this.dispatch(handlers, job);
}
await Promise.all(this.active);
this.handlers = undefined;
this.closed = this.closeRequested;
this.stopped = !this.closeRequested;
this.closing = false;
}
}