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