From fd4ca307072ba4d0abcc935690fdc7481808b9f4 Mon Sep 17 00:00:00 2001 From: soddygo Date: Tue, 15 Sep 2026 16:00:03 +0800 Subject: [PATCH] fix(grid): refresh table metadata before reloading data --- apps/desktop/src/components/grid/DataGrid.vue | 8 +- .../__tests__/useDataGridActions.spec.ts | 162 ++++++++++- .../src/composables/useDataGridActions.ts | 261 +++++++++++------- .../src/lib/dataGrid/dataGridToolbar.ts | 2 +- .../__tests__/objectMetadataCache.spec.ts | 24 ++ packages/app-tests/queryResultToolbar.test.ts | 6 +- 6 files changed, 353 insertions(+), 110 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 9e8a5d585f..496846f7b3 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -584,7 +584,7 @@ const emit = defineEmits<{ const autoRefresh = useDataGridAutoRefresh({ canRefresh: computed(() => !isSaving.value && !props.loading), - refresh: onToolbarRefresh, + refresh: () => reloadTableData("auto-refresh"), }); const autoRefreshIntervalSeconds = autoRefresh.intervalSeconds; const autoRefreshEnabled = autoRefresh.enabled; @@ -4130,6 +4130,10 @@ function prepareFullReload() { } async function onToolbarRefresh() { + await reloadTableData("refresh"); +} + +async function reloadTableData(intent: DataGridReloadIntent) { if (transactionActive.value) { discardChanges(); } @@ -4140,7 +4144,7 @@ async function onToolbarRefresh() { } markConditionInputsApplied(); prepareFullReload(); - emit("reload", props.sql, searchText.value, currentWhereInput(), currentOrderBy(), pageSize.value, resetToFirstPage ? 0 : (currentPage.value - 1) * pageSize.value, "refresh"); + emit("reload", props.sql, searchText.value, currentWhereInput(), currentOrderBy(), pageSize.value, resetToFirstPage ? 0 : (currentPage.value - 1) * pageSize.value, intent); } function setAutoRefreshInterval(seconds: number) { diff --git a/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts index 4b86a645a8..f93a2e8a5d 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ setExecuting: vi.fn(), updateSql: vi.fn(), getColumns: vi.fn(), + deleteSchemaCachePrefix: vi.fn(), listIndexes: vi.fn(), ensureConnected: vi.fn(), tableOpenPageSize: 100, @@ -35,6 +36,7 @@ vi.mock("vue-i18n", () => ({ vi.mock("@/lib/backend/api", () => ({ buildSortedQuerySql: mocks.buildSortedQuerySql, getColumns: mocks.getColumns, + deleteSchemaCachePrefix: mocks.deleteSchemaCachePrefix, listIndexes: mocks.listIndexes, })); @@ -136,6 +138,7 @@ describe("useDataGridActions", () => { clearTableMetadataCache(); vi.clearAllMocks(); mocks.executeTabSql.mockReset(); + mocks.deleteSchemaCachePrefix.mockReset().mockResolvedValue(undefined); mocks.tabs.length = 0; mocks.tableOpenPageSize = 100; mocks.infiniteScroll = true; @@ -152,10 +155,151 @@ describe("useDataGridActions", () => { mocks.listIndexes.mockResolvedValue([]); }); + it("manual refresh waits for cache deletion and fresh columns before building SQL", async () => { + const tab = tableDataTab(); + mocks.tabs.push(tab); + let release!: () => void; + mocks.deleteSchemaCachePrefix.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + mocks.getColumns.mockResolvedValue([...tab.tableMeta!.columns, { name: "added", data_type: "text", is_nullable: true }]); + const actions = useDataGridActions(computed(() => tab)); + const reload = actions.onReloadData(tab.id, tab.sql, "", "", "", 25, 50, "refresh"); + await vi.waitFor(() => expect(mocks.deleteSchemaCachePrefix).toHaveBeenCalledWith("object-meta:v1:postgres-1:app:public:users:")); + expect(mocks.getColumns).not.toHaveBeenCalled(); + expect(mocks.buildTableSelectSql).not.toHaveBeenCalled(); + release(); + await reload; + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: ["id", "added"], limit: 25, offset: 50 })); + }); + + it("preserves rows when cache deletion fails and allows retry", async () => { + const tab = tableDataTab(); + mocks.tabs.push(tab); + const result = tab.result; + const failure = new Error("cache deletion failed"); + mocks.deleteSchemaCachePrefix.mockRejectedValueOnce(failure); + const actions = useDataGridActions(computed(() => tab)); + await expect(actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh")).rejects.toMatchObject({ name: "ObjectCacheInvalidationError", reason: failure }); + expect(tab.result).toBe(result); + expect(mocks.getColumns).not.toHaveBeenCalled(); + expect(mocks.executeTabSql).not.toHaveBeenCalled(); + expect(mocks.setExecuting).toHaveBeenLastCalledWith(tab.id, false); + await actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh"); + expect(mocks.executeTabSql).toHaveBeenCalledTimes(1); + }); + + it("waits for fresh columns and suppresses a duplicate refresh", async () => { + const tab = tableDataTab(); + mocks.tabs.push(tab); + let release!: (columns: unknown[]) => void; + mocks.getColumns.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve; + }), + ); + const actions = useDataGridActions(computed(() => tab)); + const reload = actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh"); + await vi.waitFor(() => expect(mocks.getColumns).toHaveBeenCalledTimes(1)); + await actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh"); + expect(mocks.buildTableSelectSql).not.toHaveBeenCalled(); + release([{ ...tab.tableMeta!.columns[0], name: "renamed", data_type: "bigint" }]); + await reload; + expect(mocks.executeTabSql).toHaveBeenCalledTimes(1); + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: ["renamed"] })); + expect(tab.tableMeta!.columns[0].data_type).toBe("bigint"); + }); + + it("does not execute after the tab is replaced during metadata loading", async () => { + const tab = tableDataTab(); + mocks.tabs.push(tab); + let release!: (columns: unknown[]) => void; + mocks.getColumns.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve; + }), + ); + const actions = useDataGridActions(computed(() => tab)); + const reload = actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh"); + await vi.waitFor(() => expect(mocks.getColumns).toHaveBeenCalledTimes(1)); + mocks.tabs.splice(0, 1, tableDataTab()); + release(tab.tableMeta!.columns); + await reload; + expect(mocks.setTableMeta).not.toHaveBeenCalled(); + expect(mocks.executeTabSql).not.toHaveBeenCalled(); + }); + + it("does not execute old SQL or clear a replacement tab's loading state after SQL generation", async () => { + const tab = tableDataTab(); + mocks.tabs.push(tab); + let release!: (sql: string) => void; + mocks.buildTableSelectSql.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve; + }), + ); + const actions = useDataGridActions(computed(() => tab)); + const reload = actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh"); + await vi.waitFor(() => expect(mocks.buildTableSelectSql).toHaveBeenCalledTimes(1)); + mocks.tabs.splice(0, 1, tableDataTab({ isExecuting: true })); + mocks.setExecuting.mockClear(); + release("SELECT old_target"); + await reload; + expect(mocks.updateSql).not.toHaveBeenCalled(); + expect(mocks.executeTabSql).not.toHaveBeenCalled(); + expect(mocks.setExecuting).not.toHaveBeenCalled(); + }); + + it.each(["closed", "retargeted", "reconnected"] as const)("abandons SQL prepared for a %s tab", async (change) => { + const tab = tableDataTab(); + mocks.tabs.push(tab); + let release!: (sql: string) => void; + mocks.buildTableSelectSql.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve; + }), + ); + const actions = useDataGridActions(computed(() => tab)); + const reload = actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh"); + await vi.waitFor(() => expect(mocks.buildTableSelectSql).toHaveBeenCalledTimes(1)); + if (change === "closed") mocks.tabs.length = 0; + if (change === "retargeted") tab.tableMeta!.tableName = "other_table"; + if (change === "reconnected") mocks.metadataGeneration++; + release("SELECT stale_target"); + await reload; + expect(mocks.updateSql).not.toHaveBeenCalled(); + expect(mocks.executeTabSql).not.toHaveBeenCalled(); + }); + + it("does not let an older background metadata read overwrite manual refresh", async () => { + const tab = tableDataTab({ tableMetaUpdatedAt: 1 }); + mocks.tabs.push(tab); + const oldColumns = tab.tableMeta!.columns; + let release!: (columns: unknown[]) => void; + mocks.getColumns.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve; + }), + ); + const actions = useDataGridActions(computed(() => tab)); + await actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "auto-refresh"); + await vi.waitFor(() => expect(mocks.getColumns).toHaveBeenCalledTimes(1)); + mocks.getColumns.mockResolvedValueOnce([...oldColumns, { ...oldColumns[0], name: "added" }]); + await actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 0, "refresh"); + expect(tab.tableMeta!.columns.map((column) => column.name)).toEqual(["id", "added"]); + release(oldColumns); + // Drain the old metadata promise and its consumers. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(tab.tableMeta!.columns.map((column) => column.name)).toEqual(["id", "added"]); + }); + it("uses the configured table-data default when toolbar reload has no saved pagination", async () => { mocks.tableOpenPageSize = 250; mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT * FROM public.users LIMIT 250 OFFSET 0"); const tab = tableDataTab(); + mocks.tabs.push(tab); const actions = useDataGridActions(computed(() => tab)); await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh"); @@ -297,6 +441,7 @@ describe("useDataGridActions", () => { resultPageOffset: 50, }); mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT * FROM public.users LIMIT 25 OFFSET 50"); + mocks.tabs.push(tab); const actions = useDataGridActions(computed(() => tab)); await actions.onReloadData(tab.id, tab.sql, "", "", "", 25, 50, "refresh"); @@ -316,6 +461,7 @@ describe("useDataGridActions", () => { }, }); mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT * FROM public.users LIMIT 100 OFFSET 100"); + mocks.tabs.push(tab); const actions = useDataGridActions(computed(() => tab)); await actions.onPaginate(tab.id, 100, 100); @@ -448,8 +594,8 @@ describe("useDataGridActions", () => { await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh"); - // 合成的 ["Error"] 结果列不得进入 SQL 投影:真实列缺失时省略 columns(SELECT *) - expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: undefined })); + // Explicit refresh waits for actual metadata rather than projecting fallback error columns. + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: ["id"] })); await vi.waitFor(() => { expect(mocks.getColumns).toHaveBeenCalled(); expect(mocks.setTableMeta).toHaveBeenCalledWith("tab-1", expect.objectContaining({ primaryKeys: ["id"] })); @@ -467,7 +613,7 @@ describe("useDataGridActions", () => { // 第一轮:stale-tab 早退(tabs 里找不到匹配项时 refreshDataTabTableMeta 直接返回) mocks.tabs.length = 0; - await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh"); + await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "auto-refresh"); await vi.waitFor(() => expect(mocks.getColumns).toHaveBeenCalledTimes(1)); expect(mocks.setTableMeta).not.toHaveBeenCalled(); expect(tab.tableMetaPending).toBe(true); @@ -475,7 +621,7 @@ describe("useDataGridActions", () => { // 第二轮:真实 columns 仍为空,新的消费者加入同一在途请求;共享缓存应 // 去重后端调用,但本轮仍要在目标恢复后落地结果 mocks.tabs.push(tab); - await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh"); + await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "auto-refresh"); await vi.waitFor(() => { expect(mocks.getColumns).toHaveBeenCalledTimes(1); expect(mocks.setTableMeta).toHaveBeenCalledWith("tab-1", expect.objectContaining({ primaryKeys: ["id"] })); @@ -502,7 +648,7 @@ describe("useDataGridActions", () => { mocks.tabs.push(tab); const actions = useDataGridActions(computed(() => tab)); - await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh"); + await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "auto-refresh"); // Dameng 元数据必须排在数据查询之后(串行约束,同 useSidebarDataOpenRuntime); // 真实元数据落地后结束行标识等待 @@ -525,7 +671,7 @@ describe("useDataGridActions", () => { const actions = useDataGridActions(computed(() => tab)); // 查询 reject 仍会重新抛出,但元数据刷新必须已启动,标签页可恢复 - await expect(actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh")).rejects.toThrow("query failed"); + await expect(actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "auto-refresh")).rejects.toThrow("query failed"); await vi.waitFor(() => { expect(mocks.getColumns).toHaveBeenCalled(); expect(tab.tableMetaPending).toBe(false); @@ -589,12 +735,12 @@ describe("useDataGridActions", () => { expect(mocks.executeTabSql).toHaveBeenCalledTimes(1); }); - it("does not refetch metadata on a warm toolbar reload in the same generation", async () => { + it("does not refetch metadata on a warm automatic reload in the same generation", async () => { const tab = tableDataTab(); mocks.tabs.push(tab); const actions = useDataGridActions(computed(() => tab)); - await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh"); + await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "auto-refresh"); expect(mocks.getColumns).not.toHaveBeenCalled(); expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: ["id"] })); diff --git a/apps/desktop/src/composables/useDataGridActions.ts b/apps/desktop/src/composables/useDataGridActions.ts index 5a0c69968f..2db7f47ad1 100644 --- a/apps/desktop/src/composables/useDataGridActions.ts +++ b/apps/desktop/src/composables/useDataGridActions.ts @@ -1,3 +1,4 @@ +import { invalidateObjectMetadataCache } from "@/lib/metadata/objectMetadataCache"; import { type ComputedRef } from "vue"; import { useI18n } from "vue-i18n"; import { useConnectionStore } from "@/stores/connectionStore"; @@ -13,7 +14,7 @@ import * as api from "@/lib/backend/api"; import type { QueryTab } from "@/types/database"; import { useToast } from "@/composables/useToast"; import { effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/database/jdbcDialect"; -import { loadTableMetadata, TABLE_METADATA_CACHE_TTL_MS } from "@/lib/metadata/tableMetadataCache"; +import { invalidateTableMetadataCache, loadTableMetadata, TABLE_METADATA_CACHE_TTL_MS } from "@/lib/metadata/tableMetadataCache"; import { isDataTabMetadataLifecycleStale } from "@/lib/sidebar/dataTabOpenPolicy"; import { applyMongoFindSort } from "@/lib/mongo/mongoShellCommand"; import { uuid } from "@/lib/common/utils"; @@ -44,6 +45,8 @@ export function useDataGridActions(activeTab: ComputedRef) const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); const settingsStore = useSettingsStore(); + const pendingDataReloads = new Set(); + const metadataRequests = new WeakMap(); // A data-grid action names the tab that emitted the event, so a queued // or late-routed event cannot fall back to whichever tab is active now. @@ -146,13 +149,17 @@ export function useDataGridActions(activeTab: ComputedRef) tableName: tableMeta.tableName, tableType: tableMeta.tableType, }; + // Cache invalidation prevents stale cache writes, but an older caller can + // still receive its result. Only the latest request may update this tab. + const requestToken = {}; + metadataRequests.set(tab, requestToken); const metadataGenerationAtStart = connectionStore.metadataGenerationFor(target.connectionId, target.database); const trace = options.trace; console.info("[DBX][reloadData:metadata:ensure-connected:start]", { traceId: trace?.traceId, elapsed: trace?.elapsed() }); await connectionStore.ensureConnected(target.connectionId); console.info("[DBX][reloadData:metadata:ensure-connected:done]", { traceId: trace?.traceId, elapsed: trace?.elapsed() }); - if (connectionStore.metadataGenerationFor(target.connectionId, target.database) !== metadataGenerationAtStart) { + if (metadataRequests.get(tab) !== requestToken || connectionStore.metadataGenerationFor(target.connectionId, target.database) !== metadataGenerationAtStart) { console.info("[DBX][reloadData:metadata:superseded-by-connection-generation]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), table: target.tableName }); return false; } @@ -175,14 +182,23 @@ export function useDataGridActions(activeTab: ComputedRef) }); const columns = metadata.columns; console.info("[DBX][reloadData:metadata:get-columns:done]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), columnCount: columns.length }); - if (connectionStore.metadataGenerationFor(target.connectionId, target.database) !== metadataGenerationAtStart) { + if (metadataRequests.get(tab) !== requestToken || connectionStore.metadataGenerationFor(target.connectionId, target.database) !== metadataGenerationAtStart) { console.info("[DBX][reloadData:metadata:superseded-by-connection-generation]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), table: target.tableName }); return false; } const current = queryStore.tabs.find((item) => item.id === target.tabId); const currentMeta = current ? tableMetaForDataTab(current) : undefined; const currentSourceDatabase = currentMeta?.database ?? current?.database; - if (!current || current.mode !== "data" || current.connectionId !== target.connectionId || currentSourceDatabase !== target.database || currentMeta?.tableName !== target.tableName || (currentMeta.schema ?? "") !== (target.schema ?? "") || (currentMeta.catalog ?? "") !== (target.catalog ?? "")) { + if ( + !current || + current !== tab || + current.mode !== "data" || + current.connectionId !== target.connectionId || + currentSourceDatabase !== target.database || + currentMeta?.tableName !== target.tableName || + (currentMeta.schema ?? "") !== (target.schema ?? "") || + (currentMeta.catalog ?? "") !== (target.catalog ?? "") + ) { console.info("[DBX][reloadData:metadata:stale-tab]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), table: target.tableName }); return false; } @@ -213,108 +229,159 @@ export function useDataGridActions(activeTab: ComputedRef) const startedAt = performance.now(); const elapsed = () => `${Math.round(performance.now() - startedAt)}ms`; if (tab.mode === "data" && tableMetaForDataTab(tab)) { - reconcileOracleTableType(tab); - tab.whereInput = whereInput ?? ""; - queryStore.clearInvalidDataTabSort(tab.id); - const realColumnNames = tab.tableMeta?.columns.map((column) => column.name) ?? []; - let incomingSortMissing = realColumnNames.length > 0 && simpleDataGridOrderByReferencesMissingColumn(orderBy, realColumnNames); - if (incomingSortMissing) tab.orderByInput = undefined; - const pageLimit = limit ?? tab.resultPageLimit ?? tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize); - const pageOffset = offset ?? 0; - console.info("[DBX][reloadData:start]", { - traceId, - tabId: tab.id, - connectionId: tab.connectionId, - database: tab.database, - table: tableMetaForDataTab(tab)?.tableName, - elapsed: elapsed(), - }); - queryStore.setExecuting(tab.id, true); - const metadataAgeMs = tab.tableMetaUpdatedAt ? Date.now() - tab.tableMetaUpdatedAt : Number.POSITIVE_INFINITY; - // 判断元数据是否真实存在必须用原始 tab.tableMeta:tableMetaForDataTab 会在 - // 真实列缺失时用查询结果列合成 columns(包括失败结果的 ["Error"] 列), - // 不能据此跳过刷新,否则恢复/失败后的重试会被 TTL 卡住 - const hasRealTableMetaColumns = !!tab.tableMeta?.columns.length; - if (hasRealTableMetaColumns) { - try { - console.info("[DBX][reloadData:ensure-connected:start]", { traceId, elapsed: elapsed() }); - await connectionStore.ensureConnected(tab.connectionId); - console.info("[DBX][reloadData:ensure-connected:done]", { traceId, elapsed: elapsed() }); - } catch (e: any) { - console.warn("[DBX][reloadData:ensure-connected:error]", { traceId, elapsed: elapsed(), error: e }); - queryStore.setExecuting(tab.id, false); - toast(e?.message || String(e), 5000); - throw e; - } - } - const connectionGeneration = connectionStore.metadataGenerationFor(tab.connectionId, tab.database); - const lifecycleStale = isDataTabMetadataLifecycleStale(tab, connectionGeneration); - const shouldRefreshMetadata = lifecycleStale || !hasRealTableMetaColumns || metadataAgeMs > DATA_TAB_METADATA_TTL_MS; - // Dameng 元数据必须与数据查询串行(同 useSidebarDataOpenRuntime), - // 延后到查询完成后再启动。跨生命周期的强制重建除外:必须先拿到新列再 - // 构建 SQL,否则第一次 toolbar reload 仍会沿用断链前的显式列列表。 - const deferMetadataRefresh = !lifecycleStale && effectiveDatabaseTypeForConnection(connectionStore.getConfig(tab.connectionId)) === "dameng"; - const startMetadataRefresh = () => { - console.info("[DBX][reloadData:metadata:background:start]", { traceId, elapsed: elapsed(), reason: hasRealTableMetaColumns ? "stale" : "missing", metadataAgeMs }); - void refreshDataTabTableMeta(tab, { force: lifecycleStale, trace: { traceId, elapsed } }) - .then(() => { - console.info("[DBX][reloadData:metadata:background:done]", { traceId, elapsed: elapsed() }); - }) - .catch((e: any) => { - console.warn("[DBX][reloadData:metadata:background:error]", { traceId, elapsed: elapsed(), error: e }); - toast(e?.message || String(e), 5000); - }); + if (pendingDataReloads.has(tab)) return; + pendingDataReloads.add(tab); + const registered = queryStore.tabs.includes(tab); + const currentTab = () => (registered ? queryStore.tabs.find((item) => item.id === tab.id) : resolveActionTab(tab.id)); + const source = tableMetaForDataTab(tab)!; + const target = { connectionId: tab.connectionId, database: tab.database, sourceDatabase: source.database ?? tab.database, tableName: source.tableName, schema: source.schema, catalog: source.catalog }; + const stillCurrent = () => { + const meta = tableMetaForDataTab(tab); + return ( + currentTab() === tab && + tab.mode === "data" && + tab.connectionId === target.connectionId && + tab.database === target.database && + (meta?.database ?? tab.database) === target.sourceDatabase && + meta?.tableName === target.tableName && + meta?.schema === target.schema && + meta?.catalog === target.catalog + ); }; - if (lifecycleStale) { - if (!hasRealTableMetaColumns) tab.tableMetaPending = true; - console.info("[DBX][reloadData:metadata:await:start]", { traceId, elapsed: elapsed(), reason: hasRealTableMetaColumns ? "lifecycle-stale" : "missing", metadataAgeMs }); + const stopPreparing = () => { + // A late request must not reset a replacement tab's execution state. + if (currentTab() === tab) queryStore.setExecuting(tab.id, false); + }; + try { + reconcileOracleTableType(tab); + tab.whereInput = whereInput ?? ""; + queryStore.clearInvalidDataTabSort(tab.id); + const realColumnNames = tab.tableMeta?.columns.map((column) => column.name) ?? []; + let incomingSortMissing = realColumnNames.length > 0 && simpleDataGridOrderByReferencesMissingColumn(orderBy, realColumnNames); + if (incomingSortMissing) tab.orderByInput = undefined; + const pageLimit = limit ?? tab.resultPageLimit ?? tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize); + const pageOffset = offset ?? 0; + console.info("[DBX][reloadData:start]", { + traceId, + tabId: tab.id, + connectionId: tab.connectionId, + database: tab.database, + table: tableMetaForDataTab(tab)?.tableName, + elapsed: elapsed(), + }); + queryStore.setExecuting(tab.id, true); + const metadataAgeMs = tab.tableMetaUpdatedAt ? Date.now() - tab.tableMetaUpdatedAt : Number.POSITIVE_INFINITY; + // 判断元数据是否真实存在必须用原始 tab.tableMeta:tableMetaForDataTab 会在 + // 真实列缺失时用查询结果列合成 columns(包括失败结果的 ["Error"] 列), + // 不能据此跳过刷新,否则恢复/失败后的重试会被 TTL 卡住 + const hasRealTableMetaColumns = !!tab.tableMeta?.columns.length; + if (hasRealTableMetaColumns) { + try { + console.info("[DBX][reloadData:ensure-connected:start]", { traceId, elapsed: elapsed() }); + await connectionStore.ensureConnected(tab.connectionId); + console.info("[DBX][reloadData:ensure-connected:done]", { traceId, elapsed: elapsed() }); + } catch (e: any) { + console.warn("[DBX][reloadData:ensure-connected:error]", { traceId, elapsed: elapsed(), error: e }); + stopPreparing(); + toast(e?.message || String(e), 5000); + throw e; + } + } + if (!stillCurrent()) { + stopPreparing(); + return; + } + const connectionGeneration = connectionStore.metadataGenerationFor(tab.connectionId, tab.database); + const lifecycleStale = isDataTabMetadataLifecycleStale(tab, connectionGeneration); + const shouldRefreshMetadata = lifecycleStale || !hasRealTableMetaColumns || metadataAgeMs > DATA_TAB_METADATA_TTL_MS; + // Dameng 元数据必须与数据查询串行(同 useSidebarDataOpenRuntime), + // 延后到查询完成后再启动。主动刷新和跨生命周期重建除外:必须先拿到新列再 + // 构建 SQL,否则第一次 toolbar reload 仍会沿用断链前的显式列列表。 + const deferMetadataRefresh = intent !== "refresh" && !lifecycleStale && effectiveDatabaseTypeForConnection(connectionStore.getConfig(tab.connectionId)) === "dameng"; + const startMetadataRefresh = () => { + console.info("[DBX][reloadData:metadata:background:start]", { traceId, elapsed: elapsed(), reason: hasRealTableMetaColumns ? "stale" : "missing", metadataAgeMs }); + void refreshDataTabTableMeta(tab, { force: lifecycleStale, trace: { traceId, elapsed } }) + .then(() => { + console.info("[DBX][reloadData:metadata:background:done]", { traceId, elapsed: elapsed() }); + }) + .catch((e: any) => { + console.warn("[DBX][reloadData:metadata:background:error]", { traceId, elapsed: elapsed(), error: e }); + toast(e?.message || String(e), 5000); + }); + }; + if (lifecycleStale || intent === "refresh") { + if (!hasRealTableMetaColumns) tab.tableMetaPending = true; + console.info("[DBX][reloadData:metadata:await:start]", { traceId, elapsed: elapsed(), reason: intent === "refresh" ? "manual-refresh" : "lifecycle-stale", metadataAgeMs }); + try { + if (intent === "refresh") { + const meta = tableMetaForDataTab(tab)!; + const config = connectionStore.getConfig(tab.connectionId); + const match = { + connectionId: tab.connectionId, + database: meta.database ?? tab.database, + schema: metadataSchemaForConnection(config, meta.database ?? tab.database, meta.schema), + tableName: meta.tableName, + }; + invalidateTableMetadataCache(match); + await invalidateObjectMetadataCache(match, { strict: true }); + if (!stillCurrent() || connectionStore.metadataGenerationFor(tab.connectionId, tab.database) !== connectionGeneration) { + stopPreparing(); + return; + } + } + const rebuilt = await refreshDataTabTableMeta(tab, { force: true, trace: { traceId, elapsed } }); + console.info("[DBX][reloadData:metadata:await:done]", { traceId, elapsed: elapsed(), rebuilt }); + if (!rebuilt) { + stopPreparing(); + return; + } + } catch (e: any) { + console.warn("[DBX][reloadData:metadata:await:error]", { traceId, elapsed: elapsed(), error: e }); + stopPreparing(); + toast(e?.message || String(e), 5000); + throw e; + } + queryStore.clearInvalidDataTabSort(tab.id); + const rebuiltColumnNames = tab.tableMeta?.columns.map((column) => column.name) ?? []; + incomingSortMissing = rebuiltColumnNames.length > 0 && simpleDataGridOrderByReferencesMissingColumn(orderBy, rebuiltColumnNames); + if (incomingSortMissing) tab.orderByInput = undefined; + } else if (shouldRefreshMetadata) { + // 元数据缺失(如重启恢复的标签页只持久化了占位身份)时行标识未知: + // 挂起等待,防止数据查询先返回后编辑/保存以空 primaryKeys 短暂可用, + // 走整行 WHERE 保存路径(#3727)。真实元数据经 setTableMeta 落地后解除 + if (!hasRealTableMetaColumns) tab.tableMetaPending = true; + if (!deferMetadataRefresh) startMetadataRefresh(); + } else { + console.info("[DBX][reloadData:metadata:skip]", { traceId, elapsed: elapsed(), columnCount: tab.tableMeta!.columns.length, metadataAgeMs }); + } try { - const rebuilt = await refreshDataTabTableMeta(tab, { force: true, trace: { traceId, elapsed } }); - console.info("[DBX][reloadData:metadata:await:done]", { traceId, elapsed: elapsed(), rebuilt }); - if (!rebuilt) { - queryStore.setExecuting(tab.id, false); + console.info("[DBX][reloadData:build-sql:start]", { traceId, elapsed: elapsed() }); + const nextSql = await buildTableSql(tab, { whereInput, orderBy: incomingSortMissing ? undefined : orderBy, limit: pageLimit, offset: pageOffset }); + console.info("[DBX][reloadData:build-sql:done]", { traceId, elapsed: elapsed() }); + if (!stillCurrent() || connectionStore.metadataGenerationFor(tab.connectionId, tab.database) !== connectionGeneration) { + stopPreparing(); return; } - } catch (e: any) { - console.warn("[DBX][reloadData:metadata:await:error]", { traceId, elapsed: elapsed(), error: e }); - queryStore.setExecuting(tab.id, false); - toast(e?.message || String(e), 5000); + queryStore.updateSql(tab.id, nextSql); + console.info("[DBX][reloadData:execute:start]", { traceId, elapsed: elapsed() }); + await queryStore.executeTabSql(tab.id, nextSql, { + pagination: { limit: pageLimit, offset: pageOffset }, + preserveResultDuringExecution: true, + }); + console.info("[DBX][reloadData:execute:done]", { traceId, elapsed: elapsed() }); + } catch (e) { + console.error("[DBX][reloadData:error]", { traceId, elapsed: elapsed(), error: e }); + stopPreparing(); + if (shouldRefreshMetadata && deferMetadataRefresh) startMetadataRefresh(); throw e; } - queryStore.clearInvalidDataTabSort(tab.id); - const rebuiltColumnNames = tab.tableMeta?.columns.map((column) => column.name) ?? []; - incomingSortMissing = rebuiltColumnNames.length > 0 && simpleDataGridOrderByReferencesMissingColumn(orderBy, rebuiltColumnNames); - if (incomingSortMissing) tab.orderByInput = undefined; - } else if (shouldRefreshMetadata) { - // 元数据缺失(如重启恢复的标签页只持久化了占位身份)时行标识未知: - // 挂起等待,防止数据查询先返回后编辑/保存以空 primaryKeys 短暂可用, - // 走整行 WHERE 保存路径(#3727)。真实元数据经 setTableMeta 落地后解除 - if (!hasRealTableMetaColumns) tab.tableMetaPending = true; - if (!deferMetadataRefresh) startMetadataRefresh(); - } else { - console.info("[DBX][reloadData:metadata:skip]", { traceId, elapsed: elapsed(), columnCount: tab.tableMeta!.columns.length, metadataAgeMs }); - } - try { - console.info("[DBX][reloadData:build-sql:start]", { traceId, elapsed: elapsed() }); - const nextSql = await buildTableSql(tab, { whereInput, orderBy: incomingSortMissing ? undefined : orderBy, limit: pageLimit, offset: pageOffset }); - console.info("[DBX][reloadData:build-sql:done]", { traceId, elapsed: elapsed() }); - queryStore.updateSql(tab.id, nextSql); - console.info("[DBX][reloadData:execute:start]", { traceId, elapsed: elapsed() }); - await queryStore.executeTabSql(tab.id, nextSql, { - pagination: { limit: pageLimit, offset: pageOffset }, - preserveResultDuringExecution: true, - }); - console.info("[DBX][reloadData:execute:done]", { traceId, elapsed: elapsed() }); - } catch (e) { - console.error("[DBX][reloadData:error]", { traceId, elapsed: elapsed(), error: e }); - queryStore.setExecuting(tab.id, false); if (shouldRefreshMetadata && deferMetadataRefresh) startMetadataRefresh(); - throw e; + return; + } finally { + pendingDataReloads.delete(tab); } - if (shouldRefreshMetadata && deferMetadataRefresh) startMetadataRefresh(); - return; } - if (intent === "refresh" && tab.mode === "query" && (tab.results?.length ?? 0) > 1) { + if ((intent === "refresh" || intent === "auto-refresh") && tab.mode === "query" && (tab.results?.length ?? 0) > 1) { const resultGroupSql = tab.resultBaseSql || tab.lastExecutedSql || tab.sql; if (!resultGroupSql.trim()) return; tab.resultSortColumn = undefined; diff --git a/apps/desktop/src/lib/dataGrid/dataGridToolbar.ts b/apps/desktop/src/lib/dataGrid/dataGridToolbar.ts index 467e31a833..e663821cca 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridToolbar.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridToolbar.ts @@ -32,7 +32,7 @@ export function isDataGridToolbarActionCompact(action: DataGridToolbarActionKey, return actionIndex < Math.max(0, Math.floor(compactActionCount)); } -export type DataGridReloadIntent = "refresh"; +export type DataGridReloadIntent = "refresh" | "auto-refresh"; export interface DataGridToolbarActionCapability { label: string; diff --git a/apps/desktop/src/lib/metadata/__tests__/objectMetadataCache.spec.ts b/apps/desktop/src/lib/metadata/__tests__/objectMetadataCache.spec.ts index ec63fe15ce..cb3845a049 100644 --- a/apps/desktop/src/lib/metadata/__tests__/objectMetadataCache.spec.ts +++ b/apps/desktop/src/lib/metadata/__tests__/objectMetadataCache.spec.ts @@ -31,6 +31,30 @@ describe("objectMetadataCache", () => { }); }); + it("reports deletion failures only to strict callers sharing the same invalidation", async () => { + let reject!: (error: Error) => void; + mocks.deleteSchemaCachePrefix.mockReturnValueOnce( + new Promise((_, fail) => { + reject = fail; + }), + ); + const bestEffort = invalidateObjectMetadataCache(request); + const strict = invalidateObjectMetadataCache(request, { strict: true }); + const failure = new Error("storage unavailable"); + const rejected = expect(strict).rejects.toMatchObject({ name: "ObjectCacheInvalidationError", reason: failure }); + await vi.waitFor(() => expect(mocks.deleteSchemaCachePrefix).toHaveBeenCalledTimes(1)); + reject(failure); + await rejected; + await expect(bestEffort).resolves.toBeUndefined(); + await expect(invalidateObjectMetadataCache(request, { strict: true })).resolves.toBeUndefined(); + expect(mocks.deleteSchemaCachePrefix).toHaveBeenCalledTimes(2); + }); + + it("keeps forced facet reads best effort if deletion fails", async () => { + mocks.deleteSchemaCachePrefix.mockRejectedValueOnce(new Error("storage unavailable")); + await expect(loadObjectMetadataFacet(request, "columns", async () => ["fresh"], { force: true })).resolves.toEqual({ value: ["fresh"], cacheStatus: "remote" }); + }); + it("reads a facet from disk without invoking the loader", async () => { mocks.loadSchemaCache.mockResolvedValue({ version: 1, cachedAt: new Date().toISOString(), value: [{ name: "id" }] }); const loader = vi.fn().mockResolvedValue([{ name: "remote" }]); diff --git a/packages/app-tests/queryResultToolbar.test.ts b/packages/app-tests/queryResultToolbar.test.ts index 027b431625..6f7e831939 100644 --- a/packages/app-tests/queryResultToolbar.test.ts +++ b/packages/app-tests/queryResultToolbar.test.ts @@ -170,7 +170,7 @@ test("table-data toolbar refresh keeps page size independent from SQL editor set assert.match(dataGrid, /watch\([\s\S]*\(\) => settingsStore\.editorSettings\.pageSize,[\s\S]*if \(pageSizePreference\.value !== "results"\) return;[\s\S]*pageSize\.value = normalizeResultPageSize\(value, pageSize\.value\)/); assert.match(dataGrid, /settingsStore\.updateEditorSettings\(dataGridPageSizeSettingsPatch\(pageSizePreference\.value, normalizedSize\)\)/); assert.match(dataGrid, /const resetToFirstPage = hasPendingConditionInputs\(\);/); - assert.match(dataGrid, /emit\("reload", props\.sql, searchText\.value, currentWhereInput\(\), currentOrderBy\(\), pageSize\.value, resetToFirstPage \? 0 : \(currentPage\.value - 1\) \* pageSize\.value, "refresh"\)/); + assert.match(dataGrid, /emit\("reload", props\.sql, searchText\.value, currentWhereInput\(\), currentOrderBy\(\), pageSize\.value, resetToFirstPage \? 0 : \(currentPage\.value - 1\) \* pageSize\.value, intent\)/); }); test("standalone result views use the same compact toolbar breakpoint", () => { @@ -243,7 +243,9 @@ test("embedded result toolbar cannot scroll vertically", () => { test("DataGrid marks toolbar refresh separately from current-result reloads", () => { const dataGrid = source(dataGridPath); - assert.match(dataGrid, /emit\("reload", props\.sql,[^;]+"refresh"\);/); + assert.match(dataGrid, /async function onToolbarRefresh\(\)\s*\{\s*await reloadTableData\("refresh"\);/); + assert.match(dataGrid, /refresh: \(\) => reloadTableData\("auto-refresh"\)/); + assert.match(dataGrid, /async function reloadTableData\(intent: DataGridReloadIntent\)[\s\S]*?emit\("reload", props\.sql,[^;]+intent\);/); assert.match(dataGrid, /function onToolbarRollback\(\)[\s\S]*?emit\("reload", props\.sql,[^;]+\);/); });