import { describe, expect, it } from "@suaveplan/testing/runner"; import type { EventBusLike, LoggerLike } from "@suaveplan/types"; import { JOB_SCHEDULER_CODES, JobSchedulerError, } from "../errors/job-scheduler-error.js"; import { type ExecutionTimers, executeJob, type JobContext, } from "./job-execution.js"; interface CapturedEvent { type: string; payload: unknown; } function makeBus(): { bus: EventBusLike>; events: CapturedEvent[]; } { const events: CapturedEvent[] = []; const bus: EventBusLike> = { on: () => () => undefined, emit: (type, payload) => { events.push({ type: type as string, payload }); }, }; return { bus, events }; } function makeLogger(): LoggerLike { return { debug: () => undefined, info: () => undefined, warn: () => undefined, error: () => undefined, }; } interface FakeTimerState { now: number; nextId: number; pending: Map void }>; } function makeFakeTimers(state: FakeTimerState): { timers: ExecutionTimers; advance: (delta: number) => Promise; pending: () => number; } { const timers: ExecutionTimers = { setTimer: (cb, ms) => { const id = state.nextId++; state.pending.set(id, { fireAt: state.now + ms, cb }); return id; }, clearTimer: (h) => { state.pending.delete(h as number); }, }; const advance = async (delta: number): Promise => { const target = state.now + delta; for (;;) { let nextId: number | undefined; let nextAt = Number.POSITIVE_INFINITY; for (const [id, t] of state.pending) { if (t.fireAt <= target && t.fireAt < nextAt) { nextId = id; nextAt = t.fireAt; } } if (nextId === undefined) break; const entry = state.pending.get(nextId); if (entry === undefined) break; state.pending.delete(nextId); state.now = entry.fireAt; entry.cb(); await Promise.resolve(); } state.now = target; }; return { timers, advance, pending: () => state.pending.size }; } describe("executeJob", () => { it("emits start + complete on first-try success", async () => { const state: FakeTimerState = { now: 0, nextId: 1, pending: new Map(), }; const { timers } = makeFakeTimers(state); const { bus, events } = makeBus(); const handler = async (ctx: JobContext): Promise => { expect(ctx.attempt).toBe(1); }; await executeJob({ jobId: "j-1", kind: "queued", handler, scheduledAt: 0, retries: 0, retryDelayMs: 0, cancelSignal: new AbortController().signal, clock: () => state.now, logger: makeLogger(), events: bus, timers, }); expect(events.map((e) => e.type)).toEqual([ "job:start", "job:complete", ]); }); it("retries then succeeds, emitting one start per attempt", async () => { const state: FakeTimerState = { now: 0, nextId: 1, pending: new Map(), }; const { timers, advance } = makeFakeTimers(state); const { bus, events } = makeBus(); let attempts = 0; const handler = async (ctx: JobContext): Promise => { attempts = ctx.attempt; if (ctx.attempt < 3) throw new Error("transient"); }; const promise = executeJob({ jobId: "j-2", kind: "queued", handler, scheduledAt: 0, retries: 5, retryDelayMs: 100, cancelSignal: new AbortController().signal, clock: () => state.now, logger: makeLogger(), events: bus, timers, }); // Drain inter-attempt sleeps. for (let i = 0; i < 10; i++) { await Promise.resolve(); await Promise.resolve(); await advance(100); } await promise; expect(attempts).toBe(3); const starts = events.filter((e) => e.type === "job:start").length; const completes = events.filter( (e) => e.type === "job:complete" ).length; const fails = events.filter((e) => e.type === "job:fail").length; expect(starts).toBe(3); expect(completes).toBe(1); expect(fails).toBe(2); }); it("exhausts retries and throws JOB_SCHEDULER.EXECUTION_FAILED", async () => { const state: FakeTimerState = { now: 0, nextId: 1, pending: new Map(), }; const { timers, advance } = makeFakeTimers(state); const { bus } = makeBus(); const handler = async (): Promise => { throw new Error("always fails"); }; const promise = executeJob({ jobId: "j-3", kind: "queued", handler, scheduledAt: 0, retries: 2, retryDelayMs: 50, cancelSignal: new AbortController().signal, clock: () => state.now, logger: makeLogger(), events: bus, timers, }).catch((e: unknown) => e); for (let i = 0; i < 10; i++) { await Promise.resolve(); await Promise.resolve(); await advance(50); } const err = await promise; expect(err).toBeInstanceOf(JobSchedulerError); expect((err as JobSchedulerError).code).toBe( JOB_SCHEDULER_CODES.EXECUTION_FAILED ); }); it("times out a handler that never resolves", async () => { const state: FakeTimerState = { now: 0, nextId: 1, pending: new Map(), }; const { timers, advance } = makeFakeTimers(state); const { bus } = makeBus(); const handler = (ctx: JobContext): Promise => new Promise((resolve, reject) => { ctx.signal.addEventListener("abort", () => { reject(ctx.signal.reason); }); }); const promise = executeJob({ jobId: "j-4", kind: "queued", handler, scheduledAt: 0, retries: 0, retryDelayMs: 0, timeoutMs: 100, cancelSignal: new AbortController().signal, clock: () => state.now, logger: makeLogger(), events: bus, timers, }).catch((e: unknown) => e); await Promise.resolve(); await advance(100); const err = await promise; expect(err).toBeInstanceOf(JobSchedulerError); expect((err as JobSchedulerError).code).toBe( JOB_SCHEDULER_CODES.TIMEOUT ); }); it("propagates an externally-aborted cancel signal", async () => { const state: FakeTimerState = { now: 0, nextId: 1, pending: new Map(), }; const { timers } = makeFakeTimers(state); const { bus } = makeBus(); const outer = new AbortController(); outer.abort(new Error("cancelled by scheduler")); const handler = (ctx: JobContext): Promise => new Promise((_resolve, reject) => { if (ctx.signal.aborted) { reject(ctx.signal.reason); return; } ctx.signal.addEventListener("abort", () => { reject(ctx.signal.reason); }); }); const promise = executeJob({ jobId: "j-5", kind: "queued", handler, scheduledAt: 0, retries: 0, retryDelayMs: 0, cancelSignal: outer.signal, clock: () => state.now, logger: makeLogger(), events: bus, timers, }).catch((e: unknown) => e); const err = await promise; expect(err).toBeInstanceOf(JobSchedulerError); expect((err as JobSchedulerError).code).toBe( JOB_SCHEDULER_CODES.EXECUTION_FAILED ); }); it("exposes the active per-attempt controller via onAttemptController", async () => { const state: FakeTimerState = { now: 0, nextId: 1, pending: new Map(), }; const { timers } = makeFakeTimers(state); const { bus } = makeBus(); const controllers: AbortController[] = []; const handler = async (): Promise => undefined; await executeJob({ jobId: "j-6", kind: "cron", handler, scheduledAt: 0, retries: 0, retryDelayMs: 0, cancelSignal: new AbortController().signal, clock: () => state.now, logger: makeLogger(), events: bus, timers, onAttemptController: (c) => controllers.push(c), }); expect(controllers).toHaveLength(1); }); it("breaks the retry loop when cancel is requested mid-sleep", async () => { const state: FakeTimerState = { now: 0, nextId: 1, pending: new Map(), }; const { timers, advance } = makeFakeTimers(state); const { bus } = makeBus(); const outer = new AbortController(); let attempts = 0; const handler = async (): Promise => { attempts += 1; throw new Error("fail"); }; const promise = executeJob({ jobId: "j-7", kind: "queued", handler, scheduledAt: 0, retries: 5, retryDelayMs: 100, cancelSignal: outer.signal, clock: () => state.now, logger: makeLogger(), events: bus, timers, }).catch((e: unknown) => e); // Let the first attempt fail. await Promise.resolve(); // Cancel mid-sleep. outer.abort(new Error("stop")); // The sleep's abort listener resolves; the loop sees the aborted // signal and bails. await advance(0); const err = await promise; expect(err).toBeInstanceOf(JobSchedulerError); expect(attempts).toBe(1); }); });