fix(connection): invalidate data-tab metadata on disconnect/reconnect
This commit is contained in:
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;
|
||||
|
||||
Reference in new issue
Block a user