diff --git a/src/__tests__/integration-truncate-deadlock-retry.test.ts b/src/__tests__/integration-truncate-deadlock-retry.test.ts new file mode 100644 index 000000000..a5b423a11 --- /dev/null +++ b/src/__tests__/integration-truncate-deadlock-retry.test.ts @@ -0,0 +1,63 @@ +/** + * The integration suite wipes the database between files with one + * `TRUNCATE`, which needs the strictest lock on every table at once. The + * server writes in thirty-nine places without awaiting the result, so one of + * those can land after the test that caused it has returned and take the + * truncate down with a deadlock. Postgres picks a victim; ours was an + * unrelated file, twice in two days, green on the rerun both times. + * + * The helper retries that one error now. These cases hold it to exactly that: + * it gives up on anything else immediately, and it does not retry forever. + */ +import { describe, expect, it, vi } from "vitest"; + +import { truncateAllTables } from "../../tests/integration/setup"; + +function deadlock(): Error & { code: string } { + const error = new Error("deadlock detected") as Error & { code: string }; + error.code = "40P01"; + return error; +} + +function fakeClient(executeRaw: ReturnType) { + const empty = vi.fn().mockResolvedValue([]); + return { + $executeRawUnsafe: executeRaw, + moodTag: { findMany: empty, createMany: vi.fn() }, + moodTagCategory: { findMany: empty, createMany: vi.fn() }, + } as unknown as Parameters[0]; +} + +describe("truncateAllTables", () => { + it("retries a deadlocked truncate and succeeds once the lock clears", async () => { + const executeRaw = vi + .fn() + .mockRejectedValueOnce(deadlock()) + .mockRejectedValueOnce(deadlock()) + .mockResolvedValue(0); + + await truncateAllTables(fakeClient(executeRaw)); + + expect(executeRaw).toHaveBeenCalledTimes(3); + }); + + it("gives up on an error that is not a deadlock, on the first attempt", async () => { + const other = new Error("permission denied") as Error & { code: string }; + other.code = "42501"; + const executeRaw = vi.fn().mockRejectedValue(other); + + await expect(truncateAllTables(fakeClient(executeRaw))).rejects.toThrow( + "permission denied", + ); + expect(executeRaw).toHaveBeenCalledTimes(1); + }); + + it("stops retrying a deadlock that never clears", async () => { + const executeRaw = vi.fn().mockRejectedValue(deadlock()); + + await expect(truncateAllTables(fakeClient(executeRaw))).rejects.toThrow( + "deadlock detected", + ); + expect(executeRaw).toHaveBeenCalledTimes(5); + }); +}); diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index 3430ad2e6..f4512b636 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -101,9 +101,29 @@ export async function truncateAllTables(client: PrismaClient): Promise { } const quoted = tables.map((t) => `"${t}"`).join(", "); - await client.$executeRawUnsafe( - `TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE;`, - ); + // A truncate takes the strictest lock there is on every table at once, so it + // loses to anything still holding a row lock. Thirty-nine places in the + // server write without awaiting the result (a last-used stamp, a delivery + // record, an audit row), and one of those can land after the test that + // triggered it has returned. Postgres then reports a deadlock and kills the + // truncate, which fails an unrelated file and passes on the next run. + // Retrying is the honest answer: a deadlock means somebody else got there + // first, and by the next attempt the stray write has finished. Only 40P01 is + // retried, so a lock held for good still fails as it should. + for (let attempt = 0; ; attempt++) { + try { + await client.$executeRawUnsafe( + `TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE;`, + ); + break; + } catch (error) { + const code = (error as { code?: string }).code; + const deadlock = + code === "40P01" || /deadlock detected/i.test(String(error)); + if (!deadlock || attempt >= 4) throw error; + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } + } // Categories first — the tag rows FK into them. if (categorySnapshot.length > 0) {