Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
b2431e5
fix(postgres): bound metadata query timeouts and surface real diagnos…
Abeautifulsnow Aug 14, 2026
214d908
fix(postgres): derive metadata query budget from query timeout
Abeautifulsnow Aug 14, 2026
f5a288c
fix(postgres): keep metadata cancel inside the query budget and honor…
Abeautifulsnow Aug 15, 2026
4b1b73f
Merge remote-tracking branch 'origin/main' into fix/postgres-metadata…
Abeautifulsnow Aug 15, 2026
d0185e2
Merge remote-tracking branch 'origin/main' into fix/postgres-metadata…
Abeautifulsnow Aug 17, 2026
791f88a
fix(postgres): deduplicate oracle import after merge
Abeautifulsnow Aug 17, 2026
954067e
fix(postgres): adapt list_indexes callers and lint guard after main m…
Abeautifulsnow Aug 17, 2026
3133799
Merge remote-tracking branch 'origin/main' into fix/postgres-metadata…
Abeautifulsnow Aug 21, 2026
fc4ac10
fix(postgres): keep metadata query budget full, align frontend deadli…
Abeautifulsnow Aug 21, 2026
2aff5e1
test(desktop): cover abort-signal threading in listDialectDataTypes a…
Abeautifulsnow Aug 21, 2026
9a73c59
fix(postgres): preserve TLS metadata cancel transport
Abeautifulsnow Aug 21, 2026
5cde1a4
Merge remote-tracking branch 'origin/main' into fix/postgres-metadata…
Abeautifulsnow Aug 24, 2026
9062c4b
fix(postgres): drop stale map_err on cached owner query
Abeautifulsnow Aug 24, 2026
39badbc
fix(postgres): add missing legacy_tls field in cancel-context test
Abeautifulsnow Aug 24, 2026
62ec631
fix(test): restore missing #[test] on reference_key_columns unit test
Abeautifulsnow Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 59 additions & 8 deletions apps/desktop/src/components/connection/ConnectionDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { connectionDeepLinkServiceHydrationValue, parseConnectionDeepLink, parse
import { connectionUrlPlaceholder as getUrlPlaceholder } from "@/lib/connection/connectionPresentation";
import { parseGaussdbHosts, serializeGaussdbHosts, type GaussdbHostEntry } from "@/lib/connection/gaussdbHosts";
import { h2ConnectionModeForConfig, h2FileJdbcUrlWithPath, h2FilePathFromJdbcUrl, isH2SplitJdbcUrl, type H2ConnectionMode } from "@/lib/database/h2Connection";
import { metadataLoadTimeoutMs } from "@/lib/sql/queryTimeout";
import { firstZooKeeperEndpoint, normalizeZooKeeperConnectString } from "@/lib/zookeeper/zookeeperConnection";
import { setZooKeeperAuthScheme, zooKeeperAuthScheme as resolveZooKeeperAuthScheme, type ZooKeeperAuthScheme } from "@/lib/zookeeper/zookeeperConnectionOptions";
import { isLocalFileTypeDb } from "@/lib/connection/connectionFile";
Expand Down Expand Up @@ -294,6 +295,12 @@ const visibleDatabaseSelection = ref<Set<string>>(new Set());
const visibleDatabaseSearchText = ref("");
const visibleDatabaseError = ref("");
const visibleDatabaseShowSystem = ref(false);
// In-flight visible-databases picker requests. Aborted on the load timeout,
// on picker completion, and on component unmount so a dismissed dialog cannot
// be reopened by a late rejection.
const visibleDatabasesPickerAbortControllers = new Set<AbortController>();
let visibleDatabasesPickerRun = 0;
const connectionDialogMounted = ref(true);
const showVisibleNacosNamespacesDialog = ref(false);
const isLoadingVisibleNacosNamespaces = ref(false);
const visibleNacosNamespaces = ref<NacosNamespaceInfo[]>([]);
Expand Down Expand Up @@ -4280,19 +4287,33 @@ async function preloadVisibleDatabaseNames() {
if (visibleDatabaseNames.value.length > 0) return;
isLoadingVisibleDatabases.value = true;
const draftId = buildDraftVisibleDatabasesConnectionId(uuid());
const abortController = new AbortController();
visibleDatabasesPickerAbortControllers.add(abortController);
try {
const draftConfig = {
...connectionConfigForSubmit(draftId),
id: draftId,
one_time: true,
};
await api.connectDb(draftConfig);
visibleDatabaseNames.value = await loadVisibleDatabaseNames(draftId, draftConfig);
const timeoutMs = metadataLoadTimeoutMs(draftConfig, settingsStore.editorSettings.globalQueryTimeoutSecs, settingsStore.editorSettings.globalConnectTimeoutSecs);
visibleDatabaseNames.value = await Promise.race([
api.connectDb(draftConfig).then(() => loadVisibleDatabaseNames(draftId, draftConfig, abortController.signal)),
new Promise<never>((_, reject) => {
setTimeout(() => {
abortController.abort();
reject(new Error(`Connection timed out while loading databases after ${Math.ceil(timeoutMs / 1000)}s. Please check the network or VPN and try again.`));
}, timeoutMs);
}),
]);
} catch {
// silently fail
} finally {
visibleDatabasesPickerAbortControllers.delete(abortController);
abortController.abort();
if (connectionDialogMounted.value) {
isLoadingVisibleDatabases.value = false;
}
await api.disconnectDb(draftId).catch(() => undefined);
isLoadingVisibleDatabases.value = false;
}
}

Expand All @@ -4304,15 +4325,31 @@ async function openVisibleDatabasesPicker() {
visibleDatabaseError.value = "";
visibleDatabaseSearchText.value = "";
const draftId = buildDraftVisibleDatabasesConnectionId(uuid());
const abortController = new AbortController();
const runId = ++visibleDatabasesPickerRun;
visibleDatabasesPickerAbortControllers.add(abortController);

try {
const draftConfig = {
...connectionConfigForSubmit(draftId),
id: draftId,
one_time: true,
};
await api.connectDb(draftConfig);
const names = await loadVisibleDatabaseNames(draftId, draftConfig);
// Bound the connect + database-name load so a half-open connection
// degrades to a clear timeout instead of a ~30s generic hang. The abort
// controller interrupts the in-flight HTTP listDatabases request so the
// backend query is canceled and the pool slot is freed.
const timeoutMs = metadataLoadTimeoutMs(draftConfig, settingsStore.editorSettings.globalQueryTimeoutSecs, settingsStore.editorSettings.globalConnectTimeoutSecs);
const names = await Promise.race([
api.connectDb(draftConfig).then(() => loadVisibleDatabaseNames(draftId, draftConfig, abortController.signal)),
new Promise<never>((_, reject) => {
setTimeout(() => {
abortController.abort();
reject(new Error(`Connection timed out while loading databases after ${Math.ceil(timeoutMs / 1000)}s. Please check the network or VPN and try again.`));
}, timeoutMs);
}),
]);
if (runId !== visibleDatabasesPickerRun) return;
visibleDatabaseNames.value = names;
visibleDatabaseShowSystem.value = false;
const configuredSchemas = visibleSchemaObjectSelection.value;
Expand All @@ -4322,14 +4359,21 @@ async function openVisibleDatabasesPicker() {
visibleDatabaseShowSystem.value = initialSelection.some((name) => !defaultVisible.has(name));
showVisibleDatabasesDialog.value = true;
} catch (e: any) {
// A late rejection after unmount or after a newer run must not reopen a
// dismissed dialog or mutate state.
if (!connectionDialogMounted.value || runId !== visibleDatabasesPickerRun) return;
visibleDatabaseNames.value = [];
visibleDatabaseSelection.value = new Set();
visibleDatabaseError.value = mongodbAuthFailureHint(errorMessage(e));
testResult.value = { ok: false, message: visibleDatabaseError.value };
showVisibleDatabasesDialog.value = true;
} finally {
visibleDatabasesPickerAbortControllers.delete(abortController);
abortController.abort();
if (connectionDialogMounted.value && runId === visibleDatabasesPickerRun) {
isLoadingVisibleDatabases.value = false;
}
await api.disconnectDb(draftId).catch(() => undefined);
isLoadingVisibleDatabases.value = false;
}
}

Expand Down Expand Up @@ -4499,7 +4543,7 @@ async function saveVisibleNacosNamespaceSelection() {
showVisibleNacosNamespacesDialog.value = false;
}

async function loadVisibleDatabaseNames(connectionId: string, config: ConnectionConfig): Promise<string[]> {
async function loadVisibleDatabaseNames(connectionId: string, config: ConnectionConfig, signal?: AbortSignal): Promise<string[]> {
if (connectionUsesVisibleSchemaFilter(config)) {
return api.listSchemas(connectionId, config.database || "");
}
Expand All @@ -4509,7 +4553,7 @@ async function loadVisibleDatabaseNames(connectionId: string, config: Connection
if (config.db_type === "mongodb") {
return api.mongoListDatabases(connectionId);
}
return (await api.listDatabases(connectionId)).map((database) => database.name);
return (await api.listDatabases(connectionId, signal)).map((database) => database.name);
}

function normalizeProductionDatabaseSelection(selectedNames: Iterable<string>, databaseNames: string[]): string[] {
Expand Down Expand Up @@ -5572,6 +5616,13 @@ onMounted(async () => {
onUnmounted(() => {
unlistenAgentInstallProgress?.();
unlistenAgentInstallProgress = null;
connectionDialogMounted.value = false;
// Interrupt any in-flight visible-databases picker load so a late rejection
// cannot reopen a dismissed dialog after unmount.
for (const controller of visibleDatabasesPickerAbortControllers) {
controller.abort();
}
visibleDatabasesPickerAbortControllers.clear();
});

function openExternalUrl(url: string) {
Expand Down
40 changes: 39 additions & 1 deletion apps/desktop/src/lib/__tests__/sql/queryTimeout.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { CONCURRENT_INDEX_QUERY_TIMEOUT_SECS, frontendQueryTimeoutDelayMs, frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConcurrentIndex, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
import { CONCURRENT_INDEX_QUERY_TIMEOUT_SECS, effectiveConnectTimeoutSecs, frontendQueryTimeoutDelayMs, frontendQueryTimeoutSecsForSql, metadataLoadTimeoutMs, queryTimeoutSecsForConcurrentIndex, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";

describe("queryTimeout", () => {
it("gives CREATE INDEX CONCURRENTLY a dedicated long budget instead of the 30s default", () => {
Expand Down Expand Up @@ -51,4 +51,42 @@ describe("queryTimeout", () => {
it("falls back safely when an inherited global timeout is invalid", () => {
expect(queryTimeoutSecsForConnection({ query_timeout_inherit: true }, Number.NaN)).toBe(30);
});

it("resolves the metadata load deadline from the inherited global query timeout", () => {
expect(metadataLoadTimeoutMs({ query_timeout_secs: 30, query_timeout_inherit: true }, 120)).toBe(155_000);
expect(metadataLoadTimeoutMs({ query_timeout_secs: 30, query_timeout_inherit: true })).toBe(65_000);
});

it("uses the local query timeout for the metadata load deadline when not inheriting", () => {
expect(metadataLoadTimeoutMs({ query_timeout_secs: 45, query_timeout_inherit: false }, 120)).toBe(80_000);
});

it("uses the 60s backend fallback budget when the query timeout is disabled (0 = unlimited)", () => {
expect(metadataLoadTimeoutMs({ query_timeout_secs: 0, query_timeout_inherit: false }, 120)).toBe(95_000);
expect(metadataLoadTimeoutMs({ query_timeout_secs: 30, query_timeout_inherit: true }, 0)).toBe(95_000);
});

it("covers the whole backend operation (connect x3 + query + cancel) instead of just the query", () => {
// Defaults: connect 10s, query 30s -> 3*10 + 30 = 60s query+connect total,
// plus 2s cancel allowance and 3s transport buffer.
expect(metadataLoadTimeoutMs(undefined)).toBe(65_000);
expect(metadataLoadTimeoutMs({})).toBe(65_000);
// A 45s connect timeout extends the connect phases but not the query.
expect(metadataLoadTimeoutMs({ connect_timeout_secs: 45, connect_timeout_inherit: false, query_timeout_secs: 30 }, 30, 10)).toBe(170_000);
// An inherited global connect timeout is honored.
expect(metadataLoadTimeoutMs({ connect_timeout_inherit: true, query_timeout_secs: 30 }, 30, 45)).toBe(170_000);
});

it("floors the metadata load deadline at the minimum and defaults safely", () => {
expect(metadataLoadTimeoutMs({ query_timeout_secs: 1 }, 120)).toBe(36_000);
expect(metadataLoadTimeoutMs({ query_timeout_secs: 1, connect_timeout_secs: 1 }, 120, 10)).toBe(36_000);
});

it("resolves the effective connect timeout honoring inheritance and the global default", () => {
expect(effectiveConnectTimeoutSecs({ connect_timeout_secs: undefined })).toBe(10);
expect(effectiveConnectTimeoutSecs({ connect_timeout_secs: 45 })).toBe(45);
expect(effectiveConnectTimeoutSecs({ connect_timeout_inherit: true }, 45)).toBe(45);
expect(effectiveConnectTimeoutSecs({ connect_timeout_inherit: true }, 0)).toBe(10);
expect(effectiveConnectTimeoutSecs({ connect_timeout_secs: 0 })).toBe(10);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,22 @@ describe("listDialectDataTypes backend adapters", () => {
await expect(listDialectDataTypes("PostgreSQL")).resolves.toEqual(["INTEGER", "TEXT"]);
expect(fetchMock).toHaveBeenCalledWith("/api/dialect/data-types?dialect_name=PostgreSQL");
});

it("threads the abort signal through to fetch when present", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([]),
});
vi.stubGlobal("fetch", fetchMock);
const { listDatabases } = await import("@/lib/backend/http");
const controller = new AbortController();

await expect(listDatabases("conn-1", controller.signal)).resolves.toEqual([]);
// The signal must be threaded as the second fetch argument so an in-flight
// metadata request can be aborted; the no-signal path keeps the classic
// single-argument fetch shape (see the dialect route test above).
expect(fetchMock).toHaveBeenCalledWith("/api/schema/databases?connection_id=conn-1", {
signal: controller.signal,
});
});
});
13 changes: 8 additions & 5 deletions apps/desktop/src/lib/backend/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,11 +260,12 @@ const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
sidebar_table_page_size: 1000,
};

async function post<T>(url: string, body: unknown): Promise<T> {
async function post<T>(url: string, body: unknown, signal?: AbortSignal): Promise<T> {
const res = await fetch(apiUrl(url), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
...(signal ? { signal } : {}),
});
if (!res.ok) throw await backendResponseError(res);
return res.json();
Expand Down Expand Up @@ -319,8 +320,10 @@ async function postQueryWithDiagnostics<T>(url: string, body: unknown, traceId?:
return result;
}

async function get<T>(url: string): Promise<T> {
const res = await fetch(apiUrl(url));
async function get<T>(url: string, signal?: AbortSignal): Promise<T> {
// Only pass the signal when present so the fetch call keeps its classic
// single-argument shape for callers/specs that don't abort.
const res = signal ? await fetch(apiUrl(url), { signal }) : await fetch(apiUrl(url));
if (!res.ok) throw await backendResponseError(res);
return res.json();
}
Expand Down Expand Up @@ -759,8 +762,8 @@ export async function syncSavedSqlDirectory(_request: SavedSqlSyncRequest): Prom
// Schema
// ---------------------------------------------------------------------------

export async function listDatabases(connectionId: string): Promise<DatabaseInfo[]> {
return get(`/api/schema/databases?${qs({ connection_id: connectionId })}`);
export async function listDatabases(connectionId: string, signal?: AbortSignal): Promise<DatabaseInfo[]> {
return get(`/api/schema/databases?${qs({ connection_id: connectionId })}`, signal);
}

export async function listDatabaseMetadata(connectionId: string): Promise<DatabaseInfo[]> {
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/lib/backend/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1008,7 +1008,9 @@ export async function closeDatabaseConnection(connectionId: string, database: st
return invoke("close_database_connection", { connectionId, database });
}

export async function listDatabases(connectionId: string): Promise<DatabaseInfo[]> {
export async function listDatabases(connectionId: string, _signal?: AbortSignal): Promise<DatabaseInfo[]> {
// Tauri IPC has no fetch abort; the signal is accepted for signature
// compatibility with the HTTP backend and is intentionally ignored here.
return invoke("list_databases", { connectionId });
}

Expand Down
52 changes: 52 additions & 0 deletions apps/desktop/src/lib/sql/queryTimeout.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
import { tokenizeSqlSemantic } from "@/lib/sql/semantic/tokens";
import { DEFAULT_CONNECT_TIMEOUT_SECS } from "@/lib/connection/timeoutLimits";
import type { ConnectionConfig, DatabaseType } from "@/types/database";

export const DEFAULT_QUERY_TIMEOUT_SECS = 30;
Expand Down Expand Up @@ -32,6 +33,57 @@ export function queryTimeoutSecsForConnection(connection?: Pick<ConnectionConfig
return Number.isFinite(value) && value >= 0 ? value : DEFAULT_QUERY_TIMEOUT_SECS;
}

/** Floor for the metadata-load deadline so slow WAN/tunnel round-trips don't trip a too-tight guard. */
export const METADATA_LOAD_MIN_TIMEOUT_MS = 15_000;
/** Backend metadata query budget fallback when query_timeout is 0/unlimited
* (mirrors POSTGRES_METADATA_QUERY_BUDGET_FALLBACK in dbx-core). */
export const METADATA_LOAD_QUERY_BUDGET_FALLBACK_SECS = 60;
/** Backend server-side cancel allowance added ON TOP of the query budget
* (mirrors POSTGRES_METADATA_CANCEL_ALLOWANCE in dbx-core). A CancelRequest is a
* separate protocol request the server never answers, so it must not eat into
* the statement window. */
export const METADATA_LOAD_CANCEL_ALLOWANCE_MS = 2_000;
/** Transport/jitter buffer so the frontend deadline stays safely past the
* backend operation total (connect x3 + query + cancel). */
export const METADATA_LOAD_TRANSPORT_BUFFER_MS = 3_000;

/** Effective connect timeout (seconds) for a connection, honoring inheritance
* and the global default — mirrors `queryTimeoutSecsForConnection`. */
export function effectiveConnectTimeoutSecs(connection?: Pick<ConnectionConfig, "connect_timeout_secs" | "connect_timeout_inherit"> | null, globalConnectTimeoutSecs = DEFAULT_CONNECT_TIMEOUT_SECS): number {
if (connection?.connect_timeout_inherit === true) {
const globalValue = Number(globalConnectTimeoutSecs);
return Number.isFinite(globalValue) && globalValue >= 1 ? globalValue : DEFAULT_CONNECT_TIMEOUT_SECS;
}
const value = Number(connection?.connect_timeout_secs);
return Number.isFinite(value) && value >= 1 ? value : DEFAULT_CONNECT_TIMEOUT_SECS;
}

/**
* End-to-end deadline for a silent metadata load (e.g. the visible-databases
* picker or connectionStore's withMetadataLoadTimeout).
*
* The backend operation total is a hard upper bound: connectDb (pool build +
* first connect) ≤ connect timeout, checkout ≤ connect timeout, identity ≤
* connect timeout, the metadata query ≤ the effective query budget (or the 60s
* fallback when query timeout is disabled), and the server-side cancel ≤ its
* fixed 2s allowance on top. This deadline covers that total (plus a small
* transport buffer) so the backend's distinctive PostgreSQL diagnostic — never
* the generic frontend timeout — is what surfaces first.
*/
export function metadataLoadTimeoutMs(
connection?: Pick<ConnectionConfig, "connect_timeout_secs" | "connect_timeout_inherit" | "query_timeout_secs" | "query_timeout_inherit"> | null,
globalQueryTimeoutSecs = DEFAULT_QUERY_TIMEOUT_SECS,
globalConnectTimeoutSecs = DEFAULT_CONNECT_TIMEOUT_SECS,
): number {
const configuredQuerySecs = queryTimeoutSecsForConnection(connection, globalQueryTimeoutSecs);
const querySecs = configuredQuerySecs === 0 ? METADATA_LOAD_QUERY_BUDGET_FALLBACK_SECS : configuredQuerySecs;
// The backend floors checkout/identity at the connection timeout, so use the
// default as a floor here to stay conservative for sub-default connect values.
const connectSecs = Math.max(effectiveConnectTimeoutSecs(connection, globalConnectTimeoutSecs), DEFAULT_CONNECT_TIMEOUT_SECS);
const totalMs = (3 * connectSecs + querySecs) * 1000 + METADATA_LOAD_CANCEL_ALLOWANCE_MS + METADATA_LOAD_TRANSPORT_BUFFER_MS;
return Math.max(METADATA_LOAD_MIN_TIMEOUT_MS, totalMs);
}

/**
* Effective query timeout (seconds) for a table-structure change script.
*
Expand Down
Loading
Loading