mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(account): give account deletion the wipe's transaction budget
The cascade behind user.delete removes every row the account owns, so on a large record it outlived Prisma's 5 s interactive default and rolled back with a 500 on every attempt. The deletion stays atomic with the Guardian check and now carries the same budget as the data wipe.
This commit is contained in:
1 parent
8eb4ef3d56
commit
9af22f2ab2
2 files changed
+109
-2
No files matched your search
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Both transactions that end in `user.delete` carry the deletion budget.
|
||||
*
|
||||
* The cascade behind deleting an account row removes everything the account
|
||||
* owns, so on a large record it outlives Prisma's 5 s interactive default. The
|
||||
* transaction then rolled back and the person got a 500 with nothing deleted,
|
||||
* on every retry. Pinned at the call: a budget declared and not passed is the
|
||||
* failure this replaces.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { transaction } = vi.hoisted(() => ({ transaction: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: { $transaction: transaction } }));
|
||||
vi.mock("@/lib/jobs/offhost-purge", () => ({
|
||||
kickOffhostPurge: vi.fn(async () => undefined),
|
||||
requestOffhostPurge: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn(async () => undefined) }));
|
||||
|
||||
import {
|
||||
ACCOUNT_DELETION_TRANSACTION_OPTIONS,
|
||||
deleteGuardianAccountWithLifecycle,
|
||||
deleteManagedProfile,
|
||||
} from "../lifecycle";
|
||||
import { WIPE_TRANSACTION_TIMEOUT_MS } from "@/lib/data-wipe/wipe-plan";
|
||||
|
||||
/** A transaction client that holds no grants and one managed profile. */
|
||||
function fakeTx() {
|
||||
return {
|
||||
$queryRaw: vi.fn(async () => [{ locked: 1 }]),
|
||||
user: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: "profile-1",
|
||||
managedProfileAt: new Date(),
|
||||
})),
|
||||
delete: vi.fn(async () => ({})),
|
||||
},
|
||||
accountGrant: {
|
||||
findMany: vi.fn(async () => []),
|
||||
findFirst: vi.fn(async () => ({ id: "grant-1" })),
|
||||
count: vi.fn(async () => 2),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
transaction.mockReset();
|
||||
transaction.mockImplementation(
|
||||
async (run: (tx: ReturnType<typeof fakeTx>) => Promise<unknown>) =>
|
||||
run(fakeTx()),
|
||||
);
|
||||
});
|
||||
|
||||
describe("account deletion transaction budget", () => {
|
||||
it("is well past Prisma's 5 s default and matches the wipe's", () => {
|
||||
expect(ACCOUNT_DELETION_TRANSACTION_OPTIONS.timeout).toBe(
|
||||
WIPE_TRANSACTION_TIMEOUT_MS,
|
||||
);
|
||||
expect(ACCOUNT_DELETION_TRANSACTION_OPTIONS.timeout).toBeGreaterThan(5_000);
|
||||
});
|
||||
|
||||
it("is passed by the guardian account deletion", async () => {
|
||||
await deleteGuardianAccountWithLifecycle("guardian-1", async () => "ok");
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(transaction.mock.calls[0][1]).toEqual(
|
||||
ACCOUNT_DELETION_TRANSACTION_OPTIONS,
|
||||
);
|
||||
});
|
||||
|
||||
it("is passed by the managed profile deletion", async () => {
|
||||
await deleteManagedProfile({
|
||||
profileId: "profile-1",
|
||||
guardianId: "guardian-1",
|
||||
});
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(transaction.mock.calls[0][1]).toEqual(
|
||||
ACCOUNT_DELETION_TRANSACTION_OPTIONS,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,10 @@ import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only";
|
||||
import {
|
||||
WIPE_TRANSACTION_MAX_WAIT_MS,
|
||||
WIPE_TRANSACTION_TIMEOUT_MS,
|
||||
} from "@/lib/data-wipe/wipe-plan";
|
||||
|
||||
type Transaction = Prisma.TransactionClient;
|
||||
|
||||
@@ -79,6 +83,28 @@ export async function reduceManagedProfileGuardian<T>(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Budget for a transaction that ends in `user.delete`.
|
||||
*
|
||||
* Deleting an account row is one statement, but the cascade behind it removes
|
||||
* every row the account owns across the whole schema — the same set the data
|
||||
* wipe deletes model by model — so its duration grows with the record. Prisma's
|
||||
* default interactive-transaction timeout is 5 s; an account with a few years
|
||||
* of imported samples blew through it, the transaction rolled back, and the
|
||||
* person was answered 500 with nothing deleted, every time they tried.
|
||||
*
|
||||
* Splitting the delete into batches outside the transaction is not an option:
|
||||
* the Guardian invariant is decided under the record lock inside this
|
||||
* transaction, and a refusal there must leave the account whole. Batches
|
||||
* committed before the refusal would be a partial erasure the person was told
|
||||
* did not happen. So the deletion stays atomic and gets the wipe's budget,
|
||||
* which was sized for the same work.
|
||||
*/
|
||||
export const ACCOUNT_DELETION_TRANSACTION_OPTIONS = {
|
||||
timeout: WIPE_TRANSACTION_TIMEOUT_MS,
|
||||
maxWait: WIPE_TRANSACTION_MAX_WAIT_MS,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Keep account deletion and its Guardian checks inside the same transaction.
|
||||
* Profiles are locked in id order so an account protecting more than one
|
||||
@@ -123,7 +149,7 @@ export async function deleteGuardianAccountWithLifecycle<T>(
|
||||
}
|
||||
|
||||
return deleteAccount(tx);
|
||||
});
|
||||
}, ACCOUNT_DELETION_TRANSACTION_OPTIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,7 +243,7 @@ export async function deleteManagedProfile(input: {
|
||||
await requestOffhostPurge(tx, profile.id, "managed_profile_deleted");
|
||||
await tx.user.delete({ where: { id: profile.id } });
|
||||
});
|
||||
});
|
||||
}, ACCOUNT_DELETION_TRANSACTION_OPTIONS);
|
||||
await kickOffhostPurge();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user