Skip to content

Commit aadb5e4

Browse files
committed
Roll back the GC migration transaction on fiber interruption
tapError does not run on interrupt, so a process kill mid-migration left the transaction open, relying on connection close for implicit rollback. onInterrupt makes the boundary explicit.
1 parent 9b8b2b0 commit aadb5e4

2 files changed

Lines changed: 64 additions & 1 deletion

File tree

packages/core/sdk/src/sqlite-oauth-client-gc-migration.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "@effect/vitest";
2-
import { Effect } from "effect";
2+
import { Effect, Fiber } from "effect";
33

44
import { collectTables } from "./executor";
55
import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db";
@@ -291,4 +291,61 @@ describe("runSqliteOAuthClientGcMigration", () => {
291291
expect(applied).toEqual({ deleted: 0, backfilled: 0, stampedDcr: 0, stampedManual: 0 });
292292
}),
293293
);
294+
295+
it.effect("rolls back the transaction when the migration fiber is interrupted mid-run", () =>
296+
Effect.gen(function* () {
297+
const surviving = yield* Effect.promise(() =>
298+
withDb(async (db) => {
299+
// Orphaned DCR row: without rollback-on-interrupt, an interruption
300+
// landing after its DELETE (but before COMMIT) can still leave the
301+
// transaction committed by a later statement, or the DELETE alone
302+
// holding an open transaction — either way the row's fate becomes
303+
// implementation-dependent instead of "the whole run never happened."
304+
await insertOAuthClient(db, {
305+
slug: "dcr-cloudflare-com",
306+
tokenUrl: "https://oauth.cloudflare.com/token",
307+
resource: "https://cloudflare.example/mcp",
308+
originKind: "dynamic_client_registration",
309+
originIssuer: "https://cloudflare.com",
310+
});
311+
312+
// Interrupt the migration's fiber right after its DELETE statement
313+
// executes (i.e. mid-transaction, well before COMMIT), by wrapping
314+
// the client so the DELETE's returned promise resolves only after
315+
// the outer fiber has been asked to interrupt.
316+
const realExecute = db.client.execute.bind(db.client);
317+
let releaseInterrupt: (() => void) | null = null;
318+
const interruptSignal = new Promise<void>((resolve) => {
319+
releaseInterrupt = resolve;
320+
});
321+
const wrappedClient: typeof db.client = new Proxy(db.client, {
322+
get(target, prop, receiver) {
323+
if (prop !== "execute") return Reflect.get(target, prop, receiver);
324+
return async (stmt: unknown) => {
325+
const sql = typeof stmt === "string" ? stmt : (stmt as { sql: string }).sql;
326+
const result = await realExecute(stmt as never);
327+
if (typeof sql === "string" && sql.startsWith("DELETE FROM oauth_client")) {
328+
// Let the test's interrupt fire, then hand control back so the
329+
// fiber observes the interruption as its very next step.
330+
releaseInterrupt?.();
331+
await new Promise((r) => setTimeout(r, 20));
332+
}
333+
return result;
334+
};
335+
},
336+
});
337+
338+
const fiber = Effect.runFork(runSqliteOAuthClientGcMigration(wrappedClient));
339+
await interruptSignal;
340+
await Effect.runPromise(Fiber.interrupt(fiber));
341+
342+
return slugs(db);
343+
}),
344+
);
345+
346+
// Rolled back: the orphaned row is exactly as it was before the run, not
347+
// deleted (proving ROLLBACK ran) and not left half-mutated.
348+
expect(surviving).toEqual(["dcr-cloudflare-com"]);
349+
}),
350+
);
294351
});

packages/core/sdk/src/sqlite-oauth-client-gc-migration.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@ export const runSqliteOAuthClientGcMigration = (
171171
yield* execute(client, "BEGIN");
172172
return yield* applyAll.pipe(
173173
Effect.tapError(() => execute(client, "ROLLBACK").pipe(Effect.ignore)),
174+
// `tapError` only fires on a typed failure, not on fiber interruption
175+
// (e.g. the boot sequence timing out or the process shutting down
176+
// mid-migration) — so without this, an interrupted run can leave the
177+
// transaction open. Roll back explicitly on interrupt too; never on
178+
// success (COMMIT already ran by then, so this is a no-op there).
179+
Effect.onInterrupt(() => execute(client, "ROLLBACK").pipe(Effect.ignore)),
174180
);
175181
});
176182

0 commit comments

Comments
 (0)