refactor: move IndexedDB maintenance onto the kernel
1558 -> 1430줄. 손수 abort 리스너가 0이 되어 래칫을 23에서 22로 조인다. 보존한 동작 셋: - blockedTimeoutMs를 넘기지 않는다. blocked 이벤트는 유지보수에게 종단이다 — 배치는 opt-in 백그라운드 패스라 다른 컨텍스트가 사라지길 기대하며 데드라인만큼 매다는 비용이 BLOCKED를 보고하고 재시도시키는 것보다 크다 - upgrade 콜백을 생략한다. 커널은 생략을 "어떤 upgrade든 예상 밖"으로 읽고 거절하는데, 그게 유지보수가 늘 해온 동작이다. 빈 APPLIED를 넣으면 잘못된 스키마로 열린다 - onversionchange는 admit 안, 바인딩 검증 앞에 등록한다 계획이 blocked 회귀 테스트로 지목한 indexeddb-maintenance.test.ts:289-290은 실제로는 onblocked 경로가 아니라 drain 검사였다. MT의 진짜 blocked 경로에는 테스트가 없었으므로 새로 썼다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bb6080bb1c
commit
3366a81f0f
@@ -205,6 +205,7 @@ function createMaintenance(
|
||||
now?: () => number;
|
||||
nowEpochMilliseconds?: () => number;
|
||||
observe?: (event: IndexedDbObservation) => void;
|
||||
factory?: IDBFactory;
|
||||
}> = {},
|
||||
) {
|
||||
return createIndexedDbMaintenance<CurrentPayload>({
|
||||
@@ -225,6 +226,63 @@ function createMaintenance(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records every `transaction.abort()` the adapter itself calls, with the mode
|
||||
* of the transaction it aborted.
|
||||
*
|
||||
* A failure code alone cannot tell `fail` (record and abort) from
|
||||
* `requestFailed` (record only): the fake aborts a transaction on a request
|
||||
* error by itself, so both end at the same code while only one of them stops
|
||||
* the writes queued behind it. Swapping the two is exactly the mistake this
|
||||
* counter makes visible.
|
||||
*/
|
||||
function trackAborts(memory: MemoryIndexedDbFactory): Readonly<{
|
||||
factory: IDBFactory;
|
||||
aborts: readonly string[];
|
||||
}> {
|
||||
const aborts: string[] = [];
|
||||
const wrapped = new WeakSet<object>();
|
||||
const wrapDatabase = (database: IDBDatabase): IDBDatabase => {
|
||||
if (wrapped.has(database)) return database;
|
||||
wrapped.add(database);
|
||||
const openTransaction = database.transaction.bind(database);
|
||||
Object.defineProperty(database, "transaction", {
|
||||
configurable: true,
|
||||
value: (...args: Parameters<IDBDatabase["transaction"]>) => {
|
||||
const transaction = openTransaction(...args);
|
||||
const abort = transaction.abort.bind(transaction);
|
||||
Object.defineProperty(transaction, "abort", {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
aborts.push(transaction.mode);
|
||||
abort();
|
||||
},
|
||||
});
|
||||
return transaction;
|
||||
},
|
||||
});
|
||||
return database;
|
||||
};
|
||||
const factory = {
|
||||
cmp: (first: IDBValidKey, second: IDBValidKey) =>
|
||||
memory.factory.cmp(first, second),
|
||||
open: (name: string, version?: number) => {
|
||||
const request = memory.factory.open(name, version);
|
||||
let stored: IDBDatabase | undefined = request.result;
|
||||
if (stored) wrapDatabase(stored);
|
||||
Object.defineProperty(request, "result", {
|
||||
configurable: true,
|
||||
get: () => stored,
|
||||
set: (value: IDBDatabase | undefined) => {
|
||||
stored = value ? wrapDatabase(value) : value;
|
||||
},
|
||||
});
|
||||
return request;
|
||||
},
|
||||
} as unknown as IDBFactory;
|
||||
return Object.freeze({ factory, aborts });
|
||||
}
|
||||
|
||||
function seedReceipt(
|
||||
memory: MemoryIndexedDbFactory,
|
||||
idempotencyKey: string,
|
||||
@@ -299,6 +357,101 @@ describe("IndexedDB bounded codec maintenance", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a blocked open immediately instead of waiting out a deadline", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
await prepareSchema(memory);
|
||||
seedLegacy(memory, "blocked", "waiting");
|
||||
const maintenance = createMaintenance(memory, defaultPolicy());
|
||||
memory.blockNextOpen();
|
||||
|
||||
// MT-1. The blocked open is never released here. A blocked deadline could
|
||||
// only settle from a timer, and no amount of microtask draining reaches a
|
||||
// timer, so a batch that has already landed proves the blocked event
|
||||
// itself is terminal. Give maintenance a blocked timeout and this test
|
||||
// stops finishing instead of failing on a code.
|
||||
let landed = false;
|
||||
const pending = maintenance
|
||||
.migrateCodecBatch({ maxRows: 10, maxDurationMs: 10_000 })
|
||||
.then((result) => {
|
||||
landed = true;
|
||||
return result;
|
||||
});
|
||||
for (let tick = 0; tick < 50; tick += 1) await Promise.resolve();
|
||||
expect(landed).toBe(true);
|
||||
|
||||
expect(await pending).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "BLOCKED",
|
||||
operation: "INDEXEDDB_MIGRATE",
|
||||
retryable: true,
|
||||
recovery: "RELOAD_OTHER_CONTEXTS",
|
||||
},
|
||||
});
|
||||
// The connection the caller already gave up on is closed, not leaked.
|
||||
memory.releaseBlockedOpen();
|
||||
expect(memory.isConnectionClosed()).toBe(true);
|
||||
expect(memory.readRaw("records", "blocked")).toMatchObject({
|
||||
codecVersion: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unexpected schema upgrade without creating the schema", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
// No prepareSchema: the database does not exist yet, so opening at the
|
||||
// exact schema version is an upgrade.
|
||||
const maintenance = createMaintenance(memory, defaultPolicy());
|
||||
|
||||
expect(
|
||||
await maintenance.migrateCodecBatch({
|
||||
maxRows: 10,
|
||||
maxDurationMs: 10_000,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "MIGRATION_FAILED",
|
||||
operation: "INDEXEDDB_MIGRATE",
|
||||
retryable: false,
|
||||
recovery: "READ_ONLY",
|
||||
},
|
||||
});
|
||||
// MT-2. Maintenance owns no schema, so a rejected upgrade must leave the
|
||||
// database untouched rather than open it at the right version with the
|
||||
// wrong contents. Applying the upgrade instead would fail with the same
|
||||
// code here and only surface later, as a database nobody can use.
|
||||
expect(memory.hasStore("records")).toBe(false);
|
||||
expect(memory.hasStore("governance")).toBe(false);
|
||||
await prepareSchema(memory);
|
||||
expect(memory.hasStore("records")).toBe(true);
|
||||
});
|
||||
|
||||
it("closes the maintenance connection when another context needs a version change", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
await prepareSchema(memory);
|
||||
seedReceipt(memory, "versionchange-receipt", 100);
|
||||
const maintenance = createMaintenance(memory, defaultPolicy(), {
|
||||
nowEpochMilliseconds: () => 200,
|
||||
});
|
||||
memory.clearLastTransaction();
|
||||
memory.pauseTransactions();
|
||||
|
||||
const pending = maintenance.pruneExpiredReceipts({
|
||||
maxRows: 10,
|
||||
maxDurationMs: 10_000,
|
||||
});
|
||||
await waitForWriteTransaction(memory);
|
||||
expect(memory.isConnectionClosed()).toBe(false);
|
||||
|
||||
// MT-3. The listener is registered on the successful open path only, so it
|
||||
// covers exactly the window in which a batch holds the connection.
|
||||
memory.triggerVersionChange(2);
|
||||
expect(memory.isConnectionClosed()).toBe(true);
|
||||
|
||||
memory.resumeTransactions();
|
||||
await pending;
|
||||
});
|
||||
|
||||
it("resumes from a durable checkpoint and completes in bounded row batches", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
await prepareSchema(memory);
|
||||
@@ -667,7 +820,10 @@ describe("IndexedDB bounded codec maintenance", () => {
|
||||
...defaultPolicy(),
|
||||
measureStoredBytes: () => 2_000_000,
|
||||
};
|
||||
const maintenance = createMaintenance(memory, policy);
|
||||
const tracker = trackAborts(memory);
|
||||
const maintenance = createMaintenance(memory, policy, {
|
||||
factory: tracker.factory,
|
||||
});
|
||||
|
||||
expect(
|
||||
await maintenance.migrateCodecBatch({
|
||||
@@ -681,6 +837,10 @@ describe("IndexedDB bounded codec maintenance", () => {
|
||||
recovery: "READ_ONLY",
|
||||
},
|
||||
});
|
||||
// The budget verdict aborts the commit transaction rather than only
|
||||
// recording a failure, so the writes already queued behind it cannot
|
||||
// commit. Recording without aborting would end at the same failure code.
|
||||
expect(tracker.aborts).toEqual(["readwrite"]);
|
||||
expect(memory.readRaw("records", "oversized")).toMatchObject({
|
||||
codecVersion: 1,
|
||||
});
|
||||
@@ -749,10 +909,11 @@ describe("IndexedDB bounded codec maintenance", () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
await prepareSchema(memory);
|
||||
seedReceipt(memory, "quota-receipt", 100);
|
||||
const tracker = trackAborts(memory);
|
||||
const maintenance = createMaintenance(
|
||||
memory,
|
||||
defaultPolicy(),
|
||||
{ nowEpochMilliseconds: () => 200 },
|
||||
{ nowEpochMilliseconds: () => 200, factory: tracker.factory },
|
||||
);
|
||||
memory.failNextWriteCommit(
|
||||
new DOMException("private receipt", "QuotaExceededError"),
|
||||
@@ -767,6 +928,9 @@ describe("IndexedDB bounded codec maintenance", () => {
|
||||
ok: false,
|
||||
error: { code: "QUOTA_EXCEEDED" },
|
||||
});
|
||||
// The commit is what failed, so the adapter never aborts anything itself;
|
||||
// the quota code has to come from the transaction's own error.
|
||||
expect(tracker.aborts).toEqual([]);
|
||||
expect(
|
||||
memory.readRaw("receipts", "quota-receipt"),
|
||||
).toBeDefined();
|
||||
@@ -776,10 +940,11 @@ describe("IndexedDB bounded codec maintenance", () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
await prepareSchema(memory);
|
||||
seedReceipt(memory, "abort-receipt", 100);
|
||||
const tracker = trackAborts(memory);
|
||||
const maintenance = createMaintenance(
|
||||
memory,
|
||||
defaultPolicy(),
|
||||
{ nowEpochMilliseconds: () => 200 },
|
||||
{ nowEpochMilliseconds: () => 200, factory: tracker.factory },
|
||||
);
|
||||
memory.clearLastTransaction();
|
||||
memory.pauseTransactions();
|
||||
@@ -796,6 +961,9 @@ describe("IndexedDB bounded codec maintenance", () => {
|
||||
ok: false,
|
||||
error: { code: "ABORTED" },
|
||||
});
|
||||
// Exactly one abort: the caller's signal. A second one would mean the
|
||||
// cursor pump and the transaction both claim the abort.
|
||||
expect(tracker.aborts).toEqual(["readwrite"]);
|
||||
memory.resumeTransactions();
|
||||
await Promise.resolve();
|
||||
expect(
|
||||
|
||||
Reference in New Issue
Block a user