From 34e9ffd040a5b3f163f643435df3d2cd0a0e89ee Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sun, 20 Sep 2026 22:04:36 +0800 Subject: [PATCH] fix(sidebar): batch persisted cache deletes for discarded tree nodes Closes #9779 --- .../connectionStore.metadataLoading.spec.ts | 132 ++++++++++++++++++ apps/desktop/src/stores/connectionStore.ts | 54 ++++++- 2 files changed, 182 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index ff505e9e36..69cb77f287 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -3360,6 +3360,138 @@ describe("connectionStore metadata loading", () => { expect(store.restoreFilteredObjectGroupChildren(untouchedGroup)).toBe(false); }); + async function installAggregatedCacheDeleteStore(options?: { initialChildren?: TreeNode[] }) { + const tables = Array.from({ length: 5 }, (_, index) => ({ + name: `t_${String(index + 1).padStart(4, "0")}`, + table_type: "TABLE" as const, + comment: null, + })); + const listTables = vi.fn(() => Promise.resolve(tables)); + const deleteSchemaCachePrefix = vi.fn().mockResolvedValue(undefined); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix, + listTables, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + const settingsStore = useSettingsStore(); + settingsStore.editorSettings.sidebarObjectDisplay = "grouped"; + + const connection = mysqlConnection(); + const tablesGroup: TreeNode = { + id: "mysql-1:app:__tables", + label: "tree.tables", + type: "group-tables", + connectionId: connection.id, + database: "app", + isExpanded: true, + children: options?.initialChildren ?? [], + }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [ + { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [ + { + id: "mysql-1:app", + label: "app", + type: "database", + connectionId: connection.id, + database: "app", + isExpanded: true, + children: [tablesGroup], + }, + ], + }, + ]; + return { store, tablesGroup, listTables, deleteSchemaCachePrefix, tables }; + } + + it("aggregates persisted cache deletes for discarded sibling nodes into one ancestor prefix", async () => { + const { store, tablesGroup, listTables, deleteSchemaCachePrefix, tables } = await installAggregatedCacheDeleteStore(); + + await store.loadObjectGroupChildren(tablesGroup, { force: true }); + expect(tablesGroup.children?.map((child) => child.label)).toEqual(tables.map((table) => table.name)); + // Nothing was discarded yet, so no persisted cache delete may fire. + expect(deleteSchemaCachePrefix).not.toHaveBeenCalled(); + + // The refreshed list drops four of the five tables: their per-node cache + // deletes must collapse into a single ancestor-prefix request pair. + listTables.mockImplementation(() => Promise.resolve(tables.slice(4))); + await store.loadObjectGroupChildren(tablesGroup, { force: true }); + expect(tablesGroup.children?.map((child) => child.label)).toEqual(["t_0005"]); + expect(deleteSchemaCachePrefix.mock.calls.map((call) => call[0]).sort()).toEqual(["mysql-1%3Aapp%3A__tables:", "mysql-1:app:__tables:"]); + }); + + it("keeps per-node persisted deletes for discarded ids outside the ancestor prefix", async () => { + const strayNode: TreeNode = { + id: "foreign:id", + label: "legacy", + type: "table", + connectionId: "mysql-1", + database: "app", + isExpanded: false, + children: [], + }; + const { store, tablesGroup, deleteSchemaCachePrefix, tables } = await installAggregatedCacheDeleteStore({ initialChildren: [strayNode] }); + + await store.loadObjectGroupChildren(tablesGroup, { force: true }); + expect(tablesGroup.children?.map((child) => child.label)).toEqual(tables.map((table) => table.name)); + // The stray child does not live under the group's id prefix, so it cannot be + // covered by the ancestor delete and must keep its own request pair. + expect(deleteSchemaCachePrefix.mock.calls.map((call) => call[0]).sort()).toEqual(["foreign%3Aid:", "foreign:id:"]); + }); + + it("shares one persisted delete between nested discarded prefixes", async () => { + const strayParent: TreeNode = { + id: "x:a", + label: "outer", + type: "table", + connectionId: "mysql-1", + database: "app", + isExpanded: false, + children: [], + }; + const strayChild: TreeNode = { + id: "x:a:b", + label: "inner", + type: "table", + connectionId: "mysql-1", + database: "app", + isExpanded: false, + children: [], + }; + const { store, tablesGroup, deleteSchemaCachePrefix } = await installAggregatedCacheDeleteStore({ initialChildren: [strayParent, strayChild] }); + + await store.loadObjectGroupChildren(tablesGroup, { force: true }); + // "x:a:b" is covered by the "x:a" prefix, so only the covering pair fires. + expect(deleteSchemaCachePrefix.mock.calls.map((call) => call[0]).sort()).toEqual(["x%3Aa:", "x:a:"]); + expect(deleteSchemaCachePrefix).toHaveBeenCalledTimes(2); + }); + + it("issues no persisted cache delete when the refreshed list keeps every child", async () => { + const { store, tablesGroup, deleteSchemaCachePrefix, tables } = await installAggregatedCacheDeleteStore(); + + await store.loadObjectGroupChildren(tablesGroup, { force: true }); + await store.loadObjectGroupChildren(tablesGroup, { force: true }); + expect(tablesGroup.children?.map((child) => child.label)).toEqual(tables.map((table) => table.name)); + expect(deleteSchemaCachePrefix).not.toHaveBeenCalled(); + }); + it("pages table-scoped search results instead of truncating at the search budget", async () => { const tables = Array.from({ length: 5 }, (_, index) => ({ name: `t_${String(index + 1).padStart(4, "0")}`, diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index fcc0f34943..88450c1896 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -1756,9 +1756,9 @@ export const useConnectionStore = defineStore("connection", () => { } /** Drop loaded/confirmed-empty markers, metadata caches, and generations for a discarded shell. */ - function forgetTreeNodeLoadState(nodeId: string) { + function forgetTreeNodeLoadState(nodeId: string, options?: { deletePersisted?: boolean }) { forgetFilteredObjectGroupChildren(nodeId); - clearLoadedChildrenCache(nodeId); + clearLoadedChildrenCache(nodeId, options); treeNodeLoads.invalidatePrefix(nodeId); } @@ -1836,11 +1836,15 @@ export const useConnectionStore = defineStore("connection", () => { if (parent.children && parent.children.length > 0) { const oldMap = new Map(parent.children.map((c) => [c.id, c] as const)); const nextIds = new Set(children.map((child) => child.id)); + // Discarded shells collect here so their persisted-cache deletes can be + // aggregated into one prefix request per ancestor (issue #9779). + const discardedIds: string[] = []; for (const [oldId, old] of oldMap) { // Removed children keep no loaded markers; also bump generations so in-flight // loads cannot apply if the same id is recreated later. if (!nextIds.has(oldId) && old.type !== "load-more") { - forgetTreeNodeLoadState(oldId); + forgetTreeNodeLoadState(oldId, { deletePersisted: false }); + discardedIds.push(oldId); } } children = children.map((child) => { @@ -1871,10 +1875,12 @@ export const useConnectionStore = defineStore("connection", () => { // the next expand reload. Do not do this for tables/groups — load-more and list // refresh must preserve nested loaded markers (columns, etc.). if (old && (old.type === "database" || old.type === "schema" || old.type === "linked-server-schema")) { - forgetTreeNodeLoadState(child.id); + forgetTreeNodeLoadState(child.id, { deletePersisted: false }); + discardedIds.push(child.id); } return child; }); + deletePersistedTreeCachesForDiscardedDescendants(parent.id, discardedIds); } const migratedPins = migrateLegacyPinnedTreeNodeOrder(children, pinnedTreeNodeOrder.value); if (migratedPins.changed) { @@ -3392,6 +3398,14 @@ export const useConnectionStore = defineStore("connection", () => { } invalidateMetadataCachesByTreePrefix(prefix); if (options?.deletePersisted === false) return; + deletePersistedSchemaCachePrefix(prefix); + } + + // Persisted tree caches are keyed by colon-joined node ids, so both the raw + // id form and the fully-encoded legacy form must be invalidated. Failures are + // swallowed on purpose: a dropped delete only leaves a stale cache entry that + // the next save supersedes. + function deletePersistedSchemaCachePrefix(prefix: string) { const rawPrefix = `${prefix}:`; const encodedPrefix = `${schemaCacheKey(prefix)}:`; if (rawPrefix === encodedPrefix) { @@ -3401,6 +3415,38 @@ export const useConnectionStore = defineStore("connection", () => { } } + // Discarding sibling shells used to cost one prefix DELETE per node — on a + // large schema a list replacement or a remote search fired a request storm + // (issue #9779). Sibling ids all live under the parent's id prefix, so one + // ancestor delete covers the whole batch; ids that do not follow the parent's + // id path (encoded MQ/Nacos-style ids) keep per-node deletes, deduplicated to + // the minimal set so nested or duplicate prefixes share a request. The widened + // ancestor invalidation only drops caches that the next expand refetches — the + // in-memory markers for the same discarded shells were already cleared above — + // so nothing stale can survive, at worst one extra metadata fetch. + function deletePersistedTreeCachesForDiscardedDescendants(ancestorId: string, discardedIds: string[]) { + if (discardedIds.length === 0) return; + const ancestorPrefix = `${ancestorId}:`; + const prefixes = new Set(); + let coveredByAncestor = false; + for (const id of discardedIds) { + if (id.startsWith(ancestorPrefix)) coveredByAncestor = true; + else prefixes.add(id); + } + if (coveredByAncestor) prefixes.add(ancestorId); + for (const prefix of prefixes) { + // A prefix already covered by another prefix in the batch needs no request. + let covered = false; + for (const other of prefixes) { + if (other !== prefix && prefix.startsWith(`${other}:`)) { + covered = true; + break; + } + } + if (!covered) deletePersistedSchemaCachePrefix(prefix); + } + } + function schemaCachePrefixForNode(node: TreeNode): string | null { return treeNodeSchemaCachePrefix(node); }