release: WRNexusJS 0.8.0
This commit is contained in:
@@ -135,6 +135,44 @@ test("deduplicates, lists and cancels queued work", async () => {
|
||||
expect(queue.cancel(first.id)).toBe(false);
|
||||
});
|
||||
|
||||
test("enforces capacity and can retry dead-lettered work", async () => {
|
||||
const queue = createQueue({ capacity: 1, maxAttempts: 1 });
|
||||
const job = await queue.add("work", {});
|
||||
await expect(queue.add("work", {})).rejects.toThrow("WRN-QUEUE-CAPACITY");
|
||||
queue.process("work", () => {
|
||||
throw new Error("nope");
|
||||
});
|
||||
await queue.drain();
|
||||
expect(queue.failed().map(({ id }) => id)).toEqual([job.id]);
|
||||
expect(await queue.retry(job.id)).toBe(true);
|
||||
expect(queue.failed()).toHaveLength(0);
|
||||
expect(queue.size()).toBe(1);
|
||||
});
|
||||
|
||||
test("cancels active work with an AbortSignal and force-shuts down", async () => {
|
||||
const queue = createQueue();
|
||||
let aborted = false;
|
||||
queue.process(
|
||||
"work",
|
||||
(_job, { signal }) =>
|
||||
new Promise<void>((resolve) => {
|
||||
signal.addEventListener("abort", () => {
|
||||
aborted = true;
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
);
|
||||
const job = await queue.add("work", {});
|
||||
const draining = queue.drain();
|
||||
await Promise.resolve();
|
||||
expect(queue.cancel(job.id)).toBe(true);
|
||||
await draining;
|
||||
expect(aborted).toBe(true);
|
||||
expect(queue.size()).toBe(0);
|
||||
await queue.shutdown({ force: true });
|
||||
await expect(queue.add("work", {})).rejects.toThrow("WRN-QUEUE-CLOSED");
|
||||
});
|
||||
|
||||
test("runs typed workflows and parses supported cron expressions", async () => {
|
||||
const workflow = defineWorkflow<number>("double-and-label", [
|
||||
{ name: "double", run: (value: number) => value * 2 },
|
||||
@@ -190,3 +228,38 @@ test("durable queue does not remove a job before its handler succeeds", async ()
|
||||
expect(visibleDuringHandler).toBe(true);
|
||||
expect(await store.get(job.id)).toBeNull();
|
||||
});
|
||||
|
||||
test("durable queue supports capacity, cancellation, and shutdown", async () => {
|
||||
const store = memoryQueueStore();
|
||||
const queue = createDurableQueue({ store, capacity: 1 });
|
||||
const job = await queue.add("work", {});
|
||||
await expect(queue.add("work", {})).rejects.toThrow("WRN-QUEUE-CAPACITY");
|
||||
expect((await queue.list()).map(({ id }) => id)).toEqual([job.id]);
|
||||
expect(await queue.cancel(job.id)).toBe(true);
|
||||
expect(await queue.cancel(job.id)).toBe(false);
|
||||
await queue.shutdown();
|
||||
await expect(queue.add("work", {})).rejects.toThrow("WRN-QUEUE-CLOSED");
|
||||
});
|
||||
|
||||
test("force shutdown aborts an active durable job", async () => {
|
||||
const store = memoryQueueStore();
|
||||
const queue = createDurableQueue({ store });
|
||||
let aborted = false;
|
||||
queue.process(
|
||||
"work",
|
||||
(_job, { signal }) =>
|
||||
new Promise<void>((resolve) => {
|
||||
signal.addEventListener("abort", () => {
|
||||
aborted = true;
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
);
|
||||
const job = await queue.add("work", {});
|
||||
const draining = queue.drain();
|
||||
await Promise.resolve();
|
||||
await queue.shutdown({ force: true });
|
||||
await draining;
|
||||
expect(aborted).toBe(true);
|
||||
expect(await store.get(job.id)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
addBatch,
|
||||
createDurableQueue,
|
||||
createQueueScheduler,
|
||||
memoryQueueStore,
|
||||
postgresQueueStore,
|
||||
queueDashboardSnapshot,
|
||||
redisQueueStore,
|
||||
runQueueDaemon,
|
||||
type Job,
|
||||
type RedisQueueClient,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const job: Job = {
|
||||
id: "one",
|
||||
name: "email",
|
||||
data: { to: "a@b.test" },
|
||||
attempts: 0,
|
||||
maxAttempts: 3,
|
||||
runAt: 10,
|
||||
priority: 2,
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
test("Redis queue store persists, orders and atomically leases jobs", async () => {
|
||||
const strings = new Map<string, string>();
|
||||
const sets = new Map<string, Set<string>>();
|
||||
const scores = new Map<string, Map<string, number>>();
|
||||
const client: RedisQueueClient = {
|
||||
async get(key) {
|
||||
return strings.get(key) ?? null;
|
||||
},
|
||||
async set(key, value, options) {
|
||||
if (options?.NX && strings.has(key)) return null;
|
||||
strings.set(key, value);
|
||||
return "OK";
|
||||
},
|
||||
async del(...keys) {
|
||||
for (const key of keys) strings.delete(key);
|
||||
return keys.length;
|
||||
},
|
||||
async zadd(key, score, member) {
|
||||
(scores.get(key) ?? scores.set(key, new Map()).get(key)!).set(member, score);
|
||||
return 1;
|
||||
},
|
||||
async zrem(key, member) {
|
||||
return scores.get(key)?.delete(member) ? 1 : 0;
|
||||
},
|
||||
async zrangebyscore(key, min, max, options) {
|
||||
return [...(scores.get(key) ?? [])]
|
||||
.filter(([, score]) => score >= min && score <= max)
|
||||
.sort((a, b) => a[1] - b[1])
|
||||
.slice(...options!.limit)
|
||||
.map(([id]) => id);
|
||||
},
|
||||
async smembers(key) {
|
||||
return [...(sets.get(key) ?? [])];
|
||||
},
|
||||
async sadd(key, member) {
|
||||
(sets.get(key) ?? sets.set(key, new Set()).get(key)!).add(member);
|
||||
return 1;
|
||||
},
|
||||
async srem(key, member) {
|
||||
return sets.get(key)?.delete(member) ? 1 : 0;
|
||||
},
|
||||
};
|
||||
const store = redisQueueStore(client);
|
||||
await store.put(job);
|
||||
expect(await store.due(10, 5)).toEqual([job]);
|
||||
expect(await store.claim!(job.id, "a", Date.now() + 1000)).toBeTrue();
|
||||
expect(await store.claim!(job.id, "b", Date.now() + 1000)).toBeFalse();
|
||||
await store.remove(job.id);
|
||||
expect(await store.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("standalone daemon schedules, drains and shuts down on abort", async () => {
|
||||
const queue = createDurableQueue();
|
||||
let processed = 0;
|
||||
queue.process("pulse", async () => {
|
||||
processed++;
|
||||
});
|
||||
const scheduler = createQueueScheduler(queue, [{ name: "pulse", data: {}, everyMs: 10 }], {
|
||||
pollMs: 10,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 35);
|
||||
await runQueueDaemon(queue, scheduler, { signal: controller.signal, pollMs: 10 });
|
||||
expect(processed).toBeGreaterThan(0);
|
||||
expect(scheduler.snapshot().running).toBeFalse();
|
||||
});
|
||||
|
||||
test("PostgreSQL queue store parameterizes values and validates identifiers", async () => {
|
||||
const calls: Array<{ sql: string; args?: unknown[] }> = [];
|
||||
const store = postgresQueueStore({
|
||||
async query(sql, args) {
|
||||
calls.push({ sql, args });
|
||||
return { rows: [] };
|
||||
},
|
||||
});
|
||||
await store.put(job);
|
||||
expect(calls[0]!.sql).toContain("ON CONFLICT");
|
||||
expect(calls[0]!.args?.[0]).toBe("one");
|
||||
expect(() =>
|
||||
postgresQueueStore(
|
||||
{
|
||||
async query() {
|
||||
return { rows: [] };
|
||||
},
|
||||
},
|
||||
"jobs; DROP TABLE jobs",
|
||||
),
|
||||
).toThrow("Invalid queue table");
|
||||
});
|
||||
|
||||
test("scheduler, batch and dashboard provide operational queue APIs", async () => {
|
||||
let now = 1_000;
|
||||
const queue = createDurableQueue({ store: memoryQueueStore(), now: () => now });
|
||||
await addBatch(queue, "email", [{ id: 1 }, { id: 2 }], { idempotencyKey: "emails" });
|
||||
const scheduler = createQueueScheduler(queue, [{ name: "cleanup", data: {}, everyMs: 500 }], {
|
||||
now: () => now,
|
||||
});
|
||||
expect(await scheduler.tick()).toBe(1);
|
||||
expect(await scheduler.tick()).toBe(0);
|
||||
now = 1_500;
|
||||
expect(await scheduler.tick()).toBe(1);
|
||||
expect(await queueDashboardSnapshot(queue)).toMatchObject({
|
||||
pending: 4,
|
||||
byName: { email: 2, cleanup: 2 },
|
||||
});
|
||||
expect(scheduler.snapshot().schedules).toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createWorkflowEngine, defineDurableWorkflow, memoryWorkflowStore } from "../src/index.ts";
|
||||
|
||||
describe("durable workflows", () => {
|
||||
test("runs dependency steps and persists results", async () => {
|
||||
const store = memoryWorkflowStore();
|
||||
const engine = createWorkflowEngine(store);
|
||||
const workflow = defineDurableWorkflow({
|
||||
name: "report",
|
||||
steps: [
|
||||
{ name: "load", run: (input: number) => input * 2 },
|
||||
{ name: "render", dependsOn: ["load"], run: (input: number) => `report:${input}` },
|
||||
],
|
||||
});
|
||||
const result = await engine.start(workflow, 4, "report-1");
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.results.render).toBe("report:8");
|
||||
expect((await store.get("report-1"))?.progress).toBe(100);
|
||||
});
|
||||
test("pauses for a durable human approval and resumes", async () => {
|
||||
const engine = createWorkflowEngine();
|
||||
const workflow = defineDurableWorkflow({
|
||||
name: "publish",
|
||||
steps: [
|
||||
{ name: "draft", run: () => "ready" },
|
||||
{ name: "approve", dependsOn: ["draft"], approval: true, run: (input: string) => input },
|
||||
{ name: "publish", dependsOn: ["approve"], run: () => "published" },
|
||||
],
|
||||
});
|
||||
const waiting = await engine.start(workflow, null, "publish-1");
|
||||
expect(waiting).toMatchObject({ status: "waiting-approval", waitingFor: "approve" });
|
||||
const completed = await engine.approve(workflow, "publish-1", "approve", "user-42");
|
||||
expect(completed.status).toBe("completed");
|
||||
expect(completed.results["approve:approval"]).toMatchObject({ actor: "user-42" });
|
||||
});
|
||||
test("rejects invalid dependency graphs", () => {
|
||||
expect(() =>
|
||||
defineDurableWorkflow({
|
||||
name: "bad",
|
||||
steps: [{ name: "a", dependsOn: ["missing"], run() {} }],
|
||||
}),
|
||||
).toThrow("WRN-WORKFLOW-DEPENDENCY");
|
||||
expect(() =>
|
||||
defineDurableWorkflow({
|
||||
name: "cycle",
|
||||
steps: [
|
||||
{ name: "a", dependsOn: ["b"], run() {} },
|
||||
{ name: "b", dependsOn: ["a"], run() {} },
|
||||
],
|
||||
}),
|
||||
).toThrow("WRN-WORKFLOW-CYCLE");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user