Files
STTracker/packages/core/test/jobs.test.ts
T

95 lines
4.2 KiB
TypeScript

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 });
});
});