refactor: move the upload checkpoint store onto the IndexedDB kernel

712 -> 612줄. 연결 수명주기와 트랜잭션 상태기계가 커널로 갔고 CP의 손수
abort 리스너가 0이 됐다. 그래서 abort 래칫을 24에서 23으로 조인다.

보존한 동작 다섯 가지:
- 값 없는 완료는 CORRUPT_DATA/RECONCILE (translate에 NO_VALUE_PRODUCED를
  명시 매핑). 빠뜨리면 UNAVAILABLE로 바뀐다
- durability는 undefined를 명시적으로 넘긴다. 커널 기본이나 strict를 쓰면
  체크포인트 쓰기가 조용히 느려진다
- nativeFailure 6곳 전부 context.fail. requestFailed로 바꾸면 요청이
  실패했는데도 뒤 요청이 커밋된다
- abort()가 throw하면 caller-abort 주장을 철회한다 (커널이 이미 구현)
- blocked 타이머는 주입형 timers

사양이 빠뜨린 함정 하나를 추가로 막았다. bindScope는 succeed()에 해당하는
것이 없어 그대로 옮기면 정상 바인딩이 NO_VALUE_PRODUCED로 보고된다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-16 19:24:09 +09:00
co-authored by Claude Opus 5
parent 35f3c9ce23
commit bb6080bb1c
3 changed files with 546 additions and 386 deletions
@@ -80,6 +80,155 @@ function deletingFactory(
} as IDBFactory;
}
/**
* A database the test drives by hand. The memory fake commits or aborts on its
* own, so the three places the checkpoint store deliberately disagrees with the
* other IndexedDB adapters — a commit that produced no value, a request error
* that must abort, and an `abort()` that throws — are only observable here.
*
* The scope-binding transaction is driven automatically because no test below
* is about it; only checkpoint transactions are handed to the test.
*/
type ScriptedRequest = {
result: unknown;
error: DOMException | null;
onsuccess: ((event: Event) => unknown) | null;
onerror: ((event: Event) => unknown) | null;
};
type ScriptedTransaction = {
abortCalls: number;
error: DOMException | null;
oncomplete: ((event: Event) => unknown) | null;
onerror: ((event: Event) => unknown) | null;
onabort: ((event: Event) => unknown) | null;
readonly requests: ScriptedRequest[];
objectStore(name: string): IDBObjectStore;
abort(): void;
complete(): void;
};
function scriptedFactory(
options: Readonly<{ abortThrows?: boolean }> = {},
): Readonly<{
factory: IDBFactory;
/** One entry per `database.transaction(...)` call, holding its argument count. */
transactionArguments: number[];
checkpoints: ScriptedTransaction[];
}> {
const transactionArguments: number[] = [];
const checkpoints: ScriptedTransaction[] = [];
const scriptedRequest = (): ScriptedRequest => ({
result: undefined,
error: null,
onsuccess: null,
onerror: null,
});
const makeTransaction = (): ScriptedTransaction => {
const requests: ScriptedRequest[] = [];
const transaction: ScriptedTransaction = {
abortCalls: 0,
error: null,
oncomplete: null,
onerror: null,
onabort: null,
requests,
objectStore: () => {
const queue = () => {
const request = scriptedRequest();
requests.push(request);
return request as unknown as IDBRequest;
};
return {
get: queue,
add: queue,
put: queue,
delete: queue,
} as unknown as IDBObjectStore;
},
abort() {
transaction.abortCalls += 1;
if (options.abortThrows === true) {
throw new DOMException("Transaction is finished.", "InvalidStateError");
}
transaction.error = new DOMException("Transaction aborted.", "AbortError");
transaction.onerror?.(new Event("error"));
transaction.onabort?.(new Event("abort"));
},
complete() {
transaction.oncomplete?.(new Event("complete"));
},
};
return transaction;
};
const database = {
close() {},
transaction: (...args: unknown[]) => {
transactionArguments.push(args.length);
const requested = args[0];
const names =
typeof requested === "string"
? [requested]
: [...(requested as Iterable<string>)];
const transaction = makeTransaction();
if (names.includes("governance")) {
queueMicrotask(() => {
transaction.requests[0]?.onsuccess?.(new Event("success"));
queueMicrotask(() => {
transaction.requests[1]?.onsuccess?.(new Event("success"));
queueMicrotask(() => {
transaction.complete();
});
});
});
} else {
checkpoints.push(transaction);
}
return transaction as unknown as IDBTransaction;
},
} as unknown as IDBDatabase;
const factory = {
open: () => {
const request = {
result: database,
error: null,
transaction: null,
onsuccess: null,
onerror: null,
onblocked: null,
onupgradeneeded: null,
} as unknown as IDBOpenDBRequest;
queueMicrotask(() => request.onsuccess?.(new Event("success")));
return request;
},
cmp: () => 0,
databases: async () => [],
} as unknown as IDBFactory;
return { factory, transactionArguments, checkpoints };
}
async function flushTasks(): Promise<void> {
for (let tick = 0; tick < 5; tick += 1) {
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
}
}
async function openedCheckpointTransaction(
scripted: ReturnType<typeof scriptedFactory>,
): Promise<ScriptedTransaction> {
await flushTasks();
const transaction = scripted.checkpoints[0];
if (!transaction) throw new Error("no checkpoint transaction was opened");
return transaction;
}
describe("IndexedDB resumable upload checkpoint", () => {
it("length-prefixes scope tuples so delimiter placement cannot collide", () => {
const first = uploadCheckpointDatabaseName({
@@ -262,4 +411,112 @@ describe("IndexedDB resumable upload checkpoint", () => {
error: { code: "ABORTED" },
});
});
// CP-1. Three IndexedDB adapters report a value-less commit as UNAVAILABLE;
// this one reports CORRUPT_DATA/RECONCILE because a checkpoint read that
// committed while producing nothing means the stored row could not be turned
// into a checkpoint, and the caller has to reconcile rather than retry.
it("reports a checkpoint commit that produced no value as CORRUPT_DATA", async () => {
const scripted = scriptedFactory();
const store = createIndexedDbResumableUploadCheckpointStore({
scope,
factory: scripted.factory,
});
const pending = store.read("upload_key_01");
const transaction = await openedCheckpointTransaction(scripted);
transaction.complete();
expect(await pending).toMatchObject({
ok: false,
error: {
code: "CORRUPT_DATA",
operation: "UPLOAD_RECONCILE",
recovery: "RECONCILE",
},
});
});
// CP-2. A third argument would be a `{durability}` options bag. An engine
// that has never seen the bag treats it differently from no bag at all, so
// checkpoint writes stay on whatever the engine defaults to.
it("opens checkpoint transactions without a durability options bag", async () => {
const scripted = scriptedFactory();
const store = createIndexedDbResumableUploadCheckpointStore({
scope,
factory: scripted.factory,
});
const pending = store.read("upload_key_01");
const transaction = await openedCheckpointTransaction(scripted);
transaction.complete();
await pending;
expect(scripted.transactionArguments).toEqual([2, 2]);
});
// CP-3. Recording the error and letting the transaction run on would let
// `compareAndSwap`'s put commit after its get had already failed.
it("aborts the checkpoint transaction as soon as a request fails", async () => {
const scripted = scriptedFactory();
const store = createIndexedDbResumableUploadCheckpointStore({
scope,
factory: scripted.factory,
});
const pending = store.read("upload_key_01");
const transaction = await openedCheckpointTransaction(scripted);
const read = transaction.requests[0];
if (!read) throw new Error("the checkpoint read queued no request");
read.error = new DOMException("disk is unreadable", "NotReadableError");
read.onerror?.(new Event("error"));
expect(transaction.abortCalls).toBe(1);
expect(await pending).toMatchObject({
ok: false,
error: { code: "NOT_READABLE", retryable: true, recovery: "REOPEN" },
});
});
// CP-4. The transaction can already be durably committed while its completion
// event is still queued, which is when `abort()` throws. Claiming the abort
// would report a committed checkpoint as ABORTED.
it("keeps a committed checkpoint when the abort call itself throws", async () => {
const scripted = scriptedFactory({ abortThrows: true });
const store = createIndexedDbResumableUploadCheckpointStore({
scope,
factory: scripted.factory,
});
const controller = new AbortController();
const stored = checkpoint(1);
const pending = store.read(stored.uploadKey, controller.signal);
const transaction = await openedCheckpointTransaction(scripted);
const read = transaction.requests[0];
if (!read) throw new Error("the checkpoint read queued no request");
read.result = stored;
read.onsuccess?.(new Event("success"));
controller.abort();
expect(transaction.abortCalls).toBe(1);
transaction.complete();
expect(await pending).toEqual({ ok: true, value: stored });
});
// CP-5. The blocked deadline is the only bound on an open that another
// context is holding up, so it has to fire from the scheduler the store was
// built with rather than leave the read pending forever.
it("bounds a blocked open with the configured deadline", async () => {
const memory = new MemoryIndexedDbFactory();
const store = createIndexedDbResumableUploadCheckpointStore({
scope,
factory: memory.factory,
blockedTimeoutMs: 1,
});
memory.blockNextOpen();
expect(await store.read("upload_key_01")).toMatchObject({
ok: false,
error: { code: "BLOCKED", retryable: true, recovery: "RESUME" },
});
});
});