mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(sidebar): batch persisted cache deletes for discarded tree nodes
Closes #9779
This commit is contained in:
@@ -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")}`,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user