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`가
|
||||
// IndexedDB 커널로 옮겨가면서 자기 abort 리스너를 지웠다.
|
||||
// 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(
|
||||
"git",
|
||||
["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(
|
||||
memory: MemoryIndexedDbFactory,
|
||||
): 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();
|
||||
memory.blockNextOpen();
|
||||
const nativeOpen = vi.spyOn(memory.factory, "open");
|
||||
@@ -489,23 +516,59 @@ describe("IndexedDB runtime", () => {
|
||||
ok: false,
|
||||
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({
|
||||
kind: "CLOSED",
|
||||
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 () => {
|
||||
@@ -735,7 +798,24 @@ describe("IndexedDB runtime", () => {
|
||||
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", () => {
|
||||
@@ -902,6 +982,117 @@ describe("IndexedDB runtime", () => {
|
||||
).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 () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
const authorizeLifecycle = vi
|
||||
|
||||
Reference in New Issue
Block a user