From 8bd92e4f2c2cad4266740aed6bb34d85e5195673 Mon Sep 17 00:00:00 2001 From: zipg Date: Tue, 28 Jul 2026 21:09:06 +0800 Subject: [PATCH] fix(objects): avoid repeated object list reloads --- .../src/components/objects/ObjectBrowser.vue | 130 ++++++++++++------ .../table/objectBrowserContextWatch.spec.ts | 8 ++ .../table/objectBrowserRowsLoadGuard.spec.ts | 54 ++++++++ .../__tests__/objectBrowserRowsCache.spec.ts | 119 ++++++++++++++++ .../src/lib/metadata/metadataResultCache.ts | 4 +- .../src/lib/table/objectBrowserRowsCache.ts | 95 +++++++++++++ .../lib/table/objectBrowserRowsLoadGuard.ts | 23 ++++ apps/desktop/src/stores/connectionStore.ts | 5 +- 8 files changed, 390 insertions(+), 48 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/table/objectBrowserRowsLoadGuard.spec.ts create mode 100644 apps/desktop/src/lib/metadata/__tests__/objectBrowserRowsCache.spec.ts create mode 100644 apps/desktop/src/lib/table/objectBrowserRowsCache.ts create mode 100644 apps/desktop/src/lib/table/objectBrowserRowsLoadGuard.ts diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index af40add37c..289ed5cb5b 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -117,6 +117,8 @@ import { filterObjectBrowserTableColumns } from "@/lib/table/objectBrowserTableI import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard"; import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate"; import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation"; +import { cacheObjectBrowserRows, createObjectBrowserRowsCacheWriteToken, getCachedObjectBrowserRows, type ObjectBrowserRowsCacheScope, type ObjectBrowserRowsCacheWriteToken } from "@/lib/table/objectBrowserRowsCache"; +import { createObjectBrowserRowsLoadGuard, type ObjectBrowserRowsLoadHandle } from "@/lib/table/objectBrowserRowsLoadGuard"; type ObjectFilter = ObjectBrowserFilter; type ObjectBrowserColumnKey = "select" | "name" | "type" | "estimatedRows" | "totalBytes" | "created_at" | "updated_at" | "comment"; @@ -244,7 +246,7 @@ const objectColumnWidths = ref>({ updated_at: 150, comment: 260, }); -let loadId = 0; +const objectBrowserRowsLoadGuard = createObjectBrowserRowsLoadGuard(); let stopColumnResize: (() => void) | null = null; let preserveObjectFilterScrollOnce = false; @@ -2151,72 +2153,104 @@ async function saveSource() { } } -async function loadSchemas() { +async function loadSchemas(epoch: number): Promise { + if (!objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) return false; if (!needsSchema.value) { schemas.value = []; selectedSchema.value = undefined; - return; + return true; } loadingSchemas.value = true; + const connectionId = props.connection.id; + const database = props.database; try { - const names = await api.listSchemas(props.connection.id, props.database); + const names = await api.listSchemas(connectionId, database); + if (!objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) return false; schemas.value = names; if (!selectedSchema.value || !names.includes(selectedSchema.value)) { selectedSchema.value = names.includes("public") ? "public" : names[0]; } + return true; } finally { - loadingSchemas.value = false; + if (objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) loadingSchemas.value = false; } } -async function loadObjects() { - const id = ++loadId; - loadingObjects.value = true; +function objectBrowserRowsCacheScope(schema: string): ObjectBrowserRowsCacheScope { + return { + connectionId: props.connection.id, + database: props.database, + schema, + catalog: props.catalog, + }; +} + +function applyObjectBrowserRows(nextRows: ObjectBrowserRow[]) { + rows.value = nextRows; + const availableTableIds = new Set(rows.value.filter((row) => row.type === "TABLE").map((row) => row.id)); + setSelectedTableIds(new Set([...selectedTableIds.value].filter((id) => availableTableIds.has(id)))); + expandedPartitionParentIds.value = new Set([...expandedPartitionParentIds.value].filter((id) => rows.value.some((row) => row.id === id && row.partitionCount))); +} + +function finishObjectBrowserRowsLoad() { + loadingObjects.value = false; + if (!userHasSelectedFilter.value && objectCounts.value.tables > 0) { + // The default table filter is a presentation choice, not a user query + // change, so preserve the tab's saved scroll offset across remounts. + preserveObjectFilterScrollOnce = objectFilter.value !== "tables"; + objectFilter.value = "tables"; + } + restoreObjectBrowserViewport(); +} + +async function loadObjects(options?: { allowCached?: boolean }) { error.value = ""; - rows.value = []; - try { - const schema = needsSchema.value ? selectedSchema.value || "" : props.database; - const objects: ObjectInfo[] = await api.listObjects(props.connection.id, props.database, schema, undefined, undefined, undefined, undefined, props.catalog); - if (id !== loadId) return; - rows.value = buildObjectBrowserRows({ - objects, - database: props.database, - fallbackSchema: schema, - rowSchema: connectionObjectTreeNodeSchema(props.connection, props.database, selectedSchema.value), - }); - const availableTableIds = new Set(rows.value.filter((row) => row.type === "TABLE").map((row) => row.id)); - setSelectedTableIds(new Set([...selectedTableIds.value].filter((id) => availableTableIds.has(id)))); - expandedPartitionParentIds.value = new Set([...expandedPartitionParentIds.value].filter((id) => rows.value.some((row) => row.id === id && row.partitionCount))); - void loadObjectStatistics(id, schema); - } catch (e: any) { - if (id !== loadId) return; - error.value = e?.message || String(e); - } finally { - if (id === loadId) { - loadingObjects.value = false; - if (!userHasSelectedFilter.value && objectCounts.value.tables > 0) { - // The default table filter is a presentation choice, not a user query - // change, so preserve the tab's saved scroll offset across remounts. - preserveObjectFilterScrollOnce = objectFilter.value !== "tables"; - objectFilter.value = "tables"; - } - restoreObjectBrowserViewport(); + const schema = needsSchema.value ? selectedSchema.value || "" : props.database; + const request = objectBrowserRowsLoadGuard.start(objectBrowserRowsCacheScope(schema)); + const cacheWriteToken = createObjectBrowserRowsCacheWriteToken(request.scope); + if (options?.allowCached) { + const cachedRows = getCachedObjectBrowserRows(request.scope); + if (cachedRows) { + applyObjectBrowserRows(cachedRows); + finishObjectBrowserRowsLoad(); + return; } } + + loadingObjects.value = true; + rows.value = []; + try { + const objects: ObjectInfo[] = await api.listObjects(request.scope.connectionId, request.scope.database, request.scope.schema, undefined, undefined, undefined, undefined, request.scope.catalog); + if (!objectBrowserRowsLoadGuard.isCurrent(request)) return; + const nextRows = buildObjectBrowserRows({ + objects, + database: request.scope.database, + fallbackSchema: request.scope.schema, + rowSchema: connectionObjectTreeNodeSchema(props.connection, props.database, selectedSchema.value), + }); + applyObjectBrowserRows(nextRows); + const cachedAt = cacheObjectBrowserRows(cacheWriteToken, nextRows); + void loadObjectStatistics(request, cacheWriteToken, cachedAt); + } catch (e: any) { + if (!objectBrowserRowsLoadGuard.isCurrent(request)) return; + error.value = e?.message || String(e); + } finally { + if (objectBrowserRowsLoadGuard.isCurrent(request)) finishObjectBrowserRowsLoad(); + } } -async function loadObjectStatistics(id: number, schema: string) { +async function loadObjectStatistics(request: ObjectBrowserRowsLoadHandle, cacheWriteToken: ObjectBrowserRowsCacheWriteToken, cachedAt: number | undefined) { if (!rows.value.some((row) => row.type === "TABLE")) return; try { - const stats = await api.listObjectStatistics(props.connection.id, props.database, schema); - if (id !== loadId || stats.length === 0) return; - mergeObjectStatistics(stats, schema); + const stats = await api.listObjectStatistics(request.scope.connectionId, request.scope.database, request.scope.schema); + if (!objectBrowserRowsLoadGuard.isCurrent(request) || stats.length === 0) return; + mergeObjectStatistics(stats, request.scope.schema, cacheWriteToken, cachedAt); } catch (e) { console.debug("[ObjectBrowser] table statistics unavailable", e); } } -function mergeObjectStatistics(stats: ObjectStatistics[], fallbackSchema: string) { +function mergeObjectStatistics(stats: ObjectStatistics[], fallbackSchema: string, cacheWriteToken: ObjectBrowserRowsCacheWriteToken, cachedAt: number | undefined) { const statsByKey = new Map(stats.map((stat) => [objectStatisticKey(stat.schema || fallbackSchema, stat.name), stat])); rows.value = rows.value.map((row) => { if (row.type !== "TABLE") return row; @@ -2228,6 +2262,7 @@ function mergeObjectStatistics(stats: ObjectStatistics[], fallbackSchema: string totalBytes: normalizeStatisticNumber(stat.total_bytes), }; }); + cacheObjectBrowserRows(cacheWriteToken, rows.value, { cachedAt }); } function objectStatisticKey(schema: string | undefined, name: string) { @@ -2238,9 +2273,11 @@ function normalizeStatisticNumber(value: number | null | undefined): number | nu return typeof value === "number" && Number.isFinite(value) ? value : null; } -async function reload() { - await loadSchemas(); - await loadObjects(); +async function reload(options?: { allowCachedObjects?: boolean; contextEpoch?: number }) { + const epoch = options?.contextEpoch ?? objectBrowserRowsLoadGuard.invalidate(); + if (!(await loadSchemas(epoch))) return; + if (!objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) return; + await loadObjects({ allowCached: options?.allowCachedObjects }); } function refresh(): boolean { @@ -2305,12 +2342,14 @@ function onSearchKeydown(event: KeyboardEvent) { defineExpose({ focusSearch, refresh }); onBeforeUnmount(() => { + objectBrowserRowsLoadGuard.invalidate(); stopColumnResize?.(); }); watch( [() => props.connection.id, () => props.database, () => props.schema], async () => { + const contextEpoch = objectBrowserRowsLoadGuard.invalidate(); selectedSchema.value = props.schema; userHasSelectedFilter.value = false; objectFilter.value = "all"; @@ -2329,7 +2368,8 @@ watch( } catch (e) { console.warn("[DBX] ensureConnected failed for", props.connection.id, e); } - void reload(); + if (!objectBrowserRowsLoadGuard.isEpochCurrent(contextEpoch)) return; + void reload({ allowCachedObjects: true, contextEpoch }); }, { immediate: true }, ); diff --git a/apps/desktop/src/lib/__tests__/table/objectBrowserContextWatch.spec.ts b/apps/desktop/src/lib/__tests__/table/objectBrowserContextWatch.spec.ts index 1f37455cc4..df3291575b 100644 --- a/apps/desktop/src/lib/__tests__/table/objectBrowserContextWatch.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/objectBrowserContextWatch.spec.ts @@ -10,6 +10,14 @@ describe("ObjectBrowser context watcher", () => { expect(objectBrowserSource).not.toContain("() => [props.connection.id, props.database, props.schema]"); }); + it("invalidates object requests before waiting for the new connection", () => { + const watcher = objectBrowserSource.match(/watch\(\s*\[\(\) => props\.connection\.id[\s\S]*?\{ immediate: true \},\s*\);/)?.[0] ?? ""; + + expect(watcher).toContain("const contextEpoch = objectBrowserRowsLoadGuard.invalidate();"); + expect(watcher.indexOf("objectBrowserRowsLoadGuard.invalidate()")).toBeLessThan(watcher.indexOf("await connectionStore.ensureConnected")); + expect(watcher).toContain("reload({ allowCachedObjects: true, contextEpoch })"); + }); + it("ignores connection object replacement when the context values stay unchanged", async () => { const connection = ref({ id: "connection-1" }); const database = ref("database-1"); diff --git a/apps/desktop/src/lib/__tests__/table/objectBrowserRowsLoadGuard.spec.ts b/apps/desktop/src/lib/__tests__/table/objectBrowserRowsLoadGuard.spec.ts new file mode 100644 index 0000000000..7d3c993dee --- /dev/null +++ b/apps/desktop/src/lib/__tests__/table/objectBrowserRowsLoadGuard.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { createObjectBrowserRowsLoadGuard } from "@/lib/table/objectBrowserRowsLoadGuard"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe("ObjectBrowserRowsLoadGuard", () => { + it.each([ + { + name: "connection", + nextScope: { connectionId: "new", database: "db1", schema: "public" }, + }, + { + name: "database", + nextScope: { connectionId: "old", database: "db2", schema: "public" }, + }, + ])("drops statistics that finish after switching $name", async ({ nextScope }) => { + const guard = createObjectBrowserRowsLoadGuard(); + const oldRequest = guard.start({ connectionId: "old", database: "db1", schema: "public" }); + const pendingStatistics = deferred(); + const cacheWrites: Array<{ connectionId: string; database: string }> = []; + const oldStatisticsTask = (async () => { + await pendingStatistics.promise; + if (!guard.isCurrent(oldRequest)) return; + cacheWrites.push(oldRequest.scope); + })(); + + guard.invalidate(); + const currentRequest = guard.start(nextScope); + pendingStatistics.resolve(1); + await oldStatisticsTask; + + expect(guard.isCurrent(oldRequest)).toBe(false); + expect(guard.isCurrent(currentRequest)).toBe(true); + expect(oldRequest.scope).toMatchObject({ connectionId: "old", database: "db1" }); + expect(cacheWrites).toEqual([]); + }); + + it("captures an immutable copy of the complete cache scope", () => { + const guard = createObjectBrowserRowsLoadGuard(); + const scope = { connectionId: "c1", database: "db1", schema: "public", catalog: "catalog1" }; + const request = guard.start(scope); + + scope.database = "db2"; + + expect(request.scope).toEqual({ connectionId: "c1", database: "db1", schema: "public", catalog: "catalog1" }); + expect(Object.isFrozen(request.scope)).toBe(true); + }); +}); diff --git a/apps/desktop/src/lib/metadata/__tests__/objectBrowserRowsCache.spec.ts b/apps/desktop/src/lib/metadata/__tests__/objectBrowserRowsCache.spec.ts new file mode 100644 index 0000000000..8aa50e7071 --- /dev/null +++ b/apps/desktop/src/lib/metadata/__tests__/objectBrowserRowsCache.spec.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ObjectBrowserRow } from "@/lib/table/objectBrowserRows"; +import { cacheObjectBrowserRows, clearObjectBrowserRowsCache, createObjectBrowserRowsCacheWriteToken, getCachedObjectBrowserRows, invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache"; + +const row: ObjectBrowserRow = { + id: "table:users", + name: "users", + displayName: "users", + type: "TABLE", +}; + +function cacheRows(scope: Parameters[0], rows: readonly ObjectBrowserRow[] = [row]) { + return cacheObjectBrowserRows(createObjectBrowserRowsCacheWriteToken(scope), rows); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe("objectBrowserRowsCache", () => { + beforeEach(() => clearObjectBrowserRowsCache()); + afterEach(() => vi.useRealTimers()); + + it("restores cached rows only for the same object browser scope", () => { + const scope = { connectionId: "c1", database: "db", schema: "public" }; + cacheRows(scope); + + expect(getCachedObjectBrowserRows(scope)).toEqual([row]); + expect(getCachedObjectBrowserRows({ ...scope, schema: "archive" })).toBeUndefined(); + expect(getCachedObjectBrowserRows({ ...scope, connectionId: "c2" })).toBeUndefined(); + }); + + it("returns copies so component updates do not mutate the cached rows", () => { + const scope = { connectionId: "c1", database: "db", schema: "public" }; + cacheRows(scope); + + const cached = getCachedObjectBrowserRows(scope)!; + cached[0].displayName = "changed"; + + expect(getCachedObjectBrowserRows(scope)?.[0].displayName).toBe("users"); + }); + + it("restores fresh rows when a remounted component reads the shared cache", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T00:00:00Z")); + const scope = { connectionId: "c1", database: "db", schema: "public" }; + cacheRows(scope); + + expect(getCachedObjectBrowserRows(scope)).toEqual([row]); + }); + + it("rejects cached rows after the TTL", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T00:00:00Z")); + const scope = { connectionId: "c1", database: "db", schema: "public" }; + cacheRows(scope); + + vi.advanceTimersByTime(30_001); + expect(getCachedObjectBrowserRows(scope)).toBeUndefined(); + }); + + it("invalidates matching connection and database scopes", () => { + const first = { connectionId: "c1", database: "db1", schema: "public" }; + const second = { connectionId: "c1", database: "db2", schema: "public" }; + const third = { connectionId: "c2", database: "db1", schema: "public" }; + cacheRows(first); + cacheRows(second); + cacheRows(third); + + expect(invalidateObjectBrowserRowsCache({ connectionId: "c1", database: "db1" })).toBe(1); + expect(getCachedObjectBrowserRows(first)).toBeUndefined(); + expect(getCachedObjectBrowserRows(second)).toEqual([row]); + expect(getCachedObjectBrowserRows(third)).toEqual([row]); + }); + + it("rejects a late cache write started before matching metadata invalidation", async () => { + const invalidatedScope = { connectionId: "c1", database: "db1", schema: "public" }; + const isolatedScope = { connectionId: "c1", database: "db2", schema: "public" }; + const invalidatedToken = createObjectBrowserRowsCacheWriteToken(invalidatedScope); + const isolatedToken = createObjectBrowserRowsCacheWriteToken(isolatedScope); + const pendingRows = deferred(); + const lateWrite = pendingRows.promise.then((rows) => cacheObjectBrowserRows(invalidatedToken, rows)); + + invalidateObjectBrowserRowsCache({ connectionId: "c1", database: "db1", schema: "public" }); + pendingRows.resolve([row]); + + expect(await lateWrite).toBeUndefined(); + expect(cacheObjectBrowserRows(isolatedToken, [row])).toEqual(expect.any(Number)); + expect(getCachedObjectBrowserRows(invalidatedScope)).toBeUndefined(); + expect(getCachedObjectBrowserRows(isolatedScope)).toEqual([row]); + }); + + it("preserves the original object list freshness when statistics update cached rows", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T00:00:00Z")); + const scope = { connectionId: "c1", database: "db", schema: "public" }; + const token = createObjectBrowserRowsCacheWriteToken(scope); + const fetchedAt = cacheObjectBrowserRows(token, [row]); + + vi.advanceTimersByTime(29_000); + cacheObjectBrowserRows(token, [{ ...row, estimatedRows: 42 }], { cachedAt: fetchedAt }); + expect(getCachedObjectBrowserRows(scope)?.[0].estimatedRows).toBe(42); + + vi.advanceTimersByTime(1_001); + expect(getCachedObjectBrowserRows(scope)).toBeUndefined(); + }); + + it("projects table invalidation onto the object list cache scope", () => { + const scope = { connectionId: "c1", database: "db", schema: "public" }; + cacheRows(scope, [{ ...row, estimatedRows: 42 }]); + + expect(invalidateObjectBrowserRowsCache({ ...scope, tableName: "users" })).toBe(1); + expect(getCachedObjectBrowserRows(scope)).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/lib/metadata/metadataResultCache.ts b/apps/desktop/src/lib/metadata/metadataResultCache.ts index 2a5fd72816..dc6beee197 100644 --- a/apps/desktop/src/lib/metadata/metadataResultCache.ts +++ b/apps/desktop/src/lib/metadata/metadataResultCache.ts @@ -67,10 +67,10 @@ export class MetadataResultCache { return { value: entry.value, cachedAt: entry.cachedAt, ageMs, stale }; } - set(scope: MetadataScopeInput, value: T): void { + set(scope: MetadataScopeInput, value: T, options?: { cachedAt?: number }): void { const key = metadataScopeKey(scope); this.entries.delete(key); - this.entries.set(key, { scope: metadataScopeParts(scope), value, cachedAt: this.now() }); + this.entries.set(key, { scope: metadataScopeParts(scope), value, cachedAt: options?.cachedAt ?? this.now() }); this.evictOldest(); } diff --git a/apps/desktop/src/lib/table/objectBrowserRowsCache.ts b/apps/desktop/src/lib/table/objectBrowserRowsCache.ts new file mode 100644 index 0000000000..c79bc25b18 --- /dev/null +++ b/apps/desktop/src/lib/table/objectBrowserRowsCache.ts @@ -0,0 +1,95 @@ +import { metadataScopeKey, metadataScopeParts, type MetadataScopeInput } from "@/lib/metadata/metadataLoadScope"; +import { metadataCacheInvalidationMatcher, MetadataResultCache, type MetadataCacheInvalidation } from "@/lib/metadata/metadataResultCache"; +import type { ObjectBrowserRow } from "@/lib/table/objectBrowserRows"; + +const OBJECT_BROWSER_ROWS_CACHE_TTL_MS = 30_000; +const OBJECT_BROWSER_ROWS_CACHE_MAX_ENTRIES = 24; + +export interface ObjectBrowserRowsCacheScope { + connectionId: string; + database: string; + schema: string; + catalog?: string; +} + +export interface ObjectBrowserRowsCacheWriteToken { + generation: number; + scope: Readonly; +} + +interface ObjectBrowserRowsCacheGeneration { + generation: number; + scope: ReturnType; +} + +const objectBrowserRowsCache = new MetadataResultCache({ + ttlMs: OBJECT_BROWSER_ROWS_CACHE_TTL_MS, + maxEntries: OBJECT_BROWSER_ROWS_CACHE_MAX_ENTRIES, + now: () => Date.now(), +}); +const objectBrowserRowsCacheGenerations = new Map(); + +function objectBrowserRowsScope(scope: ObjectBrowserRowsCacheScope): MetadataScopeInput { + return { + kind: "object-browser-rows", + connectionId: scope.connectionId, + database: scope.database, + schema: scope.schema, + extra: scope.catalog ? { catalog: scope.catalog } : undefined, + }; +} + +function cloneRows(rows: readonly ObjectBrowserRow[]): ObjectBrowserRow[] { + return rows.map((row) => ({ ...row })); +} + +function objectBrowserRowsCacheGeneration(scope: ObjectBrowserRowsCacheScope): ObjectBrowserRowsCacheGeneration { + const cacheScope = objectBrowserRowsScope(scope); + const key = metadataScopeKey(cacheScope); + let state = objectBrowserRowsCacheGenerations.get(key); + if (!state) { + state = { generation: 0, scope: metadataScopeParts(cacheScope) }; + objectBrowserRowsCacheGenerations.set(key, state); + } + return state; +} + +function objectBrowserRowsCacheInvalidation(match: MetadataCacheInvalidation): MetadataCacheInvalidation { + const projected: MetadataCacheInvalidation = {}; + if (match.kind !== undefined) projected.kind = match.kind; + if (match.connectionId !== undefined) projected.connectionId = match.connectionId; + if (match.database !== undefined) projected.database = match.database; + if (match.schema !== undefined) projected.schema = match.schema; + return projected; +} + +export function getCachedObjectBrowserRows(scope: ObjectBrowserRowsCacheScope): ObjectBrowserRow[] | undefined { + const hit = objectBrowserRowsCache.get(objectBrowserRowsScope(scope)); + return hit ? cloneRows(hit.value) : undefined; +} + +export function createObjectBrowserRowsCacheWriteToken(scope: ObjectBrowserRowsCacheScope): ObjectBrowserRowsCacheWriteToken { + const frozenScope = Object.freeze({ ...scope }); + return Object.freeze({ generation: objectBrowserRowsCacheGeneration(frozenScope).generation, scope: frozenScope }); +} + +export function cacheObjectBrowserRows(token: ObjectBrowserRowsCacheWriteToken, rows: readonly ObjectBrowserRow[], options?: { cachedAt?: number }): number | undefined { + if (objectBrowserRowsCacheGeneration(token.scope).generation !== token.generation) return undefined; + const cachedAt = options?.cachedAt ?? Date.now(); + objectBrowserRowsCache.set(objectBrowserRowsScope(token.scope), cloneRows(rows), { cachedAt }); + return cachedAt; +} + +export function invalidateObjectBrowserRowsCache(match: MetadataCacheInvalidation): number { + const projected = objectBrowserRowsCacheInvalidation(match); + const matches = metadataCacheInvalidationMatcher(projected); + for (const state of objectBrowserRowsCacheGenerations.values()) { + if (matches(state.scope)) state.generation++; + } + return objectBrowserRowsCache.invalidate(projected); +} + +export function clearObjectBrowserRowsCache(): void { + objectBrowserRowsCache.clear(); + for (const state of objectBrowserRowsCacheGenerations.values()) state.generation++; +} diff --git a/apps/desktop/src/lib/table/objectBrowserRowsLoadGuard.ts b/apps/desktop/src/lib/table/objectBrowserRowsLoadGuard.ts new file mode 100644 index 0000000000..37974dd94f --- /dev/null +++ b/apps/desktop/src/lib/table/objectBrowserRowsLoadGuard.ts @@ -0,0 +1,23 @@ +import type { ObjectBrowserRowsCacheScope } from "@/lib/table/objectBrowserRowsCache"; + +export interface ObjectBrowserRowsLoadHandle { + epoch: number; + scope: Readonly; +} + +export interface ObjectBrowserRowsLoadGuard { + invalidate: () => number; + start: (scope: ObjectBrowserRowsCacheScope) => ObjectBrowserRowsLoadHandle; + isEpochCurrent: (epoch: number) => boolean; + isCurrent: (handle: ObjectBrowserRowsLoadHandle) => boolean; +} + +export function createObjectBrowserRowsLoadGuard(): ObjectBrowserRowsLoadGuard { + let epoch = 0; + return { + invalidate: () => ++epoch, + start: (scope) => ({ epoch: ++epoch, scope: Object.freeze({ ...scope }) }), + isEpochCurrent: (capturedEpoch) => capturedEpoch === epoch, + isCurrent: (handle) => handle.epoch === epoch, + }; +} diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index b84151a7b3..de677d269c 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -110,6 +110,7 @@ import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator, import type { MetadataScopeInput } from "@/lib/metadata/metadataLoadScope"; import { MetadataResultCache, type MetadataCacheInvalidation } from "@/lib/metadata/metadataResultCache"; import { invalidateTableMetadataCache } from "@/lib/metadata/tableMetadataCache"; +import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache"; import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter"; import { TreeNodeLoadRegistry, type TreeNodeLoadHandle } from "@/lib/metadata/treeNodeLoadHandle"; import i18n from "@/i18n"; @@ -1471,7 +1472,7 @@ export const useConnectionStore = defineStore("connection", () => { } function invalidateMetadataCaches(match: MetadataCacheInvalidation): number { - return metadataListPageCache.invalidate(match) + invalidateTableMetadataCache(match); + return metadataListPageCache.invalidate(match) + invalidateTableMetadataCache(match) + invalidateObjectBrowserRowsCache(match); } function invalidateMetadataCachesByTreePrefix(prefix: string) { @@ -2601,6 +2602,7 @@ export const useConnectionStore = defineStore("connection", () => { activeConnectionId.value = null; } invalidateCompletionCache(connectionId); + invalidateObjectBrowserRowsCache({ connectionId }); const { useQueryStore } = await import("@/stores/queryStore"); const queryStore = useQueryStore(); switch (settingsStore.editorSettings.disconnectTabHandlingMode) { @@ -2645,6 +2647,7 @@ export const useConnectionStore = defineStore("connection", () => { clearLoadedChildrenCache(node.id); } invalidateCompletionCache(connectionId, database); + invalidateObjectBrowserRowsCache({ connectionId, database }); } async function ensureConnected(connectionId: string) {