Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
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 @@ -40,6 +40,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 @@ -267,6 +268,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 @@ -4447,19 +4454,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);
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 @@ -4471,15 +4492,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);
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 @@ -4489,14 +4526,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 @@ -4572,7 +4616,7 @@ 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 @@ -4582,7 +4626,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 @@ -5616,6 +5660,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
22 changes: 21 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 { frontendQueryTimeoutDelayMs, frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
import { frontendQueryTimeoutDelayMs, frontendQueryTimeoutSecsForSql, metadataLoadTimeoutMs, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";

describe("queryTimeout", () => {
it("lets PostgreSQL row queries use the backend inactivity timeout", () => {
Expand Down Expand Up @@ -34,4 +34,24 @@ 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 timeout", () => {
expect(metadataLoadTimeoutMs({ query_timeout_secs: 30, query_timeout_inherit: true }, 120)).toBe(125_000);
expect(metadataLoadTimeoutMs({ query_timeout_secs: 30, query_timeout_inherit: true })).toBe(35_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(50_000);
});

it("gives a fixed window when the query timeout is disabled (0 = unlimited)", () => {
expect(metadataLoadTimeoutMs({ query_timeout_secs: 0, query_timeout_inherit: false }, 120)).toBe(65_000);
expect(metadataLoadTimeoutMs({ query_timeout_secs: 30, query_timeout_inherit: true }, 0)).toBe(65_000);
});

it("floors the metadata load deadline and defaults safely", () => {
expect(metadataLoadTimeoutMs(undefined)).toBe(35_000);
expect(metadataLoadTimeoutMs({})).toBe(35_000);
expect(metadataLoadTimeoutMs({ query_timeout_secs: 1 }, 120)).toBe(15_000);
});
});
11 changes: 6 additions & 5 deletions apps/desktop/src/lib/backend/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,11 +241,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 @@ -300,8 +301,8 @@ 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> {
const res = await fetch(apiUrl(url), signal ? { signal } : undefined);
if (!res.ok) throw await backendResponseError(res);
return res.json();
}
Expand Down Expand Up @@ -720,8 +721,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 @@ -948,7 +948,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
24 changes: 24 additions & 0 deletions apps/desktop/src/lib/sql/queryTimeout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,30 @@ 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;
/** Fixed window when the query timeout is disabled (0 = unlimited): the 60s
* backend metadata fallback budget plus a ~5s connect round-trip buffer, so the
* PostgreSQL diagnostic can surface before the frontend generic timeout. */
export const METADATA_LOAD_DISABLED_QUERY_TIMEOUT_MS = 65_000;

/**
* Deadline for silent metadata loads (e.g. the visible-databases picker).
* Resolves the effective query timeout (inheritance + global via
* queryTimeoutSecsForConnection), then a configured timeout gets a 5s buffer
* with a floor; 0 (query timeout disabled) gets a fixed window so "unlimited"
* isn't silently capped at the default bound. The deadline equals the backend
* metadata budget (the effective query timeout, or the 60s fallback when
* disabled) plus a ~5s connect round-trip buffer; the backend draws its
* best-effort cancel allowance from inside the budget, so the whole metadata
* call (query + cancel) returns within the budget the deadline is aligned to.
*/
export function metadataLoadTimeoutMs(connection?: Pick<ConnectionConfig, "query_timeout_secs" | "query_timeout_inherit"> | null, globalQueryTimeoutSecs = DEFAULT_QUERY_TIMEOUT_SECS): number {
const queryTimeoutSecs = queryTimeoutSecsForConnection(connection, globalQueryTimeoutSecs);
if (queryTimeoutSecs === 0) return METADATA_LOAD_DISABLED_QUERY_TIMEOUT_MS;
return Math.max(METADATA_LOAD_MIN_TIMEOUT_MS, (queryTimeoutSecs + 5) * 1000);
}

export function frontendQueryTimeoutSecsForSql(sql: string, databaseType: DatabaseType | undefined, queryTimeoutSecs: number): number {
if (queryTimeoutSecs === 0) return 0;

Expand Down
35 changes: 24 additions & 11 deletions apps/desktop/src/stores/connectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
} from "@/lib/sidebar/sidebarLayout";
import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import { mergeSqlObjectNavigationType, sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation";
import { metadataLoadTimeoutMs } from "@/lib/sql/queryTimeout";
import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
Expand Down Expand Up @@ -147,8 +148,6 @@ const ACTIVE_CONNECTION_STORAGE_KEY = "dbx-active-connection";
const SIDEBAR_TABLE_NAME_FILTERS_STORAGE_KEY = "dbx-sidebar-table-name-filters";
const CONNECTION_HEALTH_CHECK_TTL_MS = 2000;
const CONNECTION_HEALTH_CHECK_TIMEOUT_MS = 5000;
const METADATA_LOAD_MIN_TIMEOUT_MS = 15_000;
const METADATA_LOAD_DISABLED_QUERY_TIMEOUT_MS = 60_000;
const DISCONNECT_REQUEST_TIMEOUT_MS = 5_000;
const DEFAULT_KEEPALIVE_INTERVAL_SECS = 30;
const METADATA_LIST_PAGE_CACHE_TTL_MS = 30_000;
Expand Down Expand Up @@ -923,13 +922,6 @@ export const useConnectionStore = defineStore("connection", () => {
if (node) node.isLoading = false;
}

function metadataLoadTimeoutMs(config?: ConnectionConfig): number {
const queryTimeoutSecs = Number(config?.query_timeout_secs);
if (queryTimeoutSecs === 0) return METADATA_LOAD_DISABLED_QUERY_TIMEOUT_MS;
const boundedTimeoutSecs = Number.isFinite(queryTimeoutSecs) && queryTimeoutSecs > 0 ? queryTimeoutSecs + 5 : 35;
return Math.max(METADATA_LOAD_MIN_TIMEOUT_MS, boundedTimeoutSecs * 1000);
}

async function withConnectionHealthTimeout(connectionId: string, promise: Promise<void>): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
Expand All @@ -950,15 +942,36 @@ export const useConnectionStore = defineStore("connection", () => {
}

async function withMetadataLoadTimeout<T>(connectionId: string, promise: Promise<T>, label: string): Promise<T> {
const timeoutMs = metadataLoadTimeoutMs(getConfig(connectionId));
const timeoutMs = metadataLoadTimeoutMs(getConfig(connectionId), settingsStore.editorSettings.globalQueryTimeoutSecs);
const timeoutMessage = `Connection timed out while loading ${label} after ${Math.ceil(timeoutMs / 1000)}s. Please check the network or VPN and try again.`;
const errorRevision = connectionErrorRevision(connectionId);
let timedOut = false;
let timer: ReturnType<typeof setTimeout> | undefined;
void promise.then(
() => {
if (!timedOut) return;
// The timer already won; leave the enforced timeout message in place.
},
(error) => {
if (!timedOut) return;
// The backend error arrived after the UI timeout fired. The caller's
// catch already recorded the generic timeout message; replace it with
// the real database error (mirrors withConnectionAttemptTimeout) so a
// half-open connection is attributable instead of a silent generic
// timeout.
const current = connectionErrors.value[connectionId];
if (current === timeoutMessage) {
setConnectionError(connectionId, connectionAttemptOriginalErrorMessage(timeoutMessage, connectionErrorMessage(error)));
}
},
);
try {
const result = await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`Connection timed out while loading ${label} after ${Math.ceil(timeoutMs / 1000)}s. Please check the network or VPN and try again.`));
timedOut = true;
reject(new Error(timeoutMessage));
}, timeoutMs);
}),
]);
Expand Down
5 changes: 4 additions & 1 deletion crates/dbx-core/src/database_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1260,7 +1260,10 @@ async fn list_postgres_extension_members(
}
};
let mut members = PostgresExtensionMembers::default();
for (kind, name, signature) in crate::db::postgres::list_extension_member_objects(&pool, schema).await? {
let budget = crate::db::postgres::postgres_default_metadata_query_budget(&pool);
for (kind, name, signature) in
crate::db::postgres::list_extension_member_objects(&pool, schema, budget, None).await?
{
if kind == "RELATION" {
members.relation_names.insert(name);
} else if kind == "FUNCTION" {
Expand Down
6 changes: 4 additions & 2 deletions crates/dbx-core/src/db/cloudberry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,15 @@ pub async fn list_tables_filtered(
limit: Option<usize>,
offset: Option<usize>,
) -> Result<Vec<TableInfo>, String> {
let mut tables = db::postgres::list_tables_filtered(pool, schema, filter, limit, offset).await?;
let budget = db::postgres::postgres_default_metadata_query_budget(pool);
let mut tables = db::postgres::list_tables_filtered(pool, schema, filter, limit, offset, budget, None).await?;
annotate_external_tables(pool, schema, &mut tables).await;
Ok(tables)
}

pub async fn list_objects(pool: &Pool, schema: &str) -> Result<Vec<ObjectInfo>, String> {
let mut objects = db::postgres::list_objects(pool, schema, true, true, false).await?;
let budget = db::postgres::postgres_default_metadata_query_budget(pool);
let mut objects = db::postgres::list_objects(pool, schema, true, true, false, budget, None).await?;
let names = objects.iter().map(|object| object.name.clone()).collect::<Vec<_>>();
let external_names = external_table_names(pool, schema, &names).await.unwrap_or_else(|error| {
log::debug!("[cloudberry][list_objects:external-table-fallback] error={error}");
Expand Down
Loading
Loading