fix: preserve OPFS recovery authority during cleanup

Repair the compensating half of the OPFS put saga.

The coordinator now owns a single abortPreparedPut() driven by a
composition-owned bounded signal instead of the caller's already aborted one,
and the worker client no longer issues a duplicate fire-and-forget abort.
Journal rows and budget reservations are released only after the physical
effect is confirmed CLEANED or ALREADY_CLEAN; a timeout, malformed response or
EFFECT_UNKNOWN keeps PREPARING/FILES_READY and returns OBJECT_RECONCILE.

New writes carry a transaction-unique physicalGenerationId through the staging
receipt, manifest path and prepared object, so a late compensation deletes only
its own transaction's directory even when a newer transaction legitimately
reuses the same logical generation. v1 paths, receipts and prepared objects stay
readable through the rollback window.

Abort and cleanup hold the origin mutation lease through physical deletion and
staging removal. A transaction that never reached staging returns ALREADY_CLEAN
without waiting for the lease, which would otherwise deadlock against the BEGIN
it is cancelling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-13 23:21:58 +09:00
co-authored by Claude Opus 5
parent 6d1e44f206
commit 618da9abf5
11 changed files with 678 additions and 109 deletions
@@ -10,6 +10,7 @@ import type {
OpfsReconciliationReport,
OpfsStorageScope,
PutDurableObjectRequest,
OpfsPhysicalGenerationId,
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
import {
type BrowserDataFailure,
@@ -89,6 +90,12 @@ export type OpfsByteStoreDependencies = Readonly<{
storagePolicy: BrowserStoragePolicy;
policy?: Partial<OpfsRuntimePolicy>;
createTransactionId?: () => string;
createPhysicalGenerationId?: () => OpfsPhysicalGenerationId;
/**
* Composition-owned bounded signal for compensating cleanup. It is
* deliberately separate from any caller signal.
*/
compensationSignal?: AbortSignal;
now?: () => number;
observer?: OpfsSafeObserver;
/**
@@ -130,6 +137,20 @@ export function createOpfsByteStoreAdapter(
const createTransactionId =
dependencies.createTransactionId ??
(() => globalThis.crypto.randomUUID());
const createPhysicalGenerationId =
dependencies.createPhysicalGenerationId ??
(() => {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
let hex = "";
for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
return hex as OpfsPhysicalGenerationId;
});
/**
* STO-01. Compensation must not inherit the caller's already aborted signal;
* a cleanup that never starts cannot justify releasing the journal row.
*/
const compensationSignal = dependencies.compensationSignal;
const now = dependencies.now ?? Date.now;
const objects: DurableObjectStorePort = Object.freeze({
@@ -182,6 +203,10 @@ export function createOpfsByteStoreAdapter(
}
const targetGeneration = (current?.descriptor.generation ?? 0) + 1;
const transactionId = createTransactionId();
// STO-01. The logical generation is reused across transactions; this
// token makes the physical target unique so a late compensation can never
// delete a newer transaction's directory.
const physicalGenerationId = createPhysicalGenerationId();
notifyProgress(request, "PREPARING", 0);
const begun = await dependencies.journal.begin({
transactionId,
@@ -214,13 +239,24 @@ export function createOpfsByteStoreAdapter(
});
const prepared = await dependencies.worker.preparePut({
transactionId,
physicalGenerationId,
descriptor,
source: request.source,
signal: request.signal,
onProgress: request.onProgress,
});
if (!prepared.ok) {
await rollbackBestEffort(begun.value, request.signal);
const compensated = await compensatePreparedPut(
begun.value,
physicalGenerationId,
);
if (!compensated.ok) {
return observeFailure(
compensated,
dependencies.observer,
request.source.byteLength!,
);
}
return observeFailure(
prepared,
dependencies.observer,
@@ -234,7 +270,17 @@ export function createOpfsByteStoreAdapter(
prepared.value,
);
if (!filesReady.ok) {
await rollbackBestEffort(begun.value, request.signal);
const compensated = await compensatePreparedPut(
begun.value,
physicalGenerationId,
);
if (!compensated.ok) {
return observeFailure(
compensated,
dependencies.observer,
request.source.byteLength!,
);
}
return observeFailure(
rebaseFailure(filesReady.error, "OBJECT_WRITE"),
dependencies.observer,
@@ -830,19 +876,38 @@ export function createOpfsByteStoreAdapter(
return Object.freeze({ objects, maintenance });
async function rollbackBestEffort(
/**
* STO-01. The compensating half of the put saga.
*
* The journal row and its budget reservation are the only durable evidence
* that a physical staging generation may still exist, so they are released
* exactly when the physical effect is confirmed `CLEANED` or
* `ALREADY_CLEAN`. A timeout, crash, malformed response or `EFFECT_UNKNOWN`
* keeps `PREPARING`/`FILES_READY` in place and asks for reconciliation.
*/
async function compensatePreparedPut(
transaction: OpfsJournalTransaction,
signal: AbortSignal | undefined,
): Promise<void> {
await dependencies.worker.cleanupTransaction(
transaction.scope,
transaction.transactionId,
signal,
);
await dependencies.journal.rollback(
physicalGenerationId: OpfsPhysicalGenerationId,
): Promise<BrowserDataResult<void>> {
const cleanup = await dependencies.worker.abortPreparedPut({
scope: transaction.scope,
transactionId: transaction.transactionId,
physicalGenerationId,
...(compensationSignal ? { signal: compensationSignal } : {}),
});
if (!cleanup.ok || cleanup.value.kind === "EFFECT_UNKNOWN") {
return browserDataFailure("CONFLICT", "OBJECT_RECONCILE", {
recovery: "RETRY",
});
}
const rolledBack = await dependencies.journal.rollback(
transaction.transactionId,
transaction.fencingToken,
);
if (!rolledBack.ok) {
return rebaseFailure(rolledBack.error, "OBJECT_RECONCILE");
}
return browserDataSuccess(undefined);
}
async function reconcileTransaction(
@@ -860,6 +925,11 @@ export function createOpfsByteStoreAdapter(
signal,
);
if (!cleaned.ok) return cleaned;
if (cleaned.value.kind === "EFFECT_UNKNOWN") {
return browserDataFailure("CONFLICT", "OBJECT_RECONCILE", {
recovery: "RETRY",
});
}
const rolledBack = await dependencies.journal.rollback(
transaction.transactionId,
transaction.fencingToken,
@@ -1292,6 +1362,7 @@ function snapshotOpfsWorker(
openObject,
removeObject,
cleanupTransaction,
abortPreparedPut,
finalizePut,
listOrphanCandidates,
deleteOrphanChunk,
@@ -1305,6 +1376,7 @@ function snapshotOpfsWorker(
openObject,
removeObject,
cleanupTransaction,
abortPreparedPut,
finalizePut,
listOrphanCandidates,
deleteOrphanChunk,
@@ -1320,6 +1392,7 @@ function snapshotOpfsWorker(
openObject: openObject.bind(source),
removeObject: removeObject.bind(source),
cleanupTransaction: cleanupTransaction.bind(source),
abortPreparedPut: abortPreparedPut.bind(source),
finalizePut: finalizePut.bind(source),
listOrphanCandidates: listOrphanCandidates.bind(source),
deleteOrphanChunk: deleteOrphanChunk.bind(source),