feat(core): optimize query resource lifecycle
This commit is contained in:
1 parent
b4bd6c42a7
commit
295b67dd36
29 files changed
+1857
-335
No files matched your search
@@ -0,0 +1,57 @@
|
||||
import type { QueryTab } from "@/types/database";
|
||||
import type { TabResultSnapshot } from "@/lib/tabs/tabResultCache";
|
||||
|
||||
export function queryResultLifecycleSnapshot(): TabResultSnapshot {
|
||||
const first = {
|
||||
columns: ["id", "name"],
|
||||
rows: [[1, "Ada"]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 4,
|
||||
sourceStatement: "SELECT id, name FROM users",
|
||||
};
|
||||
const second = {
|
||||
columns: ["count"],
|
||||
rows: [[1]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 2,
|
||||
sourceStatement: "SELECT COUNT(*) FROM users",
|
||||
};
|
||||
const resultRuns: NonNullable<QueryTab["resultRuns"]> = [
|
||||
{
|
||||
id: "run-1",
|
||||
sequence: 1,
|
||||
result: first,
|
||||
results: [first, second],
|
||||
activeResultIndex: 0,
|
||||
resultLocalSortOriginalRows: [[1, "Ada"]],
|
||||
resultBaseSql: "SELECT id, name FROM users",
|
||||
resultPageSql: "SELECT id, name FROM users LIMIT 100 OFFSET 200",
|
||||
resultPageLimit: 100,
|
||||
resultPageOffset: 200,
|
||||
tableMeta: {
|
||||
schema: "public",
|
||||
tableName: "users",
|
||||
tableType: "TABLE",
|
||||
columns: [{ name: "id", data_type: "integer", nullable: false }],
|
||||
primaryKeys: ["id"],
|
||||
},
|
||||
},
|
||||
];
|
||||
return {
|
||||
result: first,
|
||||
results: [first, second],
|
||||
activeResultIndex: 0,
|
||||
resultLocalSortOriginalRows: [[1, "Ada"]],
|
||||
resultRuns,
|
||||
activeResultRunId: "run-1",
|
||||
queryAnalysis: { tableName: "users", schema: "public", allowInsert: true, allowInsertDelete: true },
|
||||
querySourceColumns: ["id", "name"],
|
||||
tableMeta: resultRuns[0].tableMeta,
|
||||
resultPageSql: "SELECT id, name FROM users LIMIT 100 OFFSET 200",
|
||||
resultPageLimit: 100,
|
||||
resultPageOffset: 200,
|
||||
resultCountSql: "SELECT COUNT(*) FROM users",
|
||||
resultTotalRowCount: 301,
|
||||
cachedAt: 1,
|
||||
};
|
||||
}
|
||||
+93
-47
@@ -1,95 +1,141 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cancelPendingSidebarDataOpen, runSidebarDataOpenImmediately } from "@/lib/sidebar/sidebarDataOpenCoordinator";
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("sidebarDataOpenCoordinator", () => {
|
||||
afterEach(() => {
|
||||
cancelPendingSidebarDataOpen();
|
||||
});
|
||||
|
||||
it("cancels the active request when a newer open starts", async () => {
|
||||
it("cancels the active request when a newer ordinary open starts", async () => {
|
||||
const cancel = vi.fn();
|
||||
const running = deferred();
|
||||
let firstIsCurrent = true;
|
||||
|
||||
runSidebarDataOpenImmediately(async (request) => {
|
||||
runSidebarDataOpenImmediately({ connectionKey: "conn" }, async (request) => {
|
||||
request.registerCancel(cancel);
|
||||
await Promise.resolve();
|
||||
await running.promise;
|
||||
firstIsCurrent = request.isCurrent();
|
||||
});
|
||||
runSidebarDataOpenImmediately(() => undefined);
|
||||
runSidebarDataOpenImmediately({ connectionKey: "conn" }, () => undefined);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
expect(firstIsCurrent).toBe(true);
|
||||
|
||||
running.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(firstIsCurrent).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces a superseded queued request before it starts", async () => {
|
||||
const running = deferred();
|
||||
const second = vi.fn();
|
||||
const third = vi.fn();
|
||||
|
||||
runSidebarDataOpenImmediately({ connectionKey: "conn", supersede: false }, () => running.promise);
|
||||
runSidebarDataOpenImmediately({ connectionKey: "conn" }, second);
|
||||
runSidebarDataOpenImmediately({ connectionKey: "conn" }, third);
|
||||
running.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(second).not.toHaveBeenCalled();
|
||||
expect(third).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("bounds active work per connection", async () => {
|
||||
const first = deferred();
|
||||
const starts: string[] = [];
|
||||
|
||||
runSidebarDataOpenImmediately({ connectionKey: "conn", supersede: false }, async () => {
|
||||
starts.push("first");
|
||||
await first.promise;
|
||||
});
|
||||
runSidebarDataOpenImmediately({ connectionKey: "conn", supersede: false }, () => {
|
||||
starts.push("second");
|
||||
});
|
||||
|
||||
expect(starts).toEqual(["first"]);
|
||||
first.resolve();
|
||||
await vi.waitFor(() => expect(starts).toEqual(["first", "second"]));
|
||||
});
|
||||
|
||||
it("allows different connections to make progress independently", () => {
|
||||
const blocked = deferred();
|
||||
const otherConnection = vi.fn();
|
||||
|
||||
runSidebarDataOpenImmediately({ connectionKey: "conn-a", supersede: false }, () => blocked.promise);
|
||||
runSidebarDataOpenImmediately({ connectionKey: "conn-b", supersede: false }, otherConnection);
|
||||
|
||||
expect(otherConnection).toHaveBeenCalledOnce();
|
||||
blocked.resolve();
|
||||
});
|
||||
|
||||
it("keeps explicit new-tab opens independent while still queueing them", async () => {
|
||||
const first = deferred();
|
||||
const firstCancel = vi.fn();
|
||||
const second = vi.fn();
|
||||
|
||||
runSidebarDataOpenImmediately({ connectionKey: "conn", supersede: false }, async (request) => {
|
||||
request.registerCancel(firstCancel);
|
||||
await first.promise;
|
||||
});
|
||||
runSidebarDataOpenImmediately({ connectionKey: "conn", supersede: false }, second);
|
||||
|
||||
expect(firstCancel).not.toHaveBeenCalled();
|
||||
expect(second).not.toHaveBeenCalled();
|
||||
first.resolve();
|
||||
await vi.waitFor(() => expect(second).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it("runs registered cancellation immediately for an already stale request", async () => {
|
||||
const cancel = vi.fn();
|
||||
let registerLateCancel: (() => void) | undefined;
|
||||
const running = deferred();
|
||||
|
||||
runSidebarDataOpenImmediately((request) => {
|
||||
runSidebarDataOpenImmediately({ connectionKey: "conn" }, async (request) => {
|
||||
registerLateCancel = () => request.registerCancel(cancel);
|
||||
await running.promise;
|
||||
});
|
||||
runSidebarDataOpenImmediately(() => undefined);
|
||||
runSidebarDataOpenImmediately({ connectionKey: "conn" }, () => undefined);
|
||||
registerLateCancel?.();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
running.resolve();
|
||||
});
|
||||
|
||||
it("recovers after a runner throws synchronously", async () => {
|
||||
const nextRunner = vi.fn();
|
||||
|
||||
runSidebarDataOpenImmediately(() => {
|
||||
runSidebarDataOpenImmediately({ connectionKey: "conn", supersede: false }, () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
await Promise.resolve();
|
||||
runSidebarDataOpenImmediately(nextRunner);
|
||||
await Promise.resolve();
|
||||
runSidebarDataOpenImmediately({ connectionKey: "conn", supersede: false }, nextRunner);
|
||||
|
||||
expect(nextRunner).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps only the newest rapid navigation request current", async () => {
|
||||
const states: boolean[] = [];
|
||||
const captures: Array<() => void> = [];
|
||||
it("aborts a superseded request signal", () => {
|
||||
let signal: AbortSignal | undefined;
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
runSidebarDataOpenImmediately((request) => {
|
||||
captures.push(() => states.push(request.isCurrent()));
|
||||
});
|
||||
}
|
||||
captures.forEach((capture) => capture());
|
||||
|
||||
expect(states).toEqual([false, false, true]);
|
||||
});
|
||||
|
||||
it("does not cancel work that was not registered as sidebar-owned", async () => {
|
||||
const sidebarCancel = vi.fn();
|
||||
const unrelatedEditorCancel = vi.fn();
|
||||
|
||||
runSidebarDataOpenImmediately((request) => request.registerCancel(sidebarCancel));
|
||||
runSidebarDataOpenImmediately(() => undefined);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(sidebarCancel).toHaveBeenCalledOnce();
|
||||
expect(unrelatedEditorCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prevents a superseded connection completion from becoming current", async () => {
|
||||
let finishConnection: (() => void) | undefined;
|
||||
let completionWasCurrent = true;
|
||||
|
||||
runSidebarDataOpenImmediately(async (request) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
finishConnection = resolve;
|
||||
});
|
||||
completionWasCurrent = request.isCurrent();
|
||||
runSidebarDataOpenImmediately({ connectionKey: "conn" }, (request) => {
|
||||
signal = request.signal;
|
||||
});
|
||||
runSidebarDataOpenImmediately(() => undefined);
|
||||
finishConnection?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
runSidebarDataOpenImmediately({ connectionKey: "conn" }, () => undefined);
|
||||
|
||||
expect(completionWasCurrent).toBe(false);
|
||||
expect(signal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { estimateQueryResultBytes, estimateQueryResultsBytes, selectInactiveResultEvictions } from "@/lib/tabs/queryResultSize";
|
||||
|
||||
function result(rows: unknown[][]) {
|
||||
return { columns: ["value"], rows, affected_rows: 0, execution_time_ms: 1 };
|
||||
}
|
||||
|
||||
describe("query result size estimation", () => {
|
||||
it("grows with string and binary payloads", () => {
|
||||
const small = estimateQueryResultBytes(result([["a"]]));
|
||||
const large = estimateQueryResultBytes(result([["a".repeat(10_000)], [new Uint8Array(20_000)]]));
|
||||
expect(large).toBeGreaterThan(small + 30_000);
|
||||
});
|
||||
|
||||
it("counts multiple results and tolerates cyclic document values", () => {
|
||||
const document: Record<string, unknown> = { name: "node" };
|
||||
document.self = document;
|
||||
const first = result([[document]]);
|
||||
const second = result(Array.from({ length: 100 }, (_, index) => [index]));
|
||||
expect(estimateQueryResultsBytes(undefined, [first, second])).toBeGreaterThan(estimateQueryResultBytes(first));
|
||||
});
|
||||
});
|
||||
|
||||
describe("inactive result byte budgets", () => {
|
||||
it("evicts oldest small results to the count limit", () => {
|
||||
const entries = Array.from({ length: 8 }, (_, index) => ({ id: `${index}`, estimatedBytes: 10, accessedAt: index }));
|
||||
expect(selectInactiveResultEvictions(entries, 1_000, 5)).toEqual(["0", "1", "2"]);
|
||||
});
|
||||
|
||||
it("evicts least-recently-used large results to the byte limit", () => {
|
||||
const entries = [
|
||||
{ id: "old", estimatedBytes: 80, accessedAt: 1 },
|
||||
{ id: "middle", estimatedBytes: 70, accessedAt: 2 },
|
||||
{ id: "new", estimatedBytes: 60, accessedAt: 3 },
|
||||
];
|
||||
expect(selectInactiveResultEvictions(entries, 100, 10)).toEqual(["old", "middle"]);
|
||||
});
|
||||
|
||||
it("does not consider the active result even when it exceeds the budget", () => {
|
||||
const inactive = [{ id: "inactive", estimatedBytes: 10, accessedAt: 1 }];
|
||||
expect(selectInactiveResultEvictions(inactive, 100, 5)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { encodeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
|
||||
import { estimateQueryResultBytes } from "@/lib/tabs/queryResultSize";
|
||||
import { cancelPendingSidebarDataOpen, runSidebarDataOpenImmediately, sidebarDataOpenDiagnostics } from "@/lib/sidebar/sidebarDataOpenCoordinator";
|
||||
|
||||
describe("resource lifecycle performance harness", () => {
|
||||
it("records deterministic serialization and memory-estimation measurements", () => {
|
||||
const result = {
|
||||
columns: ["id", "label", "payload"],
|
||||
rows: Array.from({ length: 25_000 }, (_, index) => [index, `row-${index}`, "x".repeat(64)]),
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
};
|
||||
|
||||
const estimateStartedAt = performance.now();
|
||||
const estimatedBytes = estimateQueryResultBytes(result);
|
||||
const estimateDurationMs = performance.now() - estimateStartedAt;
|
||||
const serializeStartedAt = performance.now();
|
||||
const encodedBytes = encodeTabResultSnapshot({ result, cachedAt: 1 }).byteLength;
|
||||
const serializationDurationMs = performance.now() - serializeStartedAt;
|
||||
|
||||
console.info("[resource-lifecycle-baseline]", JSON.stringify({ rows: result.rows.length, estimatedBytes, encodedBytes, estimateDurationMs, serializationDurationMs }));
|
||||
expect(estimatedBytes).toBeGreaterThan(encodedBytes);
|
||||
expect(estimateDurationMs).toBeLessThan(250);
|
||||
expect(serializationDurationMs).toBeLessThan(1_500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rapid table-open performance harness", () => {
|
||||
it("coalesces a 100-request burst to one active and one latest queued request", async () => {
|
||||
let release!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const committed: number[] = [];
|
||||
const startedAt = performance.now();
|
||||
runSidebarDataOpenImmediately({ connectionKey: "connection" }, async (request) => {
|
||||
await blocked;
|
||||
if (request.isCurrent()) committed.push(0);
|
||||
});
|
||||
for (let index = 1; index < 100; index += 1) {
|
||||
runSidebarDataOpenImmediately({ connectionKey: "connection" }, (request) => {
|
||||
if (request.isCurrent()) committed.push(index);
|
||||
});
|
||||
}
|
||||
const enqueueDurationMs = performance.now() - startedAt;
|
||||
const burstDiagnostics = sidebarDataOpenDiagnostics();
|
||||
release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const totalDurationMs = performance.now() - startedAt;
|
||||
console.info("[rapid-table-open-baseline]", JSON.stringify({ enqueueDurationMs, totalDurationMs, ...burstDiagnostics }));
|
||||
|
||||
expect(burstDiagnostics).toEqual({ activeTasks: 1, queuedTasks: 1, trackedRequests: 2 });
|
||||
expect(committed).toEqual([99]);
|
||||
cancelPendingSidebarDataOpen();
|
||||
});
|
||||
});
|
||||
+91
-2
@@ -1,7 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decodeTabResultSnapshot, encodeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { decodeTabResultSnapshot, encodeTabResultSnapshot, readResultCacheBackends, promoteFallbackResultCacheRead, resultCacheBackendOrder, resultCacheRuntimeConfig, selectResultCachePruneKeys, writeResultCacheBackends, type ResultCacheBackend } from "@/lib/tabs/tabResultCache";
|
||||
import { queryResultLifecycleSnapshot } from "@/lib/__tests__/fixtures/queryResultLifecycle";
|
||||
|
||||
function backend(name: ResultCacheBackend["name"], overrides: Partial<ResultCacheBackend> = {}): ResultCacheBackend {
|
||||
return {
|
||||
name,
|
||||
available: () => true,
|
||||
read: async () => undefined,
|
||||
write: async () => true,
|
||||
delete: async () => undefined,
|
||||
listMetadata: async () => [],
|
||||
prune: async () => ({ deletedEntries: 0, deletedBytes: 0, orphanDeletions: 0, remainingEntries: 0, remainingBytes: 0 }),
|
||||
deleteOwner: async () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("tab result cache statement execution metadata", () => {
|
||||
it("selects one authoritative backend before fallback", () => {
|
||||
expect(resultCacheBackendOrder(true)).toEqual(["runtime", "indexed-db"]);
|
||||
expect(resultCacheBackendOrder(false)).toEqual(["indexed-db", "runtime"]);
|
||||
});
|
||||
|
||||
it("uses explicit browser runtime configuration", () => {
|
||||
expect(resultCacheRuntimeConfig(false, { VITE_DBX_RESULT_CACHE_BACKEND: "http", VITE_DBX_RESULT_CACHE_FALLBACK: "true" } as ImportMetaEnv)).toEqual({
|
||||
primary: "runtime",
|
||||
fallbackEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes only to the first successful backend", async () => {
|
||||
const primaryWrite = vi.fn(async () => true);
|
||||
const fallbackWrite = vi.fn(async () => true);
|
||||
await writeResultCacheBackends([backend("runtime", { write: primaryWrite }), backend("indexed-db", { write: fallbackWrite })], "key", new Uint8Array([1]), { rowCount: 1, columnCount: 1 });
|
||||
expect(primaryWrite).toHaveBeenCalledOnce();
|
||||
expect(fallbackWrite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads legacy fallback data for promotion", async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
const primary = backend("runtime");
|
||||
const fallback = backend("indexed-db", { read: async () => bytes });
|
||||
expect(await readResultCacheBackends([primary, fallback], "key")).toEqual({ bytes, backend: fallback });
|
||||
});
|
||||
|
||||
it("promotes legacy IndexedDB or native bytes to the authoritative backend", async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
const primaryWrite = vi.fn(async () => true);
|
||||
const primary = backend("runtime", { write: primaryWrite });
|
||||
const legacy = backend("indexed-db");
|
||||
|
||||
expect(await promoteFallbackResultCacheRead([primary, legacy], "key", { bytes, backend: legacy }, { rowCount: 2, columnCount: 1 }, "connection-1")).toBe(true);
|
||||
expect(primaryWrite).toHaveBeenCalledWith("key", bytes, { rowCount: 2, columnCount: 1 }, "connection-1");
|
||||
});
|
||||
|
||||
it("skips an obsolete write before touching a backend", async () => {
|
||||
const write = vi.fn(async () => true);
|
||||
expect(await writeResultCacheBackends([backend("runtime", { write })], "key", new Uint8Array([1]), { rowCount: 1, columnCount: 1 }, () => false)).toBe(false);
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prunes crash leftovers and then unreferenced LRU entries", () => {
|
||||
const selection = selectResultCachePruneKeys(
|
||||
[
|
||||
{ key: "live", rowCount: 1, columnCount: 1, byteSize: 8, createdAt: 1, lastAccessedAt: 1 },
|
||||
{ key: "crash", rowCount: 1, columnCount: 1, byteSize: 7, createdAt: 1, lastAccessedAt: 1 },
|
||||
{ key: "recent", rowCount: 1, columnCount: 1, byteSize: 6, createdAt: 9_999, lastAccessedAt: 9_999 },
|
||||
],
|
||||
{ liveKeys: ["live"], maxBytes: 10, orphanGraceMs: 100, maxAgeMs: 1_000 },
|
||||
10_000,
|
||||
);
|
||||
expect(selection.keys).toEqual(["crash", "recent"]);
|
||||
expect(selection.result).toMatchObject({ orphanDeletions: 1, remainingBytes: 8 });
|
||||
});
|
||||
it("preserves statement identity and the editor fingerprint", () => {
|
||||
const encoded = encodeTabResultSnapshot({
|
||||
results: [
|
||||
@@ -35,4 +106,22 @@ describe("tab result cache statement execution metadata", () => {
|
||||
{ statementIndex: 1, error: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("restores multi-result, pagination, local-sort, and editable metadata fixtures", () => {
|
||||
const restored = decodeTabResultSnapshot(encodeTabResultSnapshot(queryResultLifecycleSnapshot()));
|
||||
|
||||
expect(restored?.results).toHaveLength(2);
|
||||
expect(restored?.resultRuns?.[0]?.resultLocalSortOriginalRows).toEqual([[1, "Ada"]]);
|
||||
expect(restored?.resultPageOffset).toBe(200);
|
||||
expect(restored?.resultTotalRowCount).toBe(301);
|
||||
expect(restored?.tableMeta?.primaryKeys).toEqual(["id"]);
|
||||
expect(restored?.querySourceColumns).toEqual(["id", "name"]);
|
||||
});
|
||||
|
||||
it("treats corrupt and unsupported snapshots as missing", () => {
|
||||
expect(decodeTabResultSnapshot(new Uint8Array([0xff, 0x00]))).toBeUndefined();
|
||||
const encoded = encodeTabResultSnapshot(queryResultLifecycleSnapshot());
|
||||
encoded[0] = 0xff;
|
||||
expect(decodeTabResultSnapshot(encoded)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { QueryTab } from "@/types/database";
|
||||
import { getResultCacheDiagnostics } from "@/lib/tabs/tabResultCache";
|
||||
|
||||
const cancellation = {
|
||||
count: 0,
|
||||
totalLatencyMs: 0,
|
||||
maxLatencyMs: 0,
|
||||
};
|
||||
|
||||
export function recordQueryCancellationLatency(durationMs: number) {
|
||||
cancellation.count += 1;
|
||||
cancellation.totalLatencyMs += durationMs;
|
||||
cancellation.maxLatencyMs = Math.max(cancellation.maxLatencyMs, durationMs);
|
||||
}
|
||||
|
||||
export function resourceLifecycleDiagnostics(tabs: readonly QueryTab[]) {
|
||||
const cache = getResultCacheDiagnostics();
|
||||
return {
|
||||
activeTasks: tabs.filter((tab) => tab.isExecuting || tab.isExplaining || tab.isCancelling).length,
|
||||
cancellationCount: cancellation.count,
|
||||
averageCancellationLatencyMs: cancellation.count ? cancellation.totalLatencyMs / cancellation.count : 0,
|
||||
maxCancellationLatencyMs: cancellation.maxLatencyMs,
|
||||
...cache,
|
||||
};
|
||||
}
|
||||
@@ -1,60 +1,166 @@
|
||||
export interface SidebarDataOpenRequest {
|
||||
isCurrent: () => boolean;
|
||||
signal: AbortSignal;
|
||||
registerCancel: (cancel: () => void | Promise<void>) => void;
|
||||
}
|
||||
|
||||
export interface SidebarDataOpenOptions {
|
||||
connectionKey: string;
|
||||
supersede?: boolean;
|
||||
}
|
||||
|
||||
type OpenDataRunner = (request: SidebarDataOpenRequest) => void | Promise<void>;
|
||||
|
||||
let generation = 0;
|
||||
let activeGeneration = 0;
|
||||
let activeCancel: (() => void | Promise<void>) | null = null;
|
||||
interface QueuedOpen {
|
||||
id: number;
|
||||
connectionKey: string;
|
||||
superseding: boolean;
|
||||
controller: AbortController;
|
||||
runner: OpenDataRunner;
|
||||
cancel: (() => void | Promise<void>) | null;
|
||||
started: boolean;
|
||||
finished: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CONNECTION_KEY = "__sidebar__";
|
||||
const MAX_ACTIVE_PER_CONNECTION = 1;
|
||||
|
||||
let nextRequestId = 0;
|
||||
let latestSupersedingRequestId = 0;
|
||||
const queuedByConnection = new Map<string, QueuedOpen[]>();
|
||||
const activeCountByConnection = new Map<string, number>();
|
||||
const requests = new Map<number, QueuedOpen>();
|
||||
|
||||
function runCancellation(cancel: (() => void | Promise<void>) | null) {
|
||||
if (!cancel) return;
|
||||
void Promise.resolve(cancel()).catch(() => undefined);
|
||||
}
|
||||
|
||||
function supersedeCurrentRequest(): number {
|
||||
generation += 1;
|
||||
runCancellation(activeCancel);
|
||||
activeCancel = null;
|
||||
activeGeneration = 0;
|
||||
return generation;
|
||||
function isCurrent(request: QueuedOpen): boolean {
|
||||
if (request.finished || request.controller.signal.aborted) return false;
|
||||
return !request.superseding || request.id === latestSupersedingRequestId;
|
||||
}
|
||||
|
||||
function executeRequest(requestGeneration: number, runner: OpenDataRunner) {
|
||||
if (requestGeneration !== generation) return;
|
||||
const request: SidebarDataOpenRequest = {
|
||||
isCurrent: () => requestGeneration === generation,
|
||||
function removeQueuedRequest(request: QueuedOpen) {
|
||||
const queue = queuedByConnection.get(request.connectionKey);
|
||||
if (!queue) return;
|
||||
const index = queue.indexOf(request);
|
||||
if (index >= 0) queue.splice(index, 1);
|
||||
if (queue.length === 0) queuedByConnection.delete(request.connectionKey);
|
||||
}
|
||||
|
||||
function cancelRequest(request: QueuedOpen) {
|
||||
if (request.finished) return;
|
||||
request.controller.abort();
|
||||
runCancellation(request.cancel);
|
||||
request.cancel = null;
|
||||
if (!request.started) {
|
||||
request.finished = true;
|
||||
removeQueuedRequest(request);
|
||||
requests.delete(request.id);
|
||||
}
|
||||
}
|
||||
|
||||
function supersedeCurrentRequest() {
|
||||
const current = requests.get(latestSupersedingRequestId);
|
||||
if (current) cancelRequest(current);
|
||||
}
|
||||
|
||||
function finishRequest(request: QueuedOpen) {
|
||||
if (request.finished) return;
|
||||
request.finished = true;
|
||||
request.cancel = null;
|
||||
requests.delete(request.id);
|
||||
const activeCount = activeCountByConnection.get(request.connectionKey) ?? 0;
|
||||
if (activeCount <= 1) activeCountByConnection.delete(request.connectionKey);
|
||||
else activeCountByConnection.set(request.connectionKey, activeCount - 1);
|
||||
drainConnection(request.connectionKey);
|
||||
}
|
||||
|
||||
function executeRequest(request: QueuedOpen) {
|
||||
if (!isCurrent(request)) {
|
||||
cancelRequest(request);
|
||||
return;
|
||||
}
|
||||
|
||||
request.started = true;
|
||||
activeCountByConnection.set(request.connectionKey, (activeCountByConnection.get(request.connectionKey) ?? 0) + 1);
|
||||
const openRequest: SidebarDataOpenRequest = {
|
||||
isCurrent: () => isCurrent(request),
|
||||
signal: request.controller.signal,
|
||||
registerCancel: (cancel) => {
|
||||
if (requestGeneration !== generation) {
|
||||
if (!isCurrent(request)) {
|
||||
runCancellation(cancel);
|
||||
return;
|
||||
}
|
||||
activeGeneration = requestGeneration;
|
||||
activeCancel = cancel;
|
||||
request.cancel = cancel;
|
||||
},
|
||||
};
|
||||
|
||||
let result: void | Promise<void>;
|
||||
try {
|
||||
result = runner(request);
|
||||
result = request.runner(openRequest);
|
||||
} catch {
|
||||
result = undefined;
|
||||
}
|
||||
void Promise.resolve(result)
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (activeGeneration !== requestGeneration) return;
|
||||
activeGeneration = 0;
|
||||
activeCancel = null;
|
||||
});
|
||||
.finally(() => finishRequest(request));
|
||||
}
|
||||
|
||||
export function runSidebarDataOpenImmediately(runner: OpenDataRunner) {
|
||||
const requestGeneration = supersedeCurrentRequest();
|
||||
executeRequest(requestGeneration, runner);
|
||||
function drainConnection(connectionKey: string) {
|
||||
if ((activeCountByConnection.get(connectionKey) ?? 0) >= MAX_ACTIVE_PER_CONNECTION) return;
|
||||
const queue = queuedByConnection.get(connectionKey);
|
||||
const request = queue?.shift();
|
||||
if (!request) {
|
||||
queuedByConnection.delete(connectionKey);
|
||||
return;
|
||||
}
|
||||
if (queue?.length === 0) queuedByConnection.delete(connectionKey);
|
||||
executeRequest(request);
|
||||
}
|
||||
|
||||
function enqueueRequest(options: SidebarDataOpenOptions, runner: OpenDataRunner) {
|
||||
const superseding = options.supersede !== false;
|
||||
if (superseding) supersedeCurrentRequest();
|
||||
|
||||
const request: QueuedOpen = {
|
||||
id: ++nextRequestId,
|
||||
connectionKey: options.connectionKey || DEFAULT_CONNECTION_KEY,
|
||||
superseding,
|
||||
controller: new AbortController(),
|
||||
runner,
|
||||
cancel: null,
|
||||
started: false,
|
||||
finished: false,
|
||||
};
|
||||
if (superseding) latestSupersedingRequestId = request.id;
|
||||
requests.set(request.id, request);
|
||||
const queue = queuedByConnection.get(request.connectionKey) ?? [];
|
||||
queue.push(request);
|
||||
queuedByConnection.set(request.connectionKey, queue);
|
||||
drainConnection(request.connectionKey);
|
||||
}
|
||||
|
||||
export function runSidebarDataOpenImmediately(runner: OpenDataRunner): void;
|
||||
export function runSidebarDataOpenImmediately(options: SidebarDataOpenOptions, runner: OpenDataRunner): void;
|
||||
export function runSidebarDataOpenImmediately(optionsOrRunner: SidebarDataOpenOptions | OpenDataRunner, maybeRunner?: OpenDataRunner) {
|
||||
if (typeof optionsOrRunner === "function") {
|
||||
enqueueRequest({ connectionKey: DEFAULT_CONNECTION_KEY }, optionsOrRunner);
|
||||
return;
|
||||
}
|
||||
if (maybeRunner) enqueueRequest(optionsOrRunner, maybeRunner);
|
||||
}
|
||||
|
||||
export function cancelPendingSidebarDataOpen() {
|
||||
supersedeCurrentRequest();
|
||||
for (const request of [...requests.values()]) cancelRequest(request);
|
||||
latestSupersedingRequestId = 0;
|
||||
}
|
||||
|
||||
export function sidebarDataOpenDiagnostics() {
|
||||
return {
|
||||
activeTasks: [...activeCountByConnection.values()].reduce((total, count) => total + count, 0),
|
||||
queuedTasks: [...queuedByConnection.values()].reduce((total, queue) => total + queue.length, 0),
|
||||
trackedRequests: requests.size,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
const ARRAY_OVERHEAD_BYTES = 24;
|
||||
const OBJECT_OVERHEAD_BYTES = 32;
|
||||
const VALUE_SLOT_BYTES = 8;
|
||||
|
||||
function estimateValueBytes(value: unknown, seen: WeakSet<object>): number {
|
||||
if (value == null) return VALUE_SLOT_BYTES;
|
||||
if (typeof value === "string") return VALUE_SLOT_BYTES + value.length * 2;
|
||||
if (typeof value === "number" || typeof value === "bigint") return 16;
|
||||
if (typeof value === "boolean") return 8;
|
||||
if (value instanceof Uint8Array) return OBJECT_OVERHEAD_BYTES + value.byteLength;
|
||||
if (value instanceof ArrayBuffer) return OBJECT_OVERHEAD_BYTES + value.byteLength;
|
||||
if (value instanceof Date) return OBJECT_OVERHEAD_BYTES;
|
||||
if (typeof value !== "object") return VALUE_SLOT_BYTES;
|
||||
if (seen.has(value)) return VALUE_SLOT_BYTES;
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) {
|
||||
return ARRAY_OVERHEAD_BYTES + value.reduce((total, item) => total + estimateValueBytes(item, seen), 0);
|
||||
}
|
||||
return OBJECT_OVERHEAD_BYTES + Object.entries(value).reduce((total, [key, entry]) => total + key.length * 2 + estimateValueBytes(entry, seen), 0);
|
||||
}
|
||||
|
||||
export function estimateQueryResultBytes(result: QueryResult | undefined): number {
|
||||
if (!result) return 0;
|
||||
return estimateValueBytes(result, new WeakSet<object>());
|
||||
}
|
||||
|
||||
export function estimateQueryResultsBytes(result: QueryResult | undefined, results: QueryResult[] | undefined): number {
|
||||
if (results?.length) return results.reduce((total, item) => total + estimateQueryResultBytes(item), ARRAY_OVERHEAD_BYTES);
|
||||
return estimateQueryResultBytes(result);
|
||||
}
|
||||
|
||||
export interface InactiveResultResidency {
|
||||
id: string;
|
||||
estimatedBytes: number;
|
||||
accessedAt: number;
|
||||
}
|
||||
|
||||
export function selectInactiveResultEvictions(entries: InactiveResultResidency[], maxBytes: number, maxCount: number): string[] {
|
||||
const ordered = [...entries].sort((left, right) => left.accessedAt - right.accessedAt);
|
||||
let bytes = ordered.reduce((total, entry) => total + entry.estimatedBytes, 0);
|
||||
const evicted: string[] = [];
|
||||
while (ordered.length - evicted.length > maxCount || bytes > maxBytes) {
|
||||
const entry = ordered[evicted.length];
|
||||
if (!entry) break;
|
||||
evicted.push(entry.id);
|
||||
bytes -= entry.estimatedBytes;
|
||||
}
|
||||
return evicted;
|
||||
}
|
||||
@@ -5,8 +5,12 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { apiUrl } from "@/lib/common/webPath";
|
||||
|
||||
const DB_NAME = "dbx-tab-runtime-cache";
|
||||
const DB_VERSION = 1;
|
||||
const DB_VERSION = 2;
|
||||
const RESULT_STORE = "resultSnapshots";
|
||||
const RESULT_METADATA_STORE = "resultSnapshotMetadata";
|
||||
const DEFAULT_PERSISTENT_CACHE_BYTES = 512 * 1024 * 1024;
|
||||
const DEFAULT_ORPHAN_GRACE_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const PAYLOAD_MAGIC = "DBX_TAB_RESULT_CACHE";
|
||||
const PAYLOAD_VERSION = 1;
|
||||
const PAYLOAD_CODEC = "msgpack-columnar";
|
||||
@@ -80,6 +84,58 @@ interface TabResultCacheEnvelope {
|
||||
payload: TabResultSnapshotPayload;
|
||||
}
|
||||
|
||||
export type ResultCacheBackendName = "indexed-db" | "runtime";
|
||||
|
||||
export interface ResultCacheMetadata {
|
||||
key: string;
|
||||
rowCount: number;
|
||||
columnCount: number;
|
||||
byteSize: number;
|
||||
createdAt: number;
|
||||
lastAccessedAt: number;
|
||||
ownerId?: string;
|
||||
}
|
||||
|
||||
export interface ResultCachePruneOptions {
|
||||
liveKeys: string[];
|
||||
maxBytes: number;
|
||||
orphanGraceMs: number;
|
||||
maxAgeMs?: number;
|
||||
}
|
||||
|
||||
export interface ResultCachePruneResult {
|
||||
deletedEntries: number;
|
||||
deletedBytes: number;
|
||||
orphanDeletions: number;
|
||||
remainingEntries: number;
|
||||
remainingBytes: number;
|
||||
}
|
||||
|
||||
export interface ResultCacheBackend {
|
||||
name: ResultCacheBackendName;
|
||||
available: () => boolean;
|
||||
read: (key: string) => Promise<Uint8Array | undefined>;
|
||||
write: (key: string, bytes: Uint8Array, stats: { rowCount: number; columnCount: number }, ownerId?: string) => Promise<boolean>;
|
||||
delete: (key: string) => Promise<void>;
|
||||
listMetadata: () => Promise<ResultCacheMetadata[]>;
|
||||
prune: (options: ResultCachePruneOptions) => Promise<ResultCachePruneResult>;
|
||||
deleteOwner: (ownerId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const resultCacheDiagnostics = {
|
||||
cacheBytes: 0,
|
||||
evictions: 0,
|
||||
serializationCount: 0,
|
||||
serializationDurationMs: 0,
|
||||
serializedBytes: 0,
|
||||
orphanDeletions: 0,
|
||||
corruptSnapshots: 0,
|
||||
};
|
||||
|
||||
export function getResultCacheDiagnostics() {
|
||||
return { ...resultCacheDiagnostics };
|
||||
}
|
||||
|
||||
function indexedDb(): IDBFactory | undefined {
|
||||
return typeof globalThis.indexedDB === "undefined" ? undefined : globalThis.indexedDB;
|
||||
}
|
||||
@@ -118,6 +174,7 @@ function openCacheDb(): Promise<IDBDatabase | null> {
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(RESULT_STORE)) db.createObjectStore(RESULT_STORE);
|
||||
if (!db.objectStoreNames.contains(RESULT_METADATA_STORE)) db.createObjectStore(RESULT_METADATA_STORE);
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => {
|
||||
@@ -129,6 +186,129 @@ function openCacheDb(): Promise<IDBDatabase | null> {
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
async function writeIndexedDbCache(key: string, bytes: Uint8Array, stats: { rowCount: number; columnCount: number }, ownerId?: string): Promise<boolean> {
|
||||
const db = await openCacheDb();
|
||||
if (!db) return false;
|
||||
const previous = (await requestToPromise(db.transaction(RESULT_METADATA_STORE, "readonly").objectStore(RESULT_METADATA_STORE).get(key))) as ResultCacheMetadata | undefined;
|
||||
const transaction = db.transaction([RESULT_STORE, RESULT_METADATA_STORE], "readwrite");
|
||||
const metadataStore = transaction.objectStore(RESULT_METADATA_STORE);
|
||||
const now = Date.now();
|
||||
await Promise.all([
|
||||
requestToPromise(transaction.objectStore(RESULT_STORE).put(bytes, key)),
|
||||
requestToPromise(
|
||||
metadataStore.put(
|
||||
{
|
||||
key,
|
||||
rowCount: stats.rowCount,
|
||||
columnCount: stats.columnCount,
|
||||
byteSize: bytes.byteLength,
|
||||
createdAt: previous?.createdAt ?? now,
|
||||
lastAccessedAt: now,
|
||||
ownerId,
|
||||
} satisfies ResultCacheMetadata,
|
||||
key,
|
||||
),
|
||||
),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function readIndexedDbCache(key: string): Promise<Uint8Array | undefined> {
|
||||
const db = await openCacheDb();
|
||||
if (!db) return undefined;
|
||||
const value = await requestToPromise(db.transaction(RESULT_STORE, "readonly").objectStore(RESULT_STORE).get(key));
|
||||
if (isBinaryPayload(value)) {
|
||||
const previous = (await requestToPromise(db.transaction(RESULT_METADATA_STORE, "readonly").objectStore(RESULT_METADATA_STORE).get(key))) as ResultCacheMetadata | undefined;
|
||||
const store = db.transaction(RESULT_METADATA_STORE, "readwrite").objectStore(RESULT_METADATA_STORE);
|
||||
const now = Date.now();
|
||||
await requestToPromise(store.put(previous ? { ...previous, lastAccessedAt: now } : { key, rowCount: 0, columnCount: 0, byteSize: value.byteLength, createdAt: now, lastAccessedAt: now }, key));
|
||||
}
|
||||
return isBinaryPayload(value) ? new Uint8Array(value) : undefined;
|
||||
}
|
||||
|
||||
async function deleteIndexedDbCache(key: string): Promise<void> {
|
||||
const db = await openCacheDb();
|
||||
if (!db) return;
|
||||
const transaction = db.transaction([RESULT_STORE, RESULT_METADATA_STORE], "readwrite");
|
||||
await Promise.all([requestToPromise(transaction.objectStore(RESULT_STORE).delete(key)), requestToPromise(transaction.objectStore(RESULT_METADATA_STORE).delete(key))]);
|
||||
}
|
||||
|
||||
async function listIndexedDbCacheMetadata(): Promise<ResultCacheMetadata[]> {
|
||||
const db = await openCacheDb();
|
||||
if (!db) return [];
|
||||
const transaction = db.transaction([RESULT_STORE, RESULT_METADATA_STORE], "readonly");
|
||||
const metadataRequest = transaction.objectStore(RESULT_METADATA_STORE).getAll();
|
||||
const keysRequest = transaction.objectStore(RESULT_STORE).getAllKeys();
|
||||
const [metadata, keys] = await Promise.all([requestToPromise(metadataRequest) as Promise<ResultCacheMetadata[]>, requestToPromise(keysRequest)]);
|
||||
const byKey = new Map(metadata.map((entry) => [entry.key, entry]));
|
||||
const missingKeys = keys.map(String).filter((key) => !byKey.has(key));
|
||||
const migratedMetadata: ResultCacheMetadata[] = [];
|
||||
for (const key of missingKeys) {
|
||||
const value = await requestToPromise(db.transaction(RESULT_STORE, "readonly").objectStore(RESULT_STORE).get(key));
|
||||
const now = Date.now();
|
||||
const entry: ResultCacheMetadata = {
|
||||
key,
|
||||
rowCount: 0,
|
||||
columnCount: 0,
|
||||
byteSize: isBinaryPayload(value) ? value.byteLength : 0,
|
||||
createdAt: now,
|
||||
lastAccessedAt: now,
|
||||
};
|
||||
byKey.set(key, entry);
|
||||
migratedMetadata.push(entry);
|
||||
}
|
||||
if (migratedMetadata.length) {
|
||||
const migration = db.transaction(RESULT_METADATA_STORE, "readwrite").objectStore(RESULT_METADATA_STORE);
|
||||
await Promise.all(migratedMetadata.map((entry) => requestToPromise(migration.put(entry, entry.key))));
|
||||
}
|
||||
return [...byKey.values()];
|
||||
}
|
||||
|
||||
export function selectResultCachePruneKeys(metadata: ResultCacheMetadata[], options: ResultCachePruneOptions, now = Date.now()): { keys: string[]; result: ResultCachePruneResult } {
|
||||
const live = new Set(options.liveKeys);
|
||||
const ordered = [...metadata].sort((left, right) => left.lastAccessedAt - right.lastAccessedAt || left.key.localeCompare(right.key));
|
||||
const deleted = new Set<string>();
|
||||
let remainingBytes = ordered.reduce((total, entry) => total + entry.byteSize, 0);
|
||||
let orphanDeletions = 0;
|
||||
for (const entry of ordered) {
|
||||
if (live.has(entry.key)) continue;
|
||||
const orphanExpired = now - entry.createdAt >= Math.max(0, options.orphanGraceMs);
|
||||
const ageExpired = options.maxAgeMs !== undefined && now - entry.lastAccessedAt >= Math.max(0, options.maxAgeMs);
|
||||
if (!orphanExpired && !ageExpired) continue;
|
||||
deleted.add(entry.key);
|
||||
remainingBytes -= entry.byteSize;
|
||||
if (orphanExpired) orphanDeletions += 1;
|
||||
}
|
||||
for (const entry of ordered) {
|
||||
if (remainingBytes <= Math.max(0, options.maxBytes)) break;
|
||||
if (live.has(entry.key) || deleted.has(entry.key)) continue;
|
||||
deleted.add(entry.key);
|
||||
remainingBytes -= entry.byteSize;
|
||||
}
|
||||
const deletedBytes = ordered.filter((entry) => deleted.has(entry.key)).reduce((total, entry) => total + entry.byteSize, 0);
|
||||
return {
|
||||
keys: [...deleted],
|
||||
result: {
|
||||
deletedEntries: deleted.size,
|
||||
deletedBytes,
|
||||
orphanDeletions,
|
||||
remainingEntries: ordered.length - deleted.size,
|
||||
remainingBytes,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function pruneIndexedDbCache(options: ResultCachePruneOptions): Promise<ResultCachePruneResult> {
|
||||
const selection = selectResultCachePruneKeys(await listIndexedDbCacheMetadata(), options);
|
||||
await Promise.all(selection.keys.map(deleteIndexedDbCache));
|
||||
return selection.result;
|
||||
}
|
||||
|
||||
async function deleteIndexedDbCacheOwner(ownerId: string): Promise<void> {
|
||||
const metadata = await listIndexedDbCacheMetadata();
|
||||
await Promise.all(metadata.filter((entry) => entry.ownerId === ownerId).map((entry) => deleteIndexedDbCache(entry.key)));
|
||||
}
|
||||
|
||||
function clonePlain<T>(value: T): T {
|
||||
const raw = toRaw(value);
|
||||
if (typeof structuredClone === "function") return structuredClone(raw);
|
||||
@@ -272,7 +452,7 @@ function canUseRemoteRuntimeCache(): boolean {
|
||||
return typeof btoa !== "undefined" && typeof atob !== "undefined" && (isTauriRuntime() || typeof fetch !== "undefined");
|
||||
}
|
||||
|
||||
async function writeRemoteRuntimeCache(key: string, bytes: Uint8Array, stats: { rowCount: number; columnCount: number }): Promise<boolean> {
|
||||
async function writeRemoteRuntimeCache(key: string, bytes: Uint8Array, stats: { rowCount: number; columnCount: number }, ownerId?: string): Promise<boolean> {
|
||||
if (!canUseRemoteRuntimeCache()) return false;
|
||||
try {
|
||||
if (isTauriRuntime()) {
|
||||
@@ -282,6 +462,7 @@ async function writeRemoteRuntimeCache(key: string, bytes: Uint8Array, stats: {
|
||||
payloadBase64: bytesToBase64(bytes),
|
||||
rowCount: stats.rowCount,
|
||||
columnCount: stats.columnCount,
|
||||
ownerId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -293,6 +474,7 @@ async function writeRemoteRuntimeCache(key: string, bytes: Uint8Array, stats: {
|
||||
payloadBase64: bytesToBase64(bytes),
|
||||
rowCount: stats.rowCount,
|
||||
columnCount: stats.columnCount,
|
||||
ownerId,
|
||||
}),
|
||||
});
|
||||
return response.ok;
|
||||
@@ -302,6 +484,53 @@ async function writeRemoteRuntimeCache(key: string, bytes: Uint8Array, stats: {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRuntimeMetadata(entry: Record<string, unknown>): ResultCacheMetadata {
|
||||
return {
|
||||
key: String(entry.key ?? ""),
|
||||
rowCount: Number(entry.rowCount ?? 0),
|
||||
columnCount: Number(entry.columnCount ?? 0),
|
||||
byteSize: Number(entry.byteSize ?? 0),
|
||||
createdAt: Number(entry.createdAt ?? 0),
|
||||
lastAccessedAt: Number(entry.lastAccessedAt ?? 0),
|
||||
ownerId: typeof entry.ownerId === "string" ? entry.ownerId : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function listRemoteRuntimeCacheMetadata(): Promise<ResultCacheMetadata[]> {
|
||||
if (!canUseRemoteRuntimeCache()) return [];
|
||||
if (isTauriRuntime()) {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const entries = await invoke<Record<string, unknown>[]>("list_tab_runtime_cache_metadata");
|
||||
return entries.map(normalizeRuntimeMetadata);
|
||||
}
|
||||
const response = await fetch(apiUrl("/api/tab-runtime-cache/metadata"));
|
||||
if (!response.ok) return [];
|
||||
return ((await response.json()) as Record<string, unknown>[]).map(normalizeRuntimeMetadata);
|
||||
}
|
||||
|
||||
async function pruneRemoteRuntimeCache(options: ResultCachePruneOptions): Promise<ResultCachePruneResult> {
|
||||
if (isTauriRuntime()) {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
return invoke<ResultCachePruneResult>("prune_tab_runtime_cache", { request: options });
|
||||
}
|
||||
const response = await fetch(apiUrl("/api/tab-runtime-cache/prune"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(options),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Runtime cache prune failed: ${response.status}`);
|
||||
return response.json() as Promise<ResultCachePruneResult>;
|
||||
}
|
||||
|
||||
async function deleteRemoteRuntimeCacheOwner(ownerId: string): Promise<void> {
|
||||
if (isTauriRuntime()) {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("delete_tab_runtime_cache_owner", { ownerId });
|
||||
return;
|
||||
}
|
||||
await fetch(apiUrl(`/api/tab-runtime-cache/owner?owner_id=${encodeURIComponent(ownerId)}`), { method: "DELETE" });
|
||||
}
|
||||
|
||||
async function readRemoteRuntimeCache(key: string): Promise<Uint8Array | undefined> {
|
||||
if (!canUseRemoteRuntimeCache()) return undefined;
|
||||
try {
|
||||
@@ -334,12 +563,82 @@ async function deleteRemoteRuntimeCache(key: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRemoteRuntimeCacheWrite(key: string, bytes: Uint8Array, stats: { rowCount: number; columnCount: number }, version: number) {
|
||||
window.setTimeout(async () => {
|
||||
if (!isCurrentCacheKeyVersion(key, version)) return;
|
||||
await writeRemoteRuntimeCache(key, bytes, stats);
|
||||
clearCacheKeyVersionIfCurrent(key, version);
|
||||
}, 0);
|
||||
const indexedDbBackend: ResultCacheBackend = {
|
||||
name: "indexed-db",
|
||||
available: () => indexedDb() !== undefined,
|
||||
read: readIndexedDbCache,
|
||||
write: writeIndexedDbCache,
|
||||
delete: deleteIndexedDbCache,
|
||||
listMetadata: listIndexedDbCacheMetadata,
|
||||
prune: pruneIndexedDbCache,
|
||||
deleteOwner: deleteIndexedDbCacheOwner,
|
||||
};
|
||||
|
||||
const runtimeBackend: ResultCacheBackend = {
|
||||
name: "runtime",
|
||||
available: canUseRemoteRuntimeCache,
|
||||
read: readRemoteRuntimeCache,
|
||||
write: writeRemoteRuntimeCache,
|
||||
delete: deleteRemoteRuntimeCache,
|
||||
listMetadata: listRemoteRuntimeCacheMetadata,
|
||||
prune: pruneRemoteRuntimeCache,
|
||||
deleteOwner: deleteRemoteRuntimeCacheOwner,
|
||||
};
|
||||
|
||||
export interface ResultCacheRuntimeConfig {
|
||||
primary: ResultCacheBackendName;
|
||||
fallbackEnabled: boolean;
|
||||
}
|
||||
|
||||
export function resultCacheRuntimeConfig(tauriRuntime = isTauriRuntime(), environment = import.meta.env): ResultCacheRuntimeConfig {
|
||||
const configured = environment.VITE_DBX_RESULT_CACHE_BACKEND;
|
||||
const primary = tauriRuntime || configured === "http" || configured === "runtime" ? "runtime" : "indexed-db";
|
||||
return { primary, fallbackEnabled: environment.VITE_DBX_RESULT_CACHE_FALLBACK !== "false" };
|
||||
}
|
||||
|
||||
export function resultCacheBackendOrder(tauriRuntime = isTauriRuntime(), config = resultCacheRuntimeConfig(tauriRuntime)): ResultCacheBackendName[] {
|
||||
return config.primary === "runtime" ? ["runtime", "indexed-db"] : ["indexed-db", "runtime"];
|
||||
}
|
||||
|
||||
function availableResultCacheBackends(includeLegacyFallback = true): ResultCacheBackend[] {
|
||||
const byName: Record<ResultCacheBackendName, ResultCacheBackend> = {
|
||||
"indexed-db": indexedDbBackend,
|
||||
runtime: runtimeBackend,
|
||||
};
|
||||
const config = resultCacheRuntimeConfig();
|
||||
const order = resultCacheBackendOrder(isTauriRuntime(), config);
|
||||
const selected = includeLegacyFallback || config.fallbackEnabled ? order : order.slice(0, 1);
|
||||
return selected.map((name) => byName[name]).filter((backend) => backend.available());
|
||||
}
|
||||
|
||||
export async function writeResultCacheBackends(backends: ResultCacheBackend[], key: string, bytes: Uint8Array, stats: { rowCount: number; columnCount: number }, isCurrent: () => boolean = () => true, ownerId?: string): Promise<boolean> {
|
||||
for (const backend of backends) {
|
||||
if (!isCurrent()) return false;
|
||||
try {
|
||||
if (await backend.write(key, bytes, stats, ownerId)) return true;
|
||||
} catch (error) {
|
||||
console.warn("[DBX][tab-result-cache:write:error]", { key, backend: backend.name, error });
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function readResultCacheBackends(backends: ResultCacheBackend[], key: string): Promise<{ bytes: Uint8Array; backend: ResultCacheBackend } | undefined> {
|
||||
for (const backend of backends) {
|
||||
try {
|
||||
const bytes = await backend.read(key);
|
||||
if (bytes) return { bytes, backend };
|
||||
} catch (error) {
|
||||
console.warn("[DBX][tab-result-cache:read:error]", { key, backend: backend.name, error });
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function promoteFallbackResultCacheRead(backends: ResultCacheBackend[], key: string, cached: { bytes: Uint8Array; backend: ResultCacheBackend }, stats: { rowCount: number; columnCount: number }, ownerId?: string): Promise<boolean> {
|
||||
const primary = backends[0];
|
||||
if (!primary || cached.backend === primary) return false;
|
||||
return primary.write(key, cached.bytes, stats, ownerId);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -375,7 +674,13 @@ export function encodeTabResultSnapshot(snapshot: TabResultSnapshot): Uint8Array
|
||||
}
|
||||
|
||||
export function decodeTabResultSnapshot(bytes: Uint8Array | ArrayBuffer): TabResultSnapshot | undefined {
|
||||
const decoded = decode(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = decode(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
|
||||
} catch {
|
||||
resultCacheDiagnostics.corruptSnapshots += 1;
|
||||
return undefined;
|
||||
}
|
||||
if (!isRecord(decoded)) return undefined;
|
||||
if (decoded.magic !== PAYLOAD_MAGIC || decoded.version !== PAYLOAD_VERSION || decoded.codec !== PAYLOAD_CODEC) {
|
||||
return undefined;
|
||||
@@ -413,57 +718,48 @@ export function buildTabResultSnapshot(tab: QueryTab): TabResultSnapshot | undef
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeTabResultSnapshot(key: string, snapshot: TabResultSnapshot | undefined): Promise<boolean> {
|
||||
function afterActiveResultPaint(): Promise<void> {
|
||||
if (typeof window === "undefined" || typeof requestAnimationFrame === "undefined") return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve();
|
||||
};
|
||||
requestAnimationFrame(finish);
|
||||
// Background WebViews and test DOMs may throttle animation frames indefinitely.
|
||||
setTimeout(finish, 100);
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeTabResultSnapshot(key: string, snapshot: TabResultSnapshot | undefined, ownerId?: string): Promise<boolean> {
|
||||
if (!snapshot) return false;
|
||||
const version = bumpCacheKeyVersion(key);
|
||||
const encoded = encodeTabResultSnapshot(snapshot);
|
||||
const stats = resultStats(snapshot);
|
||||
let wroteLocal = false;
|
||||
try {
|
||||
const db = await openCacheDb();
|
||||
if (db) {
|
||||
const tx = db.transaction(RESULT_STORE, "readwrite");
|
||||
await requestToPromise(tx.objectStore(RESULT_STORE).put(encoded, key));
|
||||
wroteLocal = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[DBX][tab-result-cache:write:error]", { key, error });
|
||||
}
|
||||
if (wroteLocal) {
|
||||
if (typeof window === "undefined") {
|
||||
void writeRemoteRuntimeCache(key, encoded, stats).finally(() => clearCacheKeyVersionIfCurrent(key, version));
|
||||
} else {
|
||||
scheduleRemoteRuntimeCacheWrite(key, encoded, stats, version);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
await afterActiveResultPaint();
|
||||
if (!isCurrentCacheKeyVersion(key, version)) return false;
|
||||
const startedAt = typeof performance === "undefined" ? Date.now() : performance.now();
|
||||
const encoded = encodeTabResultSnapshot(snapshot);
|
||||
const finishedAt = typeof performance === "undefined" ? Date.now() : performance.now();
|
||||
resultCacheDiagnostics.serializationCount += 1;
|
||||
resultCacheDiagnostics.serializationDurationMs += finishedAt - startedAt;
|
||||
resultCacheDiagnostics.serializedBytes += encoded.byteLength;
|
||||
const stats = resultStats(snapshot);
|
||||
try {
|
||||
return await writeRemoteRuntimeCache(key, encoded, stats);
|
||||
return await writeResultCacheBackends(availableResultCacheBackends(false), key, encoded, stats, () => isCurrentCacheKeyVersion(key, version), ownerId);
|
||||
} finally {
|
||||
clearCacheKeyVersionIfCurrent(key, version);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readTabResultSnapshot(key: string): Promise<TabResultSnapshot | undefined> {
|
||||
try {
|
||||
const db = await openCacheDb();
|
||||
if (db) {
|
||||
const value = await requestToPromise(db.transaction(RESULT_STORE, "readonly").objectStore(RESULT_STORE).get(key));
|
||||
if (isBinaryPayload(value)) return decodeTabResultSnapshot(value);
|
||||
if (value) return value as TabResultSnapshot;
|
||||
}
|
||||
const remoteBytes = await readRemoteRuntimeCache(key);
|
||||
const remoteSnapshot = remoteBytes ? decodeTabResultSnapshot(remoteBytes) : undefined;
|
||||
if (remoteBytes && remoteSnapshot && db) {
|
||||
void requestToPromise(db.transaction(RESULT_STORE, "readwrite").objectStore(RESULT_STORE).put(remoteBytes, key));
|
||||
}
|
||||
return remoteSnapshot;
|
||||
} catch (error) {
|
||||
console.warn("[DBX][tab-result-cache:read:error]", { key, error });
|
||||
const remoteBytes = await readRemoteRuntimeCache(key);
|
||||
return remoteBytes ? decodeTabResultSnapshot(remoteBytes) : undefined;
|
||||
}
|
||||
const backends = availableResultCacheBackends(true);
|
||||
const cached = await readResultCacheBackends(backends, key);
|
||||
if (!cached) return undefined;
|
||||
const snapshot = decodeTabResultSnapshot(cached.bytes);
|
||||
if (!snapshot) return undefined;
|
||||
void promoteFallbackResultCacheRead(backends, key, cached, resultStats(snapshot));
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function deleteTabResultSnapshot(key: string): Promise<void> {
|
||||
@@ -474,13 +770,29 @@ export async function deleteTabResultSnapshot(key: string): Promise<void> {
|
||||
else setTimeout(cleanup, 5000);
|
||||
};
|
||||
try {
|
||||
const db = await openCacheDb();
|
||||
if (db) await requestToPromise(db.transaction(RESULT_STORE, "readwrite").objectStore(RESULT_STORE).delete(key));
|
||||
await deleteRemoteRuntimeCache(key);
|
||||
await Promise.all(availableResultCacheBackends().map((backend) => backend.delete(key)));
|
||||
clearVersionLater();
|
||||
} catch (error) {
|
||||
console.warn("[DBX][tab-result-cache:delete:error]", { key, error });
|
||||
await deleteRemoteRuntimeCache(key);
|
||||
clearVersionLater();
|
||||
}
|
||||
}
|
||||
|
||||
export async function pruneTabResultSnapshots(liveKeys: Iterable<string>, options: Partial<Omit<ResultCachePruneOptions, "liveKeys">> = {}): Promise<ResultCachePruneResult | undefined> {
|
||||
const primary = availableResultCacheBackends(false)[0];
|
||||
if (!primary) return undefined;
|
||||
const result = await primary.prune({
|
||||
liveKeys: [...new Set(liveKeys)],
|
||||
maxBytes: options.maxBytes ?? DEFAULT_PERSISTENT_CACHE_BYTES,
|
||||
orphanGraceMs: options.orphanGraceMs ?? DEFAULT_ORPHAN_GRACE_MS,
|
||||
maxAgeMs: options.maxAgeMs ?? DEFAULT_MAX_AGE_MS,
|
||||
});
|
||||
resultCacheDiagnostics.cacheBytes = result.remainingBytes;
|
||||
resultCacheDiagnostics.evictions += result.deletedEntries;
|
||||
resultCacheDiagnostics.orphanDeletions += result.orphanDeletions;
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteTabResultSnapshotsForOwner(ownerId: string): Promise<void> {
|
||||
await Promise.all(availableResultCacheBackends(true).map((backend) => backend.deleteOwner(ownerId)));
|
||||
}
|
||||
Reference in new issue
Block a user