perf(sidebar): reduce schema tree memory usage
This commit is contained in:
parent
8f619fb791
commit
c81eda22b0
|
|
@ -579,12 +579,14 @@ async function toggle() {
|
|||
const databaseObjectGroup = node.type === "group-tables" || node.type === "group-views" || node.type === "group-materialized-views" || node.type === "group-procedures" || node.type === "group-functions" || node.type === "group-sequences" || node.type === "group-packages";
|
||||
if (databaseObjectGroup && connectionStore.isTreeNodeChildrenLoaded(node.id)) {
|
||||
node.isExpanded = !node.isExpanded;
|
||||
if (wasExpanded) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
|
||||
emit("node-toggled", node, wasExpanded);
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.isExpanded) {
|
||||
node.isExpanded = false;
|
||||
connectionStore.releaseCollapsedTreeNodeChildren(node.id);
|
||||
emit("node-toggled", node, wasExpanded);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { isReactive } from "vue";
|
||||
import type { ColumnInfo, ConnectionConfig, TreeNode } from "@/types/database";
|
||||
|
||||
function installLocalStorage() {
|
||||
const data = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => data.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => data.delete(key)),
|
||||
});
|
||||
}
|
||||
|
||||
function postgresConnection(): ConnectionConfig {
|
||||
return {
|
||||
id: "pg-1",
|
||||
name: "Postgres",
|
||||
db_type: "postgres",
|
||||
host: "127.0.0.1",
|
||||
port: 5432,
|
||||
username: "postgres",
|
||||
password: "",
|
||||
database: "app",
|
||||
} as ConnectionConfig;
|
||||
}
|
||||
|
||||
function columns(count: number): ColumnInfo[] {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, index) =>
|
||||
({
|
||||
name: `col_${index}`,
|
||||
data_type: "text",
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: index === 0,
|
||||
comment: null,
|
||||
}) as unknown as ColumnInfo,
|
||||
);
|
||||
}
|
||||
|
||||
function findById(nodes: TreeNode[], id: string): TreeNode | undefined {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node;
|
||||
if (node.children) {
|
||||
const found = findById(node.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const GROUP_ID = "pg-1:app:public:users:__columns";
|
||||
|
||||
async function setupStoreWithColumnGroup() {
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
|
||||
async function loadStore(getColumns: ReturnType<typeof vi.fn>) {
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
|
||||
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||
getColumns,
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
|
||||
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const connection = postgresConnection();
|
||||
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: "pg-1:app",
|
||||
label: "app",
|
||||
type: "database",
|
||||
connectionId: connection.id,
|
||||
database: "app",
|
||||
isExpanded: true,
|
||||
children: [
|
||||
{
|
||||
id: "pg-1:app:public:users",
|
||||
label: "users",
|
||||
type: "table",
|
||||
connectionId: connection.id,
|
||||
database: "app",
|
||||
schema: "public",
|
||||
tableName: "users",
|
||||
isExpanded: true,
|
||||
children: [
|
||||
{
|
||||
id: GROUP_ID,
|
||||
label: "Columns",
|
||||
type: "group-columns",
|
||||
connectionId: connection.id,
|
||||
database: "app",
|
||||
schema: "public",
|
||||
tableName: "users",
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
return store;
|
||||
}
|
||||
|
||||
return { loadStore };
|
||||
}
|
||||
|
||||
describe("connectionStore schema tree memory", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
installLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("marks leaf column nodes raw so Vue does not deep-wrap them", async () => {
|
||||
const { loadStore } = await setupStoreWithColumnGroup();
|
||||
const getColumns = vi.fn().mockResolvedValue(columns(5));
|
||||
const store = await loadStore(getColumns);
|
||||
|
||||
await store.loadColumns("pg-1", "app", "users", "public", GROUP_ID);
|
||||
|
||||
const group = findById(store.treeNodes, GROUP_ID);
|
||||
expect(group?.children).toHaveLength(5);
|
||||
// The container group stays reactive so expand/collapse still drives the UI...
|
||||
expect(isReactive(group!)).toBe(true);
|
||||
// ...but every leaf column (and, transitively, its `meta`) is raw.
|
||||
for (const leaf of group!.children ?? []) {
|
||||
expect(isReactive(leaf)).toBe(false);
|
||||
expect(isReactive(leaf.meta as object)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("releases a large collapsed subtree and reloads it on demand", async () => {
|
||||
const { loadStore } = await setupStoreWithColumnGroup();
|
||||
const getColumns = vi.fn().mockResolvedValue(columns(500));
|
||||
const store = await loadStore(getColumns);
|
||||
|
||||
await store.loadColumns("pg-1", "app", "users", "public", GROUP_ID);
|
||||
expect(findById(store.treeNodes, GROUP_ID)?.children).toHaveLength(500);
|
||||
expect(store.isTreeNodeChildrenLoaded(GROUP_ID)).toBe(true);
|
||||
|
||||
const released = store.releaseCollapsedTreeNodeChildren(GROUP_ID);
|
||||
|
||||
expect(released).toBe(true);
|
||||
expect(findById(store.treeNodes, GROUP_ID)?.children).toEqual([]);
|
||||
// Forgetting the loaded id is what makes a later expand reload the children.
|
||||
expect(store.isTreeNodeChildrenLoaded(GROUP_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a small collapsed subtree so routine expand/collapse stays instant", async () => {
|
||||
const { loadStore } = await setupStoreWithColumnGroup();
|
||||
const getColumns = vi.fn().mockResolvedValue(columns(20));
|
||||
const store = await loadStore(getColumns);
|
||||
|
||||
await store.loadColumns("pg-1", "app", "users", "public", GROUP_ID);
|
||||
|
||||
const released = store.releaseCollapsedTreeNodeChildren(GROUP_ID);
|
||||
|
||||
expect(released).toBe(false);
|
||||
expect(findById(store.treeNodes, GROUP_ID)?.children).toHaveLength(20);
|
||||
expect(store.isTreeNodeChildrenLoaded(GROUP_ID)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ref, computed, watch, markRaw } from "vue";
|
||||
import type { ColumnInfo, CompletionAssistantCandidate, CompletionAssistantObjectKind, CompletionAssistantRequest, ConnectionConfig, CatalogInfo, ForeignKeyInfo, ObjectInfo, SchemaInfo, SidebarLayout, TableInfo, TreeNode, TunnelProfile, VectorCollectionMeta } from "@/types/database";
|
||||
import { applyPinnedTreeNodeState, updatePinnedTreeNodeInPlace } from "@/lib/app/pinnedItems";
|
||||
import {
|
||||
|
|
@ -938,6 +938,28 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
return [...existingMetadataChildren, ...nextUtilityChildren];
|
||||
}
|
||||
|
||||
// Leaf tree nodes (table columns / indexes / foreign keys / triggers) are
|
||||
// immutable data payloads: they never expand, never load children, and their
|
||||
// fields are never mutated after creation. A large schema can produce tens of
|
||||
// thousands of them, and Vue's deep reactivity wraps every node AND its nested
|
||||
// `meta` object in a Proxy — the dominant memory cost of the schema tree.
|
||||
// Marking each leaf raw keeps Vue from wrapping it (and, since Vue does not
|
||||
// recurse into raw objects, its `meta` too), mirroring the markRaw() treatment
|
||||
// queryStore already applies to result rows. Containers stay reactive so their
|
||||
// children / isExpanded / isLoading mutations still drive the UI.
|
||||
const LEAF_TREE_NODE_TYPES = new Set<TreeNode["type"]>(["column", "index", "fkey", "trigger"]);
|
||||
|
||||
function markRawLeafTreeNodes(nodes: TreeNode[]): TreeNode[] {
|
||||
for (const node of nodes) {
|
||||
if (LEAF_TREE_NODE_TYPES.has(node.type)) {
|
||||
markRaw(node);
|
||||
} else if (node.children && node.children.length > 0) {
|
||||
markRawLeafTreeNodes(node.children);
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function setChildren(parent: TreeNode, children: TreeNode[]) {
|
||||
children = preserveExistingConnectionMetadataChildren(parent, children);
|
||||
if (parent.children && parent.children.length > 0) {
|
||||
|
|
@ -950,7 +972,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
return child;
|
||||
});
|
||||
}
|
||||
parent.children = applyPinnedTreeNodeState(children, pinnedTreeNodeIds.value);
|
||||
parent.children = markRawLeafTreeNodes(applyPinnedTreeNodeState(children, pinnedTreeNodeIds.value));
|
||||
loadedTreeNodeChildrenIds.value.add(parent.id);
|
||||
}
|
||||
|
||||
|
|
@ -1478,6 +1500,47 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
return loadedTreeNodeChildrenIds.value.has(nodeId);
|
||||
}
|
||||
|
||||
// Collapsing a node only hides it — its loaded children stay in memory, so a
|
||||
// long browsing session accumulates every schema the user ever expanded and
|
||||
// the webview creeps upward. When a *large* subtree is collapsed we drop its
|
||||
// children so the memory is reclaimed; re-expanding reloads them (fast, from
|
||||
// the schema cache). Small subtrees are kept so routine expand/collapse stays
|
||||
// instant and never triggers a reload.
|
||||
const RELEASE_COLLAPSED_SUBTREE_MIN_DESCENDANTS = 400;
|
||||
|
||||
function countTreeNodeDescendants(node: TreeNode, cap: number): number {
|
||||
let count = 0;
|
||||
const stack: TreeNode[] = [...(node.children ?? [])];
|
||||
while (stack.length) {
|
||||
const current = stack.pop()!;
|
||||
count += 1;
|
||||
if (count >= cap) return count;
|
||||
if (current.children?.length) stack.push(...current.children);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function forgetLoadedChildrenIdsForSubtree(node: TreeNode) {
|
||||
loadedTreeNodeChildrenIds.value.delete(node.id);
|
||||
for (const child of node.children ?? []) {
|
||||
forgetLoadedChildrenIdsForSubtree(child);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns true when the collapsed node's children were released. Caller should
|
||||
// have already set node.isExpanded = false. Re-expanding reloads on demand
|
||||
// because the node id is removed from loadedTreeNodeChildrenIds.
|
||||
function releaseCollapsedTreeNodeChildren(nodeId: string): boolean {
|
||||
const node = findNode(treeNodes.value, nodeId);
|
||||
if (!node?.children?.length) return false;
|
||||
if (countTreeNodeDescendants(node, RELEASE_COLLAPSED_SUBTREE_MIN_DESCENDANTS) < RELEASE_COLLAPSED_SUBTREE_MIN_DESCENDANTS) {
|
||||
return false;
|
||||
}
|
||||
forgetLoadedChildrenIdsForSubtree(node);
|
||||
node.children = [];
|
||||
return true;
|
||||
}
|
||||
|
||||
function canApplyTreeMetadataResult(node: TreeNode): boolean {
|
||||
if (findNode(treeNodes.value, node.id) !== node) return false;
|
||||
if (node.connectionId && !connectedIds.value.has(node.connectionId)) return false;
|
||||
|
|
@ -5146,6 +5209,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
closeDatabaseConnection,
|
||||
ensureConnected,
|
||||
isTreeNodeChildrenLoaded,
|
||||
releaseCollapsedTreeNodeChildren,
|
||||
setBeforeConnectHandler,
|
||||
initFromDisk,
|
||||
loadDatabases,
|
||||
|
|
|
|||
Loading…
Reference in New Issue