fix: report OPFS completion honestly and bound public cache staging

A durable write whose journal transaction could not be completed returned
plain success with `SUCCEEDED` telemetry. The payload was committed but
the transaction stayed `COMMITTED`, so the reconcile backlog and its quota
pressure grew while every caller was told the write had settled. That is
now a `RECONCILE` failure with the effect certainty preserved, and an
unfinished delete is observed `DEGRADED` rather than clean.

The worker seam lost causes in both directions. A bootstrap failure
answered every request with kind `CAPABILITIES`, so the gateway read a
kind mismatch and replaced the real `BLOCKED` or `QUOTA_EXCEEDED` with a
generic `UNSUPPORTED`; the envelope's correlation is now captured once at
the listener. On the client, the pending row and its timer were released
before the reply was decoded, so a trap that threw inside the decoder left
the public promise pending with nothing left to time it out, and a
throwing `requestId` getter produced a timeout instead of a prompt
protocol failure.

Public cache staging handed its signal to each `Request` and called that
ownership. A fetch that ignored it held the mutation lock forever, and a
digest that finished after the abort still wrote both the asset and the
activation marker — publishing a release nobody was waiting for. One
terminal owner now covers the whole staging body and every await
re-checks it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-15 01:25:26 +09:00
co-authored by Claude Opus 5
parent df18349682
commit 632b230c82
8 changed files with 692 additions and 42 deletions
@@ -21,6 +21,7 @@ import {
browserDataSuccess,
mapBrowserDataException,
} from "../browser-file-storage/result.ts";
import { createAbortableOperation } from "../platform/abortable-operation.ts";
import {
cacheByteBucket,
cacheEntryBucket,
@@ -409,16 +410,42 @@ export function createPublicResponseCacheAdapter(
entryBucket: cacheEntryBucket(normalized.assets.length),
});
// NS-08. One owner covers the whole staging body. Handing the signal to
// each `Request` only asked a cooperative fetch to stop: a stream or a
// digest that ignored it kept the mutation lock and, worse, could finish
// after the abort had already ended the operation and still write both the
// asset and the marker, publishing a release nobody was waiting for.
const owner = createAbortableOperation({ signal });
const ownedStep = async <Value>(
task: Promise<Value>,
compensate?: (value: Value) => void,
): Promise<Value> => {
const raced = await owner.race(task, compensate);
if (raced.kind === "REJECTED") throw raced.reason;
if (raced.kind !== "VALUE") {
throw new CacheValidationFailure("ABORTED");
}
return raced.value;
};
const assertOwned = (): void => {
if (owner.terminal() !== null) {
throw new CacheValidationFailure("ABORTED");
}
};
try {
const result = await dependencies.mutationLock!.run(
signal,
async () => {
assertOwned();
const cacheName = releaseCacheName(
policy,
normalized.releaseRegistryId,
normalized.manifestDigestHex,
);
const existingNames = await dependencies.cacheStorage!.keys();
const existingNames = await ownedStep(
Promise.resolve(dependencies.cacheStorage!.keys()),
);
// STO-RR-05. A candidate this call did not create may be the one
// currently serving traffic, so nothing about it is deleted before
// a replacement has been fetched and verified.
@@ -467,42 +494,56 @@ export function createPublicResponseCacheAdapter(
}
}
const cache = await dependencies.cacheStorage!.open(cacheName);
const cache = await ownedStep(
Promise.resolve(dependencies.cacheStorage!.open(cacheName)),
);
try {
let totalBytes = 0;
for (const asset of normalized.assets) {
if (signal?.aborted) {
throw new CacheValidationFailure("ABORTED");
}
assertOwned();
const cacheRequest = createNativeRequest(asset);
const networkRequest = createNativeRequest(
asset,
signal,
owner.signal,
);
const response =
await dependencies.fetcher!(networkRequest);
const read = await readAndValidateResponse(
response,
asset,
policy,
dependencies.crypto,
signal,
const response = await ownedStep(
Promise.resolve(dependencies.fetcher!(networkRequest)),
// A response that arrives after the owner ended is released
// rather than read.
(late) => {
void late.body?.cancel().catch(() => undefined);
},
);
const read = await ownedStep(
readAndValidateResponse(
response,
asset,
policy,
dependencies.crypto,
owner.signal,
),
);
if (!read.ok) throw new CacheValidationFailure(read.error.code);
assertOwned();
totalBytes += read.value.bytes.byteLength;
if (totalBytes > policy.maxReleaseBytes) {
throw new CacheValidationFailure("LIMIT_EXCEEDED");
}
await cache.put(
cacheRequest,
new Response(Uint8Array.from(read.value.bytes), {
status: 200,
headers: read.value.headers.map(
([name, value]) => [name, value],
await ownedStep(
Promise.resolve(
cache.put(
cacheRequest,
new Response(Uint8Array.from(read.value.bytes), {
status: 200,
headers: read.value.headers.map(
([name, value]) => [name, value],
),
}),
),
}),
),
);
}
assertOwned();
const marker: ReleaseMarker = Object.freeze({
schemaVersion: 1,
@@ -514,10 +555,14 @@ export function createPublicResponseCacheAdapter(
stagedAtEpochMs: now(),
assets: normalized.assets,
});
await cache.put(
markerRequest(policy),
jsonResponse(marker),
await ownedStep(
Promise.resolve(
cache.put(markerRequest(policy), jsonResponse(marker)),
),
);
// The marker is the activation record, so it is only a success
// once this call still owns the operation that wrote it.
assertOwned();
return browserDataSuccess(summaryFromMarker(marker));
} catch (error) {
// STO-RR-05. Only a candidate this call created is removed. A
@@ -552,6 +597,8 @@ export function createPublicResponseCacheAdapter(
dependencies.observer,
normalized.releaseRegistryId,
);
} finally {
owner.close();
}
},
@@ -325,10 +325,32 @@ export function createOpfsByteStoreAdapter(
request.source.byteLength!,
);
}
await dependencies.journal.complete(
const completed = await dependencies.journal.complete(
transactionId,
begun.value.fencingToken,
);
if (!completed.ok) {
// NS-04. The payload is durable and finalized, so nothing is rolled
// back and the `COMMITTED` row stays the reconciler's authority. What
// did not happen is settling the transaction, and reporting a plain
// success for it claimed a state nobody observed while the reconcile
// backlog and its quota pressure grew unseen.
observeOpfsSafely(dependencies.observer, {
operation: "OBJECT_WRITE",
outcome: "DEGRADED",
failureCode: completed.error.code,
byteBucket: byteBucket(prepared.value.descriptor.byteLength),
});
return {
ok: false as const,
error: Object.freeze({
...completed.error,
operation: "OBJECT_WRITE" as const,
retryable: true,
recovery: "RECONCILE" as const,
}),
};
}
observeOpfsSafely(dependencies.observer, {
operation: "OBJECT_WRITE",
outcome: "SUCCEEDED",
@@ -495,17 +517,27 @@ export function createOpfsByteStoreAdapter(
request.expectedGeneration,
request.signal,
);
if (removed.ok) {
await dependencies.journal.complete(
transactionId,
begun.value.fencingToken,
);
}
const completed = removed.ok
? await dependencies.journal.complete(
transactionId,
begun.value.fencingToken,
)
: null;
// Logical deletion is already committed. Physical cleanup is retryable
// maintenance and must not make the caller repeat a non-idempotent delete.
// NS-04. It is still not a settled state: an unfinished physical removal
// or an unsettled journal row is maintenance debt the reconciler owns, so
// it is observed as such instead of as a clean success.
const settled = removed.ok && completed !== null && completed.ok;
const debtCode = removed.ok
? completed !== null && !completed.ok
? completed.error.code
: undefined
: removed.error.code;
observeOpfsSafely(dependencies.observer, {
operation: "OBJECT_DELETE",
outcome: "SUCCEEDED",
outcome: settled ? "SUCCEEDED" : "DEGRADED",
...(debtCode ? { failureCode: debtCode } : {}),
});
return browserDataSuccess(undefined);
},
+5 -1
View File
@@ -26,7 +26,11 @@ export type OpfsRuntimePolicy = Readonly<{
export type OpfsSafeObservation = Readonly<{
operation: BrowserDataOperation;
outcome: "STARTED" | "SUCCEEDED" | "FAILED";
/**
* NS-04. `DEGRADED` is a committed effect whose bookkeeping is unsettled:
* the payload is durable but a reconciler still owns the transaction.
*/
outcome: "STARTED" | "SUCCEEDED" | "FAILED" | "DEGRADED";
failureCode?: BrowserDataFailureCode;
byteBucket?: "0" | "1B_1MiB" | "1MiB_16MiB" | "16MiB_256MiB" | "GT_256MiB";
transactionBucket?: "0" | "1_10" | "11_100" | "GT_100";
@@ -81,31 +81,44 @@ export function createOpfsWorkerGateway(
const pending = new Map<string, PendingRequest>();
let disposed = false;
const rejectAllPending = (): void => {
const rejectAllPending = (
code: "UNAVAILABLE" | "UNSUPPORTED" = "UNAVAILABLE",
): void => {
for (const request of pending.values()) {
clearTimeout(request.timeout);
request.removeAbortListener();
request.reject(new OpfsRpcError("UNAVAILABLE"));
request.reject(new OpfsRpcError(code));
}
pending.clear();
};
const onMessage = (event: MessageEvent<unknown>): void => {
if (disposed) return;
const requestId = ownStringField(event.data, "requestId");
if (requestId === null) return;
const request = pending.get(requestId);
const correlation = readCorrelation(event.data);
if (correlation === IGNORE_MESSAGE) return;
if (correlation === UNREADABLE_CORRELATION) {
// NS-06. A reply whose correlation cannot even be read is a protocol
// breach on the only channel this client has. Ignoring it left every
// in-flight request to expire on the RPC timer, so the whole channel
// fails closed promptly instead.
rejectAllPending("UNSUPPORTED");
return;
}
const request = pending.get(correlation);
if (!request) return;
pending.delete(requestId);
clearTimeout(request.timeout);
request.removeAbortListener();
// STO-07 / STO-RR-03. A reply is decoded, never adopted. A different
// operation, an unknown kind, an unknown failure code, an inherited or
// extra field and a hostile accessor are all protocol breaches, and each
// closes the request rather than leaving it to time out.
// UNSUPPORTED is the closed-taxonomy code for "this runtime cannot serve
// this"; no new failure code is invented.
// NS-06. The decode happens before the pending row and its timer are
// released: releasing them first meant a trap that threw inside the decoder
// left the public promise pending with nothing left to time it out.
const decoded = decodeWorkerResponse(event.data, request.expectedKind);
pending.delete(correlation);
clearTimeout(request.timeout);
request.removeAbortListener();
if (decoded === null) {
request.reject(new OpfsRpcError("UNSUPPORTED"));
return;
@@ -738,6 +751,31 @@ function ownStringField(source: unknown, key: string): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
/** Not a reply at all: nothing on this channel is waiting for it. */
const IGNORE_MESSAGE = Symbol("opfs-ignore-message");
/** A reply whose correlation could not be read without running foreign code. */
const UNREADABLE_CORRELATION = Symbol("opfs-unreadable-correlation");
function readCorrelation(
source: unknown,
): string | typeof IGNORE_MESSAGE | typeof UNREADABLE_CORRELATION {
if (source === null || typeof source !== "object") return IGNORE_MESSAGE;
let descriptor: PropertyDescriptor | undefined;
try {
descriptor = Object.getOwnPropertyDescriptor(source, "requestId");
} catch {
return UNREADABLE_CORRELATION;
}
if (!descriptor) return IGNORE_MESSAGE;
// An accessor would have to be invoked to be read, and invoking foreign code
// to find out who a message belongs to is exactly what must not happen.
if (!("value" in descriptor)) return UNREADABLE_CORRELATION;
const value = descriptor.value;
return typeof value === "string" && value.length > 0 && value.length <= 128
? value
: UNREADABLE_CORRELATION;
}
function hasOnlyOwnDataKeys(
source: object,
allowed: ReadonlySet<string>,
@@ -758,6 +796,20 @@ function hasOnlyOwnDataKeys(
function decodeWorkerResponse(
value: unknown,
expectedKind: OpfsWorkerRequest["kind"],
): OpfsWorkerResponse | null {
// NS-06. Total by construction: every reflection operation below can be
// trapped, and a decoder that throws would strand the request it was
// decoding rather than closing it.
try {
return decodeWorkerResponseUnguarded(value, expectedKind);
} catch {
return null;
}
}
function decodeWorkerResponseUnguarded(
value: unknown,
expectedKind: OpfsWorkerRequest["kind"],
): OpfsWorkerResponse | null {
if (value === null || typeof value !== "object") return null;
if (!hasOnlyOwnDataKeys(value, WORKER_RESPONSE_KEYS)) return null;
@@ -157,12 +157,21 @@ export async function startBrowserOpfsDedicatedWorker(
host.addEventListener("message", (event) => {
if (!hasRequestId(event.data)) return;
const requestData = event.data;
// NS-05. The envelope's correlation is captured once, up front. Answering a
// bootstrap failure with a default `CAPABILITIES` kind made the client see
// an expected-kind mismatch and overwrite the real cause — a `BLOCKED` or
// `QUOTA_EXCEEDED` outage was reported to operators as `UNSUPPORTED`.
const correlation = requestCorrelation(requestData);
void runtimePromise
.then((runtime) => runtime.handleRequest(requestData))
.then((response) => postWorkerResponse(host, response))
.catch((error: unknown) => {
host.postMessage(
failure(requestData.requestId, mapRuntimeFailure(error)),
failure(
correlation.requestId,
mapRuntimeFailure(error),
correlation.kind,
),
);
});
});
@@ -1871,6 +1880,31 @@ function hasRequestId(
);
}
/**
* NS-05. Reads a request envelope's correlation exactly once, through its own
* data descriptors, so a reply can name the request it answers even when the
* runtime that would have handled it never came up. An envelope whose kind
* cannot be read stays a protocol-level failure rather than borrowing an
* unrelated kind.
*/
function requestCorrelation(
value: Readonly<{ requestId: string }>,
): Readonly<{ requestId: string; kind: OpfsWorkerRequest["kind"] }> {
let kind: unknown;
try {
kind = Object.getOwnPropertyDescriptor(value, "kind")?.value;
} catch {
kind = undefined;
}
return {
requestId: value.requestId,
kind:
typeof kind === "string" && WORKER_REQUEST_KINDS.has(kind)
? (kind as OpfsWorkerRequest["kind"])
: "CAPABILITIES",
};
}
function isWorkerRequest(value: unknown): value is OpfsWorkerRequest {
return Boolean(
hasRequestId(value) &&