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,94 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { forEachDialect } from "@sttracker/db/testing";
|
||||
import { enqueue, startScheduler, startWorker } from "../src/jobs.ts";
|
||||
import { makeConnection, makeUser } from "./sync/helpers.ts";
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
forEachDialect("jobs", (getDb) => {
|
||||
test("enqueue with same lockKey returns null", async () => {
|
||||
const db = getDb();
|
||||
expect(await enqueue(db, "sync", {}, { lockKey: "sync:a" })).toBeString();
|
||||
expect(await enqueue(db, "sync", {}, { lockKey: "sync:a" })).toBeNull();
|
||||
expect(await enqueue(db, "sync", {}, { lockKey: "sync:b" })).toBeString();
|
||||
expect(await enqueue(db, "x", {})).toBeString();
|
||||
expect(await enqueue(db, "x", {})).toBeString();
|
||||
});
|
||||
|
||||
test("concurrent enqueue with one lockKey admits exactly one", async () => {
|
||||
const db = getDb();
|
||||
const ids = await Promise.all(Array.from({ length: 6 }, () => enqueue(db, "sync", {}, { lockKey: "k" })));
|
||||
expect(ids.filter((i) => i !== null)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("worker runs a job and releases the lock", async () => {
|
||||
const db = getDb();
|
||||
const seen: unknown[] = [];
|
||||
const id = await enqueue(db, "t", { n: 1 }, { lockKey: "l" });
|
||||
const w = startWorker(db, { t: async (p) => void seen.push(p) }, { pollMs: 10 });
|
||||
await sleep(150);
|
||||
await w.stop();
|
||||
expect(seen).toEqual([{ n: 1 }]);
|
||||
expect((await db.k.selectFrom("jobs").selectAll().where("id", "=", id!).executeTakeFirstOrThrow()).status).toBe("done");
|
||||
expect(await enqueue(db, "t", {}, { lockKey: "l" })).toBeString();
|
||||
});
|
||||
|
||||
test("a job is claimed by only one of two workers", async () => {
|
||||
const db = getDb();
|
||||
let runs = 0;
|
||||
const h = { t: async () => { runs++; await sleep(30); } };
|
||||
await enqueue(db, "t", {});
|
||||
const a = startWorker(db, h, { pollMs: 5 });
|
||||
const b = startWorker(db, h, { pollMs: 5 });
|
||||
await sleep(200);
|
||||
await Promise.all([a.stop(), b.stop()]);
|
||||
expect(runs).toBe(1);
|
||||
});
|
||||
|
||||
test("worker retries then fails", async () => {
|
||||
const db = getDb();
|
||||
let calls = 0;
|
||||
const id = (await enqueue(db, "bad", {}))!;
|
||||
const w = startWorker(db, { bad: async () => { calls++; throw new Error("nope"); } }, { pollMs: 10 });
|
||||
const row = () => db.k.selectFrom("jobs").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||
for (let i = 0; i < 100 && (await row()).attempts < attempt; i++) await sleep(10);
|
||||
const r = await row();
|
||||
expect(r.attempts).toBe(attempt);
|
||||
expect(r.last_error).toBe("nope");
|
||||
if (attempt < 5) {
|
||||
expect(r.status).toBe("queued");
|
||||
const delay = Date.parse(r.run_at) - Date.now();
|
||||
expect(delay).toBeGreaterThan(2 ** attempt * 60_000 - 5_000);
|
||||
expect(delay).toBeLessThanOrEqual(2 ** attempt * 60_000);
|
||||
await db.k.updateTable("jobs").set({ run_at: new Date(Date.now() - 1000).toISOString() }).where("id", "=", id).execute();
|
||||
}
|
||||
}
|
||||
await w.stop();
|
||||
expect((await row()).status).toBe("failed");
|
||||
expect(calls).toBe(5);
|
||||
});
|
||||
|
||||
test("scheduler enqueues due connections only", async () => {
|
||||
const db = getDb();
|
||||
const u = await makeUser(db);
|
||||
const due = await makeConnection(db, u, "p");
|
||||
const fresh = await makeConnection(db, u, "p");
|
||||
const manual = await makeConnection(db, u, "p");
|
||||
const paused = await makeConnection(db, u, "p");
|
||||
const old = new Date(Date.now() - 120 * 60_000).toISOString();
|
||||
const recent = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||
const set = (id: string, v: object) => db.k.updateTable("source_connections").set(v).where("id", "=", id).execute();
|
||||
await set(due, { schedule_minutes: 60, last_run_at: old });
|
||||
await set(fresh, { schedule_minutes: 60, last_run_at: recent });
|
||||
await set(paused, { schedule_minutes: 60, last_run_at: old, status: "paused" });
|
||||
void manual;
|
||||
const s = startScheduler(db, { tickMs: 3_600_000 });
|
||||
await s.tickNow();
|
||||
await s.tickNow();
|
||||
s.stop();
|
||||
const jobs = await db.k.selectFrom("jobs").selectAll().execute();
|
||||
expect(jobs.map((j) => j.lock_key)).toEqual([`sync:${due}`]);
|
||||
expect(JSON.parse(jobs[0]!.payload)).toEqual({ connectionId: due });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user