perf(app): reuse result byte estimates across tab switches
This commit is contained in:
parent
1dc5c10a6c
commit
b45bf7077f
|
|
@ -2664,6 +2664,7 @@ const editor = useDataGridEditor({
|
|||
pageSize,
|
||||
currentPage,
|
||||
cacheKey: computed(() => props.cacheKey),
|
||||
onResultPayloadMutated: () => queryStore.invalidateResultEstimateForPayload(props.result),
|
||||
emit,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue