import { describe, expect, it, vi } from "vitest"; vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} })); import { claimOrAwait, publishClaim, releaseClaim } from "~/v3/mollifier/idempotencyClaim.server"; import type { IdempotencyClaimResult, MollifierBuffer } from "@trigger.dev/redis-worker"; type ClaimState = { value: string | null; // Scripted return sequence for claimIdempotency calls. When set, // overrides the default behaviour of returning based on `value`. scriptedClaims?: IdempotencyClaimResult[]; }; function makeBuffer(initial: ClaimState = { value: null }): { buffer: MollifierBuffer; state: ClaimState; } { const state = { ...initial }; const buffer = { claimIdempotency: vi.fn(async (): Promise => { if (state.scriptedClaims && state.scriptedClaims.length > 0) { return state.scriptedClaims.shift()!; } if (state.value === null) { state.value = "pending"; return { kind: "claimed" }; } if (state.value === "pending") return { kind: "pending" }; return { kind: "resolved", runId: state.value }; }), readClaim: vi.fn(async (): Promise => { if (state.value === null) return null; if (state.value === "pending") return { kind: "pending" }; return { kind: "resolved", runId: state.value }; }), publishClaim: vi.fn(async ({ runId }: { runId: string }) => { state.value = runId; }), releaseClaim: vi.fn(async () => { state.value = null; }), } as unknown as MollifierBuffer; return { buffer, state }; } const baseInput = { envId: "env_a", taskIdentifier: "my-task", idempotencyKey: "k-1", }; describe("claimOrAwait", () => { it("returns 'claimed' for the first caller — empty key wins SETNX", async () => { const { buffer } = makeBuffer({ value: null }); const outcome = await claimOrAwait({ ...baseInput, buffer, generateToken: () => "token-1", }); expect(outcome).toEqual({ kind: "claimed", token: "token-1" }); }); it("returns 'resolved' immediately when the key already holds a runId", async () => { const { buffer } = makeBuffer({ value: "run_X" }); const outcome = await claimOrAwait({ ...baseInput, buffer }); expect(outcome).toEqual({ kind: "resolved", runId: "run_X" }); }); it("polls a pending key, then resolves when the runId is published", async () => { const { buffer, state } = makeBuffer({ value: "pending" }); let nowValue = 0; let pollCount = 0; const outcome = await claimOrAwait({ ...baseInput, buffer, now: () => nowValue, sleep: async (ms) => { nowValue += ms; pollCount += 1; if (pollCount === 3) state.value = "run_X"; }, safetyNetMs: 1000, pollStepMs: 25, }); expect(outcome).toEqual({ kind: "resolved", runId: "run_X" }); }); it("returns 'timed_out' when the key stays pending past safetyNetMs", async () => { const { buffer } = makeBuffer({ value: "pending" }); let nowValue = 0; const outcome = await claimOrAwait({ ...baseInput, buffer, now: () => nowValue, sleep: async (ms) => { nowValue += ms; }, safetyNetMs: 50, pollStepMs: 25, }); expect(outcome).toEqual({ kind: "timed_out" }); }); it("retries the claim when a polled key vanishes (claimant released)", async () => { const { buffer, state } = makeBuffer({ value: "pending" }); let nowValue = 0; let pollCount = 0; // Scripted retry: on the second `claimIdempotency` call we win. state.scriptedClaims = [ { kind: "pending" }, // first call (initial) { kind: "claimed" }, // second call (retry after release) ]; const outcome = await claimOrAwait({ ...baseInput, buffer, generateToken: () => "token-retry", now: () => nowValue, sleep: async (ms) => { nowValue += ms; pollCount += 1; // First poll cycle: key vanishes (release). if (pollCount === 1) state.value = null; }, safetyNetMs: 1000, pollStepMs: 25, }); expect(outcome).toEqual({ kind: "claimed", token: "token-retry" }); }); it("fails open with 'claimed' when buffer is null (mollifier disabled)", async () => { const outcome = await claimOrAwait({ ...baseInput, buffer: null, generateToken: () => "token-fallopen-null", }); expect(outcome).toEqual({ kind: "claimed", token: "token-fallopen-null" }); }); it("fails open with 'claimed' if buffer.claimIdempotency throws (Redis down)", async () => { const buffer = { claimIdempotency: vi.fn(async () => { throw new Error("ECONNREFUSED"); }), } as unknown as MollifierBuffer; const outcome = await claimOrAwait({ ...baseInput, buffer, generateToken: () => "token-fallopen-throw", }); expect(outcome).toEqual({ kind: "claimed", token: "token-fallopen-throw" }); }); it("respects an aborted signal during the wait loop", async () => { const { buffer } = makeBuffer({ value: "pending" }); const controller = new AbortController(); let nowValue = 0; let pollCount = 0; const outcome = await claimOrAwait({ ...baseInput, buffer, now: () => nowValue, sleep: async (ms) => { nowValue += ms; pollCount += 1; if (pollCount === 1) controller.abort(); }, abortSignal: controller.signal, safetyNetMs: 5000, pollStepMs: 25, }); expect(outcome).toEqual({ kind: "timed_out" }); }); }); describe("publishClaim", () => { it("writes the runId to the claim key", async () => { const { buffer, state } = makeBuffer({ value: "pending" }); await publishClaim({ ...baseInput, token: "owner-token", runId: "run_X", buffer }); expect(state.value).toBe("run_X"); expect(buffer.publishClaim).toHaveBeenCalledOnce(); }); it("no-op when buffer is null", async () => { await expect( publishClaim({ ...baseInput, token: "owner-token", runId: "run_X", buffer: null }) ).resolves.toBeUndefined(); }); it("swallows errors so trigger pipeline isn't broken by Redis hiccups", async () => { const buffer = { publishClaim: vi.fn(async () => { throw new Error("ECONNREFUSED"); }), } as unknown as MollifierBuffer; await expect( publishClaim({ ...baseInput, token: "owner-token", runId: "run_X", buffer }) ).resolves.toBeUndefined(); }); }); describe("releaseClaim", () => { it("DELs the claim so waiters can re-acquire", async () => { const { buffer, state } = makeBuffer({ value: "pending" }); await releaseClaim({ ...baseInput, token: "owner-token", buffer }); expect(state.value).toBeNull(); }); it("no-op when buffer is null", async () => { await expect( releaseClaim({ ...baseInput, token: "owner-token", buffer: null }) ).resolves.toBeUndefined(); }); }); // End-to-end: the token from `claimOrAwait`'s `claimed` outcome must // reach `buffer.claimIdempotency` and round-trip through publishClaim / // releaseClaim. Without this the compare-and-act ownership protection // in the buffer is bypassed and the stale-claimant hazard returns. describe("claim ownership token wiring", () => { it("threads the token from claimOrAwait into buffer.claimIdempotency", async () => { const { buffer } = makeBuffer({ value: null }); const outcome = await claimOrAwait({ ...baseInput, buffer, generateToken: () => "owner-token-xyz", }); expect(outcome).toEqual({ kind: "claimed", token: "owner-token-xyz" }); expect(buffer.claimIdempotency).toHaveBeenCalledWith({ ...baseInput, token: "owner-token-xyz", ttlSeconds: 30, }); }); it("threads the token from publishClaim into buffer.publishClaim", async () => { const { buffer } = makeBuffer({ value: "pending" }); await publishClaim({ ...baseInput, token: "owner-token-xyz", runId: "run_X", buffer, }); expect(buffer.publishClaim).toHaveBeenCalledWith( expect.objectContaining({ token: "owner-token-xyz", runId: "run_X", }) ); }); it("threads the token from releaseClaim into buffer.releaseClaim", async () => { const { buffer } = makeBuffer({ value: "pending" }); await releaseClaim({ ...baseInput, token: "owner-token-xyz", buffer, }); expect(buffer.releaseClaim).toHaveBeenCalledWith( expect.objectContaining({ token: "owner-token-xyz" }) ); }); });