fix(sidebar): batch persisted cache deletes for discarded tree nodes

Closes #9779
This commit is contained in:
t8y2
2026-09-21 01:44:23 +08:00
parent 216afec5e4
commit 34e9ffd040
2 changed files with 182 additions and 4 deletions
@@ -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")}`,
+50 -4
View File
@@ -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<string>();
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);
}