fix(oracle): send a single TCP connect on failure and back off retries
This commit is contained in:
10 files changed
+401
-34
No files matched your search
@@ -133,7 +133,9 @@ async function fetchStatus(options: { silent?: boolean } = {}) {
|
||||
if (!options.silent) loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await connectionStore.ensureConnected(props.connectionId, { skipIfClosedByUser: true });
|
||||
// 轮询/静默刷新走被动重连:失败后退避,避免后台每次 tick 都新建连接
|
||||
// (企业安全/零信任客户端会为每次新连接弹一次授权框)。手动刷新不受影响。
|
||||
await connectionStore.ensureConnected(props.connectionId, { skipIfClosedByUser: true, ...(options.silent === true ? { passive: true } : {}) });
|
||||
const result = await api.executeQuery(props.connectionId, "", GLOBAL_STATUS_SQL, undefined, undefined, { maxRows: 2000, clientSessionId: props.clientSessionId });
|
||||
const sample: StatusSample = { at: Date.now(), status: parseStatusResult(result) };
|
||||
const next = [...samples.value, sample];
|
||||
|
||||
@@ -146,7 +146,9 @@ async function fetchStatus(options: { silent?: boolean } = {}) {
|
||||
if (!options.silent) loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await connectionStore.ensureConnected(props.connectionId, { skipIfClosedByUser: true });
|
||||
// 轮询/静默刷新走被动重连:失败后退避,避免后台每次 tick 都新建连接
|
||||
// (企业安全/零信任客户端会为每次新连接弹一次授权框)。手动刷新不受影响。
|
||||
await connectionStore.ensureConnected(props.connectionId, { skipIfClosedByUser: true, ...(options.silent === true ? { passive: true } : {}) });
|
||||
const sql = fallbackStatusSql.value ?? activeDriver.statusSql;
|
||||
let result;
|
||||
try {
|
||||
|
||||
@@ -354,7 +354,9 @@ async function load(options: { silent?: boolean } = {}) {
|
||||
loadError.value = "";
|
||||
versionProbeWarning.value = "";
|
||||
try {
|
||||
await connectionStore.ensureConnected(connectionId, { skipIfClosedByUser: true });
|
||||
// 轮询/静默刷新走被动重连:失败后退避,避免后台每次 tick 都新建连接
|
||||
// (企业安全/零信任客户端会为每次新连接弹一次授权框)。手动刷新不受影响。
|
||||
await connectionStore.ensureConnected(connectionId, { skipIfClosedByUser: true, ...(options.silent === true ? { passive: true } : {}) });
|
||||
if (!isCurrent()) return;
|
||||
if (activeDriver.versionSql && versionMajor.value === null) {
|
||||
try {
|
||||
|
||||
@@ -180,13 +180,14 @@ async function query(sql: string, maxRows: number) {
|
||||
return api.executeQuery(props.connectionId, "", sql, undefined, undefined, { maxRows });
|
||||
}
|
||||
|
||||
async function load() {
|
||||
async function load(options: { passive?: boolean } = {}) {
|
||||
if (!supported.value) return;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
partialErrors.value = [];
|
||||
try {
|
||||
await connectionStore.ensureConnected(props.connectionId, { skipIfClosedByUser: true });
|
||||
// 首屏/自动刷新是后台路径:失败后退避,避免每次 tick 都新建连接。手动刷新不传 passive。
|
||||
await connectionStore.ensureConnected(props.connectionId, { skipIfClosedByUser: true, passive: options.passive === true });
|
||||
const requests = [
|
||||
["version", XUGU_VERSION_SQL, 1],
|
||||
["nodes", XUGU_CLUSTER_NODES_SQL, 500],
|
||||
@@ -271,7 +272,7 @@ function startAutoRefresh() {
|
||||
stopAutoRefresh();
|
||||
if (refreshSeconds.value <= 0) return;
|
||||
refreshTimer = setInterval(() => {
|
||||
if (!document.hidden) void load();
|
||||
if (!document.hidden) void load({ passive: true });
|
||||
}, refreshSeconds.value * 1000);
|
||||
}
|
||||
|
||||
@@ -281,7 +282,7 @@ function changeRefresh(value: string) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await load();
|
||||
await load({ passive: true });
|
||||
startAutoRefresh();
|
||||
});
|
||||
|
||||
|
||||
@@ -2723,7 +2723,7 @@ onMounted(async () => {
|
||||
try {
|
||||
// A restored tab issues no query, so a blocking health probe here would be
|
||||
// the only round trip left on the switch.
|
||||
await connectionStore.ensureConnected(props.connectionId, { ...(restoredDocumentData ? { verifyHealth: false } : {}), skipIfClosedByUser: true });
|
||||
await connectionStore.ensureConnected(props.connectionId, { ...(restoredDocumentData ? { verifyHealth: false, passive: true } : {}), skipIfClosedByUser: true });
|
||||
} catch (e) {
|
||||
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
|
||||
}
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
function installLocalStorage() {
|
||||
const data = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => data.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => data.delete(key)),
|
||||
});
|
||||
}
|
||||
|
||||
function pgConnection(id: string): ConnectionConfig {
|
||||
return { id, name: id, db_type: "postgres", host: "127.0.0.1", port: 5432, username: "postgres", password: "", database: "app", read_only: false } as ConnectionConfig;
|
||||
}
|
||||
|
||||
const ORA_NO_LISTENER = "Agent RPC error: ORA-12541: TNS:no listener";
|
||||
|
||||
async function setup() {
|
||||
const checkConnectionHealth = vi.fn().mockResolvedValue(undefined);
|
||||
const connectDb = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error(ORA_NO_LISTENER))
|
||||
.mockImplementation(async (config: ConnectionConfig) => config.id);
|
||||
const disconnectDb = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth,
|
||||
connectDb,
|
||||
disconnectDb,
|
||||
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||
deleteTableVGroupsForConnection: vi.fn().mockResolvedValue(undefined),
|
||||
listInstalledAgents: vi.fn().mockResolvedValue([]),
|
||||
listInstalledAgentsLocal: vi.fn().mockResolvedValue([]),
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
|
||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
store.connections = [pgConnection("conn-a")];
|
||||
return { store, connectDb, disconnectDb };
|
||||
}
|
||||
|
||||
describe("connectionStore: passive reconnect backoff", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
installLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("does not open a second connection for passive callers inside the backoff window", async () => {
|
||||
const { store, connectDb } = await setup();
|
||||
|
||||
await expect(store.ensureConnected("conn-a", { passive: true })).rejects.toThrow("ORA-12541");
|
||||
expect(connectDb).toHaveBeenCalledTimes(1);
|
||||
expect(store.connectionErrors["conn-a"]).toContain("ORA-12541");
|
||||
|
||||
// 面板轮询/自动刷新/页签恢复在退避窗口内必须复用同一次失败,不能再发起连接——
|
||||
// 否则第三方安全/零信任客户端会为每次连接各弹一个框。
|
||||
await expect(store.ensureConnected("conn-a", { passive: true })).rejects.toThrow("ORA-12541");
|
||||
expect(connectDb).toHaveBeenCalledTimes(1);
|
||||
}, 20_000);
|
||||
|
||||
it("never blocks a user-triggered ensureConnected", async () => {
|
||||
const { store, connectDb } = await setup();
|
||||
await expect(store.ensureConnected("conn-a", { passive: true })).rejects.toThrow("ORA-12541");
|
||||
expect(connectDb).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 用户点侧栏连接/打开表等走非 passive 路径:必须真的再连一次,不能被后台退避拦住。
|
||||
await expect(store.ensureConnected("conn-a")).resolves.toBeUndefined();
|
||||
expect(connectDb).toHaveBeenCalledTimes(2);
|
||||
expect(store.connectedIds.has("conn-a")).toBe(true);
|
||||
}, 20_000);
|
||||
|
||||
it("lets an explicit connect() through and clears the backoff on success", async () => {
|
||||
const { store, connectDb } = await setup();
|
||||
await expect(store.ensureConnected("conn-a", { passive: true })).rejects.toThrow("ORA-12541");
|
||||
expect(connectDb).toHaveBeenCalledTimes(1);
|
||||
|
||||
await store.connect(pgConnection("conn-a"));
|
||||
expect(connectDb).toHaveBeenCalledTimes(2);
|
||||
expect(store.connectedIds.has("conn-a")).toBe(true);
|
||||
|
||||
// 成功后退避清空,被动路径恢复正常且不再被拦。
|
||||
await store.ensureConnected("conn-a", { passive: true, verifyHealth: false });
|
||||
expect(connectDb).toHaveBeenCalledTimes(2);
|
||||
}, 20_000);
|
||||
|
||||
it("forceReconnect bypasses the backoff", async () => {
|
||||
const { store, connectDb } = await setup();
|
||||
await expect(store.ensureConnected("conn-a", { passive: true })).rejects.toThrow("ORA-12541");
|
||||
expect(connectDb).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(store.ensureConnected("conn-a", { passive: true, forceReconnect: true })).resolves.toBeUndefined();
|
||||
expect(connectDb).toHaveBeenCalledTimes(2);
|
||||
expect(store.connectedIds.has("conn-a")).toBe(true);
|
||||
}, 20_000);
|
||||
|
||||
it("stops passive reconnects after repeated failures until an explicit connect", async () => {
|
||||
const { store, connectDb } = await setup();
|
||||
connectDb.mockReset();
|
||||
connectDb.mockRejectedValue(new Error(ORA_NO_LISTENER));
|
||||
|
||||
const start = Date.now();
|
||||
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => start);
|
||||
try {
|
||||
// 退避序列 15s → 60s → 5min;每次都要等窗口过去才会再试一次。
|
||||
const passive = { passive: true };
|
||||
await expect(store.ensureConnected("conn-a", passive)).rejects.toThrow("ORA-12541");
|
||||
expect(connectDb).toHaveBeenCalledTimes(1);
|
||||
|
||||
nowSpy.mockImplementation(() => start + 16_000);
|
||||
await expect(store.ensureConnected("conn-a", passive)).rejects.toThrow("ORA-12541");
|
||||
expect(connectDb).toHaveBeenCalledTimes(2);
|
||||
|
||||
nowSpy.mockImplementation(() => start + 16_000 + 61_000);
|
||||
await expect(store.ensureConnected("conn-a", passive)).rejects.toThrow("ORA-12541");
|
||||
expect(connectDb).toHaveBeenCalledTimes(3);
|
||||
|
||||
nowSpy.mockImplementation(() => start + 16_000 + 61_000 + 301_000);
|
||||
await expect(store.ensureConnected("conn-a", passive)).rejects.toThrow("ORA-12541");
|
||||
expect(connectDb).toHaveBeenCalledTimes(4);
|
||||
|
||||
// 连续第 4 次失败后被动路径完全停止:再等一小时也不会再发起连接(不再弹框)。
|
||||
nowSpy.mockImplementation(() => start + 16_000 + 61_000 + 301_000 + 3_600_000);
|
||||
await expect(store.ensureConnected("conn-a", passive)).rejects.toThrow("ORA-12541");
|
||||
expect(connectDb).toHaveBeenCalledTimes(4);
|
||||
|
||||
// 用户显式 connect() 清空退避,允许真的再试一次。
|
||||
await expect(store.connect(pgConnection("conn-a"))).rejects.toThrow("ORA-12541");
|
||||
expect(connectDb).toHaveBeenCalledTimes(5);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -238,6 +238,15 @@ const CONNECTION_HEALTH_CHECK_TIMEOUT_MS = 5000;
|
||||
const CONNECTION_PREWARM_TTL_MS = 30_000;
|
||||
const DISCONNECT_REQUEST_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_KEEPALIVE_INTERVAL_SECS = 30;
|
||||
/**
|
||||
* 被动重连退避/熔断(只作用于显式标了 `passive: true` 的后台路径,用户操作永远不拦)。
|
||||
* 企业零信任/安全接入客户端会为每一次到受保护数据库的**新建连接**做一次授权检查,
|
||||
* 失败时弹一个原生「警告」框——所以一次故障若被面板轮询、自动刷新、页签恢复反复重连,
|
||||
* 用户看到的就是满屏弹框。这里把「失败后的被动重连」逐级收敛:第 1 次失败后 15s、
|
||||
* 第 2 次 60s、第 3 次 5min 内不再发起 connectDb;第 4 次起**完全停止被动重连**,
|
||||
* 直到任意一次成功连接(含用户手动连接)清空退避。
|
||||
*/
|
||||
const AUTOMATIC_RECONNECT_BACKOFF_MS = [15_000, 60_000, 300_000] as const;
|
||||
const METADATA_LIST_PAGE_CACHE_TTL_MS = 30_000;
|
||||
const METADATA_LIST_PAGE_CACHE_MAX_ENTRIES = 160;
|
||||
const SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS = 30_000;
|
||||
@@ -731,6 +740,11 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
console.debug("[DBX][metadata-load:trace]", event);
|
||||
};
|
||||
const connectInFlight = new Map<string, Promise<void>>();
|
||||
// 自动重连冷却:连接 id -> 冷却截止时间(Unix 毫秒)与最近一次失败。
|
||||
// 只拦「自动/被动」重连;connect() 与 forceReconnect 会清掉它。
|
||||
const automaticReconnectBlockedUntil = new Map<string, number>();
|
||||
const automaticReconnectBlockedError = new Map<string, unknown>();
|
||||
const automaticReconnectFailureCount = new Map<string, number>();
|
||||
const disconnectInFlight = new Map<string, Promise<void>>();
|
||||
const credentialCloseInFlight = new Map<string, Promise<void>>();
|
||||
const disconnectInFlightScoped = new Map<string, boolean>();
|
||||
@@ -1614,6 +1628,34 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return message;
|
||||
}
|
||||
|
||||
/** 自动重连退避中?(只用于非用户显式触发的重连,见 ensureConnected) */
|
||||
function automaticReconnectCooldownRemainingMs(connectionId: string): number {
|
||||
return Math.max(0, (automaticReconnectBlockedUntil.get(connectionId) ?? 0) - Date.now());
|
||||
}
|
||||
|
||||
function clearAutomaticReconnectCooldown(connectionId: string) {
|
||||
automaticReconnectBlockedUntil.delete(connectionId);
|
||||
automaticReconnectBlockedError.delete(connectionId);
|
||||
automaticReconnectFailureCount.delete(connectionId);
|
||||
}
|
||||
|
||||
function noteAutomaticReconnectFailure(connectionId: string, error: unknown) {
|
||||
const failures = (automaticReconnectFailureCount.get(connectionId) ?? 0) + 1;
|
||||
automaticReconnectFailureCount.set(connectionId, failures);
|
||||
automaticReconnectBlockedError.set(connectionId, error);
|
||||
if (failures > AUTOMATIC_RECONNECT_BACKOFF_MS.length) {
|
||||
// 连续多次失败:停止自动重连,等用户显式重试(避免第三方安全客户端被无限触发)。
|
||||
automaticReconnectBlockedUntil.set(connectionId, Number.POSITIVE_INFINITY);
|
||||
return;
|
||||
}
|
||||
automaticReconnectBlockedUntil.set(connectionId, Date.now() + AUTOMATIC_RECONNECT_BACKOFF_MS[failures - 1]);
|
||||
}
|
||||
|
||||
function automaticReconnectCooldownError(connectionId: string): Error {
|
||||
const lastError = automaticReconnectBlockedError.get(connectionId);
|
||||
return lastError instanceof Error ? lastError : new Error(connectionErrorMessage(lastError));
|
||||
}
|
||||
|
||||
function markConnectionLost(connectionId: string, error: unknown) {
|
||||
connectedIds.value.delete(connectionId);
|
||||
clearEtcdAccessCapabilities(connectionId);
|
||||
@@ -5211,6 +5253,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const expectedConfigFingerprint = connectionConfigFingerprint(getConfig(config.id) ?? config);
|
||||
if (getBlockingDisconnectInFlight(config.id)) await waitForBlockingDisconnectInFlight(config.id);
|
||||
if (passwordChangeReconnectRequired.get(config.id) !== passwordChangeState) throw new Error(i18n.global.t("userAdmin.passwordChangedReconnect"));
|
||||
// 用户显式连接:清掉自动重连冷却,保证“点连接/重试”永远真的会再试一次。
|
||||
clearAutomaticReconnectCooldown(config.id);
|
||||
const localAttempt = beginLocalConnectionAttempt(config.id);
|
||||
const metadataRevision = connectionStateRevision(config.id);
|
||||
try {
|
||||
@@ -5262,6 +5306,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
markConnectionHealthChecked(id);
|
||||
clearConnectionError(config.id);
|
||||
if (id !== config.id) clearConnectionError(id);
|
||||
clearAutomaticReconnectCooldown(config.id);
|
||||
if (id !== config.id) clearAutomaticReconnectCooldown(id);
|
||||
|
||||
const existing = findConnectionNode(id);
|
||||
if (existing) {
|
||||
@@ -5636,7 +5682,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
* `CONNECTION_CLOSED_BY_USER_MESSAGE`,让调用方停在"连接已关闭"的空态,而不是把连接
|
||||
* 偷偷连回来(这是"关闭连接后连接又自己回来"这类报告的根因)。
|
||||
*/
|
||||
async function ensureConnected(connectionId: string, options: { activate?: boolean; verifyHealth?: boolean; forceReconnect?: boolean; allowPasswordPrompt?: boolean; skipIfClosedByUser?: boolean } = {}) {
|
||||
async function ensureConnected(connectionId: string, options: { activate?: boolean; verifyHealth?: boolean; forceReconnect?: boolean; allowPasswordPrompt?: boolean; skipIfClosedByUser?: boolean; passive?: boolean } = {}) {
|
||||
if (options.skipIfClosedByUser && isConnectionClosedByUser(connectionId)) throw new Error(CONNECTION_CLOSED_BY_USER_MESSAGE);
|
||||
if (passwordChangeReconnectRequired.has(connectionId)) throw new Error(i18n.global.t("userAdmin.passwordChangedReconnect"));
|
||||
if (!options.forceReconnect && connectedIds.value.has(connectionId)) {
|
||||
@@ -5689,6 +5735,13 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
if (options.activate !== false) activeConnectionId.value = connectionId;
|
||||
return;
|
||||
}
|
||||
// 被动重连退避:故障后后台路径(面板轮询/自动刷新/页签恢复,调用方显式传 passive)
|
||||
// 在窗口内不再发起新的 connectDb。用户手动点击/显式重试永远不经过这里,不会被拦。
|
||||
// 第三方安全/零信任客户端会为每次新连接做一次授权检查并弹原生框,这一步把
|
||||
// “每个后台 tick 重连一次”收敛成少量失败,而不是一屏弹框。
|
||||
if (options.passive && !options.forceReconnect && automaticReconnectCooldownRemainingMs(connectionId) > 0) {
|
||||
throw automaticReconnectCooldownError(connectionId);
|
||||
}
|
||||
const localAttempt = beginLocalConnectionAttempt(connectionId);
|
||||
const metadataRevision = connectionStateRevision(connectionId);
|
||||
const connectPromise = (async () => {
|
||||
@@ -5744,6 +5797,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
connectInFlight.set(connectionId, connectPromise);
|
||||
try {
|
||||
await connectPromise;
|
||||
clearAutomaticReconnectCooldown(connectionId);
|
||||
ensureLocalConnectionAttemptActive(connectionId, localAttempt);
|
||||
if (options.activate !== false) activeConnectionId.value = connectionId;
|
||||
} catch (e) {
|
||||
@@ -5761,6 +5815,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
// 记到连接节点上,也避免搜索每轮都重复报同一个错。
|
||||
if (isSidebarSearchLoad(connectionId) && !isSupersededConnectionAttempt(e)) recordSidebarSearchConnectionFailure(connectionId, e);
|
||||
else recordConnectionError(connectionId, e);
|
||||
// 只有后台/被动路径的失败才计入退避;用户主动发起的失败不惩罚后续用户操作。
|
||||
if (options.passive && !isSupersededConnectionAttempt(e)) noteAutomaticReconnectFailure(connectionId, e);
|
||||
clearConnectionNodeLoading(connectionId);
|
||||
throw e;
|
||||
} finally {
|
||||
|
||||
@@ -9924,7 +9924,9 @@ mod tests {
|
||||
.expect("SID listener errors should allow service-name retry");
|
||||
assert_eq!(service_retry.connection_string.as_deref(), Some("jdbc:oracle:thin:@//127.0.0.1:3306/ORCL"));
|
||||
|
||||
assert!(oracle_alternate_connect_config(&config, "ORA-12541: TNS:no listener").is_some());
|
||||
// ORA-12541 / "no listener" is TCP-level unreachable: alternates cannot help
|
||||
// and each one opens another connection (and another security-client popup).
|
||||
assert!(oracle_alternate_connect_config(&config, "ORA-12541: TNS:no listener").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Live Oracle connection-attempt behaviour against a disposable test instance.
|
||||
//!
|
||||
//! These tests are `#[ignore]`d so CI never needs a database. Run them against a
|
||||
//! test Oracle when touching connection-open/retry logic:
|
||||
//!
|
||||
//! ```bash
|
||||
//! DBX_LIVE_ORACLE_HOST=127.0.0.1 \
|
||||
//! DBX_LIVE_ORACLE_PORT=1521 \
|
||||
//! DBX_LIVE_ORACLE_SERVICE=XEPDB1 \
|
||||
//! DBX_LIVE_ORACLE_USER=dbx_test \
|
||||
//! DBX_LIVE_ORACLE_PASSWORD=... \
|
||||
//! cargo test -p dbx-core --test live_oracle_connect_attempts -- --ignored --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! `DBX_LIVE_ORACLE_CLOSED_PORT` (default `1`) should point at a port nothing
|
||||
//! listens on; it is used to exercise the unreachable-listener path.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use dbx_core::agent_connection::oracle_alternate_connect_configs;
|
||||
use dbx_core::connection::AppState;
|
||||
use dbx_core::models::connection::{ConnectionConfig, DatabaseType};
|
||||
use dbx_core::query::execute_sql_statement;
|
||||
use dbx_core::types::QueryResult;
|
||||
|
||||
fn required_env(name: &str) -> String {
|
||||
std::env::var(name).unwrap_or_else(|_| panic!("{name} must be set for live Oracle tests"))
|
||||
}
|
||||
|
||||
fn live_oracle_port() -> u16 {
|
||||
required_env("DBX_LIVE_ORACLE_PORT").parse().expect("DBX_LIVE_ORACLE_PORT must be a TCP port")
|
||||
}
|
||||
|
||||
fn live_oracle_service() -> String {
|
||||
required_env("DBX_LIVE_ORACLE_SERVICE")
|
||||
}
|
||||
|
||||
fn live_oracle_config(id: &str, port: u16, oracle_connection_type: Option<&str>) -> ConnectionConfig {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": id,
|
||||
"name": id,
|
||||
"db_type": DatabaseType::Oracle,
|
||||
"driver_profile": "oracle",
|
||||
"host": required_env("DBX_LIVE_ORACLE_HOST"),
|
||||
"port": port,
|
||||
"username": required_env("DBX_LIVE_ORACLE_USER"),
|
||||
"password": required_env("DBX_LIVE_ORACLE_PASSWORD"),
|
||||
"database": live_oracle_service(),
|
||||
"oracle_connection_type": oracle_connection_type,
|
||||
"connect_timeout_secs": 10,
|
||||
"query_timeout_secs": 60,
|
||||
"idle_timeout_secs": 60,
|
||||
"idle_disconnect_secs": 0,
|
||||
"keepalive_interval_secs": 0
|
||||
}))
|
||||
.expect("live Oracle config should deserialize")
|
||||
}
|
||||
|
||||
async fn app_state() -> Arc<AppState> {
|
||||
let dir = std::env::temp_dir().join(format!("dbx-live-oracle-attempts-{}", uuid::Uuid::new_v4().simple()));
|
||||
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||
let storage = dbx_core::persistence::test_storage::open(&dir.join("storage.db")).await.expect("open test storage");
|
||||
Arc::new(AppState::new(storage))
|
||||
}
|
||||
|
||||
fn single_int(result: &QueryResult) -> i64 {
|
||||
let cell = result.rows.first().and_then(|row| row.first()).expect("one row");
|
||||
match cell {
|
||||
serde_json::Value::Number(n) => n.as_i64().expect("integer"),
|
||||
serde_json::Value::String(s) => s.parse().expect("integer string"),
|
||||
other => panic!("unexpected cell {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires DBX_LIVE_ORACLE_* env vars pointing at a disposable Oracle"]
|
||||
async fn live_oracle_connects_and_runs_a_query() {
|
||||
let state = app_state().await;
|
||||
let (id, service) = ("live-oracle-connect", live_oracle_service());
|
||||
state.configs.write().await.insert(id.to_string(), live_oracle_config(id, live_oracle_port(), None));
|
||||
|
||||
let started = Instant::now();
|
||||
let result = execute_sql_statement(&state, id, &service, "SELECT 1 AS N FROM DUAL", None, None)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("live Oracle connect failed for {service}: {error}"));
|
||||
assert_eq!(single_int(&result), 1);
|
||||
eprintln!("live Oracle connect + query: {} ms", started.elapsed().as_millis());
|
||||
|
||||
let banner =
|
||||
execute_sql_statement(&state, id, &service, "SELECT banner FROM v$version WHERE ROWNUM = 1", None, None)
|
||||
.await
|
||||
.expect("v$version query");
|
||||
let banner = banner.rows.first().and_then(|row| row.first()).cloned().unwrap_or_default();
|
||||
let banner = banner.as_str().unwrap_or_default();
|
||||
assert!(banner.contains("Oracle Database"), "unexpected v$version banner: {banner}");
|
||||
eprintln!("live Oracle banner: {banner}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires DBX_LIVE_ORACLE_* env vars pointing at a disposable Oracle"]
|
||||
async fn live_oracle_unreachable_port_does_not_fan_out_descriptor_retries() {
|
||||
// A port with no listener is the case the Oracle descriptor fallback used to
|
||||
// turn into 1 + 4 TCP connection attempts. Every attempt is a new "connection"
|
||||
// for enterprise security/zero-trust clients in front of the database, which
|
||||
// pop one native warning each, so the live failure must stay a single attempt.
|
||||
let state = app_state().await;
|
||||
let closed_port =
|
||||
std::env::var("DBX_LIVE_ORACLE_CLOSED_PORT").ok().and_then(|value| value.parse::<u16>().ok()).unwrap_or(1);
|
||||
let (id, service) = ("live-oracle-closed-port", live_oracle_service());
|
||||
let config = live_oracle_config(id, closed_port, None);
|
||||
state.configs.write().await.insert(id.to_string(), config.clone());
|
||||
|
||||
let started = Instant::now();
|
||||
let error = execute_sql_statement(&state, id, &service, "SELECT 1 AS N FROM DUAL", None, None)
|
||||
.await
|
||||
.expect_err("closed port must fail to connect");
|
||||
let elapsed = started.elapsed();
|
||||
eprintln!("live Oracle unreachable port failed in {} ms: {error}", elapsed.as_millis());
|
||||
assert!(elapsed < Duration::from_secs(30), "unreachable port should fail fast, took {elapsed:?}");
|
||||
|
||||
let alternates = oracle_alternate_connect_configs(&config, &error);
|
||||
assert!(
|
||||
alternates.is_empty(),
|
||||
"unreachable listener must not fan out into descriptor retries, got {} extra configs for: {error}",
|
||||
alternates.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires DBX_LIVE_ORACLE_* env vars pointing at a disposable Oracle"]
|
||||
async fn live_oracle_sid_form_falls_back_to_the_service_descriptor() {
|
||||
// A user who configured the SID form for a service-name database gets ORA-12505
|
||||
// from a listener that *is* reachable. The descriptor fallback must keep working
|
||||
// for that case (it is the one the popup-storm fix deliberately keeps), so the
|
||||
// alternate service-name URL has to connect and the retried statement succeed.
|
||||
let state = app_state().await;
|
||||
let (id, service) = ("live-oracle-sid-fallback", live_oracle_service());
|
||||
state.configs.write().await.insert(id.to_string(), live_oracle_config(id, live_oracle_port(), Some("sid")));
|
||||
|
||||
let result = execute_sql_statement(&state, id, &service, "SELECT 1 AS N FROM DUAL", None, None)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("SID->service descriptor fallback should connect: {error}"));
|
||||
assert_eq!(single_int(&result), 1);
|
||||
eprintln!("live Oracle SID-form fallback connected for service {service}");
|
||||
}
|
||||
@@ -407,13 +407,10 @@ pub fn oracle_alternate_connect_configs(config: &ConnectionConfig, err: &str) ->
|
||||
let normalized = err.to_lowercase();
|
||||
let candidates = if normalized.contains("ora-12505") {
|
||||
vec![service_url, descriptor_service_url, legacy_service_url]
|
||||
} else if normalized.contains("ora-12514") {
|
||||
vec![sid_url, descriptor_sid_url, legacy_service_url]
|
||||
} else {
|
||||
match config.oracle_connection_type.as_deref() {
|
||||
Some("sid") => vec![service_url, legacy_service_url, descriptor_sid_url, descriptor_service_url],
|
||||
_ => vec![sid_url, legacy_service_url, descriptor_service_url, descriptor_sid_url],
|
||||
}
|
||||
// ORA-12514: the listener answered but does not know the service name, so a
|
||||
// SID/legacy/descriptor spelling is the only thing that can help.
|
||||
vec![sid_url, descriptor_sid_url, legacy_service_url]
|
||||
};
|
||||
// The retries travel through `oracle_jdbc_connection_string` as a stored
|
||||
// connection string, so they must already carry the profile's protocol.
|
||||
@@ -481,13 +478,25 @@ fn oracle_jdbc_connection_string(
|
||||
Ok(oracle_url_for_profile(config, url))
|
||||
}
|
||||
|
||||
/// Whether Oracle's alternate connection descriptors can plausibly fix this error.
|
||||
///
|
||||
/// The alternates only rewrite how the *connect descriptor* names the service/SID
|
||||
/// (`//host:port/service`, `host:port:SID`, `(DESCRIPTION=...)`); they never change
|
||||
/// host, port or route. ORA-12505/ORA-12514 are answered by a listener that is
|
||||
/// reachable on host:port, so a wrong service/SID spelling is exactly what they
|
||||
/// diagnose. ORA-12541 ("no listener"), timeouts and resets mean the listener was
|
||||
/// never reached, so retrying three more descriptor spellings only opens three
|
||||
/// more TCP connections that cannot succeed.
|
||||
///
|
||||
/// That matters beyond wasted time: enterprise zero-trust/security clients in
|
||||
/// front of the database run an authorization check for every new connection and
|
||||
/// pop a native warning per failed check, so one failed connect used to stack up
|
||||
/// to five dialogs (1 initial + 4 alternates) per attempt. Keep the failure to one
|
||||
/// connection; ORA-12541 keeps the "try Service Name/SID" hint
|
||||
/// (`oracle_error_with_driver_hint`), it just no longer auto-retries.
|
||||
fn oracle_listener_error_can_retry(err: &str) -> bool {
|
||||
let normalized = err.to_lowercase();
|
||||
normalized.contains("ora-12505")
|
||||
|| normalized.contains("ora-12514")
|
||||
|| normalized.contains("ora-12541")
|
||||
|| normalized.contains("no listener")
|
||||
|| err.contains("没有监听程序")
|
||||
normalized.contains("ora-12505") || normalized.contains("ora-12514")
|
||||
}
|
||||
|
||||
fn oracle_service_jdbc_url(host: &str, port: u16, database: &str) -> String {
|
||||
@@ -1409,7 +1418,9 @@ mod tests {
|
||||
|
||||
assert_eq!(retry.connection_string.as_deref(), Some("jdbc:oracle:thin:@127.0.0.1:3306:ORCL"));
|
||||
assert!(oracle_alternate_connect_config(&retry, "ORA-01017: invalid username/password").is_none());
|
||||
assert!(oracle_alternate_connect_config(&cfg, "ORA-12541: TNS:no listener").is_some());
|
||||
// ORA-12541 is TCP-level: the listener was never reached, so a descriptor
|
||||
// spelling change cannot help and only opens more connections/dialogs.
|
||||
assert!(oracle_alternate_connect_config(&cfg, "ORA-12541: TNS:no listener").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1423,25 +1434,27 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_no_listener_errors_try_common_jdbc_url_variants() {
|
||||
fn oracle_no_listener_errors_do_not_fan_out_into_extra_connections() {
|
||||
// ORA-12541 / "no listener" means host:port was never reached. Rewriting only
|
||||
// the CONNECT_DATA spelling cannot fix reachability, and every alternate is a
|
||||
// brand-new TCP connection — i.e. another authorization popup in front of an
|
||||
// enterprise zero-trust/security client. One failure must stay one connection.
|
||||
let mut cfg = config(DatabaseType::Oracle, Some("ORCL"));
|
||||
cfg.host = "oracle.example.com".to_string();
|
||||
cfg.port = 1521;
|
||||
cfg.driver_profile = Some("oracle".to_string());
|
||||
cfg.oracle_connection_type = Some("service_name".to_string());
|
||||
|
||||
let retries = oracle_alternate_connect_configs(&cfg, "ORA-12541: TNS:no listener");
|
||||
let urls: Vec<_> = retries.iter().filter_map(|retry| retry.connection_string.as_deref()).collect();
|
||||
assert!(oracle_alternate_connect_configs(&cfg, "ORA-12541: TNS:no listener").is_empty());
|
||||
assert!(oracle_alternate_connect_configs(&cfg, "ORA-12541: TNS:no listener\n没有监听程序").is_empty());
|
||||
assert!(oracle_alternate_connect_config(&cfg, "ORA-12541: TNS:no listener").is_none());
|
||||
// The actionable hint survives, it just no longer auto-retries.
|
||||
assert!(oracle_error_with_driver_hint(&cfg, "ORA-12541: TNS:no listener").contains("Service Name"));
|
||||
|
||||
assert_eq!(
|
||||
urls,
|
||||
vec![
|
||||
"jdbc:oracle:thin:@oracle.example.com:1521:ORCL",
|
||||
"jdbc:oracle:thin:@oracle.example.com:1521/ORCL",
|
||||
"jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=oracle.example.com)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=ORCL)))",
|
||||
"jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=oracle.example.com)(PORT=1521))(CONNECT_DATA=(SID=ORCL)))",
|
||||
]
|
||||
);
|
||||
// Listener-answered errors keep their single-purpose alternates: those arrive
|
||||
// after a successful TCP handshake, so the descriptor really can be the fix.
|
||||
let listener_answered = oracle_alternate_connect_configs(&cfg, "ORA-12514: no service");
|
||||
assert_eq!(listener_answered.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user