test(integration): retry a truncate that lost a deadlock

The suite wipes the database between files with one TRUNCATE, which needs
the strictest lock on every table at the same time. 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 Postgres then picks the
truncate as the deadlock victim. It failed an unrelated file twice in two
days and passed on the rerun both times.

The truncate is retried up to four times, on that one error and no other,
with a short back-off. Three cases hold it there: a deadlock that clears,
an error that is not a deadlock and must not be retried at all, and a
deadlock that never clears and must still fail. All three go red without
the retry.
This commit is contained in:
Marc-André Bombeck committed 2026-09-11 12:38:31 +02:00
1 parent 655eafa757
commit 42b9aefdfd
2 files changed
+86 -3

No files matched your search

@@ -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<typeof vi.fn>) {
const empty = vi.fn().mockResolvedValue([]);
return {
$executeRawUnsafe: executeRaw,
moodTag: { findMany: empty, createMany: vi.fn() },
moodTagCategory: { findMany: empty, createMany: vi.fn() },
} as unknown as Parameters<typeof truncateAllTables>[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);
});
});
+23 -3
View File
@@ -101,9 +101,29 @@ export async function truncateAllTables(client: PrismaClient): Promise<void> {
}
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) {