chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock the db module so the BaseService default prisma doesn't try to
|
||||
// open a real connection at module load. Each test wires its own
|
||||
// prisma stub.
|
||||
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
|
||||
// Prevent the runEngine singleton from instantiating and spinning up
|
||||
// PG/Redis workers at module load — without this CI fails with
|
||||
// unhandled `PrismaClientInitializationError`s even though the
|
||||
// assertions all pass (see `mollifierDrainerWorker.test.ts`).
|
||||
vi.mock("~/v3/runEngine.server", () => ({ engine: {} }));
|
||||
|
||||
// Hoisted mock state so we can swap the buffer per test without
|
||||
// re-importing modules.
|
||||
const bufferMock: { current: unknown } = { current: null };
|
||||
vi.mock("~/v3/mollifier/mollifierBuffer.server", () => ({
|
||||
getMollifierBuffer: () => bufferMock.current,
|
||||
}));
|
||||
|
||||
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
|
||||
type FakePrisma = {
|
||||
taskRun: { updateMany: (...args: unknown[]) => Promise<{ count: number }> };
|
||||
};
|
||||
|
||||
function makePrisma(pgCount: number): FakePrisma {
|
||||
return {
|
||||
taskRun: {
|
||||
updateMany: vi.fn(async () => ({ count: pgCount })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const env = {
|
||||
id: "env_a",
|
||||
organizationId: "org_1",
|
||||
} as unknown as Parameters<ResetIdempotencyKeyService["call"]>[2];
|
||||
|
||||
describe("ResetIdempotencyKeyService — buffer-outage handling", () => {
|
||||
it("returns success when PG cleared >=1 run, even if the buffer reset throws", async () => {
|
||||
bufferMock.current = {
|
||||
resetIdempotency: vi.fn(async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
}),
|
||||
};
|
||||
const prisma = makePrisma(1);
|
||||
const service = new ResetIdempotencyKeyService(prisma as never);
|
||||
|
||||
const result = await service.call("ikey", "task", env);
|
||||
expect(result).toEqual({ id: "ikey" });
|
||||
});
|
||||
|
||||
it("returns success when PG cleared nothing but the buffer cleared a run", async () => {
|
||||
bufferMock.current = {
|
||||
resetIdempotency: vi.fn(async () => ({ clearedRunId: "run_x" })),
|
||||
};
|
||||
const prisma = makePrisma(0);
|
||||
const service = new ResetIdempotencyKeyService(prisma as never);
|
||||
|
||||
const result = await service.call("ikey", "task", env);
|
||||
expect(result).toEqual({ id: "ikey" });
|
||||
});
|
||||
|
||||
it("404s when PG and buffer both legitimately report 'nothing to clear'", async () => {
|
||||
bufferMock.current = {
|
||||
resetIdempotency: vi.fn(async () => ({ clearedRunId: null })),
|
||||
};
|
||||
const prisma = makePrisma(0);
|
||||
const service = new ResetIdempotencyKeyService(prisma as never);
|
||||
|
||||
await expect(service.call("ikey", "task", env)).rejects.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
// Regression for the silent-not-found hazard CodeRabbit flagged: if PG
|
||||
// sees nothing AND we can't read the buffer (Redis outage), the
|
||||
// previous behaviour was to 404 — masking a partial outage and
|
||||
// leaving a buffered key effectively un-reset while the caller was
|
||||
// told "doesn't exist." We now surface 503 so the caller retries.
|
||||
it("503s when PG cleared nothing AND the buffer reset failed (partial outage)", async () => {
|
||||
bufferMock.current = {
|
||||
resetIdempotency: vi.fn(async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
}),
|
||||
};
|
||||
const prisma = makePrisma(0);
|
||||
const service = new ResetIdempotencyKeyService(prisma as never);
|
||||
|
||||
const error = await service.call("ikey", "task", env).then(
|
||||
() => null,
|
||||
(err) => err
|
||||
);
|
||||
expect(error).toBeInstanceOf(ServiceValidationError);
|
||||
expect(error.status).toBe(503);
|
||||
expect(error.message).toMatch(/retry/i);
|
||||
});
|
||||
|
||||
it("404s normally when buffer is null (mollifier disabled) and PG cleared nothing", async () => {
|
||||
bufferMock.current = null;
|
||||
const prisma = makePrisma(0);
|
||||
const service = new ResetIdempotencyKeyService(prisma as never);
|
||||
|
||||
await expect(service.call("ikey", "task", env)).rejects.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
// Regression for the PG↔buffer handoff race CodeRabbit flagged on PR #3756.
|
||||
//
|
||||
// Sequence the test models (deterministic, by setup):
|
||||
// 1. ResetIdempotencyKeyService.call begins while the run is still
|
||||
// buffered. The initial pg.updateMany sees no PG row → count=0.
|
||||
// 2. Between that update and the buffer reset, the drainer materialises
|
||||
// the buffered run into PG (engine.trigger writes the row with the
|
||||
// original idempotencyKey intact) AND `buffer.ack` clears the
|
||||
// associated Redis idempotency lookup — that's part of ack's
|
||||
// atomic contract (see `buffer.ts:493` comment).
|
||||
// 3. buffer.resetIdempotency runs after ack → returns
|
||||
// `{ clearedRunId: null }` because the lookup is gone.
|
||||
//
|
||||
// Without the handoff re-check, totalCount = 0 + 0 = 0 → the service
|
||||
// throws 404 for a key that genuinely still exists on the now-
|
||||
// materialised PG row. The customer's reset is silently lost.
|
||||
//
|
||||
// Correct behaviour: the service must discover the materialised row
|
||||
// and clear its key, returning success. This test pins that contract.
|
||||
it("succeeds when a buffered run materialises into PG between the initial pgUpdate and the buffer reset (handoff race)", async () => {
|
||||
let updateManyCalls = 0;
|
||||
const prisma: FakePrisma = {
|
||||
taskRun: {
|
||||
// First call: pre-materialisation, no PG row yet → 0.
|
||||
// Second call (the fix's re-check after both surfaces report
|
||||
// nothing): post-materialisation, drainer wrote the row → 1.
|
||||
updateMany: vi.fn(async () => {
|
||||
updateManyCalls += 1;
|
||||
return updateManyCalls === 1 ? { count: 0 } : { count: 1 };
|
||||
}),
|
||||
},
|
||||
};
|
||||
const resetIdempotency = vi.fn(async () => ({ clearedRunId: null as string | null }));
|
||||
bufferMock.current = { resetIdempotency };
|
||||
|
||||
const service = new ResetIdempotencyKeyService(prisma as never);
|
||||
|
||||
const result = await service.call("ikey", "task", env);
|
||||
expect(result).toEqual({ id: "ikey" });
|
||||
|
||||
// Load-bearing pieces of the fix:
|
||||
// - The buffer path was consulted (we didn't bypass the normal
|
||||
// handoff window check), and
|
||||
// - A second pg.updateMany fired AFTER the buffer's null result,
|
||||
// catching the now-materialised row.
|
||||
expect(resetIdempotency).toHaveBeenCalledOnce();
|
||||
expect(updateManyCalls).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user