Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
222 lines
8.0 KiB
TypeScript
222 lines
8.0 KiB
TypeScript
import type { Db } from "@sttracker/db/db";
|
|
import { newId, nowIso } from "@sttracker/db/ids";
|
|
import { fromJson, toJson } from "@sttracker/db/json";
|
|
import { sql } from "kysely";
|
|
|
|
const MAX_ATTEMPTS = 5;
|
|
|
|
const STALE_LOCK_MS = 60 * 60 * 1000;
|
|
const HEARTBEAT_MS = 5 * 60 * 1000;
|
|
|
|
export interface JobContext {
|
|
/** Aborted when the worker is stopped. */
|
|
signal: AbortSignal;
|
|
}
|
|
export type JobHandler = (payload: any, ctx: JobContext) => Promise<unknown>;
|
|
|
|
/** Throw from a handler to fail the job at once, with no retry. */
|
|
export class NonRetryableError extends Error {
|
|
override readonly name = "NonRetryableError";
|
|
}
|
|
/** Throw from a handler to drop the job: it is marked `done` and the message is kept in `last_error`. */
|
|
export class DropJobError extends Error {
|
|
override readonly name = "DropJobError";
|
|
}
|
|
|
|
const logErr = (err: unknown) => console.error("[jobs]", err);
|
|
|
|
/**
|
|
* Queue a job. Returns its id, or null when a `queued|running` job already holds `lockKey`.
|
|
* On Postgres the check and insert run under an advisory transaction lock so two callers cannot both pass.
|
|
*/
|
|
export async function enqueue(
|
|
db: Db,
|
|
type: string,
|
|
payload: unknown,
|
|
opts: { runAt?: string; lockKey?: string } = {},
|
|
): Promise<string | null> {
|
|
const lockKey = opts.lockKey ?? null;
|
|
return db.k.transaction().execute(async (tx) => {
|
|
if (lockKey !== null) {
|
|
if (db.dialect === "postgres") await sql`select pg_advisory_xact_lock(hashtext(${lockKey}))`.execute(tx);
|
|
const held = await tx
|
|
.selectFrom("jobs")
|
|
.select("id")
|
|
.where("lock_key", "=", lockKey)
|
|
.where("status", "in", ["queued", "running"])
|
|
.executeTakeFirst();
|
|
if (held) return null;
|
|
}
|
|
const id = newId();
|
|
const now = nowIso();
|
|
await tx
|
|
.insertInto("jobs")
|
|
.values({
|
|
id,
|
|
type,
|
|
payload: toJson(payload ?? null),
|
|
run_at: opts.runAt ?? now,
|
|
status: "queued",
|
|
attempts: 0,
|
|
last_error: null,
|
|
locked_at: null,
|
|
lock_key: lockKey,
|
|
created_at: now,
|
|
})
|
|
.execute();
|
|
return id;
|
|
});
|
|
}
|
|
|
|
/** Claim one queued job atomically; true when this caller won it. */
|
|
async function claim(db: Db, id: string): Promise<boolean> {
|
|
const r = await db.k
|
|
.updateTable("jobs")
|
|
.set({ status: "running", locked_at: nowIso() })
|
|
.where("id", "=", id)
|
|
.where("status", "=", "queued")
|
|
.executeTakeFirst();
|
|
return r.numUpdatedRows === 1n;
|
|
}
|
|
|
|
async function runJob(db: Db, handlers: Record<string, JobHandler>, job: { id: string; type: string; payload: string; attempts: number }, signal: AbortSignal, heartbeatMs: number): Promise<void> {
|
|
// Keep `locked_at` fresh so another process's reaper does not steal a long-running job.
|
|
const beat = setInterval(() => {
|
|
db.k.updateTable("jobs").set({ locked_at: nowIso() }).where("id", "=", job.id).where("status", "=", "running").execute().catch(logErr);
|
|
}, heartbeatMs);
|
|
try {
|
|
try {
|
|
const h = handlers[job.type];
|
|
if (!h) throw new NonRetryableError(`no handler for job type "${job.type}"`);
|
|
await h(fromJson<unknown>(job.payload, null), { signal });
|
|
await db.k.updateTable("jobs").set({ status: "done", locked_at: null, last_error: null }).where("id", "=", job.id).execute();
|
|
} catch (e) {
|
|
const attempts = job.attempts + 1;
|
|
const err = e instanceof Error ? e.message : String(e);
|
|
if (signal.aborted && !(e instanceof DropJobError) && !(e instanceof NonRetryableError)) {
|
|
// Interrupted by shutdown: back to the queue untouched, no attempt burned, no backoff.
|
|
await db.k.updateTable("jobs").set({ status: "queued", locked_at: null }).where("id", "=", job.id).execute();
|
|
} else if (e instanceof DropJobError) {
|
|
await db.k.updateTable("jobs").set({ status: "done", locked_at: null, last_error: err }).where("id", "=", job.id).execute();
|
|
} else if (e instanceof NonRetryableError || attempts >= MAX_ATTEMPTS) {
|
|
await db.k.updateTable("jobs").set({ status: "failed", attempts, last_error: err, locked_at: null }).where("id", "=", job.id).execute();
|
|
} else {
|
|
const runAt = new Date(Date.now() + 2 ** attempts * 60_000).toISOString();
|
|
await db.k
|
|
.updateTable("jobs")
|
|
.set({ status: "queued", attempts, last_error: err, run_at: runAt, locked_at: null })
|
|
.where("id", "=", job.id)
|
|
.execute();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
logErr(e);
|
|
} finally {
|
|
clearInterval(beat);
|
|
}
|
|
}
|
|
|
|
/** Re-queue jobs a crashed process left `running`, so their lockKey cannot be held forever. */
|
|
async function reapStale(db: Db, staleMs: number, mine: Set<string>): Promise<void> {
|
|
const cutoff = new Date(Date.now() - staleMs).toISOString();
|
|
let q = db.k.updateTable("jobs").set({ status: "queued", locked_at: null }).where("status", "=", "running").where("locked_at", "<", cutoff);
|
|
if (mine.size > 0) q = q.where("id", "not in", [...mine]);
|
|
await q.execute();
|
|
}
|
|
|
|
/** Poll for due jobs and run them with bounded concurrency. Failures retry with backoff `2^attempts` minutes, then `failed`. */
|
|
export function startWorker(
|
|
db: Db,
|
|
handlers: Record<string, JobHandler>,
|
|
opts: { pollMs?: number; concurrency?: number; staleMs?: number; heartbeatMs?: number } = {},
|
|
): { stop(): Promise<void> } {
|
|
const pollMs = opts.pollMs ?? 1000;
|
|
const concurrency = opts.concurrency ?? 2;
|
|
const staleMs = opts.staleMs ?? STALE_LOCK_MS;
|
|
const heartbeatMs = opts.heartbeatMs ?? HEARTBEAT_MS;
|
|
const inflight = new Set<Promise<void>>();
|
|
const mine = new Set<string>();
|
|
const abort = new AbortController();
|
|
let stopped = false;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
let tickP: Promise<void> = Promise.resolve();
|
|
|
|
async function tick(): Promise<void> {
|
|
await reapStale(db, staleMs, mine);
|
|
while (!stopped && inflight.size < concurrency) {
|
|
const due = await db.k
|
|
.selectFrom("jobs")
|
|
.select(["id", "type", "payload", "attempts"])
|
|
.where("status", "=", "queued")
|
|
.where("run_at", "<=", nowIso())
|
|
.orderBy("run_at")
|
|
.limit(concurrency - inflight.size)
|
|
.execute();
|
|
let started = 0;
|
|
for (const job of due) {
|
|
if (stopped || inflight.size >= concurrency) break;
|
|
if (!(await claim(db, job.id))) continue;
|
|
started++;
|
|
mine.add(job.id);
|
|
const p: Promise<void> = runJob(db, handlers, job, abort.signal, heartbeatMs)
|
|
.catch(logErr)
|
|
.finally(() => {
|
|
inflight.delete(p);
|
|
mine.delete(job.id);
|
|
});
|
|
inflight.add(p);
|
|
}
|
|
if (started === 0) break;
|
|
}
|
|
}
|
|
|
|
function schedule() {
|
|
if (stopped) return;
|
|
timer = setTimeout(() => {
|
|
tickP = tick().catch(logErr).finally(schedule);
|
|
}, pollMs);
|
|
}
|
|
tickP = tick().catch(logErr).finally(schedule);
|
|
|
|
return {
|
|
async stop() {
|
|
stopped = true;
|
|
abort.abort();
|
|
if (timer) clearTimeout(timer);
|
|
await tickP.catch(logErr);
|
|
await Promise.allSettled([...inflight]);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Every `tickMs`, enqueue a `sync` job for each non-paused (active or errored) connection whose schedule has come due. */
|
|
export function startScheduler(db: Db, opts: { tickMs?: number } = {}): { stop(): void; tickNow(): Promise<void> } {
|
|
const tickMs = opts.tickMs ?? 60_000;
|
|
let stopped = false;
|
|
async function tick() {
|
|
const rows = await db.k
|
|
.selectFrom("source_connections")
|
|
.select(["id", "schedule_minutes", "last_run_at"])
|
|
.where("status", "!=", "paused")
|
|
.where("schedule_minutes", "is not", null)
|
|
.execute();
|
|
const now = Date.now();
|
|
for (const r of rows) {
|
|
if (stopped) return;
|
|
const due = r.last_run_at === null || Date.parse(r.last_run_at) + r.schedule_minutes! * 60_000 <= now;
|
|
if (due) await enqueue(db, "sync", { connectionId: r.id }, { lockKey: `sync:${r.id}` });
|
|
}
|
|
}
|
|
const run = () => tick().catch(logErr);
|
|
void run();
|
|
const timer = setInterval(() => void run(), tickMs);
|
|
return {
|
|
/** Run one scheduling pass now (tests, manual triggers). */
|
|
tickNow: run,
|
|
stop() {
|
|
stopped = true;
|
|
clearInterval(timer);
|
|
},
|
|
};
|
|
}
|