From 78aab1e5ed9e70ee0ff313c3f98eb2ab80c93302 Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Thu, 20 Aug 2026 09:25:14 +0800 Subject: [PATCH] fix(connection): invalidate data-tab metadata on disconnect/reconnect --- .../__tests__/useDataGridActions.spec.ts | 104 ++++++++++ .../__tests__/useNavigationTargets.spec.ts | 1 + .../useNavigationTargets.store.spec.ts | 2 + .../useSidebarDataOpenRuntime.spec.ts | 126 ++++++++++++ .../src/composables/useDataGridActions.ts | 71 ++++++- .../src/composables/useNavigationTargets.ts | 10 +- .../composables/useSidebarDataOpenRuntime.ts | 21 +- .../sidebar/dataTabOpenPolicy.spec.ts | 20 +- .../src/lib/sidebar/dataTabOpenPolicy.ts | 15 +- ...ionStore.disconnectDataTabMetadata.spec.ts | 190 ++++++++++++++++++ .../queryStore.hiddenPrimaryKey.spec.ts | 2 + .../queryStore.tableDataRefresh.spec.ts | 136 +++++++++++++ apps/desktop/src/stores/connectionStore.ts | 64 +++++- apps/desktop/src/stores/queryStore.ts | 70 ++++++- apps/desktop/src/types/database.ts | 3 + 15 files changed, 804 insertions(+), 31 deletions(-) create mode 100644 apps/desktop/src/stores/__tests__/connectionStore.disconnectDataTabMetadata.spec.ts diff --git a/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts index d26d4eb8b9..c2e56c5120 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({ setTableMeta: vi.fn(), clearInvalidDataTabSort: vi.fn(), activeResultExecutionTarget: vi.fn(), + metadataGeneration: 0, })); vi.mock("vue-i18n", () => ({ @@ -43,6 +44,7 @@ vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ getConfig: mocks.getConfig, ensureConnected: mocks.ensureConnected, + metadataGenerationFor: () => mocks.metadataGeneration, }), })); @@ -74,6 +76,7 @@ vi.mock("@/stores/queryStore", () => ({ const tab = mocks.tabs.find((item) => item.id === id); if (tab) { tab.tableMeta = meta; + tab.tableMetaGeneration = mocks.metadataGeneration; tab.tableMetaUpdatedAt = Date.now(); // 与真实 store 一致:仅真实元数据(columns 非空)落地才结束行标识等待 if (meta.columns.length > 0) tab.tableMetaPending = false; @@ -111,6 +114,7 @@ function tableDataTab(patch: Partial = {}): QueryTab { isCancelling: false, isExplaining: false, tableMetaUpdatedAt: Date.now(), + tableMetaGeneration: 0, tableMeta: { schema: "public", tableName: "users", @@ -131,6 +135,7 @@ describe("useDataGridActions", () => { mocks.infiniteScroll = true; mocks.queryResultMaxRowsEnabled = true; mocks.queryResultMaxRows = 10_000; + mocks.metadataGeneration = 0; mocks.getConfig.mockReturnValue({ id: "postgres-1", db_type: "postgres" }); mocks.buildTableSelectSql.mockResolvedValue("SELECT * FROM public.users LIMIT 100 OFFSET 0"); mocks.buildSortedQuerySql.mockResolvedValue({ ok: true, sql: "SELECT sorted" }); @@ -399,6 +404,105 @@ describe("useDataGridActions", () => { }); }); + it("rebuilds table metadata before the first toolbar reload after a reconnect boundary", async () => { + const tab = tableDataTab({ + tableMetaUpdatedAt: undefined, + tableMetaGeneration: 0, + tableMeta: { + schema: "public", + tableName: "users", + tableType: "TABLE", + columns: [{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + primaryKeys: ["id"], + }, + }); + mocks.tabs.push(tab); + mocks.metadataGeneration = 1; + mocks.getColumns.mockResolvedValueOnce([ + { name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "age", data_type: "integer", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, + ]); + mocks.listIndexes.mockResolvedValueOnce([]); + const actions = useDataGridActions(computed(() => tab)); + + await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh"); + + expect(mocks.getColumns).toHaveBeenCalledTimes(1); + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: ["id", "age"] })); + expect(tab.tableMeta?.columns.map((column) => column.name)).toEqual(["id", "age"]); + expect(tab.tableMetaGeneration).toBe(1); + expect(tab.tableMetaUpdatedAt).toBeDefined(); + expect(mocks.executeTabSql).toHaveBeenCalledTimes(1); + }); + + it("rebuilds table metadata on the first toolbar reload after a dead-pool reconnect", async () => { + const tab = tableDataTab({ + tableMetaGeneration: 0, + tableMeta: { + schema: "public", + tableName: "users", + tableType: "TABLE", + columns: [{ name: "old_name", data_type: "text", is_nullable: true, column_default: null, is_primary_key: false, extra: null }], + primaryKeys: [], + }, + }); + mocks.tabs.push(tab); + mocks.ensureConnected.mockImplementationOnce(async () => { + mocks.metadataGeneration = 1; + }); + mocks.getColumns.mockResolvedValueOnce([{ name: "new_name", data_type: "text", is_nullable: true, column_default: null, is_primary_key: false, extra: null }]); + const actions = useDataGridActions(computed(() => tab)); + + await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh"); + + expect(mocks.ensureConnected).toHaveBeenCalled(); + expect(mocks.getColumns).toHaveBeenCalledTimes(1); + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: ["new_name"] })); + expect(mocks.executeTabSql).toHaveBeenCalledTimes(1); + }); + + it("does not refetch metadata on a warm toolbar reload in the same generation", async () => { + const tab = tableDataTab(); + mocks.tabs.push(tab); + const actions = useDataGridActions(computed(() => tab)); + + await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh"); + + expect(mocks.getColumns).not.toHaveBeenCalled(); + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: ["id"] })); + expect(mocks.executeTabSql).toHaveBeenCalledTimes(1); + }); + + it("drops an in-flight toolbar metadata write after disconnect bumps generation", async () => { + const tab = tableDataTab({ + tableMetaUpdatedAt: undefined, + tableMetaGeneration: 0, + }); + mocks.tabs.push(tab); + mocks.metadataGeneration = 0; + let resolveColumns!: (columns: Array<{ name: string; data_type: string; is_nullable: boolean; column_default: null; is_primary_key: boolean; extra: null }>) => void; + mocks.getColumns.mockReturnValueOnce( + new Promise((resolve) => { + resolveColumns = resolve; + }), + ); + const actions = useDataGridActions(computed(() => tab)); + const reload = actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh"); + await vi.waitFor(() => expect(mocks.getColumns).toHaveBeenCalledTimes(1)); + + mocks.metadataGeneration = 1; + tab.tableMetaUpdatedAt = undefined; + resolveColumns([{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }]); + await reload; + + expect(mocks.setTableMeta).not.toHaveBeenCalled(); + expect(tab.tableMeta?.columns.map((column) => column.name)).toEqual(["id"]); + expect(tab.tableMetaUpdatedAt).toBeUndefined(); + expect(tab.tableMetaGeneration).toBe(0); + expect(mocks.buildTableSelectSql).not.toHaveBeenCalled(); + expect(mocks.executeTabSql).not.toHaveBeenCalled(); + }); + it("excludes hidden primary keys and remaps the selected column for database sorting", async () => { const tab = { id: "tab-1", diff --git a/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts b/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts index 58e5ed2c90..156ce8884c 100644 --- a/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts +++ b/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts @@ -28,6 +28,7 @@ vi.mock("@/stores/connectionStore", () => ({ getConfig: () => ({ id: "connection-1", db_type: mocks.databaseType }), ensureConnected: mocks.ensureConnected, connectionIdentifierQuote: () => undefined, + metadataGenerationFor: () => 0, refreshObjectListTreeNode: vi.fn(), invalidateCompletionTableCache: mocks.invalidateCompletionTableCache, }), diff --git a/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts b/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts index d6ed1ff13a..9296ae11db 100644 --- a/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts +++ b/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts @@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => ({ getConfig: vi.fn((connectionId: string) => ({ id: connectionId, db_type: "postgres" })), ensureConnected: vi.fn(), connectionIdentifierQuote: vi.fn(() => undefined), + // 与真实 store 一致:setTableMeta 记录连接元数据代次 + metadataGenerationFor: vi.fn(() => 0), refreshObjectListTreeNode: vi.fn(), invalidateCompletionTableCache: vi.fn(), }, diff --git a/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts index 217f7b8754..d802872ef8 100644 --- a/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ cachedMetadata: undefined as unknown, dataTabReuseMode: "same-table" as DataTabReuseMode, openDataTabsNextToActive: false, + metadataGeneration: 0, ensureConnected: vi.fn(), executeTabSql: vi.fn(), loadTableMetadata: vi.fn(), @@ -24,6 +25,7 @@ vi.mock("@/stores/connectionStore", () => ({ getConfig: () => ({ id: "connection-1", db_type: mocks.databaseType }), ensureConnected: mocks.ensureConnected, connectionIdentifierQuote: () => undefined, + metadataGenerationFor: () => mocks.metadataGeneration, }), })); @@ -76,6 +78,9 @@ vi.mock("@/stores/queryStore", () => ({ const tab = mocks.tabs.find((item) => item.id === id); if (tab) { tab.tableMeta = tableMeta; + // 与真实 store 一致:记录写入时的连接元数据代次 + tab.tableMetaGeneration = mocks.metadataGeneration; + tab.tableMetaUpdatedAt = Date.now(); // 与真实 store 一致:仅真实元数据(columns 非空)落地才结束行标识等待 if (tableMeta.columns.length > 0) tab.tableMetaPending = false; } @@ -145,6 +150,7 @@ describe("useSidebarDataOpenRuntime", () => { mocks.cachedMetadata = undefined; mocks.dataTabReuseMode = "same-table"; mocks.openDataTabsNextToActive = false; + mocks.metadataGeneration = 0; mocks.ensureConnected.mockResolvedValue(undefined); mocks.buildTableSelectSql.mockResolvedValue("SELECT * FROM users"); mocks.executeTabSql.mockImplementation(async () => { @@ -508,6 +514,8 @@ describe("useSidebarDataOpenRuntime", () => { primaryKeys: ["id"], }, tableMetaUpdatedAt: Date.now(), + // 真实 tab 的 tableMeta 必经 setTableMeta 写入并记录当前代次(0) + tableMetaGeneration: 0, } as QueryTab); mocks.activeTabId = null; @@ -542,4 +550,122 @@ describe("useSidebarDataOpenRuntime", () => { expect(mocks.tabs[0]?.tableMetaPending).toBeFalsy(); expect(mocks.loadTableMetadata).not.toHaveBeenCalled(); }); + + it("keeps tab-local metadata warm within the TTL window (no reload on reopen)", async () => { + const reload = vi.fn(); + mocks.loadTableMetadata.mockImplementation(async () => { + reload(); + return { + metadata: { + schema: "public", + tableName: "users", + tableType: "TABLE", + database: "app", + columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + indexes: [], + primaryKeys: ["id"], + cachedAt: Date.now(), + }, + cacheStatus: "miss", + ageMs: 0, + }; + }); + + await useSidebarDataOpenRuntime().openData(tableNode); + // 第一次打开已把 tab-local tableMetaUpdatedAt 置为新鲜 + expect(mocks.tabs[0]?.tableMetaUpdatedAt).toBeDefined(); + // 复位执行态,让第二次打开走重开(reuse)路径而非 activate 早退 + mocks.tabs[0]!.isExecuting = false; + mocks.tabs[0]!.executionId = undefined; + + // 未发生连接生命周期变化:TTL 窗口内重开同表直接复用 tab-local 元数据 + await useSidebarDataOpenRuntime().openData(tableNode); + + expect(reload).toHaveBeenCalledTimes(1); + expect(mocks.tabs[0]?.tableMeta?.tableName).toBe("users"); + }); + + it("reloads tab metadata after a reconnect boundary (issue #6623 reconnect regression)", async () => { + // 第一次打开的元数据:[id] + await useSidebarDataOpenRuntime().openData(tableNode); + expect(mocks.loadTableMetadata).toHaveBeenCalledTimes(1); + expect(mocks.tabs[0]?.tableMeta?.columns.map((column) => column.name)).toEqual(["id"]); + expect(mocks.tabs[0]?.tableMetaUpdatedAt).toBeDefined(); + + // 外部 ALTER TABLE ... ADD age:DBX 断开→重连。断开时 connectionStore 会 + // 清掉该连接下数据标签页的 freshness 戳(staleConnectionDataTabMetadata), + // 此处等价模拟该生命周期边界。 + mocks.tabs[0]!.tableMetaUpdatedAt = undefined; + mocks.tabs[0]!.isExecuting = false; + mocks.tabs[0]!.executionId = undefined; + + // 第二次打开(重连后):即使处于原 30s TTL 窗口内,也必须重新拉取结构 + mocks.loadTableMetadata.mockImplementationOnce(async () => ({ + metadata: { + schema: "public", + tableName: "users", + tableType: "TABLE", + database: "app", + columns: [ + { name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "age", data_type: "integer", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, + ], + indexes: [], + primaryKeys: ["id"], + cachedAt: Date.now(), + }, + cacheStatus: "miss", + ageMs: 0, + })); + await useSidebarDataOpenRuntime().openData(tableNode); + + expect(mocks.loadTableMetadata).toHaveBeenCalledTimes(2); + await vi.waitFor(() => { + expect(mocks.tabs[0]?.tableMeta?.columns.map((column) => column.name)).toContain("age"); + }); + }); + + it("does not write stale in-flight metadata back to the tab after a disconnect boundary", async () => { + // 手动挂起的元数据加载:openData 启动后台加载后停留在 in-flight + let resolveMetadata!: (value: Awaited>) => void; + const pendingMetadata = new Promise>>((resolve) => { + resolveMetadata = resolve; + }); + mocks.loadTableMetadata.mockReturnValueOnce(pendingMetadata); + + const openPromise = useSidebarDataOpenRuntime().openData(tableNode); + await vi.waitFor(() => { + expect(mocks.loadTableMetadata).toHaveBeenCalledTimes(1); + }); + + // 模拟 disconnect 生命周期边界:连接代次递增 + freshness 戳被清 + // (staleConnectionDataTabMetadata) + mocks.metadataGeneration = 1; + mocks.tabs[0]!.tableMetaUpdatedAt = undefined; + + // 旧连接在途的结果此时才返回:shared 缓存写回已被 per-scope 失效代数 + // 挡住,tab-local 写回必须被连接代次校验拦下(PR #6640 review blocker 1) + resolveMetadata({ + metadata: { + schema: "public", + tableName: "users", + tableType: "TABLE", + database: "app", + columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + indexes: [], + primaryKeys: ["id"], + cachedAt: Date.now(), + }, + cacheStatus: "miss", + ageMs: 0, + }); + await openPromise; + await vi.waitFor(() => { + expect(mocks.tabs[0]?.tableMetaUpdatedAt).toBeUndefined(); + }); + + // 占位元数据未被旧列覆盖:freshness 保持在失效后的"冷"状态 + expect(mocks.tabs[0]?.tableMeta?.columns).toEqual([]); + expect(mocks.tabs[0]?.tableMetaGeneration).toBe(0); + }); }); diff --git a/apps/desktop/src/composables/useDataGridActions.ts b/apps/desktop/src/composables/useDataGridActions.ts index 7fdf887aa5..8ab3145dcc 100644 --- a/apps/desktop/src/composables/useDataGridActions.ts +++ b/apps/desktop/src/composables/useDataGridActions.ts @@ -13,6 +13,7 @@ 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 { isDataTabMetadataLifecycleStale } from "@/lib/sidebar/dataTabOpenPolicy"; import { applyMongoFindSort } from "@/lib/mongo/mongoShellCommand"; import { uuid } from "@/lib/common/utils"; import { simpleDataGridOrderByReferencesMissingColumn, type DataGridSortMode } from "@/lib/dataGrid/dataGridSort"; @@ -85,10 +86,10 @@ export function useDataGridActions(activeTab: ComputedRef) }); } - async function refreshDataTabTableMeta(tab: QueryTab, trace?: { traceId: string; elapsed: () => string }): Promise { - if (tab.mode !== "data" || !tab.connectionId || !tab.database) return; + async function refreshDataTabTableMeta(tab: QueryTab, options: { force?: boolean; trace?: { traceId: string; elapsed: () => string } } = {}): Promise { + if (tab.mode !== "data" || !tab.connectionId || !tab.database) return false; const tableMeta = tableMetaForDataTab(tab); - if (!tableMeta?.tableName) return; + if (!tableMeta?.tableName) return false; const target = { tabId: tab.id, connectionId: tab.connectionId, @@ -98,15 +99,22 @@ export function useDataGridActions(activeTab: ComputedRef) tableName: tableMeta.tableName, tableType: tableMeta.tableType, }; + 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) { + console.info("[DBX][reloadData:metadata:superseded-by-connection-generation]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), table: target.tableName }); + return false; + } const config = connectionStore.getConfig(target.connectionId); const querySchema = metadataSchemaForConnection(config, target.database, target.schema); console.info("[DBX][reloadData:metadata:get-columns:start]", { traceId: trace?.traceId, elapsed: trace?.elapsed(), schema: querySchema, table: target.tableName }); // 复用共享表元数据缓存(30s TTL + in-flight 去重),多个入口对同一张表 - // 不再各自往返 getColumns/listIndexes + // 不再各自往返 getColumns/listIndexes。跨连接生命周期的强制重建走 force, + // 避免同一共享缓存把断链前的旧列再次交回本次 reload。 const { metadata } = await loadTableMetadata({ connectionId: target.connectionId, database: target.database, @@ -116,14 +124,19 @@ export function useDataGridActions(activeTab: ComputedRef) databaseType: effectiveDatabaseTypeForConnection(config) ?? config?.db_type ?? "", driverProfile: config?.driver_profile || config?.db_type, catalog: target.catalog, + force: options.force === true, }); 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) { + 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; if (!current || current.mode !== "data" || current.connectionId !== target.connectionId || current.database !== 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; + return false; } const primaryKeys = metadata.primaryKeys; queryStore.setTableMeta(target.tabId, { @@ -134,6 +147,7 @@ export function useDataGridActions(activeTab: ComputedRef) columns, primaryKeys, }); + return true; } async function onExecuteSql(sql: string) { @@ -153,7 +167,7 @@ export function useDataGridActions(activeTab: ComputedRef) tab.whereInput = whereInput ?? ""; queryStore.clearInvalidDataTabSort(tab.id); const realColumnNames = tab.tableMeta?.columns.map((column) => column.name) ?? []; - const incomingSortMissing = realColumnNames.length > 0 && simpleDataGridOrderByReferencesMissingColumn(orderBy, realColumnNames); + 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; @@ -171,13 +185,28 @@ export function useDataGridActions(activeTab: ComputedRef) // 真实列缺失时用查询结果列合成 columns(包括失败结果的 ["Error"] 列), // 不能据此跳过刷新,否则恢复/失败后的重试会被 TTL 卡住 const hasRealTableMetaColumns = !!tab.tableMeta?.columns.length; - const shouldRefreshMetadata = !hasRealTableMetaColumns || metadataAgeMs > DATA_TAB_METADATA_TTL_MS; + 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), - // 延后到查询完成后再启动 - const deferMetadataRefresh = effectiveDatabaseTypeForConnection(connectionStore.getConfig(tab.connectionId)) === "dameng"; + // 延后到查询完成后再启动。跨生命周期的强制重建除外:必须先拿到新列再 + // 构建 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, { traceId, elapsed }) + void refreshDataTabTableMeta(tab, { force: lifecycleStale, trace: { traceId, elapsed } }) .then(() => { console.info("[DBX][reloadData:metadata:background:done]", { traceId, elapsed: elapsed() }); }) @@ -186,7 +215,27 @@ export function useDataGridActions(activeTab: ComputedRef) toast(e?.message || String(e), 5000); }); }; - if (shouldRefreshMetadata) { + if (lifecycleStale) { + if (!hasRealTableMetaColumns) tab.tableMetaPending = true; + console.info("[DBX][reloadData:metadata:await:start]", { traceId, elapsed: elapsed(), reason: hasRealTableMetaColumns ? "lifecycle-stale" : "missing", 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); + 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); + 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 落地后解除 diff --git a/apps/desktop/src/composables/useNavigationTargets.ts b/apps/desktop/src/composables/useNavigationTargets.ts index 386975b780..1865acd7a8 100644 --- a/apps/desktop/src/composables/useNavigationTargets.ts +++ b/apps/desktop/src/composables/useNavigationTargets.ts @@ -101,6 +101,8 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab await connectionStore.ensureConnected(target.connectionId); if (!isPreparationCurrent()) return; if (!config) throw new Error("Connection config not found"); + const metadataGenerationAtStart = connectionStore.metadataGenerationFor(target.connectionId, target.database); + const isCurrentGeneration = () => connectionStore.metadataGenerationFor(target.connectionId, target.database) === metadataGenerationAtStart; const effectiveDbType = effectiveDatabaseTypeForConnection(config); const identifierQuote = connectionStore.connectionIdentifierQuote?.(target.connectionId); const querySchema = metadataSchemaForConnection(config, target.database, tableSchema); @@ -122,7 +124,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab whereInput: target.whereInput, limit: pageLimit, }); - if (!isPreparationCurrent()) return; + if (!isPreparationCurrent() || !isCurrentGeneration()) return; queryStore.updateSql(tabId, sql); queryStore.setTableMeta(tabId, { catalog: target.catalog, @@ -167,7 +169,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab whereInput: target.whereInput, limit: pageLimit, }); - if (!isPreparationCurrent()) return; + if (!isPreparationCurrent() || !isCurrentGeneration()) return; queryStore.updateSql(tabId, sql); queryStore.setTableMeta(tabId, { schema: tableSchema, @@ -232,7 +234,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab const primaryKeys = metadata.primaryKeys; // 异步窗口内 tab 可能已被复用为其他目标:旧请求的元数据不得落地、 // 不得解除新目标的 pending - if (!isCurrentTarget()) return; + if (!isCurrentTarget() || !isCurrentGeneration()) return; const useRowId = usesSyntheticRowIdKey(effectiveDbType, primaryKeys, targetTableType); queryStore.setTableMeta(tabId, { schema: tableSchema, @@ -349,6 +351,7 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value: const capturedMeta = tab.tableMeta!; const capturedSchema = tab.schema || capturedMeta.schema; const capturedTarget = { connectionId: tab.connectionId, database: tab.database, schema: capturedSchema, catalog: capturedMeta.catalog, tableName: capturedMeta.tableName }; + const metadataGenerationAtStart = connectionStore.metadataGenerationFor(tab.connectionId, tab.database); const metadataSchema = metadataSchemaForConnection(connection, tab.database, capturedSchema); // 分组含 tableType:主键计算依赖它,不同 tableType 不能共享加载结果 const catalogKey = `${capturedMeta.catalog ?? ""}\u0000${capturedMeta.tableType ?? ""}`; @@ -370,6 +373,7 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value: loadedByCatalog.set(catalogKey, metadata); } const currentTab = queryStore.tabs.find((item) => item.id === tab.id); + if (connectionStore.metadataGenerationFor(tab.connectionId, tab.database) !== metadataGenerationAtStart) continue; if (!canApplyDataTabMetadata(currentTab, capturedTarget)) continue; queryStore.setTableMeta(tab.id, { ...capturedMeta, diff --git a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts index 36cdbd362c..60676546c6 100644 --- a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts +++ b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts @@ -7,7 +7,7 @@ import { uuid } from "@/lib/common/utils"; import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog"; import { effectiveDatabaseTypeForConnection, connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema } from "@/lib/database/jdbcDialect"; import { getCachedTableMetadata, loadTableMetadata, TABLE_METADATA_CACHE_TTL_MS, tableMetadataToDataTabMeta } from "@/lib/metadata/tableMetadataCache"; -import { canApplyDataTabMetadata, dataTabMetadataNeedsRefresh, findExistingDataTabCandidate, type DataTabOpenMode, type DataTabReuseMode } from "@/lib/sidebar/dataTabOpenPolicy"; +import { canApplyDataTabMetadata, dataTabMetadataNeedsRefresh, findExistingDataTabCandidate, isDataTabMetadataLifecycleStale, type DataTabOpenMode, type DataTabReuseMode } from "@/lib/sidebar/dataTabOpenPolicy"; import type { SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator"; import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext"; import { buildTableSelectSql } from "@/lib/table/tableSelectSql"; @@ -93,6 +93,11 @@ export function useSidebarDataOpenRuntime() { }); try { if (ensureConnected) await connectionStore.ensureConnected(node.connectionId); + // 记录启动时的连接元数据代次:加载期间若跨过生命周期边界(disconnect / + // 关库 / 死池重连),结果已过期,不得写回 tab(PR #6640 review + // blocker 1 的 tab-local 半边——shared 缓存写回已有 per-scope 失效代 + // 数保护,tab 写回此前没有)。 + const metadataGenerationAtStart = connectionStore.metadataGenerationFor(node.connectionId, node.database); const loadedMetadata = await loadTableMetadata({ connectionId: node.connectionId, database: node.database, @@ -104,6 +109,15 @@ export function useSidebarDataOpenRuntime() { catalog: node.catalog, traceLogger: isDebugLoggingEnabled() ? (event) => openDataLog("debug", "metadata:trace", { sourceTraceId: traceId, ...event }) : undefined, }); + if (connectionStore.metadataGenerationFor(node.connectionId, node.database) !== metadataGenerationAtStart) { + openDataLog("info", "metadata:superseded-by-connection-generation", { + traceId, + tabId: targetTabId, + columnCount: loadedMetadata.metadata.columns.length, + elapsed: elapsed(), + }); + return; + } if (!canApplyTableMetadata(targetTabId)) { openDataLog("info", "metadata:stale", { traceId, @@ -161,7 +175,10 @@ export function useSidebarDataOpenRuntime() { if (existingSameTableTab && (existingSameTableTab.isExecuting || canActivateExistingDataTableTab(existingSameTableTab, { activateExecuting: false }))) { queryStore.switchTab(existingSameTableTab.id); logPhase("existing-tab-activated", { table: node.label }); - if (dataTabMetadataNeedsRefresh(existingSameTableTab, DATA_TAB_METADATA_TTL_MS)) { + // 代次失配视同冷缓存(即使位于 30s TTL 窗口内也要重建):disconnect / + // 关库 / 死池重连都可能不改变 timestamp 判定而改变连接生命周期 + const connectionGeneration = connectionStore.metadataGenerationFor(node.connectionId, node.database); + if (isDataTabMetadataLifecycleStale(existingSameTableTab, connectionGeneration) || dataTabMetadataNeedsRefresh(existingSameTableTab, DATA_TAB_METADATA_TTL_MS)) { // 真实列缺失时行标识未知:启动刷新的同时必须挂起编辑门控(所有 // "真实列缺失且启动刷新"的入口统一置 pending) if (!existingSameTableTab.tableMeta?.columns.length) existingSameTableTab.tableMetaPending = true; diff --git a/apps/desktop/src/lib/__tests__/sidebar/dataTabOpenPolicy.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/dataTabOpenPolicy.spec.ts index ad43e26435..11e2593f83 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/dataTabOpenPolicy.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/dataTabOpenPolicy.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { canApplyDataTabMetadata, dataTabMetadataNeedsRefresh, dataTabOpenModeFromTreeClick, findExistingDataTabCandidate } from "@/lib/sidebar/dataTabOpenPolicy"; +import { canApplyDataTabMetadata, dataTabMetadataNeedsRefresh, dataTabOpenModeFromTreeClick, findExistingDataTabCandidate, isDataTabMetadataLifecycleStale } from "@/lib/sidebar/dataTabOpenPolicy"; import type { QueryTab } from "@/types/database"; function click(modifiers: Partial> = {}) { @@ -166,4 +166,22 @@ describe("dataTabOpenPolicy", () => { tab.tableMetaUpdatedAt = now - ttl; expect(dataTabMetadataNeedsRefresh(tab, ttl, now)).toBe(true); }); + + it("treats missing freshness or a generation mismatch as lifecycle-stale", () => { + const tab = dataTab("users", "users"); + tab.tableMeta = { + schema: "public", + tableName: "users", + columns: [{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + primaryKeys: ["id"], + }; + tab.tableMetaUpdatedAt = Date.now(); + tab.tableMetaGeneration = 0; + + expect(isDataTabMetadataLifecycleStale(tab, 0)).toBe(false); + expect(isDataTabMetadataLifecycleStale(tab, 1)).toBe(true); + + tab.tableMetaUpdatedAt = undefined; + expect(isDataTabMetadataLifecycleStale(tab, 0)).toBe(true); + }); }); diff --git a/apps/desktop/src/lib/sidebar/dataTabOpenPolicy.ts b/apps/desktop/src/lib/sidebar/dataTabOpenPolicy.ts index 1a962ad066..0920d01d7d 100644 --- a/apps/desktop/src/lib/sidebar/dataTabOpenPolicy.ts +++ b/apps/desktop/src/lib/sidebar/dataTabOpenPolicy.ts @@ -5,7 +5,10 @@ import type { QueryTab, TreeNodeType } from "@/types/database"; export type DataTabOpenMode = "default" | "new-tab"; export type { DataTabReuseMode }; -type DataTabLike = Pick; +type DataTabLike = Pick< + QueryTab, + "id" | "mode" | "connectionId" | "database" | "catalog" | "schema" | "title" | "tableMeta" | "tableMetaUpdatedAt" | "tableMetaGeneration" | "pinned" | "isExecuting" | "isCancelling" | "isExplaining" | "txnSessionId" | "pendingDataChangeCount" | "hasPendingDataEditorDraft" +>; export interface DataTabTarget { connectionId: string; @@ -57,6 +60,16 @@ export function dataTabMetadataNeedsRefresh(tab: DataTabLike, maxAgeMs: number, return now - tab.tableMetaUpdatedAt >= maxAgeMs; } +/** + * Connection-lifetime freshness: a data tab is cold when it has no freshness + * stamp or its recorded generation no longer matches the current + * connection/database generation. TTL only applies inside the same generation. + * Missing columns remain a separate cold-cache case handled by callers. + */ +export function isDataTabMetadataLifecycleStale(tab: Pick, currentGeneration: number): boolean { + return tab.tableMetaUpdatedAt === undefined || (tab.tableMetaGeneration ?? -1) !== currentGeneration; +} + export function findExistingDataTabCandidate(tabs: T[], target: DataTabTarget, options: { openMode: DataTabOpenMode; reuseMode: DataTabReuseMode; activeTabId?: string | null }): ExistingDataTabCandidate | undefined { if (options.openMode === "new-tab" || options.reuseMode === "always-new") return undefined; diff --git a/apps/desktop/src/stores/__tests__/connectionStore.disconnectDataTabMetadata.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.disconnectDataTabMetadata.spec.ts new file mode 100644 index 0000000000..41a2b51499 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/connectionStore.disconnectDataTabMetadata.spec.ts @@ -0,0 +1,190 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConnectionConfig } from "@/types/database"; + +function installLocalStorage() { + 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)), + }); +} + +function pgConnection(id: string): ConnectionConfig { + return { + id, + name: id, + db_type: "postgres", + host: "127.0.0.1", + port: 5432, + username: "postgres", + password: "", + database: "app", + read_only: false, + } as ConnectionConfig; +} + +describe("connectionStore disconnect data-tab metadata freshness", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + installLocalStorage(); + setActivePinia(createPinia()); + }); + + it("clears data-tab metadata freshness for the disconnected connection only", async () => { + const checkConnectionHealth = vi.fn().mockResolvedValue(undefined); + const disconnectDb = vi.fn().mockResolvedValue(undefined); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth, + disconnectDb, + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useQueryStore } = await import("@/stores/queryStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + // keep-tabs:数据标签页在断开后继续存在,是 tab-local 元数据跨 reconnect + // 存活的场景(default close-tabs 会直接关掉标签页,不存在该问题) + useSettingsStore().editorSettings.disconnectTabHandlingMode = "keep-tabs-clear-results"; + + const connectionA = pgConnection("conn-a"); + const connectionB = pgConnection("conn-b"); + store.connections = [connectionA, connectionB]; + + const queryStore = useQueryStore(); + const tabA = queryStore.createTab("conn-a", "app", "users", "data", "public", undefined, undefined, { forceNew: true }); + const tabB = queryStore.createTab("conn-b", "app", "orders", "data", "public", undefined, undefined, { forceNew: true }); + queryStore.setTableMeta(tabA, { schema: "public", database: "app", tableName: "users", tableType: "TABLE", columns: [], primaryKeys: [] }); + queryStore.setTableMeta(tabB, { schema: "public", database: "app", tableName: "orders", tableType: "TABLE", columns: [], primaryKeys: [] }); + expect(queryStore.tabs.find((tab) => tab.id === tabA)?.tableMetaUpdatedAt).toBeDefined(); + expect(queryStore.tabs.find((tab) => tab.id === tabB)?.tableMetaUpdatedAt).toBeDefined(); + + store.connectedIds.add("conn-a"); + await store.disconnect("conn-a"); + + // conn-a 的 data tab:freshness 戳被清除(重连后重开必须重新拉取结构) + expect(queryStore.tabs.find((tab) => tab.id === tabA)?.tableMetaUpdatedAt).toBeUndefined(); + // conn-b 不受影响:缓存保持 warm + expect(queryStore.tabs.find((tab) => tab.id === tabB)?.tableMetaUpdatedAt).toBeDefined(); + expect(disconnectDb).toHaveBeenCalledWith("conn-a", undefined); + }, 10_000); + + it("clears data-tab metadata freshness for a closed database only", async () => { + const checkConnectionHealth = vi.fn().mockResolvedValue(undefined); + const closeDatabaseConnection = vi.fn().mockResolvedValue(undefined); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth, + closeDatabaseConnection, + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useQueryStore } = await import("@/stores/queryStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + useSettingsStore().editorSettings.disconnectTabHandlingMode = "keep-tabs-clear-results"; + + const connection = pgConnection("conn-a"); + store.connections = [connection]; + + const queryStore = useQueryStore(); + const tabApp = queryStore.createTab("conn-a", "app", "users", "data", "public", undefined, undefined, { forceNew: true }); + const tabOther = queryStore.createTab("conn-a", "other", "orders", "data", "public", undefined, undefined, { forceNew: true }); + queryStore.setTableMeta(tabApp, { schema: "public", database: "app", tableName: "users", tableType: "TABLE", columns: [], primaryKeys: [] }); + queryStore.setTableMeta(tabOther, { schema: "public", database: "other", tableName: "orders", tableType: "TABLE", columns: [], primaryKeys: [] }); + expect(queryStore.tabs.find((tab) => tab.id === tabApp)?.tableMetaUpdatedAt).toBeDefined(); + expect(queryStore.tabs.find((tab) => tab.id === tabOther)?.tableMetaUpdatedAt).toBeDefined(); + + store.connectedIds.add("conn-a"); + await store.closeDatabaseConnection("conn-a", "app"); + + expect(queryStore.tabs.find((tab) => tab.id === tabApp)?.tableMetaUpdatedAt).toBeUndefined(); + expect(queryStore.tabs.find((tab) => tab.id === tabOther)?.tableMetaUpdatedAt).toBeDefined(); + expect(closeDatabaseConnection).toHaveBeenCalledWith("conn-a", "app"); + }, 10_000); + + it("treats markConnectionLost as a metadata lifecycle boundary for one connection only", async () => { + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + getColumns: vi.fn().mockResolvedValue([{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }]), + listIndexes: vi.fn().mockResolvedValue([]), + listInstalledAgents: vi.fn().mockResolvedValue([]), + listInstalledAgentsLocal: vi.fn().mockResolvedValue([]), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + }; + }); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useQueryStore } = await import("@/stores/queryStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const { loadTableMetadata, getCachedTableMetadata, clearTableMetadataCache } = await import("@/lib/metadata/tableMetadataCache"); + clearTableMetadataCache(); + const store = useConnectionStore(); + useSettingsStore().editorSettings.disconnectTabHandlingMode = "keep-tabs-clear-results"; + + store.connections = [pgConnection("conn-a"), pgConnection("conn-b")]; + const queryStore = useQueryStore(); + const tabA = queryStore.createTab("conn-a", "app", "users", "data", "public", undefined, undefined, { forceNew: true }); + const tabB = queryStore.createTab("conn-b", "app", "orders", "data", "public", undefined, undefined, { forceNew: true }); + queryStore.setTableMeta(tabA, { schema: "public", database: "app", tableName: "users", tableType: "TABLE", columns: [], primaryKeys: [] }); + queryStore.setTableMeta(tabB, { schema: "public", database: "app", tableName: "orders", tableType: "TABLE", columns: [], primaryKeys: [] }); + const generationBeforeA = store.metadataGenerationFor("conn-a", "app"); + const generationBeforeB = store.metadataGenerationFor("conn-b", "app"); + + await loadTableMetadata({ + connectionId: "conn-a", + database: "app", + schema: "public", + tableName: "users", + tableType: "TABLE", + databaseType: "postgres", + }); + expect( + getCachedTableMetadata({ + connectionId: "conn-a", + database: "app", + schema: "public", + tableName: "users", + tableType: "TABLE", + databaseType: "postgres", + }), + ).toBeDefined(); + + store.connectedIds.add("conn-a"); + store.markConnectionLost("conn-a", new Error("connection lost")); + await vi.waitFor(() => { + expect(queryStore.tabs.find((tab) => tab.id === tabA)?.tableMetaUpdatedAt).toBeUndefined(); + }); + + expect(store.metadataGenerationFor("conn-a", "app")).toBeGreaterThan(generationBeforeA); + expect(store.metadataGenerationFor("conn-b", "app")).toBe(generationBeforeB); + expect(queryStore.tabs.find((tab) => tab.id === tabB)?.tableMetaUpdatedAt).toBeDefined(); + expect( + getCachedTableMetadata({ + connectionId: "conn-a", + database: "app", + schema: "public", + tableName: "users", + tableType: "TABLE", + databaseType: "postgres", + }), + ).toBeUndefined(); + }, 10_000); +}); diff --git a/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts index 4c859acfa7..d91857d832 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts @@ -53,6 +53,8 @@ vi.mock("@/stores/connectionStore", () => ({ getConfig: getConnectionConfig, lookupLocalCompletionTables, recordConnectionLostError: vi.fn(), + // 与真实 store 一致:setTableMeta 写入 tableMeta 时记录连接元数据代次 + metadataGenerationFor: () => 0, }), })); diff --git a/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts index eaa5dfd76f..1d9efc2ae3 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.tableDataRefresh.spec.ts @@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({ executeMulti: vi.fn(), getConnectionConfig: vi.fn(), saveOpenTabsState: vi.fn(), + loadTableMetadata: vi.fn(), + metadataGeneration: 0, })); vi.mock("@/lib/backend/api", () => ({ @@ -23,9 +25,18 @@ vi.mock("@/stores/connectionStore", () => ({ ensureConnected: vi.fn().mockResolvedValue(undefined), getConfig: mocks.getConnectionConfig, recordConnectionLostError: vi.fn(), + metadataGenerationFor: () => mocks.metadataGeneration, }), })); +vi.mock("@/lib/metadata/tableMetadataCache", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadTableMetadata: mocks.loadTableMetadata, + }; +}); + vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { pageSize: 1000 }, @@ -47,6 +58,7 @@ describe("queryStore table data refresh", () => { vi.unstubAllGlobals(); installLocalStorage(); setActivePinia(createPinia()); + mocks.metadataGeneration = 0; mocks.getConnectionConfig.mockReturnValue({ id: "pg-1", name: "Postgres", @@ -595,4 +607,128 @@ describe("queryStore table data refresh", () => { expect(tab.result?.execution_error).toBe(true); expect(mocks.executeMulti).not.toHaveBeenCalled(); }); + + it("rebuilds table metadata from the new connection before the first reload after a reconnect boundary", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("pg-1", "app", "users", "data", "public"); + // 断链前的旧结构:只有 [id] + store.setTableMeta(tabId, { + schema: "public", + tableName: "users", + tableType: "TABLE", + columns: [{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + primaryKeys: ["id"], + }); + const tab = store.tabs.find((candidate) => candidate.id === tabId)!; + // 外部 ALTER TABLE ... ADD age:DBX 断开→重连。disconnect 会 bump 连接代次 + // 并清 freshness 戳(staleConnectionDataTabMetadata),此处等价模拟: + // 旧代次(0)的 tableMeta 已与新代次(1)失配,reload 必须重建结构 + mocks.metadataGeneration = 1; + tab.tableMetaUpdatedAt = undefined; + mocks.loadTableMetadata.mockResolvedValue({ + metadata: { + schema: "public", + tableName: "users", + tableType: "TABLE", + database: "app", + columns: [ + { name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "age", data_type: "integer", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, + ], + indexes: [], + primaryKeys: ["id"], + cachedAt: Date.now(), + }, + cacheStatus: "miss", + ageMs: 0, + }); + + await store.refreshDataTab(tabId); + + // 重建从新连接源头强制拉取(force),不再使用旧显式列列表 + expect(mocks.loadTableMetadata).toHaveBeenCalledTimes(1); + expect(mocks.loadTableMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + connectionId: "pg-1", + database: "app", + schema: "public", + tableName: "users", + force: true, + }), + ); + // 重建后的新列 [id, age] 进入 SQL 构建并写回 tab + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: ["id", "age"] })); + expect(tab.tableMeta?.columns.map((column) => column.name)).toEqual(["id", "age"]); + expect(tab.tableMetaUpdatedAt).toBeDefined(); + expect(tab.tableMetaGeneration).toBe(1); + }); + + it("does not rebuild metadata on a warm reload in the same connection generation", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("pg-1", "app", "users", "data", "public"); + store.setTableMeta(tabId, { + schema: "public", + tableName: "users", + tableType: "TABLE", + columns: [{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + primaryKeys: ["id"], + }); + + await store.refreshDataTab(tabId); + + // 同代次(0)内正常刷新:直接复用 tab 元数据,不重新拉取结构 + expect(mocks.loadTableMetadata).not.toHaveBeenCalled(); + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: ["id"] })); + }); + + it("abandons a forced metadata rebuild when generation changes mid-flight", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("pg-1", "app", "users", "data", "public"); + store.setTableMeta(tabId, { + schema: "public", + tableName: "users", + tableType: "TABLE", + columns: [{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + primaryKeys: ["id"], + }); + const tab = store.tabs.find((candidate) => candidate.id === tabId)!; + mocks.metadataGeneration = 1; + tab.tableMetaUpdatedAt = undefined; + let resolveMetadata!: (value: Awaited>) => void; + mocks.loadTableMetadata.mockReturnValueOnce( + new Promise((resolve) => { + resolveMetadata = resolve; + }), + ); + + const refresh = store.refreshDataTab(tabId); + await vi.waitFor(() => expect(mocks.loadTableMetadata).toHaveBeenCalledTimes(1)); + mocks.metadataGeneration = 2; + resolveMetadata({ + metadata: { + schema: "public", + tableName: "users", + tableType: "TABLE", + database: "app", + columns: [ + { name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "age", data_type: "integer", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, + ], + indexes: [], + primaryKeys: ["id"], + cachedAt: Date.now(), + }, + cacheStatus: "miss", + ageMs: 0, + }); + await expect(refresh).resolves.toBe(false); + + expect(tab.tableMeta?.columns.map((column) => column.name)).toEqual(["id"]); + expect(tab.tableMetaUpdatedAt).toBeUndefined(); + expect(mocks.buildTableSelectSql).not.toHaveBeenCalled(); + expect(mocks.executeMulti).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index bd9f27584d..d12a52cd42 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -1038,6 +1038,9 @@ export const useConnectionStore = defineStore("connection", () => { clearConnectionHealthCheck(connectionId); if (activeConnectionId.value === connectionId) activeConnectionId.value = null; recordConnectionError(connectionId, error); + // 被动断链同样是元数据生命周期边界:查询/健康检查发现连接已失效后, + // 不得继续把旧 tableMeta 当作当前代次的 warm cache(PR #6640 blocker 3)。 + invalidateConnectionMetadataLifetime(connectionId); } function recordConnectionLostError(connectionId: string, error: unknown): boolean { @@ -1750,6 +1753,47 @@ export const useConnectionStore = defineStore("connection", () => { }; } + // 连接元数据代次(connection generation):disconnect / 关闭数据库 / 后端死池 + // 重连等生命周期边界会递增代次。数据标签页记录其 tableMeta 写入时的代次 + // (QueryTab.tableMetaGeneration),代次失配视同冷缓存——即使位于 30s TTL + // 窗口内也必须重新拉取结构,且失效前启动的 in-flight 元数据请求不得把旧 + // 结果写回 tab(issue #6623 / PR #6640 review blocker 1)。 + // 语义:连接级代次影响该连接全部数据库;数据库级代次只影响单个数据库。 + // 查询代次时取两者较新者,disconnect 只 bump 连接级、closeDatabaseConnection + // 只 bump 数据库级,因此作用域天然隔离。 + let metadataGenerationCounter = 0; + const metadataGenerationByConnection = new Map(); + const metadataGenerationByDatabase = new Map(); + + function metadataGenerationFor(connectionId: string, database?: string): number { + const connectionGeneration = metadataGenerationByConnection.get(connectionId) ?? 0; + if (database == null) return connectionGeneration; + return Math.max(connectionGeneration, metadataGenerationByDatabase.get(`${connectionId}\x00${database}`) ?? 0); + } + + function bumpMetadataGeneration(connectionId: string, database?: string) { + metadataGenerationCounter += 1; + if (database == null) { + metadataGenerationByConnection.set(connectionId, metadataGenerationCounter); + } else { + metadataGenerationByDatabase.set(`${connectionId}\x00${database}`, metadataGenerationCounter); + } + } + + /** + * 连接/数据库生命周期失效:shared metadata cache + 单调代次必须同步完成, + * 这样紧随其后的 toolbar reload 立刻看到 generation mismatch,不会把旧 + * tableMeta 当成 warm。data-tab freshness 清戳经动态 import,避免与 + * queryStore 循环依赖;generation 已变时 freshness 稍晚清除也安全。 + */ + function invalidateConnectionMetadataLifetime(connectionId: string, database?: string) { + invalidateMetadataCaches({ connectionId, database }); + bumpMetadataGeneration(connectionId, database); + void import("@/stores/queryStore").then(({ useQueryStore }) => { + useQueryStore().staleConnectionDataTabMetadata(connectionId, database); + }); + } + function invalidateMetadataCaches(match: MetadataCacheInvalidation): number { return metadataListPageCache.invalidate(match) + invalidateTableMetadataCache(match) + invalidateObjectBrowserRowsCache(match); } @@ -1852,7 +1896,7 @@ export const useConnectionStore = defineStore("connection", () => { function mergeLocatedTreeChildren(parent: TreeNode, currentChildren: TreeNode[], pageChildren: TreeNode[], connectionId: string, database: string): TreeNode[] { const tableChildren = pageChildren.filter((child) => child.type === "table"); const nonTableChildren = pageChildren.filter((child) => child.type !== "table"); - let merged = tableChildren.length ? mergeTableTreePageChildren(currentChildren, tableChildren, connectionId, database) : [...currentChildren]; + const merged = tableChildren.length ? mergeTableTreePageChildren(currentChildren, tableChildren, connectionId, database) : [...currentChildren]; const existing = new Map(merged.map((node) => [treeNodeObjectIdentity(node), node])); for (const child of nonTableChildren) { const key = treeNodeObjectIdentity(child); @@ -3410,6 +3454,11 @@ export const useConnectionStore = defineStore("connection", () => { invalidateObjectBrowserRowsCache({ connectionId }); const { useQueryStore } = await import("@/stores/queryStore"); const queryStore = useQueryStore(); + // 断开连接是明确的元数据新鲜度边界:数据标签页保留展示/编辑状态,但 + // 其 tableMeta 不得再被视为 warm cache(issue #6623——reconnect 后重开 + // 同表必须重新拉取结构)。completion/object-browser 缓存上面已清; + // 这里再走统一 helper,bump 连接代次并清 data-tab freshness。 + invalidateConnectionMetadataLifetime(connectionId); switch (settingsStore.editorSettings.disconnectTabHandlingMode) { case "close-tabs": queryStore.closeConnectionTabs(connectionId); @@ -3467,6 +3516,10 @@ export const useConnectionStore = defineStore("connection", () => { } invalidateCompletionCache(connectionId, database); invalidateObjectBrowserRowsCache({ connectionId, database }); + // 数据库级生命周期边界:与连接级断开一致,数据标签页的元数据 freshness + // 戳必须作废,重开/刷新时重新拉取结构(issue #6623)。bump 数据库级代次 + // 只影响该库的在途回写与 reload 判定,不影响本连接其它库。 + invalidateConnectionMetadataLifetime(connectionId, database); } async function ensureConnected(connectionId: string, options: { activate?: boolean; verifyHealth?: boolean } = {}) { @@ -3482,7 +3535,10 @@ export const useConnectionStore = defineStore("connection", () => { markConnectionHealthChecked(connectionId); return; } catch { - // Backend pool is dead — remove from connectedIds and reconnect + // Backend pool is dead — remove from connectedIds and reconnect. + // 死池重连同样跨越了连接生命周期:shared 表元数据缓存与数据标签页的 + // 元数据 freshness 都必须作废,否则自动重连后仍可能复用断链前的旧 + // 字段结构(issue #6623)。 cancelObjectDdlLoadsForConnection(connectionId); cancelObjectMetadataLoadsForConnection(connectionId); clearMetadataRuntimeCacheForConnection(connectionId); @@ -3490,6 +3546,7 @@ export const useConnectionStore = defineStore("connection", () => { clearPrimaryVisibleObjectNames(connectionId); clearConnectionHealthCheck(connectionId); if (activeConnectionId.value === connectionId) activeConnectionId.value = null; + invalidateConnectionMetadataLifetime(connectionId); } } let config = getConfig(connectionId); @@ -6657,7 +6714,7 @@ export const useConnectionStore = defineStore("connection", () => { const text = table.name.toLowerCase(); const schema = table.schema?.toLowerCase(); const normalized = filter.trim().toLowerCase(); - let score = schema && preferredSchema && schema === preferredSchema.toLowerCase() ? 10_000 : 0; + const score = schema && preferredSchema && schema === preferredSchema.toLowerCase() ? 10_000 : 0; if (!normalized) return score; if (text === normalized) return score + 9_000 - text.length; if (text.startsWith(normalized)) return score + 7_500 - text.length; @@ -8199,6 +8256,7 @@ export const useConnectionStore = defineStore("connection", () => { connect, cancelConnecting, disconnect, + metadataGenerationFor, disconnectAndForgetConnectionPassword, hasSessionCredential, closeDatabaseConnection, diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index a28ff868ff..e93fe12d0f 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -39,9 +39,10 @@ import { supportsClearableQuerySchema } from "@/lib/database/databaseFeatureSupp import { canInsertTableRows, canUseKeylessRowPredicate, DBX_ROWID_COLUMN, editablePrimaryKeys, usesSyntheticRowIdKey } from "@/lib/table/tableEditing"; import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/table/tableDataExport"; import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta"; +import { isDataTabMetadataLifecycleStale } from "@/lib/sidebar/dataTabOpenPolicy"; import { dataTabExecutionDatabase } from "@/lib/table/dataTabExecutionDatabase"; import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit"; -import { getCachedTableMetadata, loadTableColumns, loadTableIndexes, loadTableMetadata, type TableMetadataRequest } from "@/lib/metadata/tableMetadataCache"; +import { getCachedTableMetadata, loadTableColumns, loadTableIndexes, loadTableMetadata, tableMetadataToDataTabMeta, type TableMetadataRequest } from "@/lib/metadata/tableMetadataCache"; import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter"; import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql"; import { connectionObjectTreeNodeSchema, connectionQueryExecutionSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, gaussdbCountQueryDopHint, metadataSchemaForConnection } from "@/lib/database/jdbcDialect"; @@ -2819,26 +2820,55 @@ export const useQueryStore = defineStore("query", () => { async function refreshDataTabInternal(id: string, options?: { supersedeBusy?: boolean; propagateBuildError?: boolean }): Promise { const tab = tabs.value.find((candidate) => candidate.id === id); if (!tab || tab.mode !== "data" || (tab.isExecuting && !options?.supersedeBusy)) return false; - const tableMeta = tableMetaForDataTab(tab); - if (!tableMeta?.tableName) return false; const connStore = useConnectionStore(); const conn = connStore.getConfig(tab.connectionId); const effectiveDbType = effectiveDatabaseTypeForConnection(conn); const identifierQuote = connStore.connectionIdentifierQuote?.(tab.connectionId); - clearInvalidDataTabSortState(tab, tableMeta.columns); - const primaryKeys = tab.tableMeta ? tab.tableMeta.primaryKeys : tableMeta.primaryKeys; - const sortOrder = tab.resultSortColumn && tab.resultSortDirection ? `${quoteTableDataIdentifier(effectiveDbType, tab.resultSortColumn, identifierQuote)} ${tab.resultSortDirection.toUpperCase()}` : undefined; - const orderBy = tab.orderByInput?.trim() || sortOrder; - const limit = tab.resultPageLimit ?? tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize); - const offset = tab.resultPageOffset ?? 0; - const useDriverRowOffset = conn?.db_type === "jdbc" && effectiveDbType === "iris"; const refreshPreparationId = uuid(); // Reserve the tab synchronously before SQL construction yields so repeated // refresh requests cannot build and execute duplicate queries. setExecutingWithId(tab.id, refreshPreparationId); try { + let tableMeta = tableMetaForDataTab(tab); + if (!tableMeta?.tableName) return false; + + // 生命周期代次校验:disconnect / 关库 / 死池重连后的首次刷新必须先从 + // 新连接源头重建结构,否则旧显式列列表会生成错误 SELECT(issue #6623 / + // PR #6640 review blocker 2)。reload 路径不读 tableMetaUpdatedAt 风干 + // 判定、只读 tableMeta 本身,因此在这里显式强制重建。 + const connectionGeneration = connStore.metadataGenerationFor(tab.connectionId, tab.database); + if (isDataTabMetadataLifecycleStale(tab, connectionGeneration)) { + const metadataGenerationAtStart = connectionGeneration; + const reloadedMetadata = await loadTableMetadata({ + connectionId: tab.connectionId, + database: tab.database, + schema: tableMeta.schema, + tableName: tableMeta.tableName, + tableType: tableMeta.tableType, + catalog: tableMeta.catalog, + databaseType: effectiveDbType ?? conn?.db_type ?? "", + driverProfile: conn?.driver_profile, + force: true, + }); + // 重建期间又跨越了一次连接生命周期边界 → 放弃本次刷新,避免旧结果 + // 二次写回 tab(PR #6640 review blocker 1 的 tab-local 半边) + if (connStore.metadataGenerationFor(tab.connectionId, tab.database) !== metadataGenerationAtStart) return false; + const current = tabs.value.find((candidate) => candidate.id === id); + if (!current || current.executionId !== refreshPreparationId) return false; + setTableMeta(tab.id, tableMetadataToDataTabMeta(reloadedMetadata.metadata, { schema: tableMeta.schema })); + tableMeta = tableMetaForDataTab(tab) ?? tableMeta; + } + + clearInvalidDataTabSortState(tab, tableMeta.columns); + const primaryKeys = tab.tableMeta ? tab.tableMeta.primaryKeys : tableMeta.primaryKeys; + const sortOrder = tab.resultSortColumn && tab.resultSortDirection ? `${quoteTableDataIdentifier(effectiveDbType, tab.resultSortColumn, identifierQuote)} ${tab.resultSortDirection.toUpperCase()}` : undefined; + const orderBy = tab.orderByInput?.trim() || sortOrder; + const limit = tab.resultPageLimit ?? tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize); + const offset = tab.resultPageOffset ?? 0; + const useDriverRowOffset = conn?.db_type === "jdbc" && effectiveDbType === "iris"; + const sql = await buildTableSelectSql({ databaseType: effectiveDbType, driverProfile: conn?.driver_profile, @@ -2913,6 +2943,21 @@ export const useQueryStore = defineStore("query", () => { releaseTabsWhere((tab) => tab.connectionId === connectionId && tab.database === database); } + /** + * 连接生命周期边界(断开连接 / 关闭数据库连接 / 后端连接池失效重连)。 + * 数据标签页的 tableMeta 为展示/编辑保留(不清除 UI、主键与编辑安全门控), + * 但清掉 freshness 戳:此后 openData 的 tab-local 暖缓存判定、激活路径的 + * stale 判定、以及网格 reload 的 metadata age 判定全部按"冷缓存"处理, + * 即使位于 30s TTL 窗口内也会重新拉取结构(issue #6623)。 + */ + function staleConnectionDataTabMetadata(connectionId: string, database?: string) { + for (const tab of tabs.value) { + if (tab.mode !== "data" || tab.connectionId !== connectionId) continue; + if (database != null && tab.database !== database) continue; + tab.tableMetaUpdatedAt = undefined; + } + } + function isDatabaseOpen(connectionId: string, database: string) { return openDatabaseKeys.value.has(`${connectionId}\x00${database}`); } @@ -3297,6 +3342,10 @@ export const useQueryStore = defineStore("query", () => { const tab = tabs.value.find((t) => t.id === id); if (tab) { tab.tableMeta = meta; + // 记录写入时的连接元数据代次:disconnect/关库/死池重连会使该代次递增, + // 代次失配视同冷缓存,即使位于 30s TTL 窗口内也会重建结构(issue #6623 / + // PR #6640)。同一代次内的多次写入与连接Store保持一致,无需额外入参。 + tab.tableMetaGeneration = useConnectionStore().metadataGenerationFor(tab.connectionId, tab.database) ?? 0; tab.tableMetaUpdatedAt = Date.now(); if (meta.columns.length > 0) clearInvalidDataTabSortState(tab, meta.columns); // 只有真实元数据(columns 非空)落地才结束行标识等待;多处调用方会先写 @@ -6255,6 +6304,7 @@ export const useQueryStore = defineStore("query", () => { refreshDataTabsForTable, releaseConnectionTabs, releaseDatabaseTabs, + staleConnectionDataTabMetadata, isDatabaseOpen, openDatabaseKeys, rollbackConnectionTransactions, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 0a7edd24d9..be959e37f7 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1253,6 +1253,9 @@ export interface QueryTab { primaryKeys: string[]; }; tableMetaUpdatedAt?: number; + /** 该 tab 的 tableMeta 是哪个连接元数据代次下写入的:disconnect / 关闭数据库 / + * 死池重连等生命周期边界会让该代次递增,代次失配视同冷缓存(issue #6623 / PR #6640)。 */ + tableMetaGeneration?: number; pendingDataChangeCount?: number; /** Ephemeral editor draft that has not yet been applied to the data grid. */ hasPendingDataEditorDraft?: boolean;