diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 7dfa0622e3..a5e6f29f28 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -277,6 +277,7 @@ watch( ); watch([deferredSearchQuery, regexMode], ([newQuery, isRegexMode], [oldQuery, wasRegexMode]) => { + if (!isRegexMode || !newQuery) regexTableSearchScopes.value = []; // The regex source is a client-side projection; the remote tree-loading // search state must never carry it, or explicit node expansion would leak // the expression as a remote searchFilter. diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridFilterBuilderPersistence.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridFilterBuilderPersistence.spec.ts index 17e4b5160f..da776c2b4c 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridFilterBuilderPersistence.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridFilterBuilderPersistence.spec.ts @@ -1,11 +1,13 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { loadDataGridStructuredFilterState, saveDataGridStructuredFilterState } from "@/lib/dataGrid/dataGridFilterBuilderPersistence"; +import { clearDataGridStructuredFilterStatesForTab, loadDataGridStructuredFilterState, saveDataGridStructuredFilterState } from "@/lib/dataGrid/dataGridFilterBuilderPersistence"; describe("data grid structured filter persistence", () => { const cacheKey = "issue-436-filter-view"; const scopeKey = "mysql\0demo\0users"; beforeEach(() => { + clearDataGridStructuredFilterStatesForTab("issue-436-filter-view"); + clearDataGridStructuredFilterStatesForTab("lru-tab"); saveDataGridStructuredFilterState(cacheKey, { scopeKey, manualWhereInput: "tenant_id = 7", @@ -36,4 +38,44 @@ describe("data grid structured filter persistence", () => { serverColumnFilters: {}, }); }); + + it("clears every result-grid state owned by a closed tab", () => { + saveDataGridStructuredFilterState("tab-1-run-1-0", { + scopeKey, + manualWhereInput: "id > 1", + rules: [], + appliedWhereInput: "id > 1", + serverColumnFilters: {}, + }); + saveDataGridStructuredFilterState("tab-10-run-1-0", { + scopeKey, + manualWhereInput: "id > 10", + rules: [], + appliedWhereInput: "id > 10", + serverColumnFilters: {}, + }); + + clearDataGridStructuredFilterStatesForTab("tab-1"); + + expect(loadDataGridStructuredFilterState("tab-1-run-1-0", scopeKey)).toBeUndefined(); + expect(loadDataGridStructuredFilterState("tab-10-run-1-0", scopeKey)?.manualWhereInput).toBe("id > 10"); + clearDataGridStructuredFilterStatesForTab("tab-10"); + }); + + it("bounds cached result-grid states and refreshes recency on read", () => { + const state = { + scopeKey, + manualWhereInput: "", + rules: [], + appliedWhereInput: "", + serverColumnFilters: {}, + }; + for (let index = 0; index < 128; index += 1) saveDataGridStructuredFilterState(`lru-tab-${index}`, state); + expect(loadDataGridStructuredFilterState("lru-tab-0", scopeKey)).toBeDefined(); + + saveDataGridStructuredFilterState("lru-tab-128", state); + + expect(loadDataGridStructuredFilterState("lru-tab-0", scopeKey)).toBeDefined(); + expect(loadDataGridStructuredFilterState("lru-tab-1", scopeKey)).toBeUndefined(); + }); }); diff --git a/apps/desktop/src/lib/dataGrid/dataGridFilterBuilderPersistence.ts b/apps/desktop/src/lib/dataGrid/dataGridFilterBuilderPersistence.ts index 547bbae278..caf6b2c7e9 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridFilterBuilderPersistence.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridFilterBuilderPersistence.ts @@ -14,6 +14,7 @@ export type DataGridStructuredFilterCacheState = { serverColumnFilters: Record; }; +const STRUCTURED_FILTER_STATE_CACHE_MAX_ENTRIES = 128; const structuredFilterStateCache = new Map(); export function cloneDataGridStructuredFilterRules(rules: readonly DataGridStructuredFilterRule[]): DataGridStructuredFilterRule[] { @@ -23,6 +24,8 @@ export function cloneDataGridStructuredFilterRules(rules: readonly DataGridStruc export function loadDataGridStructuredFilterState(cacheKey: string, scopeKey: string): DataGridStructuredFilterCacheState | undefined { const cached = structuredFilterStateCache.get(cacheKey); if (!cached || cached.scopeKey !== scopeKey) return undefined; + structuredFilterStateCache.delete(cacheKey); + structuredFilterStateCache.set(cacheKey, cached); return { ...cached, rules: cloneDataGridStructuredFilterRules(cached.rules), @@ -31,9 +34,22 @@ export function loadDataGridStructuredFilterState(cacheKey: string, scopeKey: st } export function saveDataGridStructuredFilterState(cacheKey: string, state: DataGridStructuredFilterCacheState) { + structuredFilterStateCache.delete(cacheKey); structuredFilterStateCache.set(cacheKey, { ...state, rules: cloneDataGridStructuredFilterRules(state.rules), serverColumnFilters: structuredClone(state.serverColumnFilters), }); + while (structuredFilterStateCache.size > STRUCTURED_FILTER_STATE_CACHE_MAX_ENTRIES) { + const oldest = structuredFilterStateCache.keys().next().value; + if (oldest === undefined) break; + structuredFilterStateCache.delete(oldest); + } +} + +export function clearDataGridStructuredFilterStatesForTab(tabId: string) { + structuredFilterStateCache.delete(tabId); + for (const cacheKey of structuredFilterStateCache.keys()) { + if (cacheKey.startsWith(`${tabId}-`)) structuredFilterStateCache.delete(cacheKey); + } } diff --git a/apps/desktop/src/lib/metadata/__tests__/objectBrowserRowsCache.spec.ts b/apps/desktop/src/lib/metadata/__tests__/objectBrowserRowsCache.spec.ts index 8aa50e7071..93eee2a09f 100644 --- a/apps/desktop/src/lib/metadata/__tests__/objectBrowserRowsCache.spec.ts +++ b/apps/desktop/src/lib/metadata/__tests__/objectBrowserRowsCache.spec.ts @@ -94,6 +94,17 @@ describe("objectBrowserRowsCache", () => { expect(getCachedObjectBrowserRows(isolatedScope)).toEqual([row]); }); + it("rejects old write tokens after their bounded generation state is evicted", () => { + const staleScope = { connectionId: "stale", database: "db", schema: "public" }; + const staleToken = createObjectBrowserRowsCacheWriteToken(staleScope); + for (let index = 0; index < 128; index += 1) { + createObjectBrowserRowsCacheWriteToken({ connectionId: `c${index}`, database: "db", schema: "public" }); + } + + expect(cacheObjectBrowserRows(staleToken, [row])).toBeUndefined(); + expect(getCachedObjectBrowserRows(staleScope)).toBeUndefined(); + }); + it("preserves the original object list freshness when statistics update cached rows", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-07-28T00:00:00Z")); diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 3859cc9971..604117ed06 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -5070,12 +5070,20 @@ function computeBoost(candidate: string, prefix: string): number { } // --- History-based ranking --- +const COMPLETION_STATS_MAX_ENTRIES = 512; const completionStats = new Map(); /** Record a user selection to boost future rankings. */ export function recordCompletionSelection(label: string, type: string): void { const key = `${type}:${label}`; - completionStats.set(key, (completionStats.get(key) || 0) + 1); + const count = completionStats.get(key) || 0; + completionStats.delete(key); + completionStats.set(key, count + 1); + while (completionStats.size > COMPLETION_STATS_MAX_ENTRIES) { + const oldest = completionStats.keys().next().value; + if (oldest === undefined) break; + completionStats.delete(oldest); + } } function getHistoryBoost(label: string, type: string): number { diff --git a/apps/desktop/src/lib/table/objectBrowserRowsCache.ts b/apps/desktop/src/lib/table/objectBrowserRowsCache.ts index c79bc25b18..3f89b8259f 100644 --- a/apps/desktop/src/lib/table/objectBrowserRowsCache.ts +++ b/apps/desktop/src/lib/table/objectBrowserRowsCache.ts @@ -4,6 +4,7 @@ import type { ObjectBrowserRow } from "@/lib/table/objectBrowserRows"; const OBJECT_BROWSER_ROWS_CACHE_TTL_MS = 30_000; const OBJECT_BROWSER_ROWS_CACHE_MAX_ENTRIES = 24; +const OBJECT_BROWSER_ROWS_GENERATION_MAX_ENTRIES = 128; export interface ObjectBrowserRowsCacheScope { connectionId: string; @@ -28,6 +29,7 @@ const objectBrowserRowsCache = new MetadataResultCache({ now: () => Date.now(), }); const objectBrowserRowsCacheGenerations = new Map(); +let nextObjectBrowserRowsCacheGeneration = 0; function objectBrowserRowsScope(scope: ObjectBrowserRowsCacheScope): MetadataScopeInput { return { @@ -48,7 +50,15 @@ function objectBrowserRowsCacheGeneration(scope: ObjectBrowserRowsCacheScope): O const key = metadataScopeKey(cacheScope); let state = objectBrowserRowsCacheGenerations.get(key); if (!state) { - state = { generation: 0, scope: metadataScopeParts(cacheScope) }; + state = { generation: ++nextObjectBrowserRowsCacheGeneration, scope: metadataScopeParts(cacheScope) }; + objectBrowserRowsCacheGenerations.set(key, state); + while (objectBrowserRowsCacheGenerations.size > OBJECT_BROWSER_ROWS_GENERATION_MAX_ENTRIES) { + const oldest = objectBrowserRowsCacheGenerations.keys().next().value; + if (oldest === undefined) break; + objectBrowserRowsCacheGenerations.delete(oldest); + } + } else { + objectBrowserRowsCacheGenerations.delete(key); objectBrowserRowsCacheGenerations.set(key, state); } return state; @@ -83,13 +93,13 @@ export function cacheObjectBrowserRows(token: ObjectBrowserRowsCacheWriteToken, 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++; + for (const [key, state] of objectBrowserRowsCacheGenerations) { + if (matches(state.scope)) objectBrowserRowsCacheGenerations.delete(key); } return objectBrowserRowsCache.invalidate(projected); } export function clearObjectBrowserRowsCache(): void { objectBrowserRowsCache.clear(); - for (const state of objectBrowserRowsCacheGenerations.values()) state.generation++; + objectBrowserRowsCacheGenerations.clear(); } diff --git a/apps/desktop/src/stores/__tests__/connectionStore.sidebarTableStorage.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.sidebarTableStorage.spec.ts index f91c75e1db..c4bd07d9f5 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.sidebarTableStorage.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.sidebarTableStorage.spec.ts @@ -107,4 +107,83 @@ describe("connectionStore sidebar table storage", () => { expect(currentExistingTable?.sizeBytes).toBe(8192); expect(newTable?.sizeBytes).toBe(16384); }); + + it("does not cache a table-size request that finishes after the connection is lost", async () => { + const staleStatistics = deferred>(); + const listObjectStatistics = vi + .fn() + .mockReturnValueOnce(staleStatistics.promise) + .mockResolvedValueOnce([{ name: "USERS", schema: "APP", total_bytes: 8192 }]); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + listInstalledAgents: vi.fn().mockResolvedValue([]), + listObjectStatistics, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + useSettingsStore().editorSettings.sidebarObjectInfoMode = "size"; + const connection = { id: "oracle-1", name: "Oracle", db_type: "oracle", host: "127.0.0.1", port: 1521, username: "APP", password: "", database: "ORCL" } as ConnectionConfig; + const tableNode = (): TreeNode => ({ id: "oracle-1:ORCL:APP:USERS", label: "USERS", type: "table", connectionId: connection.id, database: connection.database, schema: "APP" }); + const setLiveTree = () => { + store.connectedIds.add(connection.id); + store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [tableNode()] }]; + }; + store.connections = [connection]; + setLiveTree(); + + const staleLoad = store.loadSidebarTableStorage({ connectionId: connection.id, database: connection.database, schema: "APP" }); + store.markConnectionLost(connection.id, new Error("connection closed")); + staleStatistics.resolve([{ name: "USERS", schema: "APP", total_bytes: 4096 }]); + await staleLoad; + + setLiveTree(); + await store.loadSidebarTableStorage({ connectionId: connection.id, database: connection.database, schema: "APP" }); + + expect(listObjectStatistics).toHaveBeenCalledTimes(2); + expect(store.treeNodes[0]?.children?.[0]?.sizeBytes).toBe(8192); + }); + + it("does not cache a database-size request that finishes after the connection is lost", async () => { + const staleStorage = deferred>(); + const listDatabaseStorage = vi + .fn() + .mockReturnValueOnce(staleStorage.promise) + .mockResolvedValueOnce([{ name: "app", size_bytes: 16384 }]); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + listDatabaseStorage, + listInstalledAgents: vi.fn().mockResolvedValue([]), + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + useSettingsStore().editorSettings.sidebarObjectInfoMode = "size"; + const connection = { id: "pg-1", name: "PostgreSQL", db_type: "postgres", host: "127.0.0.1", port: 5432, username: "postgres", password: "" } as ConnectionConfig; + const setLiveTree = () => { + store.connectedIds.add(connection.id); + store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [{ id: "pg-1:app", label: "app", type: "database", connectionId: connection.id, database: "app" }] }]; + }; + store.connections = [connection]; + setLiveTree(); + + const staleLoad = store.loadSidebarDatabaseStorage(connection.id); + store.markConnectionLost(connection.id, new Error("connection closed")); + staleStorage.resolve([{ name: "app", size_bytes: 4096 }]); + await staleLoad; + + setLiveTree(); + await store.loadSidebarDatabaseStorage(connection.id); + + expect(listDatabaseStorage).toHaveBeenCalledTimes(2); + expect(store.treeNodes[0]?.children?.[0]?.sizeBytes).toBe(16384); + }); }); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 786ad05c6b..d2d9e1fc9f 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -180,6 +180,9 @@ const DEFAULT_KEEPALIVE_INTERVAL_SECS = 30; const METADATA_LIST_PAGE_CACHE_TTL_MS = 30_000; const METADATA_LIST_PAGE_CACHE_MAX_ENTRIES = 160; const SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS = 30_000; +const SIDEBAR_DATABASE_STORAGE_CACHE_MAX_ENTRIES = 32; +const SIDEBAR_TABLE_STORAGE_CACHE_MAX_ENTRIES = 64; +const SIDEBAR_TABLE_SEARCH_INDEX_CACHE_MAX_ENTRIES = 32; export const COMPLETION_METADATA_CONCURRENCY = 2; const MONGO_LEGACY_DRIVER_PROFILE = "mongodb-legacy"; const MONGO_LEGACY_DRIVER_LABEL = "MongoDB (Legacy)"; @@ -418,9 +421,15 @@ export const useConnectionStore = defineStore("connection", () => { } }); const treeNodes = ref([]); - const sidebarDatabaseStorageCache = new Map(); + const sidebarDatabaseStorageCache = new MetadataResultCache({ + ttlMs: SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS, + maxEntries: SIDEBAR_DATABASE_STORAGE_CACHE_MAX_ENTRIES, + }); const sidebarDatabaseStorageInFlight = new Map>(); - const sidebarTableStorageCache = new Map(); + const sidebarTableStorageCache = new MetadataResultCache({ + ttlMs: SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS, + maxEntries: SIDEBAR_TABLE_STORAGE_CACHE_MAX_ENTRIES, + }); const sidebarTableStorageInFlight = new Map>(); const pinnedTreeNodeOrder = ref([]); const pinnedTreeNodeIds = ref>(new Set()); @@ -1078,6 +1087,7 @@ export const useConnectionStore = defineStore("connection", () => { function markConnectionLost(connectionId: string, error: unknown) { connectedIds.value.delete(connectionId); + clearSidebarStorageCaches(connectionId); clearPrimaryVisibleObjectNames(connectionId); clearConnectionIdentifierQuote(connectionId); clearConnectionNodeLoading(connectionId); @@ -2383,6 +2393,24 @@ export const useConnectionStore = defineStore("connection", () => { return `${treeCacheKey}:table-search-index-v1`; } + function getSidebarTableSearchIndexMemoryCache(cacheKey: string): TableInfo[] | null | undefined { + if (!sidebarTableSearchIndexCache.has(cacheKey)) return undefined; + const cached = sidebarTableSearchIndexCache.get(cacheKey) ?? null; + sidebarTableSearchIndexCache.delete(cacheKey); + sidebarTableSearchIndexCache.set(cacheKey, cached); + return cached; + } + + function setSidebarTableSearchIndexMemoryCache(cacheKey: string, entries: TableInfo[] | null) { + sidebarTableSearchIndexCache.delete(cacheKey); + sidebarTableSearchIndexCache.set(cacheKey, entries); + while (sidebarTableSearchIndexCache.size > SIDEBAR_TABLE_SEARCH_INDEX_CACHE_MAX_ENTRIES) { + const oldest = sidebarTableSearchIndexCache.keys().next().value; + if (oldest === undefined) break; + sidebarTableSearchIndexCache.delete(oldest); + } + } + const sidebarTableSearchIndexManifestCacheKey = "dbx:sidebar-table-search-index-manifest-v1"; function sidebarTableSearchIndexManifestEntry(parent: TreeNode, cacheKey: string): TableSearchIndexManifestEntry | null { @@ -2619,7 +2647,8 @@ export const useConnectionStore = defineStore("connection", () => { } async function readSidebarTableSearchIndexCache(cacheKey: string, connectionId: string): Promise { - if (sidebarTableSearchIndexCache.has(cacheKey)) return sidebarTableSearchIndexCache.get(cacheKey) ?? null; + const cached = getSidebarTableSearchIndexMemoryCache(cacheKey); + if (cached !== undefined) return cached; const pending = sidebarTableSearchIndexInFlight.get(cacheKey); if (pending) return pending; const connectionGeneration = sidebarTableSearchIndexConnectionGeneration(connectionId); @@ -2629,7 +2658,7 @@ export const useConnectionStore = defineStore("connection", () => { const index = decoded?.tableSearchIndex; const entries = index ? index.entries.map((entry) => ({ name: entry.name, table_type: entry.tableType, ...(entry.comment !== undefined ? { comment: entry.comment } : {}) })) : null; if (!sidebarTableSearchIndexGenerationIsCurrent(connectionId, cacheKey, connectionGeneration, scopeGeneration)) return null; - sidebarTableSearchIndexCache.set(cacheKey, entries); + setSidebarTableSearchIndexMemoryCache(cacheKey, entries); return entries; })(); sidebarTableSearchIndexInFlight.set(cacheKey, read); @@ -2705,7 +2734,7 @@ export const useConnectionStore = defineStore("connection", () => { persisted = true; }); if (!persisted || !sidebarTableSearchIndexGenerationIsCurrent(connectionId, cacheKey, connectionGeneration, scopeGeneration)) return []; - sidebarTableSearchIndexCache.set(cacheKey, deduped); + setSidebarTableSearchIndexMemoryCache(cacheKey, deduped); await registerSidebarTableSearchIndexScope(parent, cacheKey); return deduped; } @@ -3158,6 +3187,7 @@ export const useConnectionStore = defineStore("connection", () => { clearConnectionError(id); connectionErrorRevisions.delete(id); connectedIds.value.delete(id); + clearSidebarStorageCaches(id); clearPrimaryVisibleObjectNames(id); clearConnectionIdentifierQuote(id); clearConnectionHealthCheck(id); @@ -3198,6 +3228,7 @@ export const useConnectionStore = defineStore("connection", () => { if (!runtimeConfigChanged) return; clearPrimaryVisibleObjectNames(config.id); connectedIds.value.delete(config.id); + clearSidebarStorageCaches(config.id); clearConnectionIdentifierQuote(config.id); clearConnectionHealthCheck(config.id); invalidateCompletionCache(config.id); @@ -3692,6 +3723,7 @@ export const useConnectionStore = defineStore("connection", () => { if (!cancelled) return false; clearConnectionError(connectionId); connectedIds.value.delete(connectionId); + clearSidebarStorageCaches(connectionId); clearPrimaryVisibleObjectNames(connectionId); clearConnectionIdentifierQuote(connectionId); clearConnectionHealthCheck(connectionId); @@ -3711,6 +3743,7 @@ export const useConnectionStore = defineStore("connection", () => { cancelLocalConnectionAttempt(connectionId); connectedIds.value.delete(connectionId); + clearSidebarStorageCaches(connectionId); clearPrimaryVisibleObjectNames(connectionId); clearConnectionIdentifierQuote(connectionId); forgetSuccessfulLocalConnectionAttempt(connectionId); @@ -3797,6 +3830,7 @@ export const useConnectionStore = defineStore("connection", () => { clearLoadedChildrenCache(node.id); } invalidateCompletionCache(connectionId, database); + clearSidebarStorageCaches(connectionId, database); invalidateObjectBrowserRowsCache({ connectionId, database }); // 数据库级生命周期边界:与连接级断开一致,数据标签页的元数据 freshness // 戳必须作废,重开/刷新时重新拉取结构(issue #6623)。bump 数据库级代次 @@ -3920,6 +3954,29 @@ export const useConnectionStore = defineStore("connection", () => { return `${connectionId}\0${[...databases].sort().join("\0")}`; } + function sidebarDatabaseStorageCacheScope(connectionId: string, databases: readonly string[]): MetadataScopeInput { + return { kind: "sidebar-database-storage", connectionId, extra: { databases: [...databases].sort() } }; + } + + function sidebarTableStorageCacheScope(scope: SidebarTableStorageScope): MetadataScopeInput { + return { kind: "sidebar-table-storage", connectionId: scope.connectionId, database: scope.database, schema: scope.schema }; + } + + function clearSidebarStorageCaches(connectionId: string, database?: string) { + if (database === undefined) sidebarDatabaseStorageCache.invalidate({ connectionId }); + sidebarTableStorageCache.invalidate(database === undefined ? { connectionId } : { connectionId, database }); + const connectionPrefix = `${connectionId}\0`; + if (database === undefined) { + for (const key of sidebarDatabaseStorageInFlight.keys()) { + if (key.startsWith(connectionPrefix)) sidebarDatabaseStorageInFlight.delete(key); + } + } + const tablePrefix = database === undefined ? connectionPrefix : `${connectionId}\0${database}\0`; + for (const key of sidebarTableStorageInFlight.keys()) { + if (key.startsWith(tablePrefix)) sidebarTableStorageInFlight.delete(key); + } + } + async function loadSidebarDatabaseStorage(connectionId: string, options?: { force?: boolean }): Promise { if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size" || !connectedIds.value.has(connectionId)) return; if (!supportsSidebarDatabaseStorage(getConfig(connectionId))) return; @@ -3928,8 +3985,9 @@ export const useConnectionStore = defineStore("connection", () => { if (!databases.length) return; const requestKey = sidebarDatabaseStorageRequestKey(connectionId, databases); - const cached = sidebarDatabaseStorageCache.get(requestKey); - if (!options?.force && cached && cached.expiresAt > Date.now()) { + const cacheScope = sidebarDatabaseStorageCacheScope(connectionId, databases); + const cached = sidebarDatabaseStorageCache.get(cacheScope); + if (!options?.force && cached) { applySidebarDatabaseStorage(connectionNode?.children, cached.value); return; } @@ -3941,10 +3999,8 @@ export const useConnectionStore = defineStore("connection", () => { } try { const storage = await request; - sidebarDatabaseStorageCache.set(requestKey, { - expiresAt: Date.now() + SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS, - value: storage, - }); + if (sidebarDatabaseStorageInFlight.get(requestKey) !== request || !connectedIds.value.has(connectionId)) return; + sidebarDatabaseStorageCache.set(cacheScope, storage); const currentNode = findConnectionNode(connectionId); const currentNames = sidebarDatabaseNames(currentNode?.children); if (sidebarDatabaseStorageRequestKey(connectionId, currentNames) === requestKey) { @@ -3967,8 +4023,9 @@ export const useConnectionStore = defineStore("connection", () => { if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size" || !connectedIds.value.has(scope.connectionId)) return; if (!supportsSidebarTableStorage(getConfig(scope.connectionId))) return; const requestKey = sidebarTableStorageRequestKey(scope); - const cached = sidebarTableStorageCache.get(requestKey); - if (!options?.force && cached && cached.expiresAt > Date.now()) { + const cacheScope = sidebarTableStorageCacheScope(scope); + const cached = sidebarTableStorageCache.get(cacheScope); + if (!options?.force && cached) { applySidebarTableStorage(treeNodes.value, scope, cached.value); return; } @@ -3980,11 +4037,8 @@ export const useConnectionStore = defineStore("connection", () => { } try { const statistics = await request; - if (sidebarTableStorageInFlight.get(requestKey) !== request) return; - sidebarTableStorageCache.set(requestKey, { - expiresAt: Date.now() + SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS, - value: statistics, - }); + if (sidebarTableStorageInFlight.get(requestKey) !== request || !connectedIds.value.has(scope.connectionId)) return; + sidebarTableStorageCache.set(cacheScope, statistics); applySidebarTableStorage(treeNodes.value, scope, statistics); } catch (error) { console.debug("[DBX][sidebar-table-storage:unavailable]", { ...scope, error }); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index a931486e13..05e71e782b 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -57,6 +57,7 @@ import { elasticsearchRestRequestRanges, executableStatementRanges, splitSqlStat import { replaceSqlServerLeadingUseQuery, sqlServerLeadingUseScript, sqlServerUseDatabaseFromStatement } from "@/lib/sql/sqlCompletionLookupTarget"; import { externalSqlFileDisplayTitles, normalizeExternalSqlPath } from "@/lib/sql/sqlFileOpen"; import { clearDataGridPendingSnapshot, clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor"; +import { clearDataGridStructuredFilterStatesForTab } from "@/lib/dataGrid/dataGridFilterBuilderPersistence"; import { buildTabResultSnapshot, deleteTabResultSnapshot, pruneTabResultSnapshots, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabs/tabResultCache"; import { estimateQueryResultsBytes, selectInactiveResultEvictions } from "@/lib/tabs/queryResultSize"; import { queryResultBaseSql, queryResultExecutionSql, resultGridInstanceKey } from "@/lib/tabs/tabPresentation"; @@ -259,6 +260,27 @@ function markQueryResultRunsRowsRaw(resultRuns: NonNullable= results.length) return undefined; return currentIndex; @@ -1029,7 +1051,26 @@ export const useQueryStore = defineStore("query", () => { } } + function releaseTabResultObjectPayloads(tab: QueryTab) { + const results = new Set(); + if (tab.result) results.add(tab.result); + for (const result of tab.results ?? []) results.add(result); + for (const run of tab.resultRuns ?? []) { + if (run.result) results.add(run.result); + for (const result of run.results ?? []) results.add(result); + } + for (const result of results) releaseResultObjectPayload(result); + } + + function clearResultRuns(tab: QueryTab) { + for (const run of tab.resultRuns ?? []) clearResultRunPayload(run); + tab.resultRuns = undefined; + tab.activeResultRunId = undefined; + } + function clearResultRunPayload(run: NonNullable[number], options: { evicted?: boolean } = {}) { + if (run.result) releaseResultObjectPayload(run.result); + for (const result of run.results ?? []) releaseResultObjectPayload(result); run.result = undefined; run.results = undefined; run.resultLocalSortOriginalRows = undefined; @@ -1224,6 +1265,7 @@ export const useQueryStore = defineStore("query", () => { const removedRun = tab.resultRuns[runIndex]; if (removedRun?.resultSessionId) void closeResultRunSession(tab, removedRun); if (removedRun?.resultCacheKey) void deleteTabResultSnapshot(removedRun.resultCacheKey); + if (removedRun) clearResultRunPayload(removedRun); const wasActive = tab.activeResultRunId === runId; const remainingRuns = tab.resultRuns.filter((run) => run.id !== runId); tab.resultRuns = remainingRuns; @@ -1250,6 +1292,7 @@ export const useQueryStore = defineStore("query", () => { if (!tab.result && !tab.results?.length && !tab.resultEvicted) return false; const closeSession = closeResultSession(tab); + releaseTabResultObjectPayloads(tab); clearResultPayload(tab); await closeSession; return true; @@ -1274,8 +1317,8 @@ export const useQueryStore = defineStore("query", () => { closeOperations.push(closeResultRunSession(tab, run)); } - tab.resultRuns = undefined; - tab.activeResultRunId = undefined; + releaseTabResultObjectPayloads(tab); + clearResultRuns(tab); clearResultPayload(tab); await Promise.all(closeOperations); return true; @@ -2627,6 +2670,7 @@ export const useQueryStore = defineStore("query", () => { persistSavedSqlEditorPosition(tabs.value[idx]); if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id); clearDataGridPendingSnapshotsForTab(id); + clearDataGridStructuredFilterStatesForTab(id); if (tabs.value[idx].txnSessionId) void rollbackTransaction(id); if (tabs.value[idx].isExecuting) void cancelTabExecution(id); if (tabs.value[idx].isExplaining) void cancelTabExplain(id); @@ -2634,6 +2678,8 @@ export const useQueryStore = defineStore("query", () => { void closeClientConnectionSession(tabs.value[idx]); clearResultRunSnapshots(tabs.value[idx]); void deleteTabResultSnapshot(tabResultCacheKey(id)); + releaseTabResultObjectPayloads(tabs.value[idx]); + clearResultRuns(tabs.value[idx]); clearResultPayload(tabs.value[idx]); tabs.value.splice(idx, 1); if (tab.externalSqlPath) refreshExternalSqlFileTitles(); @@ -2922,6 +2968,7 @@ export const useQueryStore = defineStore("query", () => { .forEach((tab) => { if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id); clearDataGridPendingSnapshotsForTab(tab.id); + clearDataGridStructuredFilterStatesForTab(tab.id); if (tab.txnSessionId) void rollbackTransaction(tab.id); if (tab.isExecuting) void cancelTabExecution(tab.id); if (tab.isExplaining) void cancelTabExplain(tab.id); @@ -2929,6 +2976,8 @@ export const useQueryStore = defineStore("query", () => { void closeClientConnectionSession(tab); clearResultRunSnapshots(tab); void deleteTabResultSnapshot(tabResultCacheKey(tab.id)); + releaseTabResultObjectPayloads(tab); + clearResultRuns(tab); clearResultPayload(tab); }); @@ -3104,10 +3153,16 @@ export const useQueryStore = defineStore("query", () => { .filter((tab) => predicate(tab)) .forEach((tab) => { rollbackTabTransaction(tab, { resetAutoCommit: true }); + clearDataGridPendingSnapshotsForTab(tab.id); + clearDataGridStructuredFilterStatesForTab(tab.id); if (tab.isExecuting) void cancelTabExecution(tab.id); if (tab.isExplaining) void cancelTabExplain(tab.id); void closeResultSession(tab); void closeClientConnectionSession(tab); + clearResultRunSnapshots(tab); + void deleteTabResultSnapshot(tabResultCacheKey(tab.id)); + releaseTabResultObjectPayloads(tab); + clearResultRuns(tab); clearResultPayload(tab); }); } diff --git a/crates/dbx-core/src/db/agent_driver.rs b/crates/dbx-core/src/db/agent_driver.rs index 593e6c2d7c..08a17758f8 100644 --- a/crates/dbx-core/src/db/agent_driver.rs +++ b/crates/dbx-core/src/db/agent_driver.rs @@ -26,6 +26,8 @@ struct CachedAgentQuery { pub struct AgentRuntimeClient { child: Arc>, + child_reaper_started: Arc, + child_reaped: Arc, stdin: Arc>>, pending: Arc>>, stderr_tail: Arc>, @@ -66,6 +68,8 @@ impl AgentRuntimeClient { let runtime = Arc::new(Self { child: Arc::new(Mutex::new(child)), + child_reaper_started: Arc::new(AtomicBool::new(false)), + child_reaped: Arc::new(AtomicBool::new(false)), stdin: Arc::new(Mutex::new(BufWriter::new(child_stdin))), pending: Arc::new(Mutex::new(HashMap::new())), stderr_tail, @@ -106,12 +110,16 @@ impl AgentRuntimeClient { fn start_response_reader(self: &Arc, mut stdout: BufReader) { let pending = self.pending.clone(); let failed = self.failed.clone(); + let child = self.child.clone(); + let child_reaper_started = self.child_reaper_started.clone(); + let child_reaped = self.child_reaped.clone(); std::thread::spawn(move || loop { let response = match read_agent_json_response(&mut stdout) { Ok((_, response)) => response, Err(err) => { failed.store(true, Ordering::Release); fail_pending_requests(&pending, err); + terminate_and_reap_shared_agent(child, child_reaper_started, child_reaped); return; } }; @@ -285,20 +293,30 @@ impl AgentRuntimeClient { if let Ok(mut child) = self.child.lock() { let _ = child.kill(); } + start_shared_agent_reaper(self.child.clone(), self.child_reaper_started.clone(), self.child_reaped.clone()); fail_pending_requests(&self.pending, "Agent runtime terminated".to_string()); } pub async fn kill_and_wait(&self) { self.failed.store(true, Ordering::Release); fail_pending_requests(&self.pending, "Agent runtime terminated".to_string()); + if self.child_reaper_started.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire).is_err() { + wait_for_shared_agent_reaper(self.child_reaped.clone()).await; + return; + } let child = self.child.clone(); - match tokio::task::spawn_blocking(move || { - let mut child = child.lock().map_err(|_| "Shared agent process lock poisoned".to_string())?; - let _ = child.kill(); - child.wait().map(|_| ()).map_err(|err| format!("Failed to wait for shared agent runtime: {err}")) + let child_reaped = self.child_reaped.clone(); + let result = tokio::task::spawn_blocking(move || { + let result = + child.lock().map_err(|_| "Shared agent process lock poisoned".to_string()).and_then(|mut child| { + let _ = child.kill(); + child.wait().map(|_| ()).map_err(|err| format!("Failed to wait for shared agent runtime: {err}")) + }); + child_reaped.store(true, Ordering::Release); + result }) - .await - { + .await; + match result { Ok(Ok(())) => {} Ok(Err(err)) => log::warn!("{err}"), Err(err) => log::warn!("Failed to join shared agent shutdown task: {err}"), @@ -306,6 +324,44 @@ impl AgentRuntimeClient { } } +fn terminate_and_reap_shared_agent( + child: Arc>, + child_reaper_started: Arc, + child_reaped: Arc, +) { + if let Ok(mut child) = child.lock() { + let _ = child.kill(); + } + start_shared_agent_reaper(child, child_reaper_started, child_reaped); +} + +fn start_shared_agent_reaper( + child: Arc>, + child_reaper_started: Arc, + child_reaped: Arc, +) { + if child_reaper_started.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire).is_err() { + return; + } + std::thread::spawn(move || { + let result = + child.lock().map_err(|_| "Shared agent process lock poisoned".to_string()).and_then(|mut child| { + child.wait().map(|_| ()).map_err(|err| format!("Failed to wait for shared agent runtime: {err}")) + }); + child_reaped.store(true, Ordering::Release); + match result { + Ok(()) => {} + Err(err) => log::warn!("{err}"), + } + }); +} + +async fn wait_for_shared_agent_reaper(child_reaped: Arc) { + while !child_reaped.load(Ordering::Acquire) { + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + fn decode_agent_response( response: Value, strict_structured_errors: bool, @@ -3328,6 +3384,7 @@ mod tests { use std::io::Cursor; use std::io::Write; use std::process::{Command, Stdio}; + use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tokio_util::sync::CancellationToken; @@ -4103,6 +4160,39 @@ for line in sys.stdin: runtime.call(method, serde_json::json!({}), Some(Duration::from_secs(2)), None).await.unwrap() } + async fn wait_for_runtime_reap(runtime: &AgentRuntimeClient) { + tokio::time::timeout(Duration::from_secs(2), async { + while !runtime.child_reaped.load(Ordering::Acquire) { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("shared Agent child should be reaped"); + } + + #[tokio::test] + async fn shared_runtime_kill_reaps_child_process() { + let (runtime, script_path) = spawn_stateful_test_runtime("kill-reap-test").await; + + runtime.kill(); + wait_for_runtime_reap(&runtime).await; + + assert!(runtime.child.lock().unwrap().try_wait().unwrap().is_some()); + let _ = std::fs::remove_file(script_path); + } + + #[tokio::test] + async fn shared_runtime_kill_and_wait_joins_existing_reaper() { + let (runtime, script_path) = spawn_stateful_test_runtime("kill-and-wait-reap-test").await; + + runtime.kill(); + runtime.kill_and_wait().await; + + assert!(runtime.child_reaped.load(Ordering::Acquire)); + assert!(runtime.child.lock().unwrap().try_wait().unwrap().is_some()); + let _ = std::fs::remove_file(script_path); + } + #[tokio::test] async fn shared_runtime_timeout_cancels_session_with_and_without_token() { let (runtime, script_path) = spawn_stateful_test_runtime("timeout-cancel-test").await; @@ -4663,6 +4753,7 @@ for line in sys.stdin: .unwrap_err(); assert!(error.contains("end of stream") || error.contains("response channel closed")); assert!(runtime.is_failed()); + wait_for_runtime_reap(&runtime).await; let _ = std::fs::remove_file(script_path); } diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 41f113c9ef..6dc0a99b41 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -1855,6 +1855,47 @@ test("closing an ordinary query result preserves the query tab", async () => { assert.equal(tab.activeResultRunId, undefined); }); +test("closing a tab releases result payloads retained by deactivated grids", () => { + setActivePinia(createPinia()); + const store = useQueryStore(); + const tabId = store.createTab("conn-1", "db"); + const tab = store.tabs.find((item) => item.id === tabId); + assert.ok(tab); + + tab.result = { + columns: ["payload"], + column_types: ["TEXT"], + rows: [["x".repeat(10_000)]], + spatial_values: [[4326]], + mongo_documents: [{ payload: "x".repeat(10_000) }], + mongo_copy_documents: [{ $binary: "x".repeat(10_000) }], + large_value_cells: [{ row_index: 0, column_index: 0, original_bytes: 10_000 }], + elasticsearch_raw_body: "x".repeat(10_000), + messages: [{ severity: "NOTICE", message: "x".repeat(10_000) }], + affected_rows: 0, + execution_time_ms: 1, + }; + const retainedResult = tab.result; + const retainedRunResult: QueryResult = { + columns: ["older"], + rows: [["y".repeat(10_000)]], + affected_rows: 0, + execution_time_ms: 1, + }; + tab.resultRuns = [{ id: "run-1", title: "Run 1", sequence: 1, sql: "select 1", createdAt: 1, result: retainedRunResult }]; + tab.activeResultRunId = "run-1"; + + store.closeTab(tabId, { force: true }); + + assert.equal(store.tabs.some((item) => item.id === tabId), false); + assert.deepEqual(retainedResult.columns, []); + assert.deepEqual(retainedResult.rows, []); + assert.equal(retainedResult.mongo_documents, undefined); + assert.equal(retainedResult.elasticsearch_raw_body, undefined); + assert.deepEqual(retainedRunResult.rows, []); + assert.equal(tab.resultRuns, undefined); +}); + test("removing the active result run clears output when remaining caches are unavailable", async () => { setActivePinia(createPinia()); const store = useQueryStore(); @@ -4045,6 +4086,15 @@ test("releasing connection tabs keeps SQL tabs and closes object tabs", async () session_id: "session-query", }; queryTab.resultSessionId = "session-query"; + const retainedQueryResult = queryTab.result; + const retainedRunResult: QueryResult = { + columns: ["previous"], + rows: [["previous payload"]], + affected_rows: 0, + execution_time_ms: 1, + }; + queryTab.resultRuns = [{ id: "run-1", title: "Run 1", sequence: 1, sql: "select 1", createdAt: 1, result: retainedRunResult }]; + queryTab.activeResultRunId = "run-1"; dataTab.result = { columns: ["payload"], rows: [["data"]], @@ -4069,6 +4119,10 @@ test("releasing connection tabs keeps SQL tabs and closes object tabs", async () ); assert.equal(queryTab.result, undefined); assert.equal(queryTab.resultSessionId, undefined); + assert.equal(queryTab.resultRuns, undefined); + assert.equal(queryTab.activeResultRunId, undefined); + assert.deepEqual(retainedQueryResult.rows, []); + assert.deepEqual(retainedRunResult.rows, []); assert.equal(dataTab.result, undefined); assert.equal(dataTab.resultSessionId, undefined); } finally { diff --git a/packages/app-tests/sidebarRegexIndexStore.test.ts b/packages/app-tests/sidebarRegexIndexStore.test.ts index 0821cf4e7c..f586f44ff1 100644 --- a/packages/app-tests/sidebarRegexIndexStore.test.ts +++ b/packages/app-tests/sidebarRegexIndexStore.test.ts @@ -253,6 +253,30 @@ test("concurrent reads of the same index cache key hit the storage once", async } }); +test("bounds in-memory regex indexes while keeping persisted scopes reloadable", async () => { + const restoreStorage = installMemoryStorage(); + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + store.addEphemeralConnection(conn("conn-1")); + const scopes = Array.from({ length: 33 }, (_, index) => manifestScope(`index-${index}`, `parent-${index}`, { database: `db-${index}` })); + persistedCache.set(MANIFEST_CACHE_KEY, encodeTableSearchIndexManifest(scopes)); + for (let index = 0; index < scopes.length; index += 1) { + persistedCache.set(`index-${index}`, indexEnvelope([{ name: `table_${index}`, tableType: "TABLE" }])); + } + + assert.equal((await store.loadSidebarTableSearchIndexScopes()).length, 33); + loadSchemaCacheMock.mockClear(); + + // Iterating again from the oldest scope reloads each persisted entry because + // only the most recent 32 indexes are kept in memory. + assert.equal((await store.loadSidebarTableSearchIndexScopes()).length, 33); + assert.equal(loadSchemaCacheMock.mock.calls.length, 33); + } finally { + restoreStorage(); + } +}); + test("resolves the index parent by composite identity when ids collide", async () => { const restoreStorage = installMemoryStorage(); try {