From b45bf7077f57993778ad79fffe62cdbe83161027 Mon Sep 17 00:00:00 2001 From: vrustx <279631638@qq.com> Date: Fri, 17 Jul 2026 13:48:06 +0800 Subject: [PATCH] perf(app): reuse result byte estimates across tab switches --- apps/desktop/src/components/grid/DataGrid.vue | 1 + .../src/composables/useDataGridEditor.ts | 4 + .../__tests__/queryStore.touchResult.spec.ts | 219 ++++++++++++++++++ apps/desktop/src/stores/queryStore.ts | 62 +++-- 4 files changed, 268 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/stores/__tests__/queryStore.touchResult.spec.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index c6ebbf30e..d92a1fd00 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -2664,6 +2664,7 @@ const editor = useDataGridEditor({ pageSize, currentPage, cacheKey: computed(() => props.cacheKey), + onResultPayloadMutated: () => queryStore.invalidateResultEstimateForPayload(props.result), emit, }); diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index 582e2452d..b638ea095 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -95,6 +95,8 @@ export interface UseDataGridEditorOptions { pageSize: Ref; currentPage: Ref; cacheKey?: ComputedRef; + /** 保存成功后结果负载被原地修改时通知宿主,使缓存的字节估算失效。 */ + onResultPayloadMutated?: () => void; emit: { (event: "reload", sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number): void; }; @@ -1214,6 +1216,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { snapshot.newRowRefs.forEach((row) => savingNewRows.delete(row)); customHandler.applySavedChanges?.({ dirtyRows: snapshot.dirtyRows, columns: result.value.columns }); applyDirtyRowsToResult(snapshot); + options.onResultPayloadMutated?.(); clearSavedPendingChanges(snapshot); if (!hasPendingChanges.value) exitTransaction(); clearPendingChangeHistory(); @@ -1308,6 +1311,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { console.warn("[DBX] failed to record data grid history", e); } applyDirtyRowsToResult(snapshot); + options.onResultPayloadMutated?.(); snapshot.newRowRefs.forEach((row) => savingNewRows.delete(row)); clearSavedPendingChanges(snapshot); if (!hasPendingChanges.value) exitTransaction(); diff --git a/apps/desktop/src/stores/__tests__/queryStore.touchResult.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.touchResult.spec.ts new file mode 100644 index 000000000..e130148a1 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/queryStore.touchResult.spec.ts @@ -0,0 +1,219 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { QueryResult } from "@/types/database"; + +function sampleResult(rows: number): QueryResult { + return { + columns: ["id", "name"], + rows: Array.from({ length: rows }, (_, index) => [index, `row-${index}`]), + affected_rows: 0, + execution_time_ms: 1, + }; +} + +describe("queryStore touchResult estimated bytes reuse", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + setActivePinia(createPinia()); + }); + + it("reuses the cached byte estimate when switching tabs", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + + const tab1Id = store.createTab("pg-1", "app", "first", "query"); + const tab2Id = store.createTab("pg-1", "app", "second", "query"); + const tab1 = store.tabs.find((tab) => tab.id === tab1Id)!; + tab1.result = sampleResult(50); + tab1.resultEstimatedBytes = 987654; + const previousAccessedAt = 1000; + tab1.resultAccessedAt = previousAccessedAt; + + store.activeTabId = tab2Id; + store.activeTabId = tab1Id; + + // 切页只应更新访问时间,不应深遍历结果集重算字节数 + expect(tab1.resultEstimatedBytes).toBe(987654); + expect(tab1.resultAccessedAt).toBeGreaterThan(previousAccessedAt); + expect(tab1.resultCacheState).toBe("memory"); + }); + + it("computes the byte estimate on tab switch when it is missing", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + + const tab1Id = store.createTab("pg-1", "app", "first", "query"); + const tab2Id = store.createTab("pg-1", "app", "second", "query"); + const tab2 = store.tabs.find((tab) => tab.id === tab2Id)!; + tab2.result = sampleResult(3); + tab2.resultEstimatedBytes = undefined; + + store.activeTabId = tab1Id; + store.activeTabId = tab2Id; + + expect(tab2.resultEstimatedBytes).toBeGreaterThan(0); + }); + + it("refreshes the estimate when an error result replaces a large payload", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + + const tabId = store.createTab("pg-1", "app", "big", "query"); + const tab = store.tabs.find((item) => item.id === tabId)!; + tab.result = sampleResult(5000); + tab.resultEstimatedBytes = 5_000_000; + + store.setErrorResult(tabId, new Error("boom")); + + // 错误结果替换大负载后估算值必须刷新,否则内存淘汰会按旧的大结果计算 + expect(tab.resultEstimatedBytes).toBeGreaterThan(0); + expect(tab.resultEstimatedBytes).toBeLessThan(5_000_000); + }); + + it("recomputes the estimate when a result run is restored from disk", async () => { + const smallResult = sampleResult(2); + vi.doMock("@/lib/tabs/tabResultCache", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readTabResultSnapshot: vi.fn(async () => ({ resultRuns: [{ id: "run-1", result: smallResult }] }) as any), + }; + }); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + + const tabId = store.createTab("pg-1", "app", "runs", "query"); + const tab = store.tabs.find((item) => item.id === tabId)!; + tab.resultRuns = [ + { + id: "run-1", + title: "Run 1", + sequence: 1, + sql: "select 1", + createdAt: 1, + resultCacheKey: "cache-key", + // 落盘前的过期估算值,恢复后不应被直接信任 + resultEstimatedBytes: 999_999, + }, + ]; + + const restored = await store.setActiveResultRun(tabId, "run-1"); + + expect(restored).toBe(true); + expect(tab.result).toBeDefined(); + expect(tab.resultEstimatedBytes).toBeGreaterThan(0); + expect(tab.resultEstimatedBytes).toBeLessThan(999_999); + }); + + it("clears grouped results and refreshes the estimate when the connection may be lost", async () => { + // notifyConnectionMayBeLost 会初始化 connectionStore,其 setup 读取 localStorage + const data = new Map(); + 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)), + }); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + + const tabId = store.createTab("pg-1", "app", "grouped", "query"); + const tab = store.tabs.find((item) => item.id === tabId)!; + tab.results = [sampleResult(2000), sampleResult(2000)]; + tab.activeResultIndex = 0; + tab.result = tab.results[0]; + tab.resultEstimatedBytes = 4_000_000; + tab.isExecuting = true; + + store.notifyConnectionMayBeLost(); + + // 分组结果必须清空:否则错误结果不会展示,估算也会继续按旧 results 计算 + expect(tab.results).toBeUndefined(); + expect(tab.result?.columns).toContain("Error"); + expect(tab.resultEstimatedBytes).toBeGreaterThan(0); + expect(tab.resultEstimatedBytes).toBeLessThan(4_000_000); + }); + + it("invalidates run estimates when a full snapshot is restored from disk", async () => { + const smallResult = sampleResult(2); + vi.doMock("@/lib/tabs/tabResultCache", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readTabResultSnapshot: vi.fn( + async () => + ({ + result: smallResult, + resultRuns: [ + { + id: "run-1", + title: "Run 1", + sequence: 1, + sql: "select 1", + createdAt: 1, + result: smallResult, + // 落盘前的过期估算值 + resultEstimatedBytes: 888_888, + }, + ], + activeResultRunId: "run-1", + }) as any, + ), + }; + }); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + + const tabId = store.createTab("pg-1", "app", "evicted", "query"); + const tab = store.tabs.find((item) => item.id === tabId)!; + tab.resultEvicted = true; + tab.resultCacheKey = "cache-key"; + + await store.reloadEvictedTab(tabId); + + expect(tab.result).toBeDefined(); + expect(tab.resultRuns?.[0]?.resultEstimatedBytes).toBeUndefined(); + expect(tab.resultEstimatedBytes).toBeGreaterThan(0); + expect(tab.resultEstimatedBytes).toBeLessThan(888_888); + }); + + it("invalidates estimates for every holder of a mutated payload", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + + const tabId = store.createTab("pg-1", "app", "edited", "query"); + const tab = store.tabs.find((item) => item.id === tabId)!; + const payload = sampleResult(3); + tab.result = payload; + tab.resultEstimatedBytes = 111_111; + tab.resultRuns = [ + { id: "run-1", title: "Run 1", sequence: 1, sql: "select 1", createdAt: 1, result: payload, resultEstimatedBytes: 222_222 }, + { id: "run-2", title: "Run 2", sequence: 2, sql: "select 2", createdAt: 2, result: sampleResult(3), resultEstimatedBytes: 333_333 }, + ]; + + store.invalidateResultEstimateForPayload(tab.result); + + // 持有同一负载对象的 tab 与 run 估算都应失效,未持有的 run 不受影响 + expect(tab.resultEstimatedBytes).toBeUndefined(); + expect(tab.resultRuns[0]!.resultEstimatedBytes).toBeUndefined(); + expect(tab.resultRuns[1]!.resultEstimatedBytes).toBe(333_333); + }); + + it("keeps the estimate for the whole result group when switching result index", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + + const tabId = store.createTab("pg-1", "app", "multi", "query"); + const tab = store.tabs.find((item) => item.id === tabId)!; + tab.results = [sampleResult(5), sampleResult(10)]; + tab.activeResultIndex = 0; + tab.result = tab.results[0]; + tab.resultEstimatedBytes = 555555; + + store.setActiveResultIndex(tabId, 1); + + // results 数组未变,组级估算值与激活下标无关 + expect(tab.activeResultIndex).toBe(1); + expect(tab.resultEstimatedBytes).toBe(555555); + }); +}); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index bdf581fa3..c09782d0f 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -457,7 +457,11 @@ export const useQueryStore = defineStore("query", () => { if (throwOnError) throw error; } finally { if (tab.resultSessionId === sessionId) tab.resultSessionId = undefined; - if (tab.result?.session_id === sessionId) tab.result.session_id = undefined; + if (tab.result?.session_id === sessionId) { + tab.result.session_id = undefined; + // 原地修改了负载,让持有它的 tab 与 run 的估算值都失效 + invalidateResultEstimateForPayload(tab.result); + } } } @@ -505,15 +509,30 @@ export const useQueryStore = defineStore("query", () => { } } - function touchResult(tab: QueryTab | undefined, accessedAt = Date.now()) { + function touchResult(tab: QueryTab | undefined, accessedAt = Date.now(), options: { reuseEstimatedBytes?: boolean } = {}) { if (tab?.result || tab?.results) { tab.resultAccessedAt = accessedAt; - tab.resultEstimatedBytes = estimateQueryResultsBytes(tab.result, tab.results); + // 纯访问路径(如切换标签页)可复用已算好的估算值:estimateQueryResultsBytes + // 会同步深遍历整份结果集,挂在 sync watch 上会直接阻塞切页交互。 + if (!options.reuseEstimatedBytes || tab.resultEstimatedBytes === undefined) { + tab.resultEstimatedBytes = estimateQueryResultsBytes(tab.result, tab.results); + } tab.resultCacheState = "memory"; tab.resultEvicted = undefined; } } + /** 结果负载被原地修改(如保存后写回单元格)时,让持有它的 tab/run 的字节估算失效,下次访问按需重算。 */ + function invalidateResultEstimateForPayload(result: QueryResult | undefined) { + if (!result) return; + for (const tab of tabs.value) { + if (tab.result === result || tab.results?.includes(result)) tab.resultEstimatedBytes = undefined; + for (const run of tab.resultRuns ?? []) { + if (run.result === result || run.results?.includes(result)) run.resultEstimatedBytes = undefined; + } + } + } + function clearResultPayload(tab: QueryTab, options: { evicted?: boolean } = {}) { tab.result = undefined; tab.results = undefined; @@ -593,7 +612,7 @@ export const useQueryStore = defineStore("query", () => { tab.queryEditabilityReason = run.queryEditabilityReason; tab.mongoEditTarget = run.mongoEditTarget; tab.tableMeta = run.tableMeta; - touchResult(tab); + touchResult(tab, Date.now(), { reuseEstimatedBytes: true }); } async function restoreResultRunPayload(tab: QueryTab, runId: string) { @@ -614,6 +633,9 @@ export const useQueryStore = defineStore("query", () => { result: snapshotRun.result ? markQueryResultRowsRaw(snapshotRun.result) : undefined, results: snapshotRun.results ? markQueryResultsRowsRaw(snapshotRun.results) : undefined, resultCacheState: "memory" as const, + // 快照编解码会重建负载(如省略 session_id),落盘前的估算值不再对应 + // 恢复后的对象,置空以便 projectResultRun 按当前负载重算 + resultEstimatedBytes: undefined, }, ])[0]!; tab.resultRuns = tab.resultRuns?.map((item) => (item.id === runId ? restoredRun : item)); @@ -845,7 +867,8 @@ export const useQueryStore = defineStore("query", () => { tab.resultLocalSortOriginalMongoDocuments = undefined; } - touchResult(tab); + // 本地排序只是重排既有行/文档,字节规模不变,可复用估算值 + touchResult(tab, Date.now(), { reuseEstimatedBytes: true }); syncDisplayedResultRun(tab, tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql); } @@ -2272,6 +2295,7 @@ export const useQueryStore = defineStore("query", () => { tab.isCancelling = false; tab.queryExecutionStartedAt = undefined; tab.executionId = undefined; + touchResult(tab); } function clearAcknowledgedCancelIfStillRunning(id: string, executionId: string) { @@ -3806,11 +3830,9 @@ export const useQueryStore = defineStore("query", () => { if (tab) useConnectionStore().recordConnectionLostError(tab.connectionId, e); const current = tabs.value.find((t) => t.id === id); if (current && current.executionId === executionId) { - current.isExecuting = false; - current.isCancelling = false; - current.executionId = undefined; - current.queryExecutionStartedAt = undefined; - current.result = toErrorResult(e); + // 复用 setErrorResult 的完整清理:分组结果不清空的话,错误结果不会展示, + // 估算值也会继续按旧的 results 计算 + setErrorResult(id, e); } return false; } finally { @@ -3845,7 +3867,8 @@ export const useQueryStore = defineStore("query", () => { tab.resultSortDirection = undefined; tab.resultSortMode = undefined; tab.resultSortedSql = undefined; - touchResult(tab); + // results 数组未变,估算值与当前激活的 result 无关,可直接复用 + touchResult(tab, Date.now(), { reuseEstimatedBytes: true }); tab.queryAnalysis = undefined; tab.querySourceColumns = undefined; tab.queryEditabilityReason = undefined; @@ -3863,12 +3886,8 @@ export const useQueryStore = defineStore("query", () => { if (stuck.length > 0) { const connStore = useConnectionStore(); stuck.forEach((tab) => { - tab.isExecuting = false; - tab.isCancelling = false; - tab.queryExecutionStartedAt = undefined; - tab.executionId = undefined; const error = new Error(t("editor.connectionMayBeLost")); - tab.result = toErrorResult(error); + setErrorResult(tab.id, error); connStore.markConnectionLost(tab.connectionId, error); }); } @@ -3944,7 +3963,11 @@ export const useQueryStore = defineStore("query", () => { activeTabId, (id) => { rememberActiveTab(id); - touchResult(tabs.value.find((tab) => tab.id === id)); + touchResult( + tabs.value.find((tab) => tab.id === id), + Date.now(), + { reuseEstimatedBytes: true }, + ); }, { flush: "sync" }, ); @@ -3959,7 +3982,9 @@ export const useQueryStore = defineStore("query", () => { tab.result = snapshot.result ? markQueryResultRowsRaw(snapshot.result) : results?.[activeIndex] ? markQueryResultRowsRaw(results[activeIndex]) : undefined; tab.resultLocalSortOriginalRows = snapshot.resultLocalSortOriginalRows ? markRaw(snapshot.resultLocalSortOriginalRows) : undefined; tab.resultLocalSortOriginalMongoDocuments = snapshot.resultLocalSortOriginalMongoDocuments ? markRaw(snapshot.resultLocalSortOriginalMongoDocuments) : undefined; - tab.resultRuns = snapshot.resultRuns ? markQueryResultRunsRowsRaw(snapshot.resultRuns) : tab.resultRuns; + // 快照编解码会重建负载,落盘前的各 run 估算值不再对应恢复后的对象, + // 置空让 projectResultRun 按需重算 + tab.resultRuns = snapshot.resultRuns ? markQueryResultRunsRowsRaw(snapshot.resultRuns).map((run) => ({ ...run, resultEstimatedBytes: undefined })) : tab.resultRuns; tab.activeResultRunId = snapshot.activeResultRunId ?? tab.activeResultRunId; if (!tab.result && !tab.results && !tab.resultRuns) return false; @@ -4344,6 +4369,7 @@ export const useQueryStore = defineStore("query", () => { setExecuting, setExecutingWithId, setErrorResult, + invalidateResultEstimateForPayload, toggleResultAutoSave, setActiveResultRun, removeResultRun,