feat(core): jobs, scheduler, plugin registry, connections
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
207c92bb4d
commit
166b8ecb5e
20 files changed
+744
-10
No files matched your search
@@ -0,0 +1,168 @@
|
||||
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;
|
||||
|
||||
export type JobHandler = (payload: any) => Promise<void>;
|
||||
|
||||
/**
|
||||
* 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 }) {
|
||||
try {
|
||||
const h = handlers[job.type];
|
||||
if (!h) throw new Error(`no handler for job type "${job.type}"`);
|
||||
await h(fromJson<unknown>(job.payload, null));
|
||||
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 (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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 } = {},
|
||||
): { stop(): Promise<void> } {
|
||||
const pollMs = opts.pollMs ?? 1000;
|
||||
const concurrency = opts.concurrency ?? 2;
|
||||
const inflight = new Set<Promise<void>>();
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let tickP: Promise<void> = Promise.resolve();
|
||||
|
||||
async function tick(): Promise<void> {
|
||||
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++;
|
||||
const p = runJob(db, handlers, job).finally(() => inflight.delete(p));
|
||||
inflight.add(p);
|
||||
}
|
||||
if (started === 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (stopped) return;
|
||||
timer = setTimeout(() => {
|
||||
tickP = tick().catch(() => {}).finally(schedule);
|
||||
}, pollMs);
|
||||
}
|
||||
tickP = tick().catch(() => {}).finally(schedule);
|
||||
|
||||
return {
|
||||
async stop() {
|
||||
stopped = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
await tickP;
|
||||
await Promise.all(inflight);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Every `tickMs`, enqueue a `sync` job for each active 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", "=", "active")
|
||||
.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(() => {});
|
||||
void run();
|
||||
const timer = setInterval(() => void run(), tickMs);
|
||||
return {
|
||||
/** Run one scheduling pass now (tests, manual triggers). */
|
||||
tickNow: run,
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user