diff --git a/.gitignore b/.gitignore index 514c389733..3c635fac6a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ docs/.next/ docs/out/ docs/.source/ docs/superpowers/ +/docs/aegis/ src-tauri/*.dylib # OS files diff --git a/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java b/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java index 3323881d33..c91ec03ef0 100644 --- a/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java +++ b/agents/common/src/main/java/com/dbx/agent/AgentProtocol.java @@ -113,6 +113,7 @@ public final class AgentProtocol { public static final String CAPABILITY_ETCD_LEASE = "etcd_lease"; public static final String CAPABILITY_ETCD_AUTH = "etcd_auth"; public static final String CAPABILITY_MULTI_SESSION = "multi_session"; + public static final String CAPABILITY_STRUCTURED_ERROR_V1 = "structured_error_v1"; public static final List CAPABILITIES = Collections.unmodifiableList(Arrays.asList( CAPABILITY_CONNECT, @@ -147,6 +148,8 @@ public final class AgentProtocol { public static final List MULTI_SESSION_CAPABILITIES; public static final List MULTI_SESSION_ALL_CAPABILITIES; + public static final List MULTI_SESSION_JDBC_CAPABILITIES; + public static final List MULTI_SESSION_JDBC_ALL_CAPABILITIES; public static final List COMMON_METHODS = Collections.unmodifiableList(Arrays.asList( METHOD_HANDSHAKE, @@ -194,6 +197,14 @@ public final class AgentProtocol { allCapabilities.add(CAPABILITY_MULTI_SESSION); MULTI_SESSION_ALL_CAPABILITIES = Collections.unmodifiableList(allCapabilities); + List jdbcCapabilities = new java.util.ArrayList<>(MULTI_SESSION_CAPABILITIES); + jdbcCapabilities.add(CAPABILITY_STRUCTURED_ERROR_V1); + MULTI_SESSION_JDBC_CAPABILITIES = Collections.unmodifiableList(jdbcCapabilities); + + List jdbcAllCapabilities = new java.util.ArrayList<>(MULTI_SESSION_ALL_CAPABILITIES); + jdbcAllCapabilities.add(CAPABILITY_STRUCTURED_ERROR_V1); + MULTI_SESSION_JDBC_ALL_CAPABILITIES = Collections.unmodifiableList(jdbcAllCapabilities); + List methods = new java.util.ArrayList<>(COMMON_METHODS); int insertAt = methods.indexOf(METHOD_CONNECT) + 1; methods.addAll(insertAt, Arrays.asList( @@ -270,6 +281,18 @@ public final class AgentProtocol { ); } + /** + * Handshake for pooled JDBC Agents that emit the v1 structured error contract. + * Generic/custom v2 handlers must continue using multiSessionHandshakeResult(). + */ + public static HandshakeResult multiSessionJdbcHandshakeResult() { + return new HandshakeResult( + MULTI_SESSION_PROTOCOL_VERSION, + MULTI_SESSION_PROTOCOL_VERSION, + MULTI_SESSION_JDBC_CAPABILITIES + ); + } + public static final class HandshakeResult { private final int protocolVersion; private final int agentProtocolVersion; diff --git a/agents/common/src/main/java/com/dbx/agent/AgentRpcError.java b/agents/common/src/main/java/com/dbx/agent/AgentRpcError.java index bb356e1b2d..aed7c39979 100644 --- a/agents/common/src/main/java/com/dbx/agent/AgentRpcError.java +++ b/agents/common/src/main/java/com/dbx/agent/AgentRpcError.java @@ -3,15 +3,21 @@ package com.dbx.agent; import com.google.gson.JsonObject; import java.sql.SQLException; +import java.sql.SQLTimeoutException; import java.sql.SQLRecoverableException; import java.sql.SQLTransientConnectionException; import java.util.Locale; +import java.util.concurrent.CancellationException; final class AgentRpcError extends RuntimeException { private final String category; private final boolean retryable; private final String disposition; private final String stage; + private final String operationOutcome; + private final String sqlState; + private final Integer vendorCode; + private final String exceptionClass; private AgentRpcError( String message, @@ -19,6 +25,10 @@ final class AgentRpcError extends RuntimeException { boolean retryable, String disposition, String stage, + String operationOutcome, + String sqlState, + Integer vendorCode, + String exceptionClass, Throwable cause ) { super(message, cause); @@ -26,6 +36,10 @@ final class AgentRpcError extends RuntimeException { this.retryable = retryable; this.disposition = disposition; this.stage = stage; + this.operationOutcome = operationOutcome; + this.sqlState = sqlState; + this.vendorCode = vendorCode; + this.exceptionClass = exceptionClass; } static AgentRpcError resource(String stage, Throwable cause) { @@ -35,6 +49,10 @@ final class AgentRpcError extends RuntimeException { false, "replace_runtime", stage, + operationOutcome(stage), + null, + null, + cause == null ? null : cause.getClass().getName(), cause ); } @@ -46,6 +64,10 @@ final class AgentRpcError extends RuntimeException { true, "keep", stage, + operationOutcome(stage), + null, + null, + cause == null ? null : cause.getClass().getName(), cause ); } @@ -60,6 +82,13 @@ final class AgentRpcError extends RuntimeException { data.addProperty("retryable", classified.retryable); data.addProperty("sessionDisposition", classified.disposition); data.addProperty("stage", classified.stage); + data.addProperty("contractVersion", 1); + data.addProperty("operationOutcome", classified.operationOutcome); + addDiagnostic(data, "sqlState", classified.sqlState); + if (classified.vendorCode != null) { + data.addProperty("vendorCode", classified.vendorCode); + } + addDiagnostic(data, "exceptionClass", classified.exceptionClass); if (agentSessionId != null && !agentSessionId.trim().isEmpty()) { data.addProperty("agentSessionId", agentSessionId); } @@ -72,34 +101,69 @@ final class AgentRpcError extends RuntimeException { if (explicit != null) { return explicit; } + String stage = stage(method); + if (find(error, CancellationException.class) != null || find(error, InterruptedException.class) != null) { + return new AgentRpcError( + message(error), + "canceled", + false, + "quarantine", + stage, + operationOutcome(stage), + null, + null, + safeClassName(error), + error + ); + } SQLException sqlError = find(error, SQLException.class); if (sqlError != null) { String sqlState = sqlError.getSQLState(); - String stage = stage(method); + String category = sqlError instanceof SQLTimeoutException ? "timeout" : null; boolean connectionError = "connect".equals(stage) || "validate".equals(stage) || sqlError instanceof SQLRecoverableException || sqlError instanceof SQLTransientConnectionException || (sqlState != null && sqlState.toUpperCase(Locale.ROOT).startsWith("08")); boolean operationRetryable = connectionError && ("connect".equals(stage) || "validate".equals(stage)); - String disposition = connectionError && !"connect".equals(stage) ? "quarantine" : "keep"; + String disposition = category != null || (connectionError && !"connect".equals(stage)) ? "quarantine" : "keep"; return new AgentRpcError( message(error), - connectionError ? "connection" : "sql", + category == null ? (connectionError ? "connection" : "sql") : category, operationRetryable, disposition, stage, + operationOutcome(stage), + safeSqlState(sqlState), + sqlError.getErrorCode(), + safeClassName(sqlError), error ); } - return new AgentRpcError(message(error), "protocol", false, "keep", stage(method), error); + return new AgentRpcError( + message(error), + "protocol", + false, + "keep", + stage, + operationOutcome(stage), + null, + null, + safeClassName(error), + error + ); } private static String stage(String method) { if (method == null) { return "request"; } - if (AgentProtocol.METHOD_CONNECT.equals(method) || AgentProtocol.METHOD_OPEN_SESSION.equals(method)) { + if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) { + return "request"; + } + if (AgentProtocol.METHOD_CONNECT.equals(method) + || AgentProtocol.METHOD_OPEN_SESSION.equals(method) + || AgentProtocol.METHOD_TEST_CONNECTION.equals(method)) { return "connect"; } if (AgentProtocol.METHOD_VALIDATE_CONNECTION.equals(method) || AgentProtocol.METHOD_VALIDATE_SESSION.equals(method)) { @@ -108,7 +172,11 @@ final class AgentRpcError extends RuntimeException { if (AgentProtocol.METHOD_CANCEL_SESSION.equals(method)) { return "cancel"; } - if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method) || AgentProtocol.METHOD_DISCONNECT.equals(method)) { + if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method) + || AgentProtocol.METHOD_DISCONNECT.equals(method) + || AgentProtocol.METHOD_CLOSE_QUERY_SESSION.equals(method) + || AgentProtocol.METHOD_CLOSE_TABLE_READ_SESSION.equals(method) + || AgentProtocol.METHOD_SHUTDOWN.equals(method)) { return "close"; } if (AgentProtocol.METHOD_FETCH_QUERY_PAGE.equals(method) @@ -122,6 +190,41 @@ final class AgentRpcError extends RuntimeException { return error.getMessage() == null ? error.toString() : error.getMessage(); } + private static String operationOutcome(String stage) { + return switch (stage) { + case "request", "checkout", "connect", "validate" -> "not_started"; + default -> "unknown"; + }; + } + + private static String safeSqlState(String sqlState) { + return safeDiagnostic(sqlState, 16); + } + + private static String safeClassName(Throwable error) { + return error == null ? null : safeDiagnostic(error.getClass().getName(), 160); + } + + private static String safeDiagnostic(String value, int maxLength) { + if (value == null) { + return null; + } + StringBuilder safe = new StringBuilder(Math.min(value.length(), maxLength)); + for (int index = 0; index < value.length() && safe.length() < maxLength; index++) { + char character = value.charAt(index); + if (character >= 0x21 && character <= 0x7e) { + safe.append(character); + } + } + return safe.isEmpty() ? null : safe.toString(); + } + + private static void addDiagnostic(JsonObject data, String name, String value) { + if (value != null && !value.isBlank()) { + data.addProperty(name, value); + } + } + private static T find(Throwable error, Class type) { Throwable current = error; while (current != null) { diff --git a/agents/common/src/main/java/com/dbx/agent/MultiSessionJsonRpcServer.java b/agents/common/src/main/java/com/dbx/agent/MultiSessionJsonRpcServer.java index f9dd6fa4eb..4d4f9bf59a 100644 --- a/agents/common/src/main/java/com/dbx/agent/MultiSessionJsonRpcServer.java +++ b/agents/common/src/main/java/com/dbx/agent/MultiSessionJsonRpcServer.java @@ -27,7 +27,7 @@ import java.util.function.Supplier; public final class MultiSessionJsonRpcServer implements AutoCloseable { private static final String LEGACY_SESSION_ID = "__legacy__"; - private static final int MAX_SESSIONS = 256; + static final int MAX_SESSIONS = 256; private static final int MAX_REQUEST_THREADS = 64; private static final int MAX_CLEANUP_THREADS = 16; private static final long MAINTENANCE_INTERVAL_MILLIS = 60_000L; @@ -137,7 +137,7 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable { try { Object result; if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) { - result = sessionHandlerFactory == null ? AgentProtocol.multiSessionHandshakeResult() : customHandshake(); + result = sessionHandlerFactory == null ? AgentProtocol.multiSessionJdbcHandshakeResult() : customHandshake(); } else if (AgentProtocol.METHOD_OPEN_SESSION.equals(method)) { result = openSession(requiredSessionId(params), params); } else if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method)) { @@ -171,7 +171,10 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable { private Object openSession(String sessionId, JsonObject params) throws Exception { if (sessions.size() >= MAX_SESSIONS && !sessions.containsKey(sessionId)) { - throw new IllegalStateException("Agent session limit reached: " + MAX_SESSIONS); + throw AgentRpcError.backpressure( + "connect", + new IllegalStateException("Agent session limit reached: " + MAX_SESSIONS) + ); } Session session; if (sessionHandlerFactory != null) { diff --git a/agents/common/src/main/resources/agent-protocol-v2.json b/agents/common/src/main/resources/agent-protocol-v2.json index 8764572baa..bc282be4a9 100644 --- a/agents/common/src/main/resources/agent-protocol-v2.json +++ b/agents/common/src/main/resources/agent-protocol-v2.json @@ -2,9 +2,9 @@ "protocolVersion": 2, "handshakeMethod": "handshake", "handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"], - "allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "multi_session"], + "allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "multi_session", "structured_error_v1"], "capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session"], - "defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session"], + "defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session", "structured_error_v1"], "commonMethods": [ "handshake", "connect", diff --git a/agents/common/src/test/java/com/dbx/agent/AgentRpcErrorTest.java b/agents/common/src/test/java/com/dbx/agent/AgentRpcErrorTest.java new file mode 100644 index 0000000000..c4721bf6b3 --- /dev/null +++ b/agents/common/src/test/java/com/dbx/agent/AgentRpcErrorTest.java @@ -0,0 +1,97 @@ +package com.dbx.agent; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; + +import java.sql.SQLException; +import java.sql.SQLTimeoutException; +import java.util.concurrent.CancellationException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class AgentRpcErrorTest { + @Test + void serializesVersionedSqlDiagnosticsAndUnknownExecuteOutcome() { + SQLException cause = new SQLException("statement failed", "42000", 1064); + + JsonObject data = errorData(cause, AgentProtocol.METHOD_EXECUTE_QUERY, "session-1"); + + assertEquals(1, data.get("contractVersion").getAsInt()); + assertEquals("sql", data.get("category").getAsString()); + assertEquals("execute", data.get("stage").getAsString()); + assertEquals("unknown", data.get("operationOutcome").getAsString()); + assertEquals("keep", data.get("sessionDisposition").getAsString()); + assertEquals("42000", data.get("sqlState").getAsString()); + assertEquals(1064, data.get("vendorCode").getAsInt()); + assertEquals(SQLException.class.getName(), data.get("exceptionClass").getAsString()); + assertEquals("session-1", data.get("agentSessionId").getAsString()); + } + + @Test + void classifiesTimeoutAndCanceledErrorsWithoutClaimingSafeReplay() { + JsonObject timeout = errorData( + new SQLTimeoutException("timed out", "HYT00", 0), + AgentProtocol.METHOD_EXECUTE_QUERY, + "session-1" + ); + JsonObject canceled = errorData( + new CancellationException("canceled"), + AgentProtocol.METHOD_CANCEL_SESSION, + "session-1" + ); + + assertEquals("timeout", timeout.get("category").getAsString()); + assertEquals("unknown", timeout.get("operationOutcome").getAsString()); + assertFalse(timeout.get("retryable").getAsBoolean()); + assertEquals("quarantine", timeout.get("sessionDisposition").getAsString()); + assertEquals("canceled", canceled.get("category").getAsString()); + assertEquals("cancel", canceled.get("stage").getAsString()); + assertEquals("unknown", canceled.get("operationOutcome").getAsString()); + } + + @Test + void marksConnectionSetupFailureAsNotStartedAndBoundsSqlState() { + SQLException cause = new SQLException("connect failed", "12345678901234567890", -7); + + JsonObject data = errorData(cause, AgentProtocol.METHOD_CONNECT, null); + + assertEquals("connection", data.get("category").getAsString()); + assertEquals("connect", data.get("stage").getAsString()); + assertEquals("not_started", data.get("operationOutcome").getAsString()); + assertEquals(16, data.get("sqlState").getAsString().length()); + assertFalse(data.has("agentSessionId")); + } + + @Test + void removesNonGraphicCharactersFromStrictDiagnostics() { + SQLException cause = new SQLException("connect failed", "08\n006\u00e9", -7); + + JsonObject data = errorData(cause, AgentProtocol.METHOD_CONNECT, null); + + assertEquals("08006", data.get("sqlState").getAsString()); + } + + @Test + void mapsConnectionAndCloseMethodsToTheSameStagesAsTheRustDecoder() { + JsonObject testConnection = errorData( + new SQLException("connect failed", "08001", 0), + AgentProtocol.METHOD_TEST_CONNECTION, + null + ); + JsonObject closeQuery = errorData( + new SQLException("close failed", "42000", 0), + AgentProtocol.METHOD_CLOSE_QUERY_SESSION, + "session-1" + ); + + assertEquals("connect", testConnection.get("stage").getAsString()); + assertEquals("not_started", testConnection.get("operationOutcome").getAsString()); + assertEquals("close", closeQuery.get("stage").getAsString()); + assertEquals("unknown", closeQuery.get("operationOutcome").getAsString()); + } + + private static JsonObject errorData(Throwable error, String method, String agentSessionId) { + return AgentRpcError.toJson(error, method, agentSessionId).getAsJsonObject("data"); + } +} diff --git a/agents/common/src/test/java/com/dbx/agent/CommonJavaCompatibilityTest.java b/agents/common/src/test/java/com/dbx/agent/CommonJavaCompatibilityTest.java index 60a10d6040..0e71c87ef1 100644 --- a/agents/common/src/test/java/com/dbx/agent/CommonJavaCompatibilityTest.java +++ b/agents/common/src/test/java/com/dbx/agent/CommonJavaCompatibilityTest.java @@ -72,7 +72,7 @@ class CommonJavaCompatibilityTest { strings(contract.getAsJsonArray("handshakeResponseFields")) ); assertEquals( - AgentProtocol.MULTI_SESSION_ALL_CAPABILITIES, + AgentProtocol.MULTI_SESSION_JDBC_ALL_CAPABILITIES, strings(contract.getAsJsonArray("allCapabilities")) ); assertEquals( @@ -80,7 +80,7 @@ class CommonJavaCompatibilityTest { strings(contract.getAsJsonArray("capabilities")) ); assertEquals( - AgentProtocol.MULTI_SESSION_CAPABILITIES, + AgentProtocol.MULTI_SESSION_JDBC_CAPABILITIES, strings(contract.getAsJsonArray("defaultSqlCapabilities")) ); assertEquals(AgentProtocol.MULTI_SESSION_METHODS, strings(contract.getAsJsonArray("commonMethods"))); @@ -164,6 +164,7 @@ class CommonJavaCompatibilityTest { )).getAsJsonObject().getAsJsonObject("result"); assertEquals(2, handshake.get("protocolVersion").getAsInt()); assertTrue(containsCapability(handshake.getAsJsonArray("capabilities"), "multi_session")); + assertTrue(containsCapability(handshake.getAsJsonArray("capabilities"), "structured_error_v1")); server.handleRequest("{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"open_session\",\"params\":{\"agentSessionId\":\"a\"}}"); server.handleRequest("{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"open_session\",\"params\":{\"agentSessionId\":\"b\"}}"); @@ -177,6 +178,68 @@ class CommonJavaCompatibilityTest { assertEquals(0, created.get(1).disconnectCount); } + @Test + void customSessionHandlersDoNotImplicitlyAdvertiseStructuredErrors() { + MultiSessionJsonRpcServer server = MultiSessionJsonRpcServer.forSessionHandlers(() -> new SessionRpcHandler() { + @Override + public Object connect(JsonObject params) { + return Collections.singletonMap("ok", true); + } + + @Override + public Object handle(String method, JsonObject params) { + return Collections.singletonMap("ok", true); + } + + @Override + public void close() { + } + }); + + JsonObject handshake = JsonParser.parseString(server.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"handshake\",\"params\":{}}" + )).getAsJsonObject().getAsJsonObject("result"); + + assertTrue(containsCapability(handshake.getAsJsonArray("capabilities"), "multi_session")); + assertFalse(containsCapability(handshake.getAsJsonArray("capabilities"), "structured_error_v1")); + } + + @Test + void multiSessionLimitReturnsStructuredBackpressureBeforeConnectStarts() { + try (MultiSessionJsonRpcServer server = MultiSessionJsonRpcServer.forSessionHandlers(() -> new SessionRpcHandler() { + @Override + public Object connect(JsonObject params) { + return Collections.singletonMap("ok", true); + } + + @Override + public Object handle(String method, JsonObject params) { + return Collections.singletonMap("ok", true); + } + + @Override + public void close() { + } + })) { + for (int index = 0; index < MultiSessionJsonRpcServer.MAX_SESSIONS; index++) { + JsonObject response = JsonParser.parseString(server.handleRequest(openSessionRequest(index, "session-" + index))) + .getAsJsonObject(); + assertTrue(response.has("result"), response::toString); + } + + JsonObject response = JsonParser.parseString(server.handleRequest( + openSessionRequest(MultiSessionJsonRpcServer.MAX_SESSIONS, "overflow") + )).getAsJsonObject(); + JsonObject data = response.getAsJsonObject("error").getAsJsonObject("data"); + + assertEquals("resource", data.get("category").getAsString()); + assertTrue(data.get("retryable").getAsBoolean()); + assertEquals("keep", data.get("sessionDisposition").getAsString()); + assertEquals("connect", data.get("stage").getAsString()); + assertEquals("not_started", data.get("operationOutcome").getAsString()); + } + } + @Test void multiSessionServerKeepsProtocolOutputWhenGlobalStdoutChanges() { synchronized (System.class) { @@ -1113,6 +1176,17 @@ class CommonJavaCompatibilityTest { return false; } + private static String openSessionRequest(int requestId, String sessionId) { + JsonObject params = new JsonObject(); + params.addProperty("agentSessionId", sessionId); + JsonObject request = new JsonObject(); + request.addProperty("jsonrpc", "2.0"); + request.addProperty("id", requestId); + request.addProperty("method", AgentProtocol.METHOD_OPEN_SESSION); + request.add("params", params); + return request.toString(); + } + private static void awaitCondition(java.util.function.BooleanSupplier condition) throws InterruptedException { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); while (!condition.getAsBoolean() && System.nanoTime() < deadline) { diff --git a/agents/docs/agent-protocol-v2.md b/agents/docs/agent-protocol-v2.md index 9ca6e7dcbb..3bd3eb2c3d 100644 --- a/agents/docs/agent-protocol-v2.md +++ b/agents/docs/agent-protocol-v2.md @@ -1,6 +1,6 @@ # Agent Protocol v2: Multi-session runtimes -Protocol v2 allows one Agent process to serve multiple isolated database sessions. The handshake advertises `protocolVersion: 2` and the `multi_session` capability. DBX falls back to the v1 one-process-per-pool lifecycle when that capability is absent. +Protocol v2 allows one Agent process to serve multiple isolated database sessions. Pooled JDBC Agents that use the common structured error producer advertise `protocolVersion: 2`, `multi_session`, and `structured_error_v1`. Generic/custom v2 handlers may advertise only `multi_session`. DBX falls back to the v1 one-process-per-pool lifecycle when `multi_session` is absent. ## Session lifecycle @@ -37,14 +37,21 @@ JSON-RPC failures may include structured recovery data: ```json { + "contractVersion": 1, "category": "timeout|canceled|connection|protocol|resource|sql", "retryable": false, "sessionDisposition": "keep|quarantine|replace_runtime", "agentSessionId": "optional-session-id", - "stage": "checkout|connect|validate|execute|fetch|cancel|close" + "stage": "request|checkout|connect|validate|execute|fetch|cancel|close", + "operationOutcome": "not_started|unknown", + "sqlState": "optional-jdbc-sql-state", + "vendorCode": 0, + "exceptionClass": "optional-java-exception-class" } ``` +`contractVersion: 1` is guaranteed only when the handshake advertises `structured_error_v1`. Unknown extra fields are allowed, but unknown enum values, missing required fields, invalid types, or an `agentSessionId` that does not match the current request are contract violations. `operationOutcome` describes whether the user operation may have reached the database; `retryable` is an internal hint and never authorizes automatic SQL replay. + `keep` preserves the logical session, `quarantine` removes only that session from routing, and `replace_runtime` requires DBX to atomically remove every pool sharing the runtime before terminating it. Agent code reports the disposition but must not independently terminate a shared runtime because it does not own DBX routing state. Temporary workload checkout backpressure uses `category=resource`, `retryable=true`, and `sessionDisposition=keep`; only unrecoverable runtime or cleanup saturation requests `replace_runtime`. The complete JDBC pool checkout runs under a bounded runtime executor, including HikariCP idle-connection validation, physical connection creation, and driver setup. Workload admission, the runtime-wide physical connection budget, physical creation, and checkout consume one absolute deadline rather than restarting the timeout at each stage. Connection return, eviction, and physical close use separate bounded executors so they cannot deadlock checkout or creation. If a driver call outlives its boundary, or cleanup cannot confirm the physical connection state, the connection identity is poisoned and returns `category=resource` with `sessionDisposition=replace_runtime` on the current or next checkout. A late connection must be evicted and closed instead of published, and DBX must not replay the timed-out user operation automatically. diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 463675c152..fa14d1a7c7 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -1309,7 +1309,7 @@ async function newQuery() { } catch (e: any) { toast( t("connection.connectFailed", { - message: translateBackendError(t, e?.message || String(e)), + message: translateBackendError(t, e), }), 5000, ); @@ -1344,7 +1344,7 @@ async function newQuery() { } catch (e: any) { toast( t("connection.connectFailed", { - message: translateBackendError(t, e?.message || String(e)), + message: translateBackendError(t, e), }), 5000, ); @@ -1367,7 +1367,7 @@ async function openConnectionQuery(connectionId: string) { } catch (e: any) { toast( t("connection.connectFailed", { - message: translateBackendError(t, e?.message || String(e)), + message: translateBackendError(t, e), }), 5000, ); @@ -1381,7 +1381,7 @@ async function openConnectionQuery(connectionId: string) { } catch (e: any) { toast( t("connection.connectFailed", { - message: translateBackendError(t, e?.message || String(e)), + message: translateBackendError(t, e), }), 5000, ); @@ -1399,7 +1399,7 @@ async function openConnectionQuery(connectionId: string) { } catch (e: any) { toast( t("connection.connectFailed", { - message: translateBackendError(t, e?.message || String(e)), + message: translateBackendError(t, e), }), 5000, ); @@ -1471,7 +1471,7 @@ async function onClickTable(table: SqlObjectNavigationTarget) { try { await openTableTarget(target, { tableInfoTab: "ddl" }); } catch (e: any) { - toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000); + toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000); } } @@ -1481,7 +1481,7 @@ async function onViewTableData(table: SqlObjectNavigationTarget) { try { await openTableTarget(target); } catch (e: any) { - toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000); + toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000); } } @@ -1509,7 +1509,7 @@ async function onOpenObjectSource(table: SqlObjectNavigationTarget, initialEditi queryEditorObjectSourceTarget.value = { connectionId: target.connectionId, database: target.database, schema: target.schema, name: target.tableName, objectType, initialEditing }; showQueryEditorObjectSourceDialog.value = true; } catch (e: any) { - toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000); + toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000); } } @@ -1547,7 +1547,7 @@ async function changeActiveConnection(connectionId: string) { } catch (e: any) { toast( t("connection.connectFailed", { - message: translateBackendError(t, e?.message || String(e)), + message: translateBackendError(t, e), }), 5000, ); diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index c570d84412..ac75ce528e 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -1502,7 +1502,7 @@ function finishAgentDriverInstall() { function failAgentDriverInstall(error: unknown) { agentInstallOperationId.value = null; agentInstallRunning.value = false; - agentInstallError.value = translateBackendError(t, errorMessage(error)); + agentInstallError.value = translateBackendError(t, error); showAgentInstallDialog.value = true; } @@ -1559,7 +1559,7 @@ async function ensureRequiredAgentDriverInstalled(config: ConnectionConfig): Pro await refreshLocalAgentDrivers(); finishAgentDriverInstall(); } catch (error) { - testResult.value = { ok: false, message: errorMessage(error) }; + testResult.value = { ok: false, message: translateBackendError(t, error) }; failAgentDriverInstall(error); throw error; } @@ -1601,7 +1601,7 @@ async function installSqlServerLegacyCompatibilityComponentIfNeeded(): Promise props.result.columns.length === 1 && props.result.columns[0] === "Error" && props.result.rows.length > 0); +const isErrorResult = computed(() => isQueryExecutionErrorResult(props.result) && props.result.rows.length > 0); const errorMessage = computed(() => (isErrorResult.value ? String(props.result.rows[0]?.[0] ?? "") : "")); // --- Selection composable --- const selection = useDataGridSelection({ diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 87d735b687..59469fd400 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -3,6 +3,7 @@ import { computed, ref, defineAsyncComponent, watch, nextTick, onMounted, onUnmo import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog"; import { canReloadUnavailableDataTab } from "@/lib/table/tableDataRefresh"; +import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError"; import type { CSSProperties } from "vue"; import { useI18n } from "vue-i18n"; import { Check, Columns3Cog, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus, ShieldAlert, AlignLeft, AlignRight, PanelsTopLeft } from "@lucide/vue"; @@ -340,7 +341,7 @@ const hasNumericData = computed(() => { const activeQueryError = computed(() => { const result = props.activeTab.result; - if (!result?.columns.includes("Error")) return ""; + if (!result || !isQueryExecutionErrorResult(result)) return ""; return String(result.rows[0]?.[0] ?? ""); }); const hasQueryOutput = computed( @@ -1480,7 +1481,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand @export-archive="exportResultArchive" /> -