perf(app): reuse result byte estimates across tab switches

This commit is contained in:
vrustx 2026-07-17 13:48:06 +08:00 committed by GitHub
parent 1dc5c10a6c
commit b45bf7077f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 268 additions and 18 deletions

View File

@ -2664,6 +2664,7 @@ const editor = useDataGridEditor({
pageSize,
currentPage,
cacheKey: computed(() => props.cacheKey),
onResultPayloadMutated: () => queryStore.invalidateResultEstimateForPayload(props.result),
emit,
});

View File

@ -95,6 +95,8 @@ export interface UseDataGridEditorOptions {
pageSize: Ref<number>;
currentPage: Ref<number>;
cacheKey?: ComputedRef<string | undefined>;
/** 保存成功后结果负载被原地修改时通知宿主,使缓存的字节估算失效。 */
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();

View File

@ -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<typeof import("@/lib/tabs/tabResultCache")>();
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<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)),
});
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<typeof import("@/lib/tabs/tabResultCache")>();
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);
});
});

View File

@ -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,