fix(connection): invalidate data-tab metadata on disconnect/reconnect

This commit is contained in:
jearhe authored and GitHub committed 2026-08-20 09:25:14 +08:00
1 parent b537c4b891
commit 78aab1e5ed
15 files changed
+804 -31

No files matched your search

@@ -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> = {}): 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",
@@ -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,
}),
@@ -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(),
},
@@ -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<ReturnType<typeof mocks.loadTableMetadata>>) => void;
const pendingMetadata = new Promise<Awaited<ReturnType<typeof mocks.loadTableMetadata>>>((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);
});
});
@@ -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<QueryTab | undefined>)
});
}
async function refreshDataTabTableMeta(tab: QueryTab, trace?: { traceId: string; elapsed: () => string }): Promise<void> {
if (tab.mode !== "data" || !tab.connectionId || !tab.database) return;
async function refreshDataTabTableMeta(tab: QueryTab, options: { force?: boolean; trace?: { traceId: string; elapsed: () => string } } = {}): Promise<boolean> {
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<QueryTab | undefined>)
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<QueryTab | undefined>)
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<QueryTab | undefined>)
columns,
primaryKeys,
});
return true;
}
async function onExecuteSql(sql: string) {
@@ -153,7 +167,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
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<QueryTab | undefined>)
// 真实列缺失时用查询结果列合成 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<QueryTab | undefined>)
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 落地后解除
@@ -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,
@@ -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;