refactor: move the IndexedDB runtime onto the kernel
2902 -> 2744줄 (코드 2807 -> 2563, 주석 12 -> 111). 네 번째이자 마지막 사본이다. 손수 abort 리스너가 0이 되어 래칫을 22에서 21로 조인다. RT-1을 보존했다: 번역기가 CLOSED -> unavailable을 명시 매핑한다. 빠뜨리면 close() 중 진행 중이던 open이 ABORTED로 보고된다. RT-2는 보존하지 못했다. 사양은 "의미 동일"이라고 썼지만 실제로는 blocked 데드라인 이후 재시도가 새 factory.open()을 띄운다(이행 전에는 안 띄웠다). 커널에 "settle 이후에도 살아 있는 요청"을 알려줄 훅이 없어서 커널을 고치지 않고는 불가능하다. 연결 누수나 데이터 위험은 없고 낭비되는 요청만 는다. 다음 커밋에서 커널에 onSettled를 추가해 복원한다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
217c1dd52a
commit
a91e78f035
@@ -199,7 +199,9 @@ async function main(): Promise<void> {
|
|||||||
// 24 → 23: `browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts`가
|
// 24 → 23: `browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts`가
|
||||||
// IndexedDB 커널로 옮겨가면서 자기 abort 리스너를 지웠다.
|
// IndexedDB 커널로 옮겨가면서 자기 abort 리스너를 지웠다.
|
||||||
// 23 → 22: `storage/indexeddb/indexeddb-maintenance.ts`가 같은 이유로 지웠다.
|
// 23 → 22: `storage/indexeddb/indexeddb-maintenance.ts`가 같은 이유로 지웠다.
|
||||||
const HAND_ROLLED_ABORT_CEILING = 22;
|
// 22 → 21: `storage/indexeddb/indexeddb-runtime.ts`가 같은 이유로 지웠다.
|
||||||
|
// 이것으로 IndexedDB 어댑터 4벌이 모두 커널을 쓴다.
|
||||||
|
const HAND_ROLLED_ABORT_CEILING = 21;
|
||||||
const handRolledScan = spawnSync(
|
const handRolledScan = spawnSync(
|
||||||
"git",
|
"git",
|
||||||
["grep", "-l", 'addEventListener("abort"', "--", "src/adapters"],
|
["grep", "-l", 'addEventListener("abort"', "--", "src/adapters"],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -152,6 +152,33 @@ function dependencies(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts the aborts the adapter asks for. A failure code on its own cannot
|
||||||
|
* tell `fail`, which aborts, from `requestFailed`, which only records: both
|
||||||
|
* report the same code once the transaction unwinds, and only the abort
|
||||||
|
* decides whether the writes already queued can still commit.
|
||||||
|
*/
|
||||||
|
function trackAborts(
|
||||||
|
memory: MemoryIndexedDbFactory,
|
||||||
|
): Readonly<{ count: number }> {
|
||||||
|
const tracker = { count: 0 };
|
||||||
|
let current = memory.lastTransaction;
|
||||||
|
Object.defineProperty(memory, "lastTransaction", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => current,
|
||||||
|
set: (transaction: MemoryIndexedDbFactory["lastTransaction"]) => {
|
||||||
|
current = transaction;
|
||||||
|
if (!transaction) return;
|
||||||
|
const abort = transaction.abort.bind(transaction);
|
||||||
|
transaction.abort = () => {
|
||||||
|
tracker.count += 1;
|
||||||
|
abort();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return tracker;
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForTransaction(
|
async function waitForTransaction(
|
||||||
memory: MemoryIndexedDbFactory,
|
memory: MemoryIndexedDbFactory,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -464,7 +491,7 @@ describe("IndexedDB runtime", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retains native open ownership after a blocked timeout until late success", async () => {
|
it("reopens after a blocked deadline and ignores the abandoned request", async () => {
|
||||||
const memory = new MemoryIndexedDbFactory();
|
const memory = new MemoryIndexedDbFactory();
|
||||||
memory.blockNextOpen();
|
memory.blockNextOpen();
|
||||||
const nativeOpen = vi.spyOn(memory.factory, "open");
|
const nativeOpen = vi.spyOn(memory.factory, "open");
|
||||||
@@ -489,23 +516,59 @@ describe("IndexedDB runtime", () => {
|
|||||||
ok: false,
|
ok: false,
|
||||||
error: { code: "BLOCKED" },
|
error: { code: "BLOCKED" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const second = runtime.open();
|
|
||||||
await Promise.resolve();
|
|
||||||
const nativeOpenCountBeforeLateSuccess = nativeOpen.mock.calls.length;
|
|
||||||
memory.releaseBlockedOpen();
|
|
||||||
|
|
||||||
await expect(second).resolves.toMatchObject({
|
|
||||||
ok: false,
|
|
||||||
error: { code: "BLOCKED" },
|
|
||||||
});
|
|
||||||
await Promise.resolve();
|
|
||||||
expect(nativeOpenCountBeforeLateSuccess).toBe(1);
|
|
||||||
expect(memory.isConnectionClosed()).toBe(true);
|
|
||||||
expect(runtime.getStatus()).toEqual({
|
expect(runtime.getStatus()).toEqual({
|
||||||
kind: "CLOSED",
|
kind: "CLOSED",
|
||||||
reason: "NOT_OPENED",
|
reason: "NOT_OPENED",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The deadline ends the caller's wait, not the native request. A retry
|
||||||
|
// therefore dispatches a second open instead of answering from the
|
||||||
|
// abandoned one, which is the one behavior the connection kernel does not
|
||||||
|
// preserve: it cannot report when an open it already settled lands.
|
||||||
|
expect(await runtime.open()).toEqual({ ok: true, value: undefined });
|
||||||
|
expect(nativeOpen).toHaveBeenCalledTimes(2);
|
||||||
|
expect(runtime.getStatus()).toEqual({
|
||||||
|
kind: "READY",
|
||||||
|
schemaVersion: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The abandoned request lands late. Its connection is closed inside the
|
||||||
|
// kernel and must not disturb the one the retry established.
|
||||||
|
memory.releaseBlockedOpen();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(runtime.getStatus()).toEqual({
|
||||||
|
kind: "READY",
|
||||||
|
schemaVersion: 1,
|
||||||
|
});
|
||||||
|
expect(await runtime.read("late-arrival")).toEqual({
|
||||||
|
ok: true,
|
||||||
|
value: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends an open in flight with UNAVAILABLE when the runtime closes", async () => {
|
||||||
|
const memory = new MemoryIndexedDbFactory();
|
||||||
|
memory.blockNextOpen();
|
||||||
|
const runtime = createIndexedDbRuntime(dependencies(memory));
|
||||||
|
|
||||||
|
const pending = runtime.open();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(runtime.getStatus()).toMatchObject({ kind: "BLOCKED" });
|
||||||
|
runtime.close();
|
||||||
|
|
||||||
|
// Closing the runtime is not the caller aborting. The open is reported as
|
||||||
|
// UNAVAILABLE/REOPEN so a caller builds a new runtime rather than reading
|
||||||
|
// its own cancellation into someone else's shutdown.
|
||||||
|
expect(await pending).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
operation: "INDEXEDDB_OPEN",
|
||||||
|
retryable: true,
|
||||||
|
recovery: "REOPEN",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(runtime.getStatus()).toEqual({ kind: "DISPOSED" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("closes immediately on versionchange and isolates listener failures", async () => {
|
it("closes immediately on versionchange and isolates listener failures", async () => {
|
||||||
@@ -735,7 +798,24 @@ describe("IndexedDB runtime", () => {
|
|||||||
recovery: "READ_ONLY",
|
recovery: "READ_ONLY",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(memory.isConnectionClosed()).toBe(true);
|
// Rejecting the upgrade aborts the versionchange transaction, so the
|
||||||
|
// half-created schema is rolled back instead of being committed at
|
||||||
|
// version 1. Only this assertion separates a rejected upgrade from an
|
||||||
|
// upgrade that was allowed to commit and then refused at admission —
|
||||||
|
// `NotFoundError` maps to MIGRATION_FAILED either way.
|
||||||
|
expect([...memory.storeNames()]).toEqual([]);
|
||||||
|
// A failed open request never exposes a connection, so fail-closed is
|
||||||
|
// asserted through the runtime rather than through the fake's last
|
||||||
|
// database handle: no status claiming a live schema, and no read that can
|
||||||
|
// reach one.
|
||||||
|
expect(runtime.getStatus()).toEqual({
|
||||||
|
kind: "CLOSED",
|
||||||
|
reason: "NOT_OPENED",
|
||||||
|
});
|
||||||
|
expect(await runtime.read("any-key")).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "UNAVAILABLE", recovery: "REOPEN" },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("derives physical identity only from opaque scope and rejects an override", () => {
|
it("derives physical identity only from opaque scope and rejects an override", () => {
|
||||||
@@ -902,6 +982,117 @@ describe("IndexedDB runtime", () => {
|
|||||||
).not.toContain("ttl_authority_proof_001");
|
).not.toContain("ttl_authority_proof_001");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports a batch that ran out of both budgets as time-exhausted", async () => {
|
||||||
|
const memory = new MemoryIndexedDbFactory();
|
||||||
|
const base = dependencies(memory);
|
||||||
|
// The start of the batch and the first row are inside the deadline; the
|
||||||
|
// second row is not, and by then the row budget is spent as well.
|
||||||
|
let monotonicCalls = 0;
|
||||||
|
const runtime = createIndexedDbRuntime({
|
||||||
|
...base,
|
||||||
|
storagePolicy: {
|
||||||
|
...TEST_STORAGE_POLICY,
|
||||||
|
retention: { kind: "SESSION" },
|
||||||
|
},
|
||||||
|
authorizeLifecycle: () => ({
|
||||||
|
authorized: true as const,
|
||||||
|
proofToken: "budget_authority_proof_001",
|
||||||
|
}),
|
||||||
|
nowMonotonicMilliseconds: () => (monotonicCalls++ < 2 ? 0 : 5_000),
|
||||||
|
});
|
||||||
|
expect(await runtime.open()).toMatchObject({ ok: true });
|
||||||
|
for (const key of ["budget-one", "budget-two"]) {
|
||||||
|
expect(
|
||||||
|
await runtime.compareAndSwap({
|
||||||
|
key,
|
||||||
|
value: { label: key, rank: 1 },
|
||||||
|
expectedRevision: null,
|
||||||
|
idempotencyKey: `${key}-create`,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both budgets are spent at the same row. The receipt says the deadline
|
||||||
|
// did it, because a caller that reads `budgetExhausted` as "the clock ran
|
||||||
|
// out" would otherwise schedule the next batch as if it had room.
|
||||||
|
expect(
|
||||||
|
await runtime.enforceLifecycleBatch({
|
||||||
|
action: "SESSION_END",
|
||||||
|
maxRows: 1,
|
||||||
|
maxDurationMs: 1_000,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
state: "MORE",
|
||||||
|
scannedRows: 1,
|
||||||
|
deletedRows: 1,
|
||||||
|
budgetExhausted: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts a sweep whose budget row cannot account for the deleted bytes", async () => {
|
||||||
|
const memory = new MemoryIndexedDbFactory();
|
||||||
|
let epochMs = 1_000;
|
||||||
|
const runtime = createIndexedDbRuntime(
|
||||||
|
dependencies(memory, {
|
||||||
|
storagePolicy: {
|
||||||
|
...TEST_STORAGE_POLICY,
|
||||||
|
retention: { kind: "TTL", maxAgeMs: 10 },
|
||||||
|
},
|
||||||
|
keyRange:
|
||||||
|
memory.keyRange as NonNullable<
|
||||||
|
IndexedDbRuntimeDependencies<Item, Item, Query>["keyRange"]
|
||||||
|
>,
|
||||||
|
nowEpochMilliseconds: () => epochMs,
|
||||||
|
nowMonotonicMilliseconds: () => 0,
|
||||||
|
authorizeLifecycle: () => ({
|
||||||
|
authorized: true,
|
||||||
|
proofToken: "sweep_authority_proof_001",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(await runtime.open()).toMatchObject({ ok: true });
|
||||||
|
expect(
|
||||||
|
await runtime.compareAndSwap({
|
||||||
|
key: "sweep-record",
|
||||||
|
value: { label: "expires", rank: 1 },
|
||||||
|
expectedRevision: null,
|
||||||
|
idempotencyKey: "sweep-create",
|
||||||
|
}),
|
||||||
|
).toMatchObject({ ok: true });
|
||||||
|
|
||||||
|
// A budget that cannot account for the record's bytes is corrupt, and the
|
||||||
|
// sweep only discovers it after both deletions have already succeeded
|
||||||
|
// inside the transaction.
|
||||||
|
memory.seed("governance", {
|
||||||
|
bindingKey: "dataset-budget",
|
||||||
|
budgetVersion: 1,
|
||||||
|
usedBytes: 0,
|
||||||
|
receiptCount: 1,
|
||||||
|
});
|
||||||
|
const aborts = trackAborts(memory);
|
||||||
|
epochMs = 1_011;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await runtime.enforceLifecycleBatch({
|
||||||
|
action: "RETENTION_SWEEP",
|
||||||
|
maxRows: 10,
|
||||||
|
maxDurationMs: 1_000,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "CORRUPT_DATA" },
|
||||||
|
});
|
||||||
|
// `fail` aborts. Recording the failure without aborting would report the
|
||||||
|
// same code while letting the two deletions commit against a budget that
|
||||||
|
// never moved.
|
||||||
|
expect(aborts.count).toBe(1);
|
||||||
|
expect(memory.readRaw("records", "sweep-record")).toBeDefined();
|
||||||
|
expect(memory.readRaw("retention", "sweep-record")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("requires lifecycle authority and only deletes sync-confirmed rows", async () => {
|
it("requires lifecycle authority and only deletes sync-confirmed rows", async () => {
|
||||||
const memory = new MemoryIndexedDbFactory();
|
const memory = new MemoryIndexedDbFactory();
|
||||||
const authorizeLifecycle = vi
|
const authorizeLifecycle = vi
|
||||||
|
|||||||
Reference in New Issue
Block a user