diff --git a/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java b/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java index c12640d4ce..a26f610e3d 100644 --- a/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java +++ b/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java @@ -14,6 +14,14 @@ public interface DatabaseAgent { /** Opt in to request-boundary timing, including pooled connection lifecycle. */ default boolean supportsQueryTiming() { return false; } + /** A dedicated manual transaction must never migrate to a replacement connection. */ + default boolean permitsAutomaticReconnect() { return true; } + + default List executeQueryResults(String sql, String schema, ExecuteQueryOptions options) { + throw new UnsupportedOperationException("Complete manual transaction batches are not supported by this driver"); + } + + void connect(ConnectParams params); boolean testConnection(ConnectParams params); diff --git a/agents/common/src/main/java/com/dbx/agent/JdbcExecutor.java b/agents/common/src/main/java/com/dbx/agent/JdbcExecutor.java index 1c212c5e87..ea6193fff8 100644 --- a/agents/common/src/main/java/com/dbx/agent/JdbcExecutor.java +++ b/agents/common/src/main/java/com/dbx/agent/JdbcExecutor.java @@ -42,6 +42,61 @@ public final class JdbcExecutor { return effectiveMaxRows == Integer.MAX_VALUE ? Integer.MAX_VALUE : effectiveMaxRows + 1; } + /** Execute one batch once, consuming every row and response even when retention is capped. */ + public List executeAll(Connection conn, String sql, int maxRows, Integer fetchSize, + int timeoutSecs, ResultValueReader valueReader) { + return unchecked(() -> { + long start = System.currentTimeMillis(); + int retainedRows = Math.max(maxRows, 1); + List results = new ArrayList<>(); + try (Statement stmt = conn.createStatement()) { + activeStatements.add(stmt); + try { + // setMaxRows can truncate the server response; cap retained rows instead. + applyQueryTimeout(stmt, timeoutSecs); + if (fetchSize != null && fetchSize > 0) stmt.setFetchSize(fetchSize); + boolean hasResult = stmt.execute(sql); + while (true) { + if (hasResult) { + try (ResultSet rs = stmt.getResultSet()) { + QueryResult result = readResultSet(rs, System.currentTimeMillis() - start, retainedRows, valueReader); + while (rs.next()) { /* fully consume rows beyond the UI limit */ } + results.add(result); + } + } else { + int count = stmt.getUpdateCount(); + if (count < 0) break; + results.add(new QueryResult(Collections.emptyList(), Collections.emptyList(), count, + System.currentTimeMillis() - start, false)); + } + appendStatementWarnings(results.get(results.size() - 1), stmt); + hasResult = stmt.getMoreResults(); + } + if (results.isEmpty()) results.add(new QueryResult(Collections.emptyList(), Collections.emptyList(), + 0, System.currentTimeMillis() - start, false)); + appendStatementWarnings(results.get(results.size() - 1), stmt); + return results; + } finally { + activeStatements.remove(stmt); + } + } + }); + } + + private static void appendStatementWarnings(QueryResult result, Statement stmt) { + try { + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + for (SQLWarning warning = stmt.getWarnings(); warning != null && seen.add(warning); warning = warning.getNextWarning()) { + String message = warning.getMessage(); + if (message != null && !message.isBlank()) result.addInformationalMessage(message, + warning.getErrorCode() == 0 ? null : Integer.toString(warning.getErrorCode())); + } + stmt.clearWarnings(); + } catch (SQLException ignored) { + // Advisory warnings cannot change an already acknowledged execution. + } + } + public QueryResult execute(Connection conn, String sql, String schema, Function setSchemaSql) { return execute(conn, sql, schema, setSchemaSql, DEFAULT_MAX_ROWS, null, this::defaultResultValue); } diff --git a/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java b/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java index 3a6c796a21..e851434207 100644 --- a/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java +++ b/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java @@ -288,16 +288,14 @@ public final class JsonRpcServer { return agent.listSubpartitions(params.get("schema").getAsString(), params.get("table").getAsString()); } if (AgentProtocol.METHOD_EXECUTE_QUERY.equals(method)) { - return agent.executeQuery( - params.get("sql").getAsString(), - stringOrNull(params, "schema"), - new ExecuteQueryOptions( - intOrDefault(params, "maxRows", JdbcExecutor.DEFAULT_MAX_ROWS), - intOrNull(params, "fetchSize"), - intOrDefault(params, "timeoutSecs", 0), - booleanOrDefault(params, "deferLobs", false) - ) - ); + ExecuteQueryOptions options = new ExecuteQueryOptions( + intOrDefault(params, "maxRows", JdbcExecutor.DEFAULT_MAX_ROWS), + intOrNull(params, "fetchSize"), intOrDefault(params, "timeoutSecs", 0), + booleanOrDefault(params, "deferLobs", false)); + if (params.has("returnAllResults") && params.get("returnAllResults").getAsBoolean()) { + return agent.executeQueryResults(params.get("sql").getAsString(), stringOrNull(params, "schema"), options); + } + return agent.executeQuery(params.get("sql").getAsString(), stringOrNull(params, "schema"), options); } if (AgentProtocol.METHOD_EXECUTE_QUERY_PAGE.equals(method)) { return agent.executeQueryPage( @@ -410,6 +408,7 @@ public final class JsonRpcServer { } private void ensureLiveConnection(String method) { + if (!agent.permitsAutomaticReconnect()) return; if (lastConnectParams == null || !shouldValidateConnection(method)) { return; } diff --git a/agents/common/src/main/java/com/dbx/agent/QueryResult.java b/agents/common/src/main/java/com/dbx/agent/QueryResult.java index 03c2c81aad..af6549c594 100644 --- a/agents/common/src/main/java/com/dbx/agent/QueryResult.java +++ b/agents/common/src/main/java/com/dbx/agent/QueryResult.java @@ -20,6 +20,22 @@ public final class QueryResult { private Long server_execute_time_us; private java.util.Map query_timings_ms; private boolean truncated; + // Optional protocol field: existing drivers omit it, manual SQL Server batches retain PRINT/warnings. + private List> messages; + + public List> getMessages() { + return messages == null ? Collections.emptyList() : messages; + } + + public void addInformationalMessage(String message, String code) { + if (messages == null) messages = new ArrayList<>(); + if (messages.size() >= 512) return; + Map entry = new java.util.LinkedHashMap<>(); + entry.put("severity", "INFO"); + entry.put("message", message); + if (code != null) entry.put("code", code); + messages.add(entry); + } public QueryResult() { this(Collections.emptyList(), Collections.emptyList(), 0L, 0L, false); diff --git a/agents/common/src/test/java/com/dbx/agent/JdbcExecutorTest.java b/agents/common/src/test/java/com/dbx/agent/JdbcExecutorTest.java index f15999d71a..63be35e506 100644 --- a/agents/common/src/test/java/com/dbx/agent/JdbcExecutorTest.java +++ b/agents/common/src/test/java/com/dbx/agent/JdbcExecutorTest.java @@ -26,6 +26,70 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class JdbcExecutorTest { + @Test + void executeAllPreservesPrintMessagesAlongsideRows() { + CountingResultSetFixture rows = countingResultSet(new Object[][]{{1, "value"}}); + List results = JdbcExecutor.INSTANCE.executeAll( + executionConnection(true, -1, new SQLWarning("printed", "01000", 42), new AtomicInteger(), rows.resultSet(), null), + "SELECT; PRINT", 10, null, 5, JdbcExecutor.INSTANCE::defaultResultValue); + assertEquals("printed", results.get(0).getMessages().get(0).get("message")); + assertEquals("42", results.get(0).getMessages().get(0).get("code")); + assertEquals(1, results.get(0).getRows().size()); + } + + @Test + void executeAllDrainsTruncatedRowsAndLaterResultsWithoutReexecuting() { + AtomicInteger executeCalls = new AtomicInteger(); + AtomicInteger index = new AtomicInteger(); + AtomicInteger drained = new AtomicInteger(); + ResultSet first = countingResultSet(new Object[][]{{1, "a"}, {2, "b"}, {3, "c"}, {4, "d"}}).resultSet(); + ResultSet tracked = (ResultSet) Proxy.newProxyInstance(ResultSet.class.getClassLoader(), new Class[]{ResultSet.class}, (ignored, method, args) -> { + Object result = method.invoke(first, args); + if (method.getName().equals("next") && Boolean.TRUE.equals(result)) drained.incrementAndGet(); + return result; + }); + ResultSet second = countingResultSet(new Object[][]{{9, "last"}}).resultSet(); + Statement statement = (Statement) Proxy.newProxyInstance(Statement.class.getClassLoader(), new Class[]{Statement.class}, (ignored, method, args) -> { + switch (method.getName()) { + case "execute": executeCalls.incrementAndGet(); return true; + case "setMaxRows": throw new AssertionError("UI retention must not cap JDBC responses"); + case "getResultSet": return index.get() == 0 ? tracked : second; + case "getMoreResults": int next = index.incrementAndGet(); return next == 2; + case "getUpdateCount": return index.get() == 1 ? 7 : -1; + default: return defaultValue(method.getReturnType()); + } + }); + Connection conn = (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class[]{Connection.class}, (ignored, method, args) -> + method.getName().equals("createStatement") ? statement : defaultValue(method.getReturnType())); + List results = JdbcExecutor.INSTANCE.executeAll(conn, "SELECT; UPDATE; SELECT", 1, null, 5, + JdbcExecutor.INSTANCE::defaultResultValue); + assertEquals(1, executeCalls.get()); + assertEquals(4, drained.get()); + assertEquals(3, results.size()); + assertTrue(results.get(0).getTruncated()); + assertEquals(1, results.get(0).getRows().size()); + assertEquals(7, results.get(1).getAffected_rows()); + assertEquals(9, results.get(2).getRows().get(0).get(0)); + } + + @Test + void executeAllPropagatesLateErrorsInsteadOfReturningPartialSuccess() { + AtomicInteger executeCalls = new AtomicInteger(); + Statement stmt = (Statement) Proxy.newProxyInstance(Statement.class.getClassLoader(), new Class[]{Statement.class}, (ignored, method, args) -> { + switch (method.getName()) { + case "execute": executeCalls.incrementAndGet(); return false; + case "getUpdateCount": return 1; + case "getMoreResults": throw new SQLException("late failure"); + default: return defaultValue(method.getReturnType()); + } + }); + Connection conn = (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class[]{Connection.class}, (ignored, method, args) -> + method.getName().equals("createStatement") ? stmt : defaultValue(method.getReturnType())); + assertThrows(RuntimeException.class, () -> JdbcExecutor.INSTANCE.executeAll(conn, "UPDATE", 1, null, 5, + JdbcExecutor.INSTANCE::defaultResultValue)); + assertEquals(1, executeCalls.get()); + } + @Test void statementRowLimitIncludesOverflowProbeWithoutIntegerOverflow() { assertEquals(2, JdbcExecutor.statementMaxRows(0)); diff --git a/agents/drivers/duckdb/Cargo.lock b/agents/drivers/duckdb/Cargo.lock index 614d19a8be..f3bc332b18 100644 --- a/agents/drivers/duckdb/Cargo.lock +++ b/agents/drivers/duckdb/Cargo.lock @@ -5886,8 +5886,6 @@ dependencies = [ [[package]] name = "tiberius" version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1446cb4198848d1562301a3340424b4f425ef79f35ef9ee034769a9dd92c10d" dependencies = [ "async-trait", "asynchronous-codec", diff --git a/agents/drivers/duckdb/Cargo.toml b/agents/drivers/duckdb/Cargo.toml index 5b1a243ec9..371b4d7621 100644 --- a/agents/drivers/duckdb/Cargo.toml +++ b/agents/drivers/duckdb/Cargo.toml @@ -42,6 +42,7 @@ tokio-postgres = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", r postgres-types = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "ac8ad476de59093d33ab9f294531c956e0c6a724" } postgres-protocol = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "ac8ad476de59093d33ab9f294531c956e0c6a724" } mysql_async = { git = "https://github.com/t8y2/mysql_async.git", rev = "b615bb0b10b5975182a4aa069cabc3d09021f6ab" } +tiberius = { path = "../../../vendor/tiberius" } [profile.release] panic = "abort" diff --git a/agents/drivers/sqlserver-legacy/src/main/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgent.java b/agents/drivers/sqlserver-legacy/src/main/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgent.java index bd8c9348f3..b5826c8df5 100644 --- a/agents/drivers/sqlserver-legacy/src/main/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgent.java +++ b/agents/drivers/sqlserver-legacy/src/main/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgent.java @@ -1,6 +1,9 @@ package com.dbx.agent.sqlserverlegacy; import com.dbx.agent.ConfiguredJdbcAgent; +import com.dbx.agent.ExecuteQueryOptions; +import com.dbx.agent.JdbcExecutor; +import com.dbx.agent.QueryResult; import com.dbx.agent.ColumnInfo; import com.dbx.agent.ConnectParams; import com.dbx.agent.DdlBuilder; @@ -63,6 +66,8 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent { Arrays.asList("TABLE", "VIEW", "SYSTEM TABLE") ); private volatile boolean sqlServer2000Mode; + private volatile boolean manualTransactionSession; + private volatile boolean manualTransactionExecuted; public SqlServerLegacyAgent() { super(PROFILE); @@ -71,6 +76,114 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent { enableLegacyTlsAlgorithms(); } + @Override + public Map beginManualTransaction(String schema) { + Connection conn = getConnection(); + if (conn == null) throw new IllegalStateException("Not connected"); + // Metadata is advisory when an old JDBC driver cannot implement it. + // A definitive false is different from an unavailable metadata method. + return unchecked(() -> { + java.sql.DatabaseMetaData metadata = null; + boolean unsupported = false; + try { + metadata = conn.getMetaData(); + unsupported = !metadata.supportsTransactions(); + } catch (SQLException | AbstractMethodError unavailable) { + // setAutoCommit is the authoritative operation in this case. + } + if (unsupported) throw new IllegalStateException("DBX_MANUAL_TRANSACTION_UNSUPPORTED: JDBC driver reports transactions are unsupported"); + if (!conn.getAutoCommit()) throw new IllegalStateException("Manual transaction already open"); + conn.setAutoCommit(false); + manualTransactionSession = true; + manualTransactionExecuted = false; + Map result = new LinkedHashMap<>(); + result.put("ok", true); + result.put("manualTransactionBatch", true); + if (metadata != null) { + try { + result.put("productVersion", metadata.getDatabaseProductVersion()); + result.put("productType", metadata.getDatabaseProductName()); + result.put("driverName", metadata.getDriverName()); + result.put("driverVersion", metadata.getDriverVersion()); + } catch (SQLException | AbstractMethodError unavailable) { + // Diagnostics must not turn a successful BEGIN into a failure. + } + } + return result; + }); + } + + @Override + public Map commitManualTransaction() { + Connection conn = getConnection(); + if (conn == null) throw new IllegalStateException("Not connected"); + return unchecked(() -> { + if (conn.getAutoCommit()) throw new IllegalStateException("No manual transaction open"); + if (manualTransactionExecuted && transactionCount(conn, 30) != 1) throw new IllegalStateException("DBX_MANUAL_TRANSACTION_STATE_LOST: commit state cannot be confirmed"); + conn.commit(); + // This is a dedicated, terminal session. Do not setAutoCommit(true): + // a failure in that cleanup must not obscure an acknowledged COMMIT. + return Collections.singletonMap("ok", (Object) true); + }); + } + + @Override + public Map rollbackManualTransaction() { + Connection conn = getConnection(); + if (conn == null) throw new IllegalStateException("Not connected"); + return unchecked(() -> { + if (conn.getAutoCommit()) throw new IllegalStateException("No manual transaction open"); + if (manualTransactionExecuted && transactionCount(conn, 30) == 0) throw new IllegalStateException("DBX_MANUAL_TRANSACTION_STATE_LOST: transaction already ended; rollback cannot be confirmed"); + conn.rollback(); + return Collections.singletonMap("ok", (Object) true); + }); + } + + @Override + public boolean permitsAutomaticReconnect() { + return !manualTransactionSession; + } + + @Override + public List executeQueryResults(String sql, String schema, ExecuteQueryOptions options) { + Connection conn = getConnection(); + if (!manualTransactionSession || conn == null) throw new IllegalStateException("No dedicated manual transaction connection"); + return unchecked(() -> { + if (conn.getAutoCommit()) throw new IllegalStateException("DBX_MANUAL_TRANSACTION_STATE_LOST: autoCommit changed"); + beforeQueryExecution(conn, options.getTimeoutSecs()); + int before = transactionCount(conn, options.getTimeoutSecs()); + if (before == 0 && !manualTransactionExecuted) { + // JDBC may defer its physical BEGIN. Open the DBX transaction before + // the first user batch, including PRINT/read-only procedures. Explicit + // BEGIN with IMPLICIT_TRANSACTIONS ON would open two transactions. + JdbcExecutor.current().executeAll(conn, "SET IMPLICIT_TRANSACTIONS OFF; BEGIN TRANSACTION", 1, null, + options.getTimeoutSecs(), resultValueReader()); + before = transactionCount(conn, options.getTimeoutSecs()); + } + if (before != 1) throw new IllegalStateException("DBX_MANUAL_TRANSACTION_STATE_LOST: transaction not active or nested"); + manualTransactionExecuted = true; + List results = JdbcExecutor.current().executeAll(conn, sql, options.getMaxRows(), + options.getFetchSize(), options.getTimeoutSecs(), resultValueReader()); + int after = transactionCount(conn, options.getTimeoutSecs()); + if (after != 1) { + throw new IllegalStateException("DBX_MANUAL_TRANSACTION_STATE_LOST: transaction ended or changed inside the batch"); + } + return results; + }); + } + + private static int transactionCount(Connection conn, int timeoutSecs) throws SQLException { + try (java.sql.Statement stmt = conn.createStatement()) { + stmt.setQueryTimeout(timeoutSecs > 0 ? timeoutSecs : 30); + try (ResultSet rows = stmt.executeQuery("SELECT @@TRANCOUNT")) { + if (!rows.next()) throw new SQLException("Missing SQL Server transaction status"); + int count = rows.getInt(1); + while (rows.next()) { /* consume status response */ } + return count; + } + } + } + @Override public boolean supportsConnectionPooling() { // The mssql-jdbc -> jTDS fallback is session state on this Agent @@ -116,7 +229,7 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent { throw fallbackError; } } - throw withLegacyTlsDiagnostics(error); + throw withLegacyTlsDiagnostics(error, jdbcDriverVersion(), requestedTlsProtocol(params)); } } @@ -141,6 +254,8 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent { @Override protected void afterDisconnect() { + manualTransactionSession = false; + manualTransactionExecuted = false; sqlServer2000Mode = false; } @@ -305,7 +420,7 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent { if (urlParams == null || urlParams.trim().isEmpty()) { return properties; } - for (String pair : urlParams.trim().split("[&;]")) { + for (String pair : connectionPropertyParts(urlParams.trim(), true)) { String value = pair.trim(); int separator = value.indexOf('='); if (separator <= 0) { @@ -636,10 +751,70 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent { Map properties = baseConnectionProperties(params); properties.put("encrypt", "true"); properties.put("trustServerCertificate", "true"); - properties.put("sslProtocol", "TLSv1"); + properties.put("sslProtocol", requestedTlsProtocol(params)); return appendProperties(baseJdbcUrl(params), properties); } + private static String requestedTlsProtocol(ConnectParams params) { + // Keep the legacy default for existing SQL Server 2000 connections, but + // allow an explicit modern protocol when this driver is used on 2019/2022. + String protocol = tlsProtocolProperty(params.getConnection_string(), "TLSv1", false); + protocol = tlsProtocolProperty(params.getUrl_params(), protocol, true); + switch (protocol.toUpperCase(Locale.ROOT)) { + case "TLS": return "TLS"; + case "TLSV1": return "TLSv1"; + case "TLSV1.1": return "TLSv1.1"; + case "TLSV1.2": return "TLSv1.2"; + case "TLSV1.3": return "TLSv1.3"; + default: throw new IllegalArgumentException("Unsupported SQL Server sslProtocol; use TLS, TLSv1, TLSv1.1, TLSv1.2 or TLSv1.3"); + } + } + + private static String tlsProtocolProperty(String properties, String fallback, boolean urlParams) { + if (properties == null) return fallback; + String protocol = fallback; + for (String property : connectionPropertyParts(properties, urlParams)) { + String part = property.trim(); + while (part.startsWith("?")) part = part.substring(1).trim(); + int separator = part.indexOf('='); + if (separator > 0 && "sslProtocol".equalsIgnoreCase(part.substring(0, separator).trim())) { + String value = part.substring(separator + 1).trim(); + protocol = value.startsWith("{") && value.endsWith("}") + ? value.substring(1, value.length() - 1).replace("}}", "}") : value; + } + } + return protocol; + } + private static List connectionPropertyParts(String value, boolean ampersandDelimiter) { + List parts = new ArrayList<>(); + int start = 0; + boolean propertyValue = false; + boolean valueStarted = false; + boolean escaped = false; + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + if (escaped) { + if (ch == '}') { + if (i + 1 < value.length() && value.charAt(i + 1) == '}') i++; + else escaped = false; + } + continue; + } + if (ch == ';' || (ampersandDelimiter && ch == '&')) { + parts.add(value.substring(start, i)); + start = i + 1; + propertyValue = false; + valueStarted = false; + } else if (!propertyValue && ch == '=') { + propertyValue = true; + } else if (propertyValue && !valueStarted && !Character.isWhitespace(ch)) { + valueStarted = true; + escaped = ch == '{'; + } + } + parts.add(value.substring(start)); + return parts; + } static String relaxedDisabledAlgorithms(String current) { if (current == null || current.trim().isEmpty()) { return ""; @@ -659,15 +834,15 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent { } static String legacyTlsDiagnostics() { - return legacyTlsDiagnostics(jdbcDriverVersion()); + return legacyTlsDiagnostics(jdbcDriverVersion(), "TLSv1"); } - private static String legacyTlsDiagnostics(String jdbcVersion) { + private static String legacyTlsDiagnostics(String jdbcVersion, String protocol) { String disabledAlgorithms = Security.getProperty(TLS_DISABLED_ALGORITHMS_KEY); return "DBX SQL Server legacy TLS diagnostics: java=" + System.getProperty("java.version", "unknown") + ", javaVendor=" + System.getProperty("java.vendor", "unknown") + ", jdbc=" + jdbcVersion - + ", sslProtocol=TLSv1" + + ", sslProtocol=" + protocol + ", tlsV1Disabled=" + isDisabled(disabledAlgorithms, "TLSV1") + ", tlsRsaDisabled=" + isDisabled(disabledAlgorithms, "TLS_RSA_*") + ", rsaPkcs1Sha1HandshakeDisabled=" @@ -681,9 +856,13 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent { } static SQLException withLegacyTlsDiagnostics(SQLException error, String jdbcVersion) { + return withLegacyTlsDiagnostics(error, jdbcVersion, "TLSv1"); + } + + static SQLException withLegacyTlsDiagnostics(SQLException error, String jdbcVersion, String protocol) { String message = error.getMessage() == null ? error.toString() : error.getMessage(); return new SQLException( - message + "\n\n" + legacyTlsDiagnostics(jdbcVersion), + message + "\n\n" + legacyTlsDiagnostics(jdbcVersion, protocol), error.getSQLState(), error.getErrorCode(), error @@ -774,7 +953,7 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent { private static String sanitizeSqlServerUrl(String value) { String trimmed = trimSqlServerUrl(value); - String[] parts = trimmed.split(";"); + String[] parts = connectionPropertyParts(trimmed, false).toArray(new String[0]); if (parts.length <= 1) { return trimmed; } @@ -804,7 +983,7 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent { return properties; } - for (String pair : urlParams.trim().split("[&;]")) { + for (String pair : connectionPropertyParts(urlParams.trim(), true)) { String value = pair.trim(); while (value.startsWith("?") || value.startsWith("&") || value.startsWith(";")) { value = value.substring(1).trim(); diff --git a/agents/drivers/sqlserver-legacy/src/test/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgentTest.java b/agents/drivers/sqlserver-legacy/src/test/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgentTest.java index f80564e7c2..b498d12541 100644 --- a/agents/drivers/sqlserver-legacy/src/test/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgentTest.java +++ b/agents/drivers/sqlserver-legacy/src/test/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgentTest.java @@ -27,6 +27,125 @@ import java.util.Locale; import java.util.Map; class SqlServerLegacyAgentTest { + private static final class ManualFixture { + final SqlServerLegacyAgent agent = new SqlServerLegacyAgent(); + final java.util.concurrent.atomic.AtomicInteger count = new java.util.concurrent.atomic.AtomicInteger(); + final java.util.concurrent.atomic.AtomicInteger begins = new java.util.concurrent.atomic.AtomicInteger(); + final java.util.concurrent.atomic.AtomicInteger rollbacks = new java.util.concurrent.atomic.AtomicInteger(); + final java.util.concurrent.atomic.AtomicBoolean automatic = new java.util.concurrent.atomic.AtomicBoolean(true); + ManualFixture() { + DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> + method.getName().equals("supportsTransactions") ? true : defaultValue(method.getReturnType())); + TestSupport.setPrivateConnection(agent, proxy(Connection.class, (method, args) -> { + switch (method.getName()) { + case "getMetaData": return metadata; + case "getAutoCommit": return automatic.get(); + case "setAutoCommit": automatic.set((boolean) args[0]); return null; + case "rollback": rollbacks.incrementAndGet(); count.set(0); return null; + case "createStatement": + return proxy(java.sql.Statement.class, (stmtMethod, stmtArgs) -> { + switch (stmtMethod.getName()) { + case "executeQuery": + java.util.concurrent.atomic.AtomicBoolean first = new java.util.concurrent.atomic.AtomicBoolean(true); + return proxy(ResultSet.class, (rowMethod, rowArgs) -> { + if (rowMethod.getName().equals("next")) return first.getAndSet(false); + if (rowMethod.getName().equals("getInt")) return count.get(); + return defaultValue(rowMethod.getReturnType()); + }); + case "execute": + String sql = (String) stmtArgs[0]; + if (sql.contains("BEGIN TRANSACTION")) { begins.incrementAndGet(); count.set(1); } + if (sql.contains("HIDDEN_COMMIT_ERROR")) { count.set(0); throw new SQLException("failure after hidden commit"); } + return false; + case "getUpdateCount": return -1; + default: return defaultValue(stmtMethod.getReturnType()); + } + }); + default: return defaultValue(method.getReturnType()); + } + })); + agent.beginManualTransaction(null); + } + } + + @Test + void lazyJdbcBeginOpensOnePhysicalTransactionBeforePrintAndReadOnlyExec() { + ManualFixture fixture = new ManualFixture(); + Assertions.assertEquals(0, fixture.count.get()); + fixture.agent.executeQueryResults("PRINT 'hello'", null, new com.dbx.agent.ExecuteQueryOptions(1, null, 5)); + fixture.agent.executeQueryResults("EXEC('SELECT 1')", null, new com.dbx.agent.ExecuteQueryOptions(1, null, 5)); + Assertions.assertEquals(1, fixture.begins.get()); + Assertions.assertEquals(1, fixture.count.get()); + Assertions.assertFalse(fixture.agent.permitsAutomaticReconnect()); + fixture.agent.rollbackManualTransaction(); + Assertions.assertEquals(1, fixture.rollbacks.get()); + } + + @Test + void hiddenCommitFollowedByErrorCannotBeReportedAsSuccessfulRollback() { + ManualFixture fixture = new ManualFixture(); + Assertions.assertThrows(RuntimeException.class, () -> fixture.agent.executeQueryResults( + "HIDDEN_COMMIT_ERROR", null, new com.dbx.agent.ExecuteQueryOptions(1, null, 5))); + Assertions.assertEquals(0, fixture.count.get()); + Assertions.assertThrows(IllegalStateException.class, () -> fixture.agent.rollbackManualTransaction()); + Assertions.assertEquals(0, fixture.rollbacks.get()); + Assertions.assertFalse(fixture.agent.permitsAutomaticReconnect()); + } + + @Test + void manualSessionDisablesReconnectAndCommitAckDoesNotDependOnAutocommitCleanup() { + SqlServerLegacyAgent agent = new SqlServerLegacyAgent(); + java.util.concurrent.atomic.AtomicBoolean automatic = new java.util.concurrent.atomic.AtomicBoolean(true); + java.util.concurrent.atomic.AtomicInteger commits = new java.util.concurrent.atomic.AtomicInteger(); + DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> { + if (method.getName().equals("supportsTransactions")) return true; + return defaultValue(method.getReturnType()); + }); + TestSupport.setPrivateConnection(agent, proxy(Connection.class, (method, args) -> { + switch (method.getName()) { + case "getMetaData": return metadata; + case "getAutoCommit": return automatic.get(); + case "setAutoCommit": + if ((boolean) args[0]) throw new SQLException("cleanup failed"); + automatic.set(false); return null; + case "commit": commits.incrementAndGet(); return null; + default: return defaultValue(method.getReturnType()); + } + })); + Assertions.assertTrue(agent.permitsAutomaticReconnect()); + Assertions.assertEquals(true, agent.beginManualTransaction(null).get("manualTransactionBatch")); + Assertions.assertFalse(agent.permitsAutomaticReconnect()); + Assertions.assertEquals(true, agent.commitManualTransaction().get("ok")); + Assertions.assertEquals(1, commits.get()); + Assertions.assertFalse(agent.permitsAutomaticReconnect()); + agent.disconnect(); + Assertions.assertTrue(agent.permitsAutomaticReconnect()); + } + + @Test + void metadataUnsupportedRejectsBeforeChangingAutocommitButUnavailableMetadataUsesActualBegin() { + SqlServerLegacyAgent unsupported = new SqlServerLegacyAgent(); + DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> defaultValue(method.getReturnType())); + TestSupport.setPrivateConnection(unsupported, proxy(Connection.class, (method, args) -> { + if (method.getName().equals("getMetaData")) return metadata; + if (method.getName().equals("setAutoCommit")) throw new AssertionError("Unsupported BEGIN must not change the connection"); + return defaultValue(method.getReturnType()); + })); + Assertions.assertThrows(IllegalStateException.class, () -> unsupported.beginManualTransaction(null)); + SqlServerLegacyAgent unavailable = new SqlServerLegacyAgent(); + java.util.concurrent.atomic.AtomicBoolean automatic = new java.util.concurrent.atomic.AtomicBoolean(true); + TestSupport.setPrivateConnection(unavailable, proxy(Connection.class, (method, args) -> { + switch (method.getName()) { + case "getMetaData": throw new SQLException("metadata unavailable"); + case "getAutoCommit": return automatic.get(); + case "setAutoCommit": automatic.set((boolean) args[0]); return null; + default: return defaultValue(method.getReturnType()); + } + })); + Assertions.assertEquals(true, unavailable.beginManualTransaction(null).get("ok")); + Assertions.assertFalse(automatic.get()); + } + @Test void sqlServer2000PreloginFailuresTriggerTheOldDriverFallback() { // Real mssql-jdbc prelogin rejection for SQL Server 2000. @@ -368,7 +487,7 @@ class SqlServerLegacyAgentTest { } @Test - void legacyTlsUrlUsesSqlServerTlsV1Properties() { + void legacyTlsUrlHonorsExplicitProtocolAndRetainsEncryption() { ConnectParams params = new ConnectParams( "db.example.com", 14330, @@ -381,7 +500,7 @@ class SqlServerLegacyAgentTest { ); Assertions.assertEquals( - "jdbc:sqlserver://db.example.com:14330;databaseName=appdb;applicationName=dbx;encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1", + "jdbc:sqlserver://db.example.com:14330;databaseName=appdb;applicationName=dbx;encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1.2", SqlServerLegacyAgent.legacyTlsUrl(params) ); } @@ -458,11 +577,47 @@ class SqlServerLegacyAgentTest { ); Assertions.assertEquals( - "jdbc:sqlserver://db.example.com:1433;databaseName=custom;applicationName=dbx;encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1", + "jdbc:sqlserver://db.example.com:1433;databaseName=custom;applicationName=dbx;encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1.2", SqlServerLegacyAgent.legacyTlsUrl(params) ); } + @Test + void legacyTlsUrlParametersOverrideUrlProtocolCaseInsensitively() { + ConnectParams params = new ConnectParams("ignored", 0, "", "sa", "secret", + "SSLPROTOCOL=tlsv1.2", "jdbc:sqlserver://db.example.com:1433;sslProtocol=TLSv1", false); + Assertions.assertEquals("jdbc:sqlserver://db.example.com:1433;encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1.2", + SqlServerLegacyAgent.legacyTlsUrl(params)); + } + + @Test + void legacyTlsUrlRejectsInvalidProtocolRatherThanSilentlyDowngrading() { + ConnectParams params = new ConnectParams("db.example.com", 1433, "", "sa", "secret", + "sslProtocol=not-a-protocol", "", false); + Assertions.assertThrows(IllegalArgumentException.class, () -> SqlServerLegacyAgent.legacyTlsUrl(params)); + } + @Test + void legacyTlsUrlSupportsDriverTlsV13() { + ConnectParams params = new ConnectParams("db.example.com", 1433, "", "sa", "secret", + "sslProtocol=tlsv1.3", "", false); + Assertions.assertTrue(SqlServerLegacyAgent.legacyTlsUrl(params).endsWith(";sslProtocol=TLSv1.3")); + } + + @Test + void legacyTlsUrlPreservesBracedValuesAndIgnoresEmbeddedProtocolProperties() { + ConnectParams params = new ConnectParams("ignored", 0, "", "sa", "secret", "", + "jdbc:sqlserver://db.example.com:1433;applicationName={app;sslProtocol=invalid};sslProtocol={TLSv1.2}", false); + Assertions.assertEquals("jdbc:sqlserver://db.example.com:1433;applicationName={app;sslProtocol=invalid};encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1.2", + SqlServerLegacyAgent.legacyTlsUrl(params)); + params.setConnection_string("jdbc:sqlserver://db.example.com:1433;applicationName={app}};sslProtocol=invalid}"); + Assertions.assertTrue(SqlServerLegacyAgent.legacyTlsUrl(params).endsWith(";sslProtocol=TLSv1")); + } + + @Test + void legacyTlsDiagnosticsReportsExplicitProtocol() { + SQLException error = SqlServerLegacyAgent.withLegacyTlsDiagnostics(new SQLException("connection failed"), "13.2", "TLSv1.2"); + Assertions.assertTrue(error.getMessage().contains("sslProtocol=TLSv1.2")); + } @Test void relaxedDisabledAlgorithmsRemovesOnlyLegacyTlsEntries() { String current = diff --git a/agents/test-support/src/main/java/com/dbx/agent/testing/SqlServerFaultDriver.java b/agents/test-support/src/main/java/com/dbx/agent/testing/SqlServerFaultDriver.java new file mode 100644 index 0000000000..6abb810b96 --- /dev/null +++ b/agents/test-support/src/main/java/com/dbx/agent/testing/SqlServerFaultDriver.java @@ -0,0 +1,67 @@ +package com.dbx.agent.testing; + +import java.io.*; +import java.lang.reflect.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.sql.*; +import java.util.*; +import java.util.logging.Logger; + +/** Live-test-only wrapper. All SQL goes through the real bundled JDBC driver. */ +public final class SqlServerFaultDriver implements Driver { + private static final String PREFIX = "jdbc:dbx-fault:"; + static { + try { DriverManager.registerDriver(new SqlServerFaultDriver()); } + catch (SQLException error) { throw new ExceptionInInitializerError(error); } + } + public Connection connect(String url, Properties info) throws SQLException { + if (!acceptsURL(url)) return null; + String delegateClass = null, mode = null; + int controlPort = 0; + StringBuilder delegateUrl = new StringBuilder(); + for (String part : url.substring(PREFIX.length()).split(";")) { + if (part.startsWith("dbxFaultDriver=")) delegateClass = part.substring(15); + else if (part.startsWith("dbxFaultControlPort=")) controlPort = Integer.parseInt(part.substring(20)); + else if (part.startsWith("dbxFaultMode=")) mode = part.substring(13); + else { if (delegateUrl.length() > 0) delegateUrl.append(';'); delegateUrl.append(part); } + } + try { + Driver delegate = (Driver) Class.forName(delegateClass).getDeclaredConstructor().newInstance(); + Connection connection = delegate.connect(delegateUrl.toString(), info); + if (connection == null) throw new SQLException("Fault fixture delegate rejected URL"); + final int port = controlPort; + final String fault = mode; + return (Connection) java.lang.reflect.Proxy.newProxyInstance(SqlServerFaultDriver.class.getClassLoader(), + new Class[] {Connection.class}, (proxy, method, args) -> { + String name = method.getName(); + if ((name.equals("commit") && "commit_response_loss".equals(fault)) + || (name.equals("rollback") && "rollback_response_loss".equals(fault))) signal(port, "ARM"); + if (name.equals("close") && "cleanup_failure".equals(fault)) signal(port, "CLOSE_ENTERED"); + Object result; + try { result = method.invoke(connection, args); } + catch (InvocationTargetException error) { throw error.getCause(); } + if (name.equals("close") && "cleanup_failure".equals(fault)) { + signal(port, "CLOSE_FAILED"); + throw new SQLException("Injected close failure after actual JDBC close", "HY000"); + } + return result; + }); + } catch (SQLException error) { throw error; } + catch (ReflectiveOperationException error) { throw new SQLException("Cannot load real fault fixture delegate", error); } + } + private static void signal(int port, String command) throws SQLException { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(InetAddress.getLoopbackAddress(), port), 3000); + socket.setSoTimeout(3000); + socket.getOutputStream().write((command + "\n").getBytes(StandardCharsets.US_ASCII)); + if (socket.getInputStream().read() != 'K') throw new IOException("Fault controller did not acknowledge"); + } catch (IOException error) { throw new SQLException("Fault controller failed", error); } + } + public boolean acceptsURL(String url) { return url != null && url.startsWith(PREFIX); } + public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) { return new DriverPropertyInfo[0]; } + public int getMajorVersion() { return 1; } + public int getMinorVersion() { return 0; } + public boolean jdbcCompliant() { return false; } + public Logger getParentLogger() { return Logger.getLogger("com.dbx.agent.testing"); } +} \ No newline at end of file diff --git a/apps/desktop/src/components/layout/EditorToolbar.vue b/apps/desktop/src/components/layout/EditorToolbar.vue index f7b73cafde..2d0a4649b6 100644 --- a/apps/desktop/src/components/layout/EditorToolbar.vue +++ b/apps/desktop/src/components/layout/EditorToolbar.vue @@ -389,6 +389,10 @@ const showTxnActions = computed(() => { }); const transactionTooltip = computed(() => { if (hasOpenAutoCommitTransaction.value) return t("settings.keepExplicitTransactionInAutoCommitDescription"); + if (props.activeConnection?.db_type === "sqlserver" && isManualTransactionMode.value) { + const status = props.activeTab.txnStatus; + return status ? t(`toolbar.sqlserverTxnStatus.${status}`) : t("toolbar.sqlserverIndependentTransaction"); + } const isAgent = (props.activeConnection?.db_type as string) === "agent"; const isManual = isManualTransactionMode.value; if (isAgent && isManual) return t("toolbar.manualTransactionAgent"); @@ -876,7 +880,7 @@ async function changeCatalog(selectedCatalog: string) { size="icon" class="h-6 w-8 px-1" :class="isManualTransactionMode ? 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-300' : 'text-orange-600/70 hover:bg-orange-500/10 hover:text-orange-700 dark:text-orange-300/70 dark:hover:text-orange-200'" - :disabled="activeTab.isExecuting || activeTab.isExplaining" + :disabled="activeTab.isExecuting || activeTab.isExplaining || activeTab.txnStatus === 'opening' || activeTab.txnStatus === 'ending'" :aria-label="transactionTooltip" :aria-pressed="isManualTransactionMode || hasOpenAutoCommitTransaction" @click="emit('update:autoCommit', autoCommit === false)" @@ -895,7 +899,14 @@ async function changeCatalog(selectedCatalog: string) { - @@ -905,7 +916,14 @@ async function changeCatalog(selectedCatalog: string) { - @@ -1089,6 +1107,10 @@ async function changeCatalog(selectedCatalog: string) { +
+ {{ activeTab.txnNotice }} + +
{{ t("toolbar.txnAutoRolledBack") }} diff --git a/apps/desktop/src/components/layout/__tests__/EditorToolbar.transactionMode.spec.ts b/apps/desktop/src/components/layout/__tests__/EditorToolbar.transactionMode.spec.ts index 2e4bd7e4c3..d3b7fa844f 100644 --- a/apps/desktop/src/components/layout/__tests__/EditorToolbar.transactionMode.spec.ts +++ b/apps/desktop/src/components/layout/__tests__/EditorToolbar.transactionMode.spec.ts @@ -85,7 +85,7 @@ describe("EditorToolbar commit/rollback visibility", () => { }); }); - async function mountToolbar(props: { dbType: string; stickyProvenReadOnlyState: boolean; txnPossiblyDirty?: boolean; txnSessionId?: string }) { + async function mountToolbar(props: { dbType: string; stickyProvenReadOnlyState: boolean; txnPossiblyDirty?: boolean; txnSessionId?: string; txnStatus?: string; isExecuting?: boolean }) { const connectionStore = useConnectionStore(); connectionStore.connections = [ { @@ -105,7 +105,8 @@ describe("EditorToolbar commit/rollback visibility", () => { database: "db", sql: "SELECT 1", mode: "query", - isExecuting: false, + isExecuting: props.isExecuting ?? false, + txnStatus: props.txnStatus, isCancelling: false, isExplaining: false, }, @@ -162,4 +163,17 @@ describe("EditorToolbar commit/rollback visibility", () => { expect(host.querySelector(rollbackSelector)).not.toBeNull(); host.remove(); }); + it.each(["opening", "ending"])("SQL Server disables transaction actions while %s", async (txnStatus) => { + const host = await mountToolbar({ dbType: "sqlserver", stickyProvenReadOnlyState: false, txnSessionId: "sqlserver-txn-1", txnStatus }); + expect(host.querySelector(commitSelector)?.disabled).toBe(true); + expect(host.querySelector(rollbackSelector)?.disabled).toBe(true); + host.remove(); + }); + + it("SQL Server exposes Commit/Rollback for its dedicated active transaction", async () => { + const host = await mountToolbar({ dbType: "sqlserver", stickyProvenReadOnlyState: false, txnSessionId: "sqlserver-txn-1", txnStatus: "active" }); + expect(host.querySelector(commitSelector)?.disabled).toBe(false); + expect(host.querySelector(rollbackSelector)?.disabled).toBe(false); + host.remove(); + }); }); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 7432b4ae75..686f6b77be 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -438,6 +438,10 @@ export default { insertValueHintsToggleOn: "INSERT column hints: On (click to disable)", insertValueHintsToggleOff: "INSERT column hints: Off (click to enable)", autoCommit: "Auto Commit", + sqlserverIndependentTransaction: "Manual transactions use a separate connection. Temporary tables and SET options from auto-commit mode are not inherited.", + txnRollbackConfirmed: "Rollback confirmed.", + txnCommitConfirmed: "Commit confirmed.", + sqlserverTxnStatus: { opening: "Opening manual transaction", active: "Manual transaction active", executing: "Executing in manual transaction", ending: "Ending manual transaction", lost: "Transaction session lost", unknown: "Transaction outcome unknown" }, manualTransaction: "Manual Transaction", autoCommitAgent: "Auto Commit (depends on Agent backend)", manualTransactionAgent: "Manual Transaction (depends on Agent backend)", @@ -2980,6 +2984,16 @@ export default { legacyFailure: "Database agent returned an unclassified error.", }, transaction: { + unsupported: "The selected SQL Server driver does not support manual transactions.", + busy: "The transaction is busy. Wait for the current operation to finish.", + contextChanged: "The transaction context changed and this session cannot continue.", + connectionLost: "The transaction connection was lost. This SQL was not replayed; start a new transaction to continue.", + rollbackFailed: "The transaction ended, but rollback could not be confirmed. Verify the database before running the SQL again.", + commitUnknown: "The COMMIT response was lost. The transaction may already be committed. Verify the database before running the SQL again.", + controlConflict: "Manual mode manages transaction control and the database context. Remove BEGIN/COMMIT/ROLLBACK/SAVE, SET IMPLICIT_TRANSACTIONS or USE from the script.", + idleTimeout: "The transaction ended after five minutes of inactivity. Start a new transaction to continue.", + executionFailed: "SQL execution failed and this manual transaction ended.", + sessionExpired: "The manual transaction expired after {timeoutSecs} seconds of inactivity and was rolled back. A new transaction will be started automatically.", }, legacy: "Backend returned an error.", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 92434df0fa..eac391b3c0 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -431,6 +431,10 @@ export default withEnglishFallback({ insertValueHintsToggleOn: "Sugerencias de columnas INSERT: activadas (clic para desactivar)", insertValueHintsToggleOff: "Sugerencias de columnas INSERT: desactivadas (clic para activar)", autoCommit: "Auto Commit", + sqlserverIndependentTransaction: "Las transacciones manuales usan una conexión independiente. No heredan las tablas temporales ni las opciones SET del modo de confirmación automática.", + txnRollbackConfirmed: "Reversión confirmada.", + txnCommitConfirmed: "Confirmación de la transacción recibida.", + sqlserverTxnStatus: { opening: "Abriendo transacción manual", active: "Transacción manual activa", executing: "Ejecutando en transacción manual", ending: "Finalizando transacción manual", lost: "Sesión de transacción perdida", unknown: "Resultado de transacción desconocido" }, manualTransaction: "Transacción Manual", autoCommitAgent: "Auto Commit (depende del backend del Agente)", manualTransactionAgent: "Transacción Manual (depende del backend del Agente)", @@ -2926,6 +2930,15 @@ export default withEnglishFallback({ legacyFailure: "El agente de la base de datos devolvió un error no clasificado.", }, transaction: { + unsupported: "El controlador de SQL Server seleccionado no admite transacciones manuales.", + busy: "La transacción está ocupada. Espere a que termine la operación actual.", + contextChanged: "El contexto de la transacción cambió y esta sesión no puede continuar.", + connectionLost: "Se perdió la conexión de la transacción. Este SQL no se volvió a ejecutar; inicie una nueva transacción para continuar.", + rollbackFailed: "La transacción terminó, pero no se pudo confirmar la reversión. Verifique la base de datos antes de volver a ejecutar el SQL.", + commitUnknown: "Se perdió la respuesta de COMMIT. La transacción puede haberse confirmado. Verifique la base de datos antes de volver a ejecutar el SQL.", + controlConflict: "El modo manual administra las transacciones y el contexto de la base de datos. Quite BEGIN/COMMIT/ROLLBACK/SAVE, SET IMPLICIT_TRANSACTIONS o USE del script.", + idleTimeout: "La transacción terminó tras cinco minutos de inactividad. Inicie una nueva transacción para continuar.", + executionFailed: "La ejecución del SQL falló y esta transacción manual terminó.", sessionExpired: "La transacción manual expiró después de {timeoutSecs} segundos de inactividad y se revirtió. Se iniciará una nueva transacción automáticamente.", }, legacy: "El backend devolvió un error.", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index d53cfe8de2..916ec75eb5 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -425,6 +425,10 @@ export default withEnglishFallback({ insertValueHintsToggleOn: "Suggerimenti colonne INSERT: attivi (clic per disattivare)", insertValueHintsToggleOff: "Suggerimenti colonne INSERT: disattivi (clic per attivare)", autoCommit: "Auto Commit", + sqlserverIndependentTransaction: "Le transazioni manuali usano una connessione separata. Non ereditano le tabelle temporanee e le opzioni SET della modalità di commit automatico.", + txnRollbackConfirmed: "Rollback confermato.", + txnCommitConfirmed: "Commit confermato.", + sqlserverTxnStatus: { opening: "Apertura della transazione manuale", active: "Transazione manuale attiva", executing: "Esecuzione nella transazione manuale", ending: "Chiusura della transazione manuale", lost: "Sessione della transazione persa", unknown: "Esito della transazione sconosciuto" }, manualTransaction: "Transazione Manuale", autoCommitAgent: "Auto Commit (dipende dal backend dell'Agente)", manualTransactionAgent: "Transazione Manuale (dipende dal backend dell'Agente)", @@ -2781,6 +2785,15 @@ export default withEnglishFallback({ legacyFailure: "L'agente del database ha restituito un errore non classificato.", }, transaction: { + unsupported: "Il driver SQL Server selezionato non supporta le transazioni manuali.", + busy: "La transazione è occupata. Attendere il completamento dell'operazione corrente.", + contextChanged: "Il contesto della transazione è cambiato e questa sessione non può continuare.", + connectionLost: "La connessione della transazione è stata persa. Questo SQL non è stato rieseguito; avviare una nuova transazione per continuare.", + rollbackFailed: "La transazione è terminata, ma il rollback non è stato confermato. Verificare il database prima di rieseguire il SQL.", + commitUnknown: "La risposta a COMMIT è stata persa. La transazione potrebbe essere già stata confermata. Verificare il database prima di rieseguire il SQL.", + controlConflict: "La modalità manuale gestisce le transazioni e il contesto del database. Rimuovere BEGIN/COMMIT/ROLLBACK/SAVE, SET IMPLICIT_TRANSACTIONS o USE dallo script.", + idleTimeout: "La transazione è terminata dopo cinque minuti di inattività. Avviare una nuova transazione per continuare.", + executionFailed: "L'esecuzione del SQL è fallita e questa transazione manuale è terminata.", sessionExpired: "La transazione manuale è scaduta dopo {timeoutSecs} secondi di inattività ed è stata annullata. Verrà avviata automaticamente una nuova transazione.", }, legacy: "Il backend ha restituito un errore.", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 41c0c4b021..9a32624032 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -426,6 +426,10 @@ export default withEnglishFallback({ insertValueHintsToggleOn: "INSERT 列名ヒント:オン(クリックでオフ)", insertValueHintsToggleOff: "INSERT 列名ヒント:オフ(クリックでオン)", autoCommit: "自動コミット", + sqlserverIndependentTransaction: "手動トランザクションは専用の接続を使用します。自動コミットモードの一時テーブルや SET オプションは引き継がれません。", + txnRollbackConfirmed: "ロールバックを確認しました。", + txnCommitConfirmed: "コミットを確認しました。", + sqlserverTxnStatus: { opening: "手動トランザクションを開始中", active: "手動トランザクションが有効", executing: "手動トランザクションで実行中", ending: "手動トランザクションを終了中", lost: "トランザクションセッションが失われました", unknown: "トランザクションの結果が不明です" }, manualTransaction: "手動トランザクション", autoCommitAgent: "自動コミット(Agentバックエンドに依存)", manualTransactionAgent: "手動トランザクション(Agentバックエンドに依存)", @@ -2807,6 +2811,15 @@ export default withEnglishFallback({ legacyFailure: "データベース Agent が分類できないエラーを返しました。", }, transaction: { + unsupported: "選択した SQL Server ドライバーは手動トランザクションに対応していません。", + busy: "トランザクションは処理中です。現在の操作が完了するまでお待ちください。", + contextChanged: "トランザクションのコンテキストが変更されたため、このセッションは継続できません。", + connectionLost: "トランザクションの接続が失われました。この SQL は再実行されていません。続行するには新しいトランザクションを開始してください。", + rollbackFailed: "トランザクションは終了しましたが、ロールバックは確認できませんでした。SQL を再実行する前にデータベースを確認してください。", + commitUnknown: "COMMIT の応答が失われました。トランザクションは既にコミットされている可能性があります。SQL を再実行する前にデータベースを確認してください。", + controlConflict: "手動モードがトランザクション制御とデータベースのコンテキストを管理します。スクリプトから BEGIN/COMMIT/ROLLBACK/SAVE、SET IMPLICIT_TRANSACTIONS、USE を削除してください。", + idleTimeout: "5 分間操作がなかったためトランザクションが終了しました。続行するには新しいトランザクションを開始してください。", + executionFailed: "SQL の実行に失敗し、この手動トランザクションは終了しました。", sessionExpired: "手動トランザクションは {timeoutSecs} 秒間操作がなかったため期限切れとなり、ロールバックされました。新しいトランザクションを自動的に開始します。", }, legacy: "バックエンドがエラーを返しました。", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index ab80627a49..a819c06a3a 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -434,6 +434,10 @@ export default withEnglishFallback({ insertValueHintsToggleOn: "INSERT 열 이름 힌트: 켜짐 (클릭하여 끄기)", insertValueHintsToggleOff: "INSERT 열 이름 힌트: 꺼짐 (클릭하여 켜기)", autoCommit: "자동 커밋", + sqlserverIndependentTransaction: "수동 트랜잭션은 별도의 연결을 사용합니다. 자동 커밋 모드의 임시 테이블과 SET 옵션은 상속되지 않습니다.", + txnRollbackConfirmed: "롤백이 확인되었습니다.", + txnCommitConfirmed: "커밋이 확인되었습니다.", + sqlserverTxnStatus: { opening: "수동 트랜잭션 시작 중", active: "수동 트랜잭션 활성", executing: "수동 트랜잭션에서 실행 중", ending: "수동 트랜잭션 종료 중", lost: "트랜잭션 세션이 손실되었습니다", unknown: "트랜잭션 결과를 알 수 없습니다" }, manualTransaction: "수동 트랜잭션", autoCommitAgent: "자동 커밋 (Agent 백엔드에 따라)", manualTransactionAgent: "수동 트랜잭션 (Agent 백엔드에 따라)", @@ -2697,6 +2701,15 @@ export default withEnglishFallback({ legacyFailure: "데이터베이스 Agent가 분류되지 않은 오류를 반환했습니다.", }, transaction: { + unsupported: "선택한 SQL Server 드라이버는 수동 트랜잭션을 지원하지 않습니다.", + busy: "트랜잭션이 사용 중입니다. 현재 작업이 완료될 때까지 기다리세요.", + contextChanged: "트랜잭션 컨텍스트가 변경되어 이 세션을 계속할 수 없습니다.", + connectionLost: "트랜잭션 연결이 손실되었습니다. 이 SQL은 다시 실행되지 않았습니다. 계속하려면 새 트랜잭션을 시작하세요.", + rollbackFailed: "트랜잭션은 종료되었지만 롤백을 확인할 수 없습니다. SQL을 다시 실행하기 전에 데이터베이스를 확인하세요.", + commitUnknown: "COMMIT 응답이 손실되었습니다. 트랜잭션이 이미 커밋되었을 수 있습니다. SQL을 다시 실행하기 전에 데이터베이스를 확인하세요.", + controlConflict: "수동 모드에서 트랜잭션 제어와 데이터베이스 컨텍스트를 관리합니다. 스크립트에서 BEGIN/COMMIT/ROLLBACK/SAVE, SET IMPLICIT_TRANSACTIONS 또는 USE를 제거하세요.", + idleTimeout: "5분간 활동이 없어 트랜잭션이 종료되었습니다. 계속하려면 새 트랜잭션을 시작하세요.", + executionFailed: "SQL 실행이 실패하여 이 수동 트랜잭션이 종료되었습니다.", sessionExpired: "수동 트랜잭션이 {timeoutSecs}초 동안 활동이 없어 만료되고 롤백되었습니다. 새 트랜잭션을 자동으로 시작합니다.", }, legacy: "백엔드에서 오류를 반환했습니다.", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 9db3443099..a819d07b94 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -426,6 +426,10 @@ export default withEnglishFallback({ insertValueHintsToggleOn: "Dicas de colunas INSERT: ativadas (clique para desativar)", insertValueHintsToggleOff: "Dicas de colunas INSERT: desativadas (clique para ativar)", autoCommit: "Auto Commit", + sqlserverIndependentTransaction: "As transações manuais usam uma conexão separada. As tabelas temporárias e as opções SET do modo de confirmação automática não são herdadas.", + txnRollbackConfirmed: "Reversão confirmada.", + txnCommitConfirmed: "Confirmação da transação recebida.", + sqlserverTxnStatus: { opening: "Abrindo transação manual", active: "Transação manual ativa", executing: "Executando na transação manual", ending: "Encerrando transação manual", lost: "Sessão da transação perdida", unknown: "Resultado da transação desconhecido" }, manualTransaction: "Transação Manual", autoCommitAgent: "Auto Commit (depende do backend do Agente)", manualTransactionAgent: "Transação Manual (depende do backend do Agente)", @@ -2783,6 +2787,15 @@ export default withEnglishFallback({ legacyFailure: "O Agent do banco retornou um erro não classificado.", }, transaction: { + unsupported: "O driver do SQL Server selecionado não oferece suporte a transações manuais.", + busy: "A transação está ocupada. Aguarde a conclusão da operação atual.", + contextChanged: "O contexto da transação mudou e esta sessão não pode continuar.", + connectionLost: "A conexão da transação foi perdida. Este SQL não foi executado novamente; inicie uma nova transação para continuar.", + rollbackFailed: "A transação terminou, mas a reversão não pôde ser confirmada. Verifique o banco de dados antes de executar o SQL novamente.", + commitUnknown: "A resposta de COMMIT foi perdida. A transação pode já ter sido confirmada. Verifique o banco de dados antes de executar o SQL novamente.", + controlConflict: "O modo manual gerencia as transações e o contexto do banco de dados. Remova BEGIN/COMMIT/ROLLBACK/SAVE, SET IMPLICIT_TRANSACTIONS ou USE do script.", + idleTimeout: "A transação terminou após cinco minutos de inatividade. Inicie uma nova transação para continuar.", + executionFailed: "A execução do SQL falhou e esta transação manual terminou.", sessionExpired: "A transação manual expirou após {timeoutSecs} segundos de inatividade e foi revertida. Uma nova transação será iniciada automaticamente.", }, legacy: "O backend retornou um erro.", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index 7a22269a46..9b27f595ae 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -408,6 +408,10 @@ export default withEnglishFallback({ insertValueHintsToggleOn: "Подсказки столбцов INSERT: вкл. (нажмите, чтобы отключить)", insertValueHintsToggleOff: "Подсказки столбцов INSERT: выкл. (нажмите, чтобы включить)", autoCommit: "Автофиксация", + sqlserverIndependentTransaction: "Ручные транзакции используют отдельное соединение. Временные таблицы и параметры SET из режима автоматической фиксации не наследуются.", + txnRollbackConfirmed: "Откат подтверждён.", + txnCommitConfirmed: "Фиксация подтверждена.", + sqlserverTxnStatus: { opening: "Открытие ручной транзакции", active: "Ручная транзакция активна", executing: "Выполнение в ручной транзакции", ending: "Завершение ручной транзакции", lost: "Сеанс транзакции потерян", unknown: "Результат транзакции неизвестен" }, manualTransaction: "Ручная транзакция", autoCommitAgent: "Автофиксация (зависит от бэкенда агента)", manualTransactionAgent: "Ручная транзакция (зависит от бэкенда агента)", @@ -2848,6 +2852,15 @@ export default withEnglishFallback({ legacyFailure: "Агент базы данных вернул неклассифицированную ошибку.", }, transaction: { + unsupported: "Выбранный драйвер SQL Server не поддерживает ручные транзакции.", + busy: "Транзакция занята. Дождитесь завершения текущей операции.", + contextChanged: "Контекст транзакции изменился; этот сеанс не может продолжаться.", + connectionLost: "Соединение транзакции потеряно. Этот SQL не выполнялся повторно; начните новую транзакцию, чтобы продолжить.", + rollbackFailed: "Транзакция завершена, но откат не подтверждён. Проверьте базу данных перед повторным выполнением SQL.", + commitUnknown: "Ответ на COMMIT потерян. Возможно, транзакция уже зафиксирована. Проверьте базу данных перед повторным выполнением SQL.", + controlConflict: "Ручной режим управляет транзакциями и контекстом базы данных. Удалите из скрипта BEGIN/COMMIT/ROLLBACK/SAVE, SET IMPLICIT_TRANSACTIONS или USE.", + idleTimeout: "Транзакция завершена после пяти минут бездействия. Начните новую транзакцию, чтобы продолжить.", + executionFailed: "Выполнение SQL завершилось ошибкой; эта ручная транзакция завершена.", sessionExpired: "Ручная транзакция истекла после {timeoutSecs} секунд бездействия и была отменена. Новая транзакция будет запущена автоматически.", }, legacy: "Бэкенд вернул ошибку.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index d2daa6d46e..343f163893 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -375,6 +375,10 @@ export default withEnglishFallback({ insertValueHintsToggleOn: "INSERT 列名提示:已开启(点击关闭)", insertValueHintsToggleOff: "INSERT 列名提示:已关闭(点击开启)", autoCommit: "自动提交", + sqlserverIndependentTransaction: "手动事务使用独立连接,不继承自动提交连接上的临时表和 SET 设置。", + txnRollbackConfirmed: "已确认回滚。", + txnCommitConfirmed: "已确认提交。", + sqlserverTxnStatus: { opening: "正在开启手动事务", active: "手动事务活动中", executing: "正在事务内执行", ending: "正在结束手动事务", lost: "事务会话已失效", unknown: "事务结果未知" }, manualTransaction: "手动事务", autoCommitAgent: "自动提交(取决于 Agent 后端数据库)", manualTransactionAgent: "手动事务(取决于 Agent 后端数据库)", @@ -2914,6 +2918,16 @@ export default withEnglishFallback({ legacyFailure: "数据库 Agent 返回了未分类错误。", }, transaction: { + unsupported: "当前 SQL Server 驱动不支持手动事务。", + busy: "事务正在处理请求,请等待当前操作结束。", + contextChanged: "事务上下文已改变,此会话无法继续。", + connectionLost: "事务连接已丢失,本次 SQL 未自动重跑;请重新执行以开启新事务。", + rollbackFailed: "事务会话已结束,但无法确认回滚结果;再次执行 SQL 前请核对数据库。", + commitUnknown: "提交响应丢失,事务可能已经提交;再次执行 SQL 前请核对数据库。", + controlConflict: "手动模式统一管理事务与数据库上下文,请移除 SQL 中的 BEGIN/COMMIT/ROLLBACK/SAVE、SET IMPLICIT_TRANSACTIONS 或 USE。", + idleTimeout: "事务已因 5 分钟无操作而结束,请重新执行以开启新事务。", + executionFailed: "SQL 执行失败,本次手动事务已结束。", + sessionExpired: "手动事务因 {timeoutSecs} 秒无操作已过期并回滚,DBX 将自动开始新事务。", }, legacy: "后端返回了错误。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index c186e9be51..b5bdf46673 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -430,6 +430,10 @@ export default withEnglishFallback({ insertValueHintsToggleOn: "INSERT 欄位提示:已開啟(點擊關閉)", insertValueHintsToggleOff: "INSERT 欄位提示:已關閉(點擊開啟)", autoCommit: "自動提交", + sqlserverIndependentTransaction: "手動交易使用獨立連線,不繼承自動提交連線上的暫存資料表和 SET 設定。", + txnRollbackConfirmed: "已確認復原。", + txnCommitConfirmed: "已確認提交。", + sqlserverTxnStatus: { opening: "正在開啟手動交易", active: "手動交易進行中", executing: "正在交易內執行", ending: "正在結束手動交易", lost: "交易工作階段已失效", unknown: "交易結果未知" }, manualTransaction: "手動事務", autoCommitAgent: "自動提交(取決於 Agent 後端資料庫)", manualTransactionAgent: "手動事務(取決於 Agent 後端資料庫)", @@ -2788,6 +2792,16 @@ export default withEnglishFallback({ legacyFailure: "資料庫 Agent 回傳了未分類錯誤。", }, transaction: { + unsupported: "目前 SQL Server 驅動程式不支援手動交易。", + busy: "交易正在處理請求,請等待目前操作結束。", + contextChanged: "交易上下文已改變,此工作階段無法繼續。", + connectionLost: "交易連線已中斷,本次 SQL 未自動重跑;請重新執行以開啟新交易。", + rollbackFailed: "交易工作階段已結束,但無法確認復原結果;再次執行 SQL 前請核對資料庫。", + commitUnknown: "提交回應遺失,交易可能已經提交;再次執行 SQL 前請核對資料庫。", + controlConflict: "手動模式統一管理交易與資料庫上下文,請移除 SQL 中的 BEGIN/COMMIT/ROLLBACK/SAVE、SET IMPLICIT_TRANSACTIONS 或 USE。", + idleTimeout: "交易已因 5 分鐘無操作而結束,請重新執行以開啟新交易。", + executionFailed: "SQL 執行失敗,本次手動交易已結束。", + sessionExpired: "手動交易因 {timeoutSecs} 秒無操作而過期並回滾,DBX 將自動開始新的交易。", }, legacy: "後端回傳了錯誤。", diff --git a/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts index a0aa2e66ba..caa99d980c 100644 --- a/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts @@ -181,7 +181,7 @@ describe("supportsTransaction", () => { expect(supportsTransaction("cloudflare-d1")).toBe(false); expect(supportsTransaction("sqlite")).toBe(false); expect(supportsTransaction("clickhouse")).toBe(false); - expect(supportsTransaction("sqlserver")).toBe(false); + expect(supportsTransaction("sqlserver")).toBe(true); expect(supportsTransaction("rqlite")).toBe(false); expect(supportsTransaction("agent")).toBe(false); }); @@ -204,6 +204,8 @@ describe("defaultAutoCommitForDbType", () => { it("honors the configured default transaction mode", () => { expect(defaultAutoCommitForDbType("mysql", "manual")).toBe(false); expect(defaultAutoCommitForDbType("postgres", "manual")).toBe(false); + expect(defaultAutoCommitForDbType("sqlserver", "manual")).toBe(false); + expect(defaultAutoCommitForDbType("sqlserver", "auto")).toBe(true); expect(defaultAutoCommitForDbType("oracle", "manual")).toBe(false); expect(defaultAutoCommitForDbType("jdbc", "manual")).toBe(false); expect(defaultAutoCommitForDbType("oceanbase-oracle", "manual")).toBe(false); diff --git a/apps/desktop/src/lib/backend/__tests__/httpErrorParsing.spec.ts b/apps/desktop/src/lib/backend/__tests__/httpErrorParsing.spec.ts index afedd055dd..97c4e320a3 100644 --- a/apps/desktop/src/lib/backend/__tests__/httpErrorParsing.spec.ts +++ b/apps/desktop/src/lib/backend/__tests__/httpErrorParsing.spec.ts @@ -57,6 +57,12 @@ describe("HTTP backend error parsing", () => { } }); + test.each(["committed", "rolled_back", "unknown"] as const)("preserves a confirmed transaction outcome (%s)", async (outcome) => { + const transaction = { ...envelope, code: "DBX-TXN-1007", transactionOutcome: outcome }; + const error = await backendResponseError(new Response(JSON.stringify(transaction), { status: 500 })); + expect(error.backendError.transactionOutcome).toBe(outcome); + }); + test("uses a stable summary for an empty body", async () => { const error = await backendResponseError(new Response("", { status: 503 })); expect(error.backendError.code).toBe("DBX-LEGACY-0001"); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 82139eaf86..8376e1839f 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -277,6 +277,7 @@ export const executeInManualTransaction = forward("executeInManualTransaction"); export const commitManualTransaction = forward("commitManualTransaction"); export const rollbackManualTransaction = forward("rollbackManualTransaction"); export const cancelQuery = forward("cancelQuery"); +export const cancelQueryAndWait = forward("cancelQueryAndWait"); export const cancelConditionalUpdate = forward("cancelConditionalUpdate"); export const closeQuerySession = forward("closeQuerySession"); export const closeClientConnectionSession = forward("closeClientConnectionSession"); diff --git a/apps/desktop/src/lib/backend/errorUtils.ts b/apps/desktop/src/lib/backend/errorUtils.ts index 850e1beeaa..75de432db0 100644 --- a/apps/desktop/src/lib/backend/errorUtils.ts +++ b/apps/desktop/src/lib/backend/errorUtils.ts @@ -25,6 +25,7 @@ export interface BackendError { /** Compatibility provenance. New callers should prefer origin metadata. */ source: string; operationOutcome: "not_started" | "unknown"; + transactionOutcome?: "committed" | "rolled_back" | "unknown"; origin?: { subsystem: string; adapter: string; @@ -161,6 +162,7 @@ function isBackendError(value: unknown): value is BackendError { } } if (candidate.detail !== undefined && typeof candidate.detail !== "string") return false; + if (candidate.transactionOutcome !== undefined && !["committed", "rolled_back", "unknown"].includes(String(candidate.transactionOutcome))) return false; // Optional driver-reported position. Malformed values are rejected so a // corrupted envelope never drives a wrong editor jump, while a missing field // stays valid (all non-PostgreSQL errors and older backends). diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 669991ccd9..47dfafca6f 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -1638,7 +1638,19 @@ export async function beginManualTransaction(_connectionId: string, _database: s throw new Error("Manual transaction management is only available in the desktop app."); } -export async function executeInManualTransaction(_txnSessionId: string, _sql: string, _database: string, _schema?: string, _maxRows?: number, _tableDataPreview?: boolean, _pageSize?: number, _resultSessionId?: string, _classificationSql?: string): Promise { +export async function executeInManualTransaction( + _txnSessionId: string, + _sql: string, + _database: string, + _schema?: string, + _maxRows?: number, + _tableDataPreview?: boolean, + _pageSize?: number, + _resultSessionId?: string, + _classificationSql?: string, + _executionId?: string, + _timeoutSecs?: number, +): Promise { throw new Error("Manual transaction management is only available in the desktop app."); } @@ -1650,6 +1662,10 @@ export async function rollbackManualTransaction(_txnSessionId: string): Promise< throw new Error("Manual transaction management is only available in the desktop app."); } +export async function cancelQueryAndWait(_executionId: string): Promise<{ requested: boolean; terminal: boolean }> { + throw new Error("Manual transaction cancellation confirmation is only available in the desktop app."); +} + export async function cancelQuery(executionId: string): Promise { const result = await post("/api/query/cancel", { executionId }); return typeof result === "boolean" ? result : result.cancelled === true; diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 7d01ac6e29..a9e3ad2f0e 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1961,6 +1961,10 @@ export async function refreshConnections(): Promise { return invoke("refresh_connections"); } +export async function cancelQueryAndWait(executionId: string): Promise<{ requested: boolean; terminal: boolean }> { + return invokeBackend("cancel_conditional_update", { executionId }); +} + export async function cancelQuery(executionId: string): Promise { return invoke("cancel_query", { executionId }); } @@ -2029,11 +2033,23 @@ export async function executeInTransaction(connectionId: string, database: strin } export async function beginManualTransaction(connectionId: string, database: string, schema?: string, catalog?: string): Promise { - return invoke("begin_manual_transaction", { connectionId, database, schema, catalog }); + return invokeBackend("begin_manual_transaction", { connectionId, database, schema, catalog }); } -export async function executeInManualTransaction(txnSessionId: string, sql: string, database: string, schema?: string, maxRows?: number, tableDataPreview?: boolean, pageSize?: number, resultSessionId?: string, classificationSql?: string): Promise { - return invoke("execute_in_manual_transaction", { +export async function executeInManualTransaction( + txnSessionId: string, + sql: string, + database: string, + schema?: string, + maxRows?: number, + tableDataPreview?: boolean, + pageSize?: number, + resultSessionId?: string, + classificationSql?: string, + executionId?: string, + timeoutSecs?: number, +): Promise { + return invokeBackend("execute_in_manual_transaction", { txnSessionId, sql, database, @@ -2043,15 +2059,17 @@ export async function executeInManualTransaction(txnSessionId: string, sql: stri pageSize, resultSessionId, classificationSql, + executionId, + timeoutSecs, }); } export async function commitManualTransaction(txnSessionId: string): Promise { - return invoke("commit_manual_transaction", { txnSessionId }); + return invokeBackend("commit_manual_transaction", { txnSessionId }); } export async function rollbackManualTransaction(txnSessionId: string): Promise { - return invoke("rollback_manual_transaction", { txnSessionId }); + return invokeBackend("rollback_manual_transaction", { txnSessionId }); } export async function analyzeSqlReferences(sql: string, dialect?: string): Promise { diff --git a/apps/desktop/src/lib/database/databaseFeatureSupport.ts b/apps/desktop/src/lib/database/databaseFeatureSupport.ts index 6921292406..e39321c2ef 100644 --- a/apps/desktop/src/lib/database/databaseFeatureSupport.ts +++ b/apps/desktop/src/lib/database/databaseFeatureSupport.ts @@ -281,7 +281,7 @@ export function usesPostgresLikeStructureCopy(dbType?: DatabaseType): boolean { return !!dbType && PG_LIKE_STRUCTURE_TYPES.has(dbType); } -const TRANSACTION_SUPPORTED_TYPES: readonly string[] = ["postgres", "mysql", "oracle", "jdbc", "oceanbase-oracle", "dameng"]; +const TRANSACTION_SUPPORTED_TYPES: readonly string[] = ["postgres", "mysql", "oracle", "jdbc", "oceanbase-oracle", "dameng", "sqlserver"]; /** Oracle-family databases, kept ONLY for the Oracle-specific ALTER SESSION SET * CURRENT_SCHEMA compensation in queryStore. Do not use for toolbar/dirty-bit diff --git a/apps/desktop/src/stores/__tests__/queryStore.manualTransactionExpiry.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.manualTransactionExpiry.spec.ts index a1246e3de0..55170d2e78 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.manualTransactionExpiry.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.manualTransactionExpiry.spec.ts @@ -4,6 +4,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ analyzeEditableQueryEditability: vi.fn(), beginManualTransaction: vi.fn(), + commitManualTransaction: vi.fn(), + rollbackManualTransaction: vi.fn(), + cancelQueryAndWait: vi.fn(), closeClientConnectionSession: vi.fn(), closeQuerySession: vi.fn(), executeInManualTransaction: vi.fn(), @@ -16,6 +19,9 @@ const mocks = vi.hoisted(() => ({ vi.mock("@/lib/backend/api", () => ({ analyzeEditableQueryEditability: mocks.analyzeEditableQueryEditability, beginManualTransaction: mocks.beginManualTransaction, + commitManualTransaction: mocks.commitManualTransaction, + rollbackManualTransaction: mocks.rollbackManualTransaction, + cancelQueryAndWait: mocks.cancelQueryAndWait, closeClientConnectionSession: mocks.closeClientConnectionSession, closeQuerySession: mocks.closeQuerySession, executeInManualTransaction: mocks.executeInManualTransaction, @@ -319,4 +325,142 @@ describe("queryStore manual transaction expiry recovery", () => { expect(mocks.executeMulti).toHaveBeenCalledOnce(); expect(tab.result?.execution_error).toBe(true); }); + + async function sqlServerTab() { + mocks.getConnectionConfig.mockReturnValue({ id: "sqlserver-1", name: "SQL Server", db_type: "sqlserver", database: "db", query_timeout_secs: 30 }); + mocks.rollbackManualTransaction.mockResolvedValue({ columns: [], rows: [] }); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const id = store.createTab("sqlserver-1", "db", "SQL", "query", "dbo"); + store.setAutoCommit(id, false); + return { store, id, tab: store.tabs.find((tab) => tab.id === id)! }; + } + + function transactionError(code: string, outcome: "unknown" | "rolled_back" | "committed" = "unknown") { + return { ...expiredTransactionError(), code, messageKey: code === "DBX-TXN-1007" ? "backendErrors.transaction.commitUnknown" : "backendErrors.transaction.connectionLost", transactionOutcome: outcome }; + } + + it("SQL Server retains one session across executions and forwards cancellation identity and timeout", async () => { + const { store, id, tab } = await sqlServerTab(); + mocks.beginManualTransaction.mockResolvedValue("sqlserver-txn-fixed"); + mocks.executeInManualTransaction.mockResolvedValue(successfulUpdate()); + await store.executeTabSql(id, "UPDATE users SET active = 1"); + await store.executeTabSql(id, "UPDATE users SET active = 2"); + expect(mocks.beginManualTransaction).toHaveBeenCalledOnce(); + expect(mocks.executeInManualTransaction).toHaveBeenCalledTimes(2); + for (const call of mocks.executeInManualTransaction.mock.calls) { + expect(call[0]).toBe("sqlserver-txn-fixed"); + expect(call[9]).toEqual(expect.any(String)); + expect(call[10]).toBe(30); + } + expect(tab.txnStatus).toBe("active"); + expect(tab.autoCommit).toBe(false); + }); + + it.each([expiredTransactionError(), transactionError("DBX-TXN-1005"), transactionError("DBX-TXN-1009", "rolled_back")])("SQL Server never silently replays a failed execution (%s)", async (error) => { + const { store, id, tab } = await sqlServerTab(); + mocks.beginManualTransaction.mockResolvedValueOnce("sqlserver-txn-old").mockResolvedValueOnce("sqlserver-txn-new"); + mocks.executeInManualTransaction.mockRejectedValueOnce(error).mockResolvedValueOnce(successfulUpdate()); + await store.executeTabSql(id, "UPDATE users SET active = 1"); + expect(mocks.executeInManualTransaction).toHaveBeenCalledOnce(); + expect(mocks.beginManualTransaction).toHaveBeenCalledOnce(); + expect(mocks.executeMulti).not.toHaveBeenCalled(); + expect(tab.txnSessionId).toBeUndefined(); + expect(tab.autoCommit).toBe(false); + expect(tab.txnAutoRolledBack).toBe(false); + expect(tab.result?.execution_error).toBe(true); + await store.executeTabSql(id, "UPDATE users SET active = 2"); + expect(mocks.beginManualTransaction).toHaveBeenCalledTimes(2); + expect(mocks.executeInManualTransaction).toHaveBeenCalledTimes(2); + expect(tab.txnSessionId).toBe("sqlserver-txn-new"); + }); + + it("SQL Server refuses unsupported BEGIN even for a read without automatic execution", async () => { + const { store, id, tab } = await sqlServerTab(); + mocks.beginManualTransaction.mockRejectedValueOnce(new Error("Unknown method: begin_manual_transaction")); + await store.executeTabSql(id, "SELECT 1"); + expect(mocks.executeInManualTransaction).not.toHaveBeenCalled(); + expect(mocks.executeMulti).not.toHaveBeenCalled(); + expect(tab.autoCommit).toBe(false); + expect(tab.result?.execution_error).toBe(true); + }); + + it("SQL Server clears a lost commit session, reports unknown and never retries commit", async () => { + const { store, id, tab } = await sqlServerTab(); + tab.txnSessionId = "sqlserver-txn-commit"; + mocks.commitManualTransaction.mockRejectedValueOnce(transactionError("DBX-TXN-1007")); + await store.commitTransaction(id); + expect(mocks.commitManualTransaction).toHaveBeenCalledOnce(); + expect(mocks.rollbackManualTransaction).not.toHaveBeenCalled(); + expect(tab.txnStatus).toBe("unknown"); + expect(tab.txnSessionId).toBeUndefined(); + expect(tab.autoCommit).toBe(false); + }); + + it("SQL Server refuses switching to auto commit when rollback is unconfirmed", async () => { + const { store, id, tab } = await sqlServerTab(); + tab.txnSessionId = "sqlserver-txn-rollback"; + mocks.rollbackManualTransaction.mockRejectedValueOnce(transactionError("DBX-TXN-1006")); + store.setAutoCommit(id, true); + await vi.waitFor(() => expect(tab.txnStatus).toBe("lost")); + expect(tab.autoCommit).toBe(false); + expect(tab.txnSessionId).toBeUndefined(); + expect(tab.txnAutoRolledBack).toBe(false); + }); + + it("SQL Server preserves the transaction handle on a busy rollback rejection", async () => { + const { store, id, tab } = await sqlServerTab(); + tab.txnSessionId = "sqlserver-txn-busy"; + mocks.rollbackManualTransaction.mockRejectedValueOnce(transactionError("DBX-TXN-1003")); + await store.rollbackTransaction(id); + expect(tab.txnSessionId).toBe("sqlserver-txn-busy"); + expect(tab.txnStatus).toBe("active"); + }); + + it("SQL Server keeps manual mode while cancellation is not terminal", async () => { + const { store, id, tab } = await sqlServerTab(); + tab.txnSessionId = "sqlserver-txn-running"; + tab.isExecuting = true; + tab.executionId = "running-id"; + mocks.cancelQueryAndWait.mockResolvedValueOnce({ requested: true, terminal: false }); + store.setAutoCommit(id, true); + await vi.waitFor(() => expect(mocks.cancelQueryAndWait).toHaveBeenCalledWith("running-id")); + await Promise.resolve(); + expect(tab.autoCommit).toBe(false); + expect(tab.txnSessionId).toBe("sqlserver-txn-running"); + expect(mocks.rollbackManualTransaction).not.toHaveBeenCalled(); + }); + + it("SQL Server treats a lost desktop commit response as unknown even without a backend envelope", async () => { + const { store, id, tab } = await sqlServerTab(); + tab.txnSessionId = "sqlserver-txn-transport"; + mocks.commitManualTransaction.mockRejectedValueOnce(new Error("IPC response lost")); + await store.commitTransaction(id); + expect(tab.txnStatus).toBe("unknown"); + expect(tab.txnSessionId).toBeUndefined(); + expect(tab.txnNotice).toContain("COMMIT"); + expect(mocks.commitManualTransaction).toHaveBeenCalledOnce(); + expect(mocks.rollbackManualTransaction).not.toHaveBeenCalled(); + }); + + it.each(["database", "close"])("SQL Server cleans a late BEGIN after a %s change without dispatching SQL", async (action) => { + const { store, id, tab } = await sqlServerTab(); + let finishBegin!: (session: string) => void; + mocks.beginManualTransaction.mockReturnValueOnce( + new Promise((resolve) => { + finishBegin = resolve; + }), + ); + const execution = store.executeTabSql(id, "UPDATE users SET active = 1"); + await vi.waitFor(() => expect(mocks.beginManualTransaction).toHaveBeenCalledOnce()); + if (action === "database") store.updateDatabase(id, "other_database"); + else store.closeTab(id, { force: true }); + finishBegin("sqlserver-txn-late"); + await execution; + expect(mocks.rollbackManualTransaction).toHaveBeenCalledOnce(); + expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("sqlserver-txn-late"); + expect(mocks.executeInManualTransaction).not.toHaveBeenCalled(); + expect(mocks.executeMulti).not.toHaveBeenCalled(); + expect(tab.txnSessionId).toBeUndefined(); + }); }); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 7227afc258..4056105dd9 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -5202,11 +5202,32 @@ export const useQueryStore = defineStore("query", () => { manualTransactionTargetEpochs.set(tab, manualTransactionTargetEpoch(tab) + 1); } + const pendingSqlServerTransactionEnds = new Map>(); + + function isSqlServerTransactionTab(tab: QueryTab): boolean { + return tab.txnSessionId?.startsWith("sqlserver-txn-") === true || useConnectionStore().getConfig(tab.connectionId)?.db_type === "sqlserver"; + } + + function reportSqlServerTransactionError(tab: QueryTab, error: unknown) { + const backend = normalizeBackendError(error); + const recoverable = backend?.code === "DBX-TXN-1003" || backend?.code === "DBX-TXN-1008"; + if (!recoverable) clearManualTransactionSession(tab); + tab.txnStatus = recoverable ? (tab.txnSessionId ? "active" : undefined) : backend?.code === "DBX-TXN-1007" ? "unknown" : "lost"; + tab.txnNotice = translateBackendError(i18n.global.t, error); + if (backend?.transactionOutcome === "rolled_back") tab.txnNotice += " " + i18n.global.t("toolbar.txnRollbackConfirmed"); + if (backend?.transactionOutcome === "committed") tab.txnNotice += " " + i18n.global.t("toolbar.txnCommitConfirmed"); + } + async function ensureManualTransactionSession(id: string, database: string, schema?: string, catalog?: string): Promise { const tab = tabs.value.find((item) => item.id === id); if (!tab || tab.mode !== "query" || tab.autoCommit !== false || !tab.connectionId) { throw new Error("Manual transaction mode is no longer active for this query tab"); } + const sqlserver = isSqlServerTransactionTab(tab); + if (sqlserver && pendingSqlServerTransactionEnds.has(id)) { + await pendingSqlServerTransactionEnds.get(id); + return ensureManualTransactionSession(id, database, schema, catalog); + } if (tab.txnSessionId) return tab.txnSessionId; const epoch = manualTransactionTargetEpoch(tab); const pending = pendingManualTransactionStarts.get(id); @@ -5216,6 +5237,13 @@ export const useQueryStore = defineStore("query", () => { const originalDatabase = tab.database; const originalCatalog = tab.catalog; const originalSchema = tab.schema; + if (sqlserver) { + tab.txnStatus = "opening"; + if (!tab.txnIndependentConnectionExplained) { + tab.txnNotice = i18n.global.t("toolbar.sqlserverIndependentTransaction"); + tab.txnIndependentConnectionExplained = true; + } + } const start = api .beginManualTransaction(connectionId, database, schema, catalog) .then(async (sessionId) => { @@ -5224,8 +5252,13 @@ export const useQueryStore = defineStore("query", () => { throw new Error("Query tab changed while the manual transaction was starting"); } tab.txnSessionId = sessionId; + if (sqlserver) tab.txnStatus = "active"; return sessionId; }) + .catch((error) => { + if (sqlserver && tabs.value.find((item) => item.id === id) === tab && manualTransactionTargetEpoch(tab) === epoch) reportSqlServerTransactionError(tab, error); + throw error; + }) .finally(() => { if (pendingManualTransactionStarts.get(id)?.promise === start) pendingManualTransactionStarts.delete(id); }); @@ -5238,10 +5271,18 @@ export const useQueryStore = defineStore("query", () => { if (tab) { if (tab.autoCommit !== autoCommit) invalidateManualTransactionTarget(tab); const wasManual = tab.autoCommit === false; + if (autoCommit && wasManual && isSqlServerTransactionTab(tab) && tab.txnSessionId) { + void rollbackTransaction(id).then(() => { + if (!tab.txnSessionId && tab.txnStatus !== "lost" && tab.txnStatus !== "unknown") tab.autoCommit = true; + }); + return; + } tab.autoCommit = autoCommit; if (autoCommit && wasManual) { if (tab.txnSessionId) { - void rollbackTransaction(id); + void rollbackTransaction(id).catch((error) => { + if (isSqlServerTransactionTab(tab)) reportSqlServerTransactionError(tab, error); + }); } else { clearManualTransactionSession(tab); } @@ -5293,6 +5334,7 @@ export const useQueryStore = defineStore("query", () => { * discarded. Callers must not assign these fields individually. */ function clearManualTransactionSession(tab: QueryTab) { tab.txnSessionId = undefined; + tab.txnStatus = undefined; tab.txnAutoRolledBack = false; if (tab.txnPossiblyDirty !== undefined) tab.txnPossiblyDirty = false; } @@ -5323,6 +5365,35 @@ export const useQueryStore = defineStore("query", () => { if (tab.autoCommitOpenTransaction) await executeCurrentSql("COMMIT", { tabId: tab.id }); return; } + if (isSqlServerTransactionTab(tab)) { + if (tab.isExecuting || pendingSqlServerTransactionEnds.has(id)) return; + const sessionId = tab.txnSessionId; + const epoch = manualTransactionTargetEpoch(tab); + tab.txnStatus = "ending"; + const ending = (async () => { + try { + await api.commitManualTransaction(sessionId); + if (tab.txnSessionId === sessionId) { + clearManualTransactionSession(tab); + if (manualTransactionTargetEpoch(tab) === epoch) tab.txnNotice = undefined; + } + } catch (error) { + if (tab.txnSessionId === sessionId) { + if (manualTransactionTargetEpoch(tab) === epoch) { + reportSqlServerTransactionError(tab, error); + if (!normalizeBackendError(error)) { + tab.txnStatus = "unknown"; + tab.txnNotice = i18n.global.t("backendErrors.transaction.commitUnknown") + " " + formatError(error); + } + } else clearManualTransactionSession(tab); + } + } finally { + pendingSqlServerTransactionEnds.delete(id); + } + })(); + pendingSqlServerTransactionEnds.set(id, ending); + return ending; + } try { await api.commitManualTransaction(tab.txnSessionId); } finally { @@ -5337,6 +5408,43 @@ export const useQueryStore = defineStore("query", () => { if (tab.autoCommitOpenTransaction) await executeCurrentSql("ROLLBACK", { tabId: tab.id }); return; } + if (isSqlServerTransactionTab(tab)) { + const pending = pendingSqlServerTransactionEnds.get(id); + if (pending) return pending; + const sessionId = tab.txnSessionId; + const epoch = manualTransactionTargetEpoch(tab); + tab.txnStatus = "ending"; + const ending = (async () => { + try { + if (tab.isExecuting && tab.executionId) { + const cancellation = await api.cancelQueryAndWait(tab.executionId); + if (!cancellation.terminal) { + tab.txnStatus = "executing"; + tab.txnNotice = i18n.global.t("backendErrors.transaction.busy"); + return; + } + } + if (tab.txnSessionId !== sessionId) return; + await api.rollbackManualTransaction(sessionId); + if (tab.txnSessionId === sessionId) { + clearManualTransactionSession(tab); + if (manualTransactionTargetEpoch(tab) === epoch) tab.txnNotice = undefined; + } + } catch (error) { + if (tab.txnSessionId === sessionId) { + if (manualTransactionTargetEpoch(tab) === epoch) reportSqlServerTransactionError(tab, error); + else { + clearManualTransactionSession(tab); + tab.txnNotice = translateBackendError(i18n.global.t, error); + } + } + } finally { + pendingSqlServerTransactionEnds.delete(id); + } + })(); + pendingSqlServerTransactionEnds.set(id, ending); + return ending; + } const sessionId = tab.txnSessionId; // Remove the old session before the backend responds: a target switch may // start a new transaction while this rollback is still in flight. @@ -7969,7 +8077,7 @@ export const useQueryStore = defineStore("query", () => { queryExecutionLog("info", "begin-manual-txn:done", { traceId, txnSessionId: tab.txnSessionId, elapsed: elapsed() }); } catch (error) { const risk = classifySqlRisk(sqlToExecute, { dialect: effectiveDbType }).risk; - if (!isUnsupportedManualTransactionMethod(error) || risk !== "read") throw error; + if (effectiveDbType === "sqlserver" || !isUnsupportedManualTransactionMethod(error) || risk !== "read") throw error; tab.autoCommit = true; clearManualTransactionSession(tab); useLegacyReadFallback = true; @@ -7999,14 +8107,28 @@ export const useQueryStore = defineStore("query", () => { // single-shot call: the session-consume loop only runs in the // auto-commit path, and opening a cursor session here would // strand it after returning the first page. - const executeInTransaction = (sessionId: string) => - useAgentResultSession && !isOffsetJumpPage - ? api.executeInManualTransaction(sessionId, sqlToExecute, executionDatabase, executionSchema, agentProtocolQueryResultMaxRows(queryResultMaxRows), useLargeValuePreview, pageLimit, options?.pagination?.sessionId, classificationSql) - : api.executeInManualTransaction(sessionId, sqlToExecute, executionDatabase, executionSchema, pageLimit ?? agentProtocolQueryResultMaxRows(queryResultMaxRows), useLargeValuePreview, undefined, undefined, classificationSql); + const executeInTransaction = (sessionId: string) => { + const cursor = useAgentResultSession && !isOffsetJumpPage; + const args = [ + sessionId, + sqlToExecute, + executionDatabase, + executionSchema, + cursor ? agentProtocolQueryResultMaxRows(queryResultMaxRows) : (pageLimit ?? agentProtocolQueryResultMaxRows(queryResultMaxRows)), + useLargeValuePreview, + cursor ? pageLimit : undefined, + cursor ? options?.pagination?.sessionId : undefined, + classificationSql, + ] as const; + return effectiveDbType === "sqlserver" ? api.executeInManualTransaction(...args, executionId, frontendTimeoutSecs) : api.executeInManualTransaction(...args); + }; try { - return await executeInTransaction(txnSessionId); + if (effectiveDbType === "sqlserver") tab.txnStatus = "executing"; + const results = await executeInTransaction(txnSessionId); + if (effectiveDbType === "sqlserver" && tab.txnSessionId === txnSessionId) tab.txnStatus = "active"; + return results; } catch (error) { - if (options?.pagination?.sessionId || manualTransactionRecoveryAttempted || !isManualTransactionSessionExpired(error)) throw error; + if (effectiveDbType === "sqlserver" || options?.pagination?.sessionId || manualTransactionRecoveryAttempted || !isManualTransactionSessionExpired(error)) throw error; if (tab.executionId !== executionId || tab.autoCommit !== false || manualTransactionTargetEpoch(tab) !== executionTargetEpoch) throw error; manualTransactionRecoveryAttempted = true; // A session that only ever ran proven read-only statements lost @@ -8303,7 +8425,9 @@ export const useQueryStore = defineStore("query", () => { if (findExecutionTab(id) !== tab || tab.executionId !== executionId || manualTransactionTargetEpoch(tab) !== executionTargetEpoch) return false; // Handle manual transaction auto-rollback (idle timeout only for the banner; // other statement failures still clear the session without the 5-minute notice). - if (tab.autoCommit === false) { + if (tab.autoCommit === false && isSqlServerTransactionTab(tab)) { + reportSqlServerTransactionError(tab, e); + } else if (tab.autoCommit === false) { const errMsg: string = e?.message ?? String(e); const idleTimeout = /5 minutes of inactivity/i.test(errMsg) || errMsg.includes("5 分钟无操作") || errMsg.includes("已自动回滚"); if (idleTimeout) { diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index e1907564db..f15b39d18a 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -2342,6 +2342,10 @@ export interface QueryTab { autoCommit?: boolean; /** Session ID for an active manual transaction, set after beginManualTransaction */ txnSessionId?: string; + /** Runtime-only SQL Server transaction lifecycle and last terminal notice. */ + txnStatus?: "opening" | "active" | "executing" | "ending" | "lost" | "unknown"; + txnNotice?: string; + txnIndependentConnectionExplained?: boolean; /** Set to true when a manual transaction was auto-rolled back due to inactivity */ txnAutoRolledBack?: boolean; /** Sticky proven-read-only dialects (Oracle/OceanBase-Oracle/MySQL/PostgreSQL), diff --git a/crates/dbx-core/src/connection/mod.rs b/crates/dbx-core/src/connection/mod.rs index 1a3c8ab954..f1fec6cef1 100644 --- a/crates/dbx-core/src/connection/mod.rs +++ b/crates/dbx-core/src/connection/mod.rs @@ -263,6 +263,13 @@ enum ConnectionDatabaseInfoSource { /// Held connection for a manual transaction session pub enum TxnConnection { + /// Isolated native SQL Server client, discarded after an interrupted stream. + SqlServer { + client: Option>>, + client_session_id: String, + database: Option, + cleanup_guard: ClientSessionPoolCleanupGuard, + }, Postgres(Box), /// A dedicated MySQL connection. Cancellation may consume and discard this /// connection instead of trying to reuse it after an interrupted result set. @@ -286,6 +293,7 @@ pub enum TxnConnection { } pub struct TransactionSession { + pub sqlserver: Option, pub connection: Arc>, pub pool_key: String, pub last_activity: std::time::Instant, @@ -423,6 +431,7 @@ pub struct AppState { /// exists. mysql_preserved_transactions: Arc>>, pub transaction_sessions: Arc>>, + pub sqlserver_transaction_ends: Arc>, /// `save_password=false` 连接本次运行期的临时密码(内存,进程退出即丢, /// 绝不落盘)。键为 `(owner_scope, connection_id)`:桌面端 owner 为空串, /// Web 端 owner 为已认证会话 token,不同登录会话互不可见。建池/池重建/ @@ -1699,6 +1708,7 @@ impl AppState { postgres_cancel_contexts: Arc::new(RwLock::new(HashMap::new())), mysql_preserved_transactions: Arc::new(RwLock::new(HashSet::new())), transaction_sessions: Arc::new(RwLock::new(HashMap::new())), + sqlserver_transaction_ends: Arc::new(std::sync::Mutex::new(Default::default())), session_credentials: SessionCredentialStore::new(), write_unlock_windows: crate::write_unlock::WriteUnlockWindows::default(), metadata_gates: Arc::new(Mutex::new(HashMap::new())), @@ -5951,6 +5961,10 @@ impl AppState { keys.into_iter().filter_map(|id| map.remove(&id).map(|session| (id, session))).collect() }; for (session_id, session) in sessions { + if session.sqlserver.is_some() { + crate::query::sqlserver_manual_transaction::disconnect(self, &session_id, session).await; + continue; + } let mut conn = session.connection.lock().await; let outcome = crate::query::rollback_manual_txn_connection(&mut conn).await; if let Err(error) = outcome { diff --git a/crates/dbx-core/src/query/mod.rs b/crates/dbx-core/src/query/mod.rs index d6335c047e..a1f9be84c3 100644 --- a/crates/dbx-core/src/query/mod.rs +++ b/crates/dbx-core/src/query/mod.rs @@ -7,6 +7,7 @@ pub mod plugin_data; pub mod plugin_plan; pub mod query_cancel; pub mod redis_ops; +pub mod sqlserver_manual_transaction; pub mod two_phase_commit; pub use dbx_drivers::execution::{ @@ -5798,6 +5799,9 @@ async fn begin_transaction_session( catalog: Option<&str>, consistent_snapshot: bool, ) -> Result { + if !consistent_snapshot && connection_database_type(state, connection_id).await == Some(DatabaseType::SqlServer) { + return sqlserver_manual_transaction::begin(state, connection_id, database, schema, catalog).await; + } let mysql_catalog_dialect = connection_mysql_catalog_dialect(state, connection_id).await; let pool_database = query_pool_database(database, catalog); // Probe the primary pool to learn the backend kind. Agent manual TX opens a @@ -5956,6 +5960,7 @@ async fn begin_transaction_session( let txn_session_id = uuid::Uuid::new_v4().to_string(); let session = TransactionSession { + sqlserver: None, connection: Arc::new(tokio::sync::Mutex::new(txn_conn)), pool_key: pool_key.clone(), last_activity: std::time::Instant::now(), @@ -6059,6 +6064,8 @@ pub async fn execute_in_manual_transaction( pub struct ManualTransactionExecutionOptions { pub max_rows: Option, pub table_data_preview: bool, + pub execution_id: Option, + pub timeout_secs: Option, pub page_size: Option, pub result_session_id: Option, /// User-facing SQL to classify (Oracle-only). When present, the core @@ -6077,6 +6084,9 @@ pub async fn execute_in_manual_transaction_with_options( schema: Option<&str>, options: ManualTransactionExecutionOptions, ) -> Result, String> { + if sqlserver_manual_transaction::is_session_id(txn_session_id) { + return sqlserver_manual_transaction::execute(state, txn_session_id, sql, database, schema, options).await; + } const TXN_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(MANUAL_TRANSACTION_IDLE_TIMEOUT_SECS); // Resolve statements and validate before taking the per-session connection @@ -6176,6 +6186,9 @@ pub async fn execute_in_manual_transaction_with_options( let mut conn = connection.lock().await; for (i, statement) in statements.iter().enumerate() { let result = match &mut *conn { + TxnConnection::SqlServer { .. } => { + return Err("SQL Server transaction must use its batch executor".to_string()) + } TxnConnection::Postgres(conn) => { execute_manual_txn_postgres_statement(conn.as_ref(), statement, row_limit).await } @@ -6423,6 +6436,7 @@ where .await } TxnConnection::Mysql(None) => Err(MANUAL_TRANSACTION_SESSION_NOT_FOUND_ERROR.to_string()), + TxnConnection::SqlServer { .. } => Err("Streaming SQL Server manual transactions is not supported".to_owned()), TxnConnection::Agent { .. } => { Err("Streaming rows inside an agent manual transaction is not supported".to_string()) } @@ -6524,6 +6538,28 @@ async fn rollback_manual_txn_connection_with_postgres_timeout( postgres_timeout: Option, ) -> Result<(), String> { match conn { + TxnConnection::SqlServer { client, client_session_id: _client_session_id, .. } => { + if let Some(client) = client { + let mut client = client.lock().await; + let count = db::sqlserver::manual_transaction_status(&mut client).await?.count; + if count == 0 { + return Err("The transaction has already ended; rollback cannot be confirmed".to_string()); + } + #[cfg(feature = "test-support")] + sqlserver_manual_transaction::live_test_hooks::run( + _client_session_id, + sqlserver_manual_transaction::live_test_hooks::Phase::BeforeRollback, + ); + db::sqlserver::execute_simple_batch_with_max_rows_metadata( + &mut client, + "ROLLBACK TRANSACTION", + Some(1), + ) + .await?; + } else { + return Err("The transaction connection was discarded; rollback cannot be confirmed".to_string()); + } + } TxnConnection::Postgres(conn) => { if let Some(timeout) = postgres_timeout { db::postgres::execute_postgres_infra_statement(conn, "ROLLBACK", timeout, "manual_txn.rollback") @@ -6586,7 +6622,8 @@ async fn discard_mysql_manual_txn_connection(conn: &mut TxnConnection, timeout: async fn release_manual_txn_session_pool(state: &AppState, connection_id: &str, conn: &mut TxnConnection) { let (client_session_id, database, cleanup_guard) = match conn { - TxnConnection::Agent { client_session_id, database, cleanup_guard, .. } + TxnConnection::SqlServer { client_session_id, database, cleanup_guard, .. } + | TxnConnection::Agent { client_session_id, database, cleanup_guard, .. } | TxnConnection::ExternalDriver { client_session_id, database, cleanup_guard, .. } => { (client_session_id, database, cleanup_guard) } @@ -6873,6 +6910,9 @@ async fn execute_manual_txn_mysql_statement( /// Commit an existing manual transaction session. pub async fn commit_manual_transaction(state: &AppState, txn_session_id: &str) -> Result { + if sqlserver_manual_transaction::is_session_id(txn_session_id) { + return sqlserver_manual_transaction::finish(state, txn_session_id, true).await; + } let session = { let mut sessions = state.transaction_sessions.write().await; sessions.remove(txn_session_id).ok_or("Transaction session not found")? @@ -6880,6 +6920,7 @@ pub async fn commit_manual_transaction(state: &AppState, txn_session_id: &str) - let mut conn = session.connection.lock().await; match &mut *conn { + TxnConnection::SqlServer { .. } => return Err("SQL Server transaction must use its commit handler".to_string()), TxnConnection::Postgres(conn) => { conn.execute_typed("COMMIT", &[]).await.map_err(|e| format!("COMMIT failed: {e}"))?; } @@ -6930,6 +6971,9 @@ pub async fn commit_manual_transaction(state: &AppState, txn_session_id: &str) - /// Rollback an existing manual transaction session. pub async fn rollback_manual_transaction(state: &AppState, txn_session_id: &str) -> Result { + if sqlserver_manual_transaction::is_session_id(txn_session_id) { + return sqlserver_manual_transaction::finish(state, txn_session_id, false).await; + } let session = { let mut sessions = state.transaction_sessions.write().await; sessions.remove(txn_session_id).ok_or("Transaction session not found")? @@ -7068,6 +7112,7 @@ mod tests { state.transaction_sessions.write().await.insert( "snapshot".to_string(), TransactionSession { + sqlserver: None, connection: Arc::new(tokio::sync::Mutex::new(TxnConnection::Postgres(Box::new(connection)))), pool_key: "conn-1".to_string(), last_activity: std::time::Instant::now(), @@ -10306,6 +10351,7 @@ for line in sys.stdin: state.transaction_sessions.write().await.insert( "txn-test".to_string(), TransactionSession { + sqlserver: None, connection: Arc::new(tokio::sync::Mutex::new(TxnConnection::ExternalDriver { session, config, @@ -11519,6 +11565,7 @@ for line in sys.stdin: state.transaction_sessions.write().await.insert( txn_session_id.clone(), TransactionSession { + sqlserver: None, connection: Arc::new(tokio::sync::Mutex::new(TxnConnection::Agent { client: Arc::new(crate::db::agent_driver::PooledAgentClient::new(client)), client_session_id, diff --git a/crates/dbx-core/src/query/sqlserver_manual_transaction.rs b/crates/dbx-core/src/query/sqlserver_manual_transaction.rs new file mode 100644 index 0000000000..bbbf265679 --- /dev/null +++ b/crates/dbx-core/src/query/sqlserver_manual_transaction.rs @@ -0,0 +1,1230 @@ +//! Interactive SQL Server transactions own one isolated TDS/JDBC session. +//! An invalid session is never reconnected or replayed. Only acknowledged +//! COMMIT/ROLLBACK responses establish a known database outcome. +use super::*; +use crate::backend_error::{BackendError, ManualTransactionFailureKind as Kind, TransactionOutcome as Outcome}; +use crate::connection::ConnectionLifecycleSnapshot; +use crate::query_cancel::RunningTaskMetadata; +use serde::{Deserialize, Serialize}; +use std::sync::Mutex as StdMutex; +use std::time::Instant; + +// Wire-level fault tests arm a scoped proxy at the exact operation boundary. +// This module and every call site are absent from normal desktop builds. +#[cfg(feature = "test-support")] +pub mod live_test_hooks { + use std::collections::HashMap; + use std::sync::{Mutex, OnceLock}; + #[derive(Clone, Copy, Hash, Eq, PartialEq)] + pub enum Phase { + BeforeCommit, + BeforeRollback, + AfterCommitAck, + } + type Hook = Box; + type Hooks = HashMap<(String, Phase), Hook>; + fn hooks() -> &'static Mutex { + static HOOKS: OnceLock> = OnceLock::new(); + HOOKS.get_or_init(|| Mutex::new(HashMap::new())) + } + pub struct Guard(String, Phase); + impl Drop for Guard { + fn drop(&mut self) { + hooks().lock().unwrap().remove(&(self.0.clone(), self.1)); + } + } + pub fn install(id: &str, phase: Phase, hook: impl FnOnce() + Send + 'static) -> Guard { + assert!(hooks().lock().unwrap().insert((id.to_owned(), phase), Box::new(hook)).is_none()); + Guard(id.to_owned(), phase) + } + pub(crate) fn run(id: &str, phase: Phase) { + let hook = hooks().lock().unwrap().remove(&(id.to_owned(), phase)); + if let Some(hook) = hook { + hook(); + } + } +} +const PREFIX: &str = "sqlserver-txn-"; +const ERROR_PREFIX: &str = "DBX_MANUAL_TRANSACTION_ERROR:"; +const IDLE: Duration = Duration::from_secs(MANUAL_TRANSACTION_IDLE_TIMEOUT_SECS); +const MAX_ENDED_SESSIONS: usize = 1024; + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct Failure { + kind: Kind, + outcome: Outcome, + detail: String, +} +impl Failure { + fn new(kind: Kind, outcome: Outcome, detail: impl Into) -> Self { + Self { kind, outcome, detail: detail.into() } + } + fn encode(&self) -> String { + format!("{ERROR_PREFIX}{}", serde_json::to_string(self).expect("transaction error serializes")) + } +} + +/// Preserve the existing core String API, but return a typed envelope at the +/// desktop boundary. This marker is never rendered by the frontend. +pub fn backend_error(error: &str) -> Option { + let failure: Failure = serde_json::from_str(error.strip_prefix(ERROR_PREFIX)?).ok()?; + Some(BackendError::from_manual_transaction_failure(failure.kind, failure.outcome, &failure.detail)) +} + +#[derive(Default)] +pub struct EndedSessions { + entries: HashMap, + cleanup_started: bool, +} +impl EndedSessions { + fn prune(&mut self) { + self.entries.retain(|_, (time, _)| time.elapsed() < IDLE); + } + fn record(&mut self, id: &str, failure: Failure) { + self.prune(); + if self.entries.len() >= MAX_ENDED_SESSIONS { + if let Some(oldest) = self.entries.iter().min_by_key(|(_, (time, _))| *time).map(|(id, _)| id.clone()) { + self.entries.remove(&oldest); + } + } + self.entries.insert(id.to_owned(), (Instant::now(), failure)); + } + fn missing(&mut self, id: &str) -> String { + self.prune(); + self.entries.get(id).map(|(_, error)| error.encode()).unwrap_or_else(|| { + Failure::new( + Kind::ConnectionLost, + Outcome::Unknown, + "The transaction session is no longer available; its outcome cannot be confirmed", + ) + .encode() + }) + } +} + +#[derive(Clone, Debug, Default)] +pub struct DriverInfo { + pub server_version: Option, + pub product_type: Option, + pub driver_name: String, + pub driver_version: Option, +} + +#[derive(Clone)] +pub struct SessionMetadata { + pub lifecycle: ConnectionLifecycleSnapshot, + pub driver: DriverInfo, + ends: Arc>, +} +impl SessionMetadata { + fn record(&self, id: &str, failure: Failure) { + self.ends.lock().unwrap_or_else(|e| e.into_inner()).record(id, failure); + } +} + +pub fn is_session_id(id: &str) -> bool { + id.starts_with(PREFIX) +} +fn missing(state: &AppState, id: &str) -> String { + state.sqlserver_transaction_ends.lock().unwrap_or_else(|e| e.into_inner()).missing(id) +} +fn failure(kind: Kind, outcome: Outcome, detail: impl Into) -> String { + Failure::new(kind, outcome, detail).encode() +} + +fn start_terminal_reason_cleanup(ends: &Arc>) { + { + let mut map = ends.lock().unwrap_or_else(|e| e.into_inner()); + if map.cleanup_started { + return; + } + map.cleanup_started = true; + } + let ends = Arc::downgrade(ends); + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(60)).await; + let Some(ends) = ends.upgrade() else { + return; + }; + ends.lock().unwrap_or_else(|e| e.into_inner()).prune(); + } + }); +} + +pub async fn begin( + state: &AppState, + connection_id: &str, + database: &str, + schema: Option<&str>, + catalog: Option<&str>, +) -> Result { + start_terminal_reason_cleanup(&state.sqlserver_transaction_ends); + let lifecycle = state.connection_lifecycle_snapshot(connection_id); + let database_context = query_pool_database(database, catalog); + let client_session_id = format!("manual-txn-{}", uuid::Uuid::new_v4()); + // Own the routing key before creating the pool: cancellation during the + // connection handshake must also clean up a late pool installation. + let cleanup_guard = state + .workload_session_pool_cleanup_guard(connection_id, database_context, &client_session_id) + .await + .ok_or_else(|| { + failure(Kind::Unsupported, Outcome::Unknown, "SQL Server requires an isolated transaction connection") + })?; + let pool_key = state + .get_or_create_pool_for_session(connection_id, database_context, Some(&client_session_id)) + .await + .map_err(|e| failure(Kind::ConnectionLost, Outcome::Unknown, e))?; + let pool = state.pool_handle(&pool_key).await; + let mut conn = match pool { + Some(PoolKind::SqlServer(client)) => TxnConnection::SqlServer { + client: Some(client), + client_session_id, + database: database_context.map(str::to_owned), + cleanup_guard, + }, + Some(PoolKind::Agent(client)) => TxnConnection::Agent { + client, + client_session_id, + database: database_context.map(str::to_owned), + cleanup_guard, + }, + _ => { + return Err(failure( + Kind::Unsupported, + Outcome::Unknown, + "The selected SQL Server driver does not implement manual transactions", + )) + } + }; + let begin = async { + match &mut conn { + TxnConnection::SqlServer { client: Some(client), .. } => { + let mut client = client.lock().await; + let info = db::sqlserver::execute_simple_batch_with_max_rows_metadata(&mut client, + "SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)), CAST(SERVERPROPERTY('Edition') AS nvarchar(128))", Some(1)).await?; + let row = info.iter().find_map(|result| result.result.rows.first()); + let driver = DriverInfo { + server_version: row + .and_then(|row| row.first()) + .and_then(serde_json::Value::as_str) + .map(str::to_owned), + product_type: row.and_then(|row| row.get(1)).and_then(serde_json::Value::as_str).map(str::to_owned), + driver_name: "tiberius (native TDS)".to_owned(), + driver_version: None, + }; + let status = db::sqlserver::manual_transaction_status(&mut client).await?; + if status.count != 0 { + return Err("The new SQL Server connection already owns a transaction".to_owned()); + } + db::sqlserver::execute_simple_batch_with_max_rows_metadata(&mut client, "BEGIN TRANSACTION", Some(1)) + .await?; + require_native_transaction(&mut client).await?; + Ok(driver) + } + TxnConnection::Agent { client, .. } => { + let mut client = client.lock().await; + let info = client.begin_manual_transaction::(None).await?; + if info.get("manualTransactionBatch").and_then(serde_json::Value::as_bool) != Some(true) { + return Err("DBX_MANUAL_TRANSACTION_UNSUPPORTED: Update the SQL Server legacy component to enable safe manual transaction batches".to_owned()); + } + client.enable_dedicated_transaction_query_cancellation(); + if let Some(info) = info.as_object() { + log::info!( + "[manual-txn:sqlserver:jdbc] product_version={:?} driver={:?} driver_version={:?}", + info.get("productVersion"), + info.get("driverName"), + info.get("driverVersion") + ); + } + Ok(DriverInfo { + server_version: info.get("productVersion").and_then(serde_json::Value::as_str).map(str::to_owned), + product_type: info.get("productType").and_then(serde_json::Value::as_str).map(str::to_owned), + driver_name: info + .get("driverName") + .and_then(serde_json::Value::as_str) + .unwrap_or("SQL Server JDBC") + .to_owned(), + driver_version: info.get("driverVersion").and_then(serde_json::Value::as_str).map(str::to_owned), + }) + } + _ => unreachable!(), + } + }; + let result = tokio::select! { + biased; + _ = lifecycle.cancellation().cancelled() => Err("Connection changed while the transaction was opening".to_owned()), + result = tokio::time::timeout(db::connection_timeout(), begin) => result.unwrap_or_else(|_| Err("Opening the transaction timed out".to_owned())), + }; + let driver = match result { + Ok(driver) => driver, + Err(error) => { + // BEGIN may have reached the server. Drop the isolated connection; + // never issue another command on an interrupted response stream. + let kind = if error.contains("DBX_MANUAL_TRANSACTION_UNSUPPORTED") { + Kind::Unsupported + } else if is_connection_error(&error) + || lifecycle.cancellation().is_cancelled() + || error.contains("timed out") + { + Kind::ConnectionLost + } else { + Kind::ExecutionFailed + }; + release_manual_txn_session_pool(state, connection_id, &mut conn).await; + return Err(failure(kind, Outcome::Unknown, error)); + } + }; + log::info!( + "[manual-txn:sqlserver] version={:?} product={:?} driver={} driver_version={:?}", + driver.server_version, + driver.product_type, + driver.driver_name, + driver.driver_version + ); + let id = format!("{PREFIX}{}", uuid::Uuid::new_v4()); + let metadata = SessionMetadata { lifecycle, driver, ends: state.sqlserver_transaction_ends.clone() }; + let session = TransactionSession { + sqlserver: Some(metadata.clone()), + connection: Arc::new(tokio::sync::Mutex::new(conn)), + pool_key, + last_activity: Instant::now(), + busy: false, + snapshot_rotation_safe: false, + connection_id: connection_id.to_owned(), + database: database.to_owned(), + schema: schema.map(str::to_owned), + }; + { + let mut sessions = state.transaction_sessions.write().await; + if !state.connection_lifecycle_is_current(connection_id, &metadata.lifecycle) { + return Err(failure( + Kind::ContextChanged, + Outcome::Unknown, + "Connection changed while the transaction was opening", + )); + } + sessions.insert(id.clone(), session); + } + spawn_idle_watcher(state.transaction_sessions.clone(), id.clone()); + Ok(id) +} + +async fn require_native_transaction(client: &mut db::sqlserver::SqlServerClient) -> Result<(), String> { + let status = db::sqlserver::manual_transaction_status(client).await?; + if !status.is_active() { + return Err(format!( + "SQL Server transaction state changed (count={}, state={:?}); the session cannot continue", + status.count, status.xact_state + )); + } + Ok(()) +} + +/// A request dropped before it reaches its normal terminal path must discard +/// its connection rather than leave a busy, possibly half-consumed session. +struct ExecutionGuard { + sessions: Arc>>, + id: String, + armed: bool, +} +impl Drop for ExecutionGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + let sessions = self.sessions.clone(); + let id = self.id.clone(); + tokio::spawn(async move { + let session = { + let mut sessions = sessions.write().await; + sessions.remove(&id) + }; + if let Some(session) = session { + if let Some(meta) = &session.sqlserver { + meta.record( + &id, + Failure::new(Kind::ConnectionLost, Outcome::Unknown, "Transaction request was interrupted"), + ); + } + // The owned Agent RPC task consumes its cancellation corridor. + // Waiting for its client lock prevents pool close from killing it + // before both replies arrive; never send a competing cancel RPC. + { + let mut conn = session.connection.lock().await; + interrupt_fixed_connection(&mut conn).await; + } + drop(session); + } + }); + } +} + +/// TCP FIN alone need not stop a SQL2000 batch immediately. ATTENTION is +/// best effort, never proof of rollback; callers always discard the connection. +async fn send_native_attention(conn: &mut TxnConnection) { + if let TxnConnection::SqlServer { client: Some(client), .. } = conn { + let attention = async { client.lock().await.send_attention().await }; + let _ = tokio::time::timeout(db::connection_timeout().min(Duration::from_secs(5)), attention).await; + } +} + +/// Dropping the outer request cancels the owned JDBC RPC, without dropping +/// its response reader or aborting the bounded cancellation corridor. +struct AgentQueryCancellation(Option); +impl Drop for AgentQueryCancellation { + fn drop(&mut self) { + if let Some(token) = &self.0 { + token.cancel(); + } + } +} + +async fn interrupt_fixed_connection(conn: &mut TxnConnection) { + send_native_attention(conn).await; + if let TxnConnection::Agent { client, .. } = conn { + // The owned RPC holds this lock until query + cancel replies, or until + // its bounded cancellation grace kills an unresponsive dedicated Agent. + let _finished = client.lock().await; + } +} + +struct CancellationRelay(tokio::task::JoinHandle<()>); +impl Drop for CancellationRelay { + fn drop(&mut self) { + self.0.abort(); + } +} + +pub async fn execute( + state: &AppState, + id: &str, + sql: &str, + database: &str, + schema: Option<&str>, + options: ManualTransactionExecutionOptions, +) -> Result, String> { + let batches = split_sql_batches(sql); + if batches.iter().any(|batch| db::sqlserver::manual_transaction_control_conflict(batch)) { + return Err(failure( + Kind::ControlConflict, + Outcome::Unknown, + "Transaction control and USE are managed by the manual transaction session", + )); + } + let (connection, metadata, connection_id, pool_key, expired, context_changed) = { + let mut sessions = state.transaction_sessions.write().await; + let session = sessions.get_mut(id).ok_or_else(|| missing(state, id))?; + let metadata = session.sqlserver.clone().expect("SQL Server session metadata"); + if session.busy { + return Err(failure(Kind::Busy, Outcome::Unknown, "The transaction is currently executing")); + } + let context_changed = session.database != database || session.schema.as_deref() != schema; + let expired = session.last_activity.elapsed() >= IDLE; + session.busy = true; + ( + session.connection.clone(), + metadata, + session.connection_id.clone(), + session.pool_key.clone(), + expired, + context_changed, + ) + }; + let mut guard = ExecutionGuard { sessions: state.transaction_sessions.clone(), id: id.to_owned(), armed: true }; + let mut conn = connection.lock().await; + if context_changed || expired || metadata.lifecycle.cancellation().is_cancelled() { + state.transaction_sessions.write().await.remove(id); + let error = cleanup_failure( + state, + id, + &connection_id, + &mut conn, + &metadata, + if context_changed { + Kind::ContextChanged + } else if expired { + Kind::IdleTimeout + } else { + Kind::ConnectionLost + }, + "The transaction session ended before execution", + false, + ) + .await; + guard.armed = false; + return Err(error); + } + if let Err(error) = check_read_only_for_connection_multi(state, &pool_key, &batches).await { + if let Some(session) = state.transaction_sessions.write().await.get_mut(id) { + session.busy = false; + } + guard.armed = false; + return Err(failure(Kind::ControlConflict, Outcome::Unknown, error)); + } + let registered = options.execution_id.as_ref().map(|execution_id| { + let registered = state.running_queries.register_task_for_terminal_confirmation( + execution_id.clone(), + RunningTaskMetadata::query(connection_id.clone(), database.to_owned(), None), + ); + state.running_queries.set_pool_key(execution_id, pool_key.clone()); + Arc::new(registered) + }); + let token = registered.as_ref().map(|query| query.token()).unwrap_or_default(); + let timeout = resolve_query_timeout(options.timeout_secs); + let agent_transport = matches!(&*conn, TxnConnection::Agent { .. }); + let run = async { + let mut results = Vec::new(); + for batch in &batches { + let rewritten = sql_for_execution_context_with_identifier_quote( + Some(DatabaseType::SqlServer), + batch, + schema, + Some("["), + ); + match &mut *conn { + TxnConnection::SqlServer { client: Some(client), .. } => { + let mut client = client.lock().await; + let batch_results = db::sqlserver::execute_simple_batch_with_max_rows_metadata( + &mut client, + &rewritten, + options.max_rows, + ) + .await?; + results.extend(sqlserver_batch_results(batch_results)); + require_native_transaction(&mut client).await?; + } + TxnConnection::Agent { client, .. } => { + let opts = manual_txn_agent_query_options( + options.max_rows.unwrap_or(MAX_ROWS), + options.table_data_preview, + None, + None, + ); + let mut params = + agent_execute_query_params(&rewritten, Some(database).filter(|d| !d.is_empty()), None, opts); + params["timeoutSecs"] = serde_json::json!(timeout.map(|t| t.as_secs()).unwrap_or(0)); + params["returnAllResults"] = serde_json::json!(true); + let client = client.clone(); + let cancellation = token.clone(); + let completion = registered.clone(); + let mut drop_cancellation = AgentQueryCancellation(Some(token.clone())); + let task = tokio::spawn(async move { + // Keep terminal confirmation pending while JDBC is still + // executing or cancelling after the outer future is dropped. + let _completion = completion; + let mut client = client.lock().await; + client + .call_with_timeout_and_cancel::>( + "execute_query", + params, + timeout, + Some(cancellation), + ) + .await + }); + let result = task.await; + drop_cancellation.0 = None; + let batch_results = + result.map_err(|error| format!("SQL Server Agent query task failed: {error}"))??; + results.extend(batch_results.into_iter().map(|result| { + ExecuteMultiResult::success_with_optional_server_large_values( + result, + options.table_data_preview, + ) + })); + } + _ => return Err("The SQL Server transaction connection is no longer available".to_owned()), + } + } + if results.is_empty() { + results.push(empty_query_result(0).into()); + } + Ok(results) + }; + let result = if agent_transport { + // Let the Agent corridor consume cancellation and timeout itself. An + // outer select would drop its future before it sends cancel_session. + let lifecycle = metadata.lifecycle.cancellation().clone(); + let cancellation = token.clone(); + let relay = CancellationRelay(tokio::spawn(async move { + match timeout { + Some(timeout) => tokio::select! { + _ = lifecycle.cancelled() => {}, + _ = tokio::time::sleep(timeout) => {}, + }, + None => lifecycle.cancelled().await, + } + cancellation.cancel(); + })); + let result = run.await; + drop(relay); + result + } else { + tokio::select! { + biased; + _ = metadata.lifecycle.cancellation().cancelled() => Err("SQL Server connection was disconnected".to_owned()), + result = wait_for_result_opt(Some(token.clone()), timeout, run) => result, + } + }; + match result { + Ok(results) => { + let mut sessions = state.transaction_sessions.write().await; + if let Some(session) = sessions.get_mut(id) { + session.busy = false; + session.last_activity = Instant::now(); + guard.armed = false; + Ok(results) + } else { + guard.armed = false; + Err(failure(Kind::ConnectionLost, Outcome::Unknown, "Connection ended while the query was executing")) + } + } + Err(error) => { + // Interrupted streams are poisoned: never send ROLLBACK through + // unread TDS packets. Detaching closes this exact dedicated session. + let interrupted = token.is_cancelled() + || metadata.lifecycle.cancellation().is_cancelled() + || error.contains("DBX_MANUAL_TRANSACTION_STATE_LOST") + || error.contains("count=0") + || is_connection_error(&error) + || error.to_ascii_lowercase().contains("timed out") + || error.to_ascii_lowercase().contains("timeout"); + state.transaction_sessions.write().await.remove(id); + if token.is_cancelled() + || metadata.lifecycle.cancellation().is_cancelled() + || error.to_ascii_lowercase().contains("timed out") + || error.to_ascii_lowercase().contains("timeout") + { + send_native_attention(&mut conn).await; + } + let encoded = cleanup_failure( + state, + id, + &connection_id, + &mut conn, + &metadata, + if interrupted { Kind::ConnectionLost } else { Kind::ExecutionFailed }, + &error, + interrupted, + ) + .await; + guard.armed = false; + Err(encoded) + } + } +} + +async fn rollback_fixed_connection(conn: &mut TxnConnection) -> Result<(), String> { + // The generic Agent helper intentionally tolerates an already-ended + // transaction for other dialects. SQL Server needs a positive acknowledgment. + match conn { + TxnConnection::Agent { client, .. } => { + client.lock().await.rollback_manual_transaction::().await.map(|_| ()) + } + _ => rollback_manual_txn_connection(conn).await, + } +} + +async fn cleanup_failure( + state: &AppState, + id: &str, + connection_id: &str, + conn: &mut TxnConnection, + metadata: &SessionMetadata, + kind: Kind, + detail: &str, + discard: bool, +) -> String { + let result = if discard { + None + } else { + Some(tokio::time::timeout(db::connection_timeout(), rollback_fixed_connection(conn)).await) + }; + let (kind, outcome, detail) = match result { + Some(Ok(Ok(()))) => (kind, Outcome::RolledBack, detail.to_owned()), + Some(Ok(Err(error))) => (Kind::RollbackFailed, Outcome::Unknown, format!("{detail}; rollback failed: {error}")), + Some(Err(_)) => (Kind::RollbackFailed, Outcome::Unknown, format!("{detail}; rollback timed out")), + None => (kind, Outcome::Unknown, detail.to_owned()), + }; + release_manual_txn_session_pool(state, connection_id, conn).await; + if let TxnConnection::SqlServer { client, .. } = conn { + *client = None; + } + let error = Failure::new(kind, outcome, detail); + metadata.record(id, error.clone()); + error.encode() +} + +pub async fn finish(state: &AppState, id: &str, commit: bool) -> Result { + let session = { + let mut sessions = state.transaction_sessions.write().await; + let session = sessions.get(id).ok_or_else(|| missing(state, id))?; + if session.busy { + return Err(failure(Kind::Busy, Outcome::Unknown, "The transaction is currently executing")); + } + sessions.remove(id).expect("checked session") + }; + let metadata = session.sqlserver.as_ref().expect("SQL Server session metadata"); + let mut conn = session.connection.lock().await; + if metadata.lifecycle.cancellation().is_cancelled() || session.last_activity.elapsed() >= IDLE { + let error = cleanup_failure( + state, + id, + &session.connection_id, + &mut conn, + metadata, + if metadata.lifecycle.cancellation().is_cancelled() { Kind::ConnectionLost } else { Kind::IdleTimeout }, + "The transaction ended before it could be committed", + false, + ) + .await; + return Err(error); + } + // Missing requests during COMMIT must already see an unknown outcome. + metadata.record( + id, + Failure::new( + if commit { Kind::CommitUnknown } else { Kind::RollbackFailed }, + Outcome::Unknown, + "The transaction is ending", + ), + ); + let operation = async { + if !commit { + return rollback_fixed_connection(&mut conn).await; + } + match &mut *conn { + TxnConnection::SqlServer { client: Some(client), .. } => { + let mut client = client.lock().await; + require_native_transaction(&mut client).await?; + #[cfg(feature = "test-support")] + live_test_hooks::run(id, live_test_hooks::Phase::BeforeCommit); + db::sqlserver::execute_simple_batch_with_max_rows_metadata(&mut client, "COMMIT TRANSACTION", Some(1)) + .await + .map(|_| ()) + } + TxnConnection::Agent { client, .. } => { + client.lock().await.commit_manual_transaction::().await.map(|_| ()) + } + _ => Err("Transaction connection was discarded".to_owned()), + } + }; + let result = tokio::time::timeout(db::connection_timeout(), operation).await; + if matches!(result, Ok(Ok(()))) { + metadata.record( + id, + Failure::new( + Kind::ContextChanged, + if commit { Outcome::Committed } else { Outcome::RolledBack }, + "The transaction has already ended", + ), + ); + } + #[cfg(feature = "test-support")] + if commit && matches!(result, Ok(Ok(()))) { + live_test_hooks::run(id, live_test_hooks::Phase::AfterCommitAck); + } + // Release only this workload connection. A successful COMMIT stays + // successful even if closing that connection subsequently fails. + release_manual_txn_session_pool(state, &session.connection_id, &mut conn).await; + if let TxnConnection::SqlServer { client, .. } = &mut *conn { + *client = None; + } + match result { + Ok(Ok(())) => { + metadata.record( + id, + Failure::new( + Kind::ContextChanged, + if commit { Outcome::Committed } else { Outcome::RolledBack }, + "The transaction has already ended", + ), + ); + Ok(empty_query_result(0)) + } + result => { + let detail = match result { + Ok(Err(error)) => error, + Err(_) => "Ending the transaction timed out".to_owned(), + _ => unreachable!(), + }; + let error = + Failure::new(if commit { Kind::CommitUnknown } else { Kind::RollbackFailed }, Outcome::Unknown, detail); + metadata.record(id, error.clone()); + Err(error.encode()) + } + } +} + +pub async fn disconnect(state: &AppState, id: &str, session: TransactionSession) { + let metadata = session.sqlserver.as_ref().expect("SQL Server session metadata"); + metadata.record(id, Failure::new(Kind::ConnectionLost, Outcome::Unknown, "The connection was disconnected")); + // Lifecycle cancellation releases an executing request's lock first. + let mut conn = session.connection.lock().await; + if session.busy { + // The map may already belong to disconnect when ExecutionGuard runs. + // This owner must also interrupt a dropped native request before FIN. + interrupt_fixed_connection(&mut conn).await; + } + let _ = cleanup_failure( + state, + id, + &session.connection_id, + &mut conn, + metadata, + Kind::ConnectionLost, + "The connection was disconnected", + session.busy, + ) + .await; +} + +fn spawn_idle_watcher(sessions: Arc>>, id: String) { + tokio::spawn(async move { + loop { + let delay = { + let map = sessions.read().await; + let Some(session) = map.get(&id) else { + return; + }; + if session.busy { + Duration::from_secs(1) + } else { + IDLE.saturating_sub(session.last_activity.elapsed()) + } + }; + tokio::time::sleep(delay).await; + let removed = { + let mut map = sessions.write().await; + match map.get(&id) { + Some(session) if !session.busy && session.last_activity.elapsed() >= IDLE => map.remove(&id), + None => return, + _ => None, + } + }; + if let Some(session) = removed { + let metadata = session.sqlserver.as_ref().expect("SQL Server session metadata"); + metadata.record( + &id, + Failure::new(Kind::IdleTimeout, Outcome::Unknown, "The idle transaction is being closed"), + ); + let mut conn = session.connection.lock().await; + let outcome = + match tokio::time::timeout(db::connection_timeout(), rollback_fixed_connection(&mut conn)).await { + Ok(Ok(())) => Failure::new( + Kind::IdleTimeout, + Outcome::RolledBack, + "Transaction rolled back after five minutes of inactivity", + ), + Ok(Err(error)) => Failure::new( + Kind::RollbackFailed, + Outcome::Unknown, + format!("Idle transaction rollback could not be confirmed: {error}"), + ), + Err(_) => { + Failure::new(Kind::RollbackFailed, Outcome::Unknown, "Idle transaction rollback timed out") + } + }; + metadata.record(&id, outcome); + // The dedicated guard also detaches the pool when this task ends. + return; + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn missing_is_not_idle_rollback_and_terminal_outcomes_are_preserved() { + let mut ended = EndedSessions::default(); + let missing = backend_error(&ended.missing("sqlserver-txn-missing")).unwrap(); + let json = serde_json::to_value(missing).unwrap(); + assert_eq!(json["code"], "DBX-TXN-1005"); + assert_eq!(json["transactionOutcome"], "unknown"); + ended.record( + "sqlserver-txn-committed", + Failure::new(Kind::ContextChanged, Outcome::Committed, "Already committed"), + ); + let json = serde_json::to_value(backend_error(&ended.missing("sqlserver-txn-committed")).unwrap()).unwrap(); + assert_eq!(json["transactionOutcome"], "committed"); + } + #[test] + fn terminal_reason_history_is_bounded_and_expires() { + let mut ended = EndedSessions::default(); + for index in 0..MAX_ENDED_SESSIONS + 4 { + ended.record(&format!("{PREFIX}{index}"), Failure::new(Kind::IdleTimeout, Outcome::RolledBack, "Idle")); + } + assert_eq!(ended.entries.len(), MAX_ENDED_SESSIONS); + ended.entries.insert( + "old".into(), + (Instant::now() - IDLE, Failure::new(Kind::IdleTimeout, Outcome::RolledBack, "Idle")), + ); + assert!(ended.missing("old").contains("connection_lost")); + assert!(!ended.entries.contains_key("old")); + } + + async fn mock_session(rollback_fails: bool, commit_fails: bool) -> (Arc, String, tempfile::TempDir) { + use crate::db::agent_driver::{AgentDriverClient, AgentLaunchSpec, PooledAgentClient}; + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("agent.py"); + let events = dir.path().join("events.jsonl"); + std::fs::write( + &script, + r#"import json, sys, time +log = sys.argv[1] +rollback_fails = sys.argv[2] == 'true' +commit_fails = sys.argv[3] == 'true' +print(json.dumps({'ready':True}), flush=True) +pending = None +for line in sys.stdin: + request = json.loads(line) + method = request['method'] + params = request.get('params', {}) + with open(log, 'a') as output: + output.write(json.dumps({'method':method, 'params':params}) + '\n') + if method == 'handshake': + print(json.dumps({'jsonrpc':'2.0','id':request['id'],'result':{'protocolVersion':2,'agentProtocolVersion':2,'capabilities':['multi_session']}}), flush=True) + continue + if method == 'execute_query' and params.get('sql') == 'DELAYED_CANCEL': + pending = request['id'] + continue + if method == 'cancel_session' and pending is not None: + assert params['agentSessionId'] == '__legacy__' + print(json.dumps({'jsonrpc':'2.0','id':request['id'],'result':{'ok':True}}), flush=True) + time.sleep(0.4) + with open(log, 'a') as output: + output.write(json.dumps({'method':'cancel_completed'}) + '\n') + print(json.dumps({'jsonrpc':'2.0','id':pending,'error':{'code':-1,'message':'Query cancelled'}}), flush=True) + pending = None + continue + sql = params.get('sql','') + error = None + if method == 'execute_query': + if not params.get('returnAllResults'): error = 'batch mode missing' + elif 'RAISE_ERROR' in sql: error = 'server rejected SQL' + elif 'STATE_LOST' in sql: error = 'DBX_MANUAL_TRANSACTION_STATE_LOST: hidden commit' + elif 'SHORT_SLOW' in sql: time.sleep(0.65) + elif 'SLOW' in sql: time.sleep(2) + result = [{'columns':['sql'], 'rows':[[sql]], 'affected_rows':0, 'execution_time_ms':1}] + else: + result = {'ok':True} + if method == 'rollback_manual_transaction' and rollback_fails: error = 'rollback rejected' + if method == 'commit_manual_transaction' and commit_fails: error = 'commit response unavailable' + response = {'jsonrpc':'2.0', 'id':request['id']} + if error: response['error'] = {'code':-32000, 'message':error} + else: response['result'] = result + print(json.dumps(response), flush=True) +"#, + ) + .unwrap(); + let python = if cfg!(windows) { "python" } else { "python3" }; + let client = AgentDriverClient::spawn(AgentLaunchSpec::new(python).with_args([ + script.to_string_lossy().to_string(), + events.to_string_lossy().to_string(), + rollback_fails.to_string(), + commit_fails.to_string(), + ])) + .await + .unwrap(); + let storage = crate::persistence::test_storage::open(&dir.path().join("state.db")).await.unwrap(); + let state = Arc::new(AppState::new(storage)); + let config: crate::models::connection::ConnectionConfig = serde_json::from_value(serde_json::json!({ + "id":"mock-sqlserver", "name":"mock", "db_type":"sqlserver", "host":"localhost", "port":1433, + "username":"", "password":"", "query_timeout_secs":30, "keepalive_interval_secs":0 + })) + .unwrap(); + state.configs.write().await.insert(config.id.clone(), config); + let client_session_id = format!("manual-txn-{}", uuid::Uuid::new_v4()); + let cleanup_guard = + state.workload_session_pool_cleanup_guard("mock-sqlserver", Some("db"), &client_session_id).await.unwrap(); + let id = format!("{PREFIX}{}", uuid::Uuid::new_v4()); + state.transaction_sessions.write().await.insert( + id.clone(), + TransactionSession { + sqlserver: Some(SessionMetadata { + lifecycle: state.connection_lifecycle_snapshot("mock-sqlserver"), + driver: DriverInfo::default(), + ends: state.sqlserver_transaction_ends.clone(), + }), + connection: Arc::new(tokio::sync::Mutex::new(TxnConnection::Agent { + client: Arc::new(PooledAgentClient::new(client)), + client_session_id, + database: Some("db".to_owned()), + cleanup_guard, + })), + pool_key: "mock-sqlserver".to_owned(), + last_activity: Instant::now(), + busy: false, + snapshot_rotation_safe: false, + connection_id: "mock-sqlserver".to_owned(), + database: "db".to_owned(), + schema: None, + }, + ); + (state, id, dir) + } + + fn events(dir: &tempfile::TempDir) -> Vec { + std::fs::read_to_string(dir.path().join("events.jsonl")) + .unwrap_or_default() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() + } + fn error_json(error: &str) -> serde_json::Value { + serde_json::to_value(backend_error(error).unwrap()).unwrap() + } + + #[tokio::test] + async fn go_batches_keep_variables_together_and_use_the_same_dedicated_session() { + let (state, id, dir) = mock_session(false, false).await; + let sql = + "DECLARE @value int = 1; SELECT @value;\nGO\nBEGIN TRY SELECT 2; END TRY BEGIN CATCH SELECT 3; END CATCH"; + let results = + execute(&state, &id, sql, "db", None, ManualTransactionExecutionOptions::default()).await.unwrap(); + assert_eq!(results.len(), 2); + assert_eq!(events(&dir).iter().filter(|e| e["method"] == "execute_query").count(), 2); + assert!(results[0].result.rows[0][0].as_str().unwrap().contains("DECLARE @value int = 1; SELECT @value")); + execute(&state, &id, "SELECT 4", "db", None, ManualTransactionExecutionOptions::default()).await.unwrap(); + assert!(state.transaction_sessions.read().await.contains_key(&id)); + finish(&state, &id, false).await.unwrap(); + assert!(!state.transaction_sessions.read().await.contains_key(&id)); + } + + #[tokio::test] + async fn execution_failure_rolls_back_once_and_invalid_session_never_replays() { + let (state, id, dir) = mock_session(false, false).await; + let error = execute(&state, &id, "RAISE_ERROR", "db", None, ManualTransactionExecutionOptions::default()) + .await + .unwrap_err(); + assert_eq!(error_json(&error)["transactionOutcome"], "rolled_back"); + assert!(!state.transaction_sessions.read().await.contains_key(&id)); + let _ = execute(&state, &id, "RAISE_ERROR", "db", None, ManualTransactionExecutionOptions::default()) + .await + .unwrap_err(); + assert_eq!(events(&dir).iter().filter(|e| e["method"] == "execute_query").count(), 1); + assert_eq!(events(&dir).iter().filter(|e| e["method"] == "rollback_manual_transaction").count(), 1); + } + + #[tokio::test] + async fn rollback_failure_and_commit_response_loss_remain_unknown() { + let (state, id, dir) = mock_session(true, false).await; + let error = execute(&state, &id, "RAISE_ERROR", "db", None, ManualTransactionExecutionOptions::default()) + .await + .unwrap_err(); + let error = error_json(&error); + assert_eq!(error["code"], "DBX-TXN-1006"); + assert_eq!(error["transactionOutcome"], "unknown"); + assert_eq!(events(&dir).iter().filter(|e| e["method"] == "rollback_manual_transaction").count(), 1); + let (state, id, dir) = mock_session(false, true).await; + let error = finish(&state, &id, true).await.unwrap_err(); + assert_eq!(error_json(&error)["code"], "DBX-TXN-1007"); + assert_eq!(error_json(&error)["transactionOutcome"], "unknown"); + let _ = finish(&state, &id, true).await.unwrap_err(); + assert_eq!(events(&dir).iter().filter(|e| e["method"] == "commit_manual_transaction").count(), 1); + assert_eq!(events(&dir).iter().filter(|e| e["method"] == "rollback_manual_transaction").count(), 0); + } + + #[tokio::test] + async fn busy_and_control_conflicts_keep_session_without_executing_sql() { + let (state, id, dir) = mock_session(false, false).await; + let error = + execute(&state, &id, "COMMIT", "db", None, ManualTransactionExecutionOptions::default()).await.unwrap_err(); + assert_eq!(error_json(&error)["code"], "DBX-TXN-1008"); + state.transaction_sessions.write().await.get_mut(&id).unwrap().busy = true; + assert_eq!(error_json(&finish(&state, &id, true).await.unwrap_err())["code"], "DBX-TXN-1003"); + assert_eq!(error_json(&finish(&state, &id, false).await.unwrap_err())["code"], "DBX-TXN-1003"); + assert_eq!( + error_json( + &execute(&state, &id, "SELECT 1", "db", None, ManualTransactionExecutionOptions::default()) + .await + .unwrap_err() + )["code"], + "DBX-TXN-1003" + ); + assert!(events(&dir).is_empty()); + state.transaction_sessions.write().await.get_mut(&id).unwrap().busy = false; + finish(&state, &id, false).await.unwrap(); + } + + #[tokio::test] + async fn context_change_ends_session_before_user_sql_and_commit_ack_is_retained() { + let (state, id, dir) = mock_session(false, false).await; + let error = execute(&state, &id, "SELECT 1", "other", None, ManualTransactionExecutionOptions::default()) + .await + .unwrap_err(); + assert_eq!(error_json(&error)["code"], "DBX-TXN-1004"); + assert!(events(&dir).iter().all(|e| e["method"] != "execute_query")); + let (state, id, dir) = mock_session(false, false).await; + finish(&state, &id, true).await.unwrap(); + assert_eq!(error_json(&finish(&state, &id, true).await.unwrap_err())["transactionOutcome"], "committed"); + assert_eq!(events(&dir).iter().filter(|e| e["method"] == "commit_manual_transaction").count(), 1); + } + + #[tokio::test] + async fn cancellation_discards_connection_and_confirms_terminal_completion_without_replay() { + let (state, id, dir) = mock_session(false, false).await; + let query_state = state.clone(); + let query_id = id.clone(); + let task = tokio::spawn(async move { + execute( + &query_state, + &query_id, + "SLOW", + "db", + None, + ManualTransactionExecutionOptions { + execution_id: Some("cancel-sqlserver".to_owned()), + ..Default::default() + }, + ) + .await + }); + for _ in 0..100 { + if !events(&dir).is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let cancellation = state.running_queries.cancel_and_wait("cancel-sqlserver", Duration::from_secs(5)).await; + assert!(cancellation.terminal); + let error = task.await.unwrap().unwrap_err(); + assert_eq!(error_json(&error)["transactionOutcome"], "unknown"); + assert!(!state.transaction_sessions.read().await.contains_key(&id)); + assert_eq!(events(&dir).iter().filter(|e| e["method"] == "execute_query").count(), 1); + assert!(events(&dir).iter().all(|e| e["method"] != "rollback_manual_transaction")); + } + + #[tokio::test] + async fn agent_deadline_covers_all_go_batches() { + let (state, id, dir) = mock_session(false, false).await; + let error = execute( + &state, + &id, + "SHORT_SLOW\nGO\nSHORT_SLOW", + "db", + None, + ManualTransactionExecutionOptions { timeout_secs: Some(1), ..Default::default() }, + ) + .await + .unwrap_err(); + assert_eq!(error_json(&error)["transactionOutcome"], "unknown"); + assert!(!state.transaction_sessions.read().await.contains_key(&id)); + assert_eq!(events(&dir).iter().filter(|e| e["method"] == "execute_query").count(), 2); + } + + async fn dropped_agent_request_probe(disconnecting: bool) { + let (state, id, dir) = mock_session(false, false).await; + let connection = state.transaction_sessions.read().await[&id].connection.clone(); + { + let conn = connection.lock().await; + let TxnConnection::Agent { client, .. } = &*conn else { panic!("expected Agent") }; + let mut client = client.lock().await; + client.try_optional_handshake("0.6.34").await.unwrap(); + client.enable_dedicated_transaction_query_cancellation(); + } + // Only the production RPC task may keep the dedicated client alive. + drop(connection); + let query_state = state.clone(); + let query_id = id.clone(); + let task = tokio::spawn(async move { + execute( + &query_state, + &query_id, + "DELAYED_CANCEL", + "db", + None, + ManualTransactionExecutionOptions { + execution_id: Some("dropped-jdbc".to_owned()), + ..Default::default() + }, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(3), async { + while !events(&dir).iter().any(|event| event["method"] == "execute_query") { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + let disconnect_task = if disconnecting { + let session = state.transaction_sessions.write().await.remove(&id).unwrap(); + let state = state.clone(); + let id = id.clone(); + Some(tokio::spawn(async move { disconnect(&state, &id, session).await })) + } else { + None + }; + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + tokio::time::timeout(Duration::from_secs(2), async { + while !events(&dir).iter().any(|event| event["method"] == "cancel_session") { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + assert!( + state.running_queries.diagnostics().active_execution_ids.contains(&"dropped-jdbc".to_owned()), + "outer abort incorrectly confirmed completion before JDBC cancellation" + ); + if let Some(task) = disconnect_task { + task.await.unwrap(); + } + tokio::time::timeout(Duration::from_secs(2), async { + while !events(&dir).iter().any(|event| event["method"] == "cancel_completed") { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("Agent was closed before delayed JDBC cancellation completed"); + tokio::time::timeout(Duration::from_secs(2), async { + while !state.running_queries.diagnostics().active_execution_ids.is_empty() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + assert_eq!(events(&dir).iter().filter(|event| event["method"] == "execute_query").count(), 1); + assert!(!state.transaction_sessions.read().await.contains_key(&id)); + assert_eq!(error_json(&missing(&state, &id))["transactionOutcome"], "unknown"); + } + + #[tokio::test] + async fn dropped_agent_request_waits_for_delayed_cancellation() { + dropped_agent_request_probe(false).await; + } + + #[tokio::test] + async fn disconnect_owns_cleanup_when_agent_request_is_dropped() { + dropped_agent_request_probe(true).await; + } + + #[tokio::test] + async fn dropped_request_does_not_hold_global_session_map_during_cleanup() { + let (state, id, _dir) = mock_session(false, false).await; + let connection = state.transaction_sessions.read().await[&id].connection.clone(); + let held = connection.lock().await; + let query_state = state.clone(); + let query_id = id.clone(); + let task = tokio::spawn(async move { + execute(&query_state, &query_id, "SELECT 1", "db", None, ManualTransactionExecutionOptions::default()).await + }); + for _ in 0..100 { + if state.transaction_sessions.read().await[&id].busy { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + task.abort(); + let _ = task.await; + tokio::time::sleep(Duration::from_millis(20)).await; + let writable = tokio::time::timeout(Duration::from_secs(1), state.transaction_sessions.write()).await; + drop(held); + assert!(writable.is_ok(), "connection cleanup must not keep the global map locked"); + } +} diff --git a/crates/dbx-core/tests/live_sqlserver_manual_transactions.rs b/crates/dbx-core/tests/live_sqlserver_manual_transactions.rs new file mode 100644 index 0000000000..3a8255374e --- /dev/null +++ b/crates/dbx-core/tests/live_sqlserver_manual_transactions.rs @@ -0,0 +1,893 @@ +//! Run against each SQL Server version/driver profile; ignored without an explicit writable test endpoint. +use dbx_core::connection::AppState; +#[path = "support/sqlserver_live_observer.rs"] +mod sqlserver; +use dbx_core::models::connection::ConnectionConfig; +use dbx_core::query::{ + begin_manual_transaction, commit_manual_transaction, execute_in_manual_transaction, + execute_in_manual_transaction_with_options, rollback_manual_transaction, ManualTransactionExecutionOptions, +}; +use std::time::Duration; +#[path = "support/sqlserver_fault_proxy.rs"] +mod fault_proxy; +use dbx_core::query::sqlserver_manual_transaction::live_test_hooks::{self, Phase}; +use fault_proxy::FaultProxy; +use std::sync::atomic::Ordering; + +async fn setup() -> (AppState, ConnectionConfig, sqlserver::SqlServerClient, tempfile::TempDir) { + let host = std::env::var("DBX_TEST_SQLSERVER_HOST").expect("DBX_TEST_SQLSERVER_HOST"); + let password = std::env::var("DBX_TEST_SQLSERVER_PASSWORD").expect("DBX_TEST_SQLSERVER_PASSWORD"); + let user = std::env::var("DBX_TEST_SQLSERVER_USER").unwrap_or_else(|_| "sa".to_owned()); + let database = std::env::var("DBX_TEST_SQLSERVER_DATABASE").expect("Use a writable, isolated test database"); + let port: u16 = std::env::var("DBX_TEST_SQLSERVER_PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(1433); + let config: ConnectionConfig = serde_json::from_value(serde_json::json!({ + "id":"live-sqlserver-manual", "name":"live SQL Server manual transactions", "db_type":"sqlserver", + "host":host, "port":port, "username":user, "password":password, "database":database, + "driver_profile":std::env::var("DBX_TEST_SQLSERVER_DRIVER_PROFILE").ok(), + "jdbc_driver_class":std::env::var("DBX_TEST_SQLSERVER_JDBC_CLASS").ok(), + "connection_string":std::env::var("DBX_TEST_SQLSERVER_JDBC_URL").ok(), + "connect_timeout_secs":10, "query_timeout_secs":30, "keepalive_interval_secs":0 + })) + .unwrap(); + let observer = sqlserver::connect_with_port_explicit( + &host, + port, + true, + &user, + &password, + Some(&database), + Duration::from_secs(10), + ) + .await + .unwrap(); + let dir = tempfile::tempdir().unwrap(); + let storage = dbx_core::persistence::test_storage::open(&dir.path().join("state.db")).await.unwrap(); + let state = AppState::new_with_plugin_and_agent_dir_and_app_version( + storage, + dir.path().join("plugins"), + dir.path().join("agents"), + "0.6.31", + ); + if config.driver_profile.as_deref() == Some("sqlserver-legacy") { + let jar = std::env::var("DBX_TEST_SQLSERVER_AGENT_JAR").expect("built SQL Server Agent JAR"); + let java = std::env::var("DBX_TEST_SQLSERVER_JAVA").expect("Java 21 executable"); + dbx_core::agent_service::import_agent_jar(&state.agent_manager, "sqlserver-legacy", std::path::Path::new(&jar)) + .await + .unwrap(); + state + .agent_manager + .mutate_state(|agent| { + agent.java_runtime = dbx_core::agent_manager::JavaRuntimeConfig { + mode: dbx_core::agent_manager::JavaRuntimeMode::Custom, + custom_java_path: Some(java), + }; + }) + .unwrap(); + } + state.configs.write().await.insert(config.id.clone(), config.clone()); + (state, config, observer, dir) +} + +async fn assert_driver(state: &AppState, id: &str) { + let sessions = state.transaction_sessions.read().await; + let driver = &sessions[id].sqlserver.as_ref().unwrap().driver; + println!("Actual driver: {} {:?}; server {:?}", driver.driver_name, driver.driver_version, driver.server_version); + if sqlserver::sql_server_2000() { + let major = driver.server_version.as_deref().unwrap().split('.').next().unwrap().parse::().unwrap(); + assert_eq!(major, 8, "SQL Server 2000 compatibility tests require the actual 8.x engine"); + } + let conn = sessions[id].connection.lock().await; + if let dbx_core::connection::TxnConnection::Agent { client, .. } = &*conn { + println!("Actual Agent runtime: {}", if client.uses_shared_runtime() { "shared" } else { "dedicated process" }); + } + drop(conn); + let expected = std::env::var("DBX_TEST_SQLSERVER_EXPECT_DRIVER") + .expect("DBX_TEST_SQLSERVER_EXPECT_DRIVER must identify the actual driver under test"); + assert!(!expected.trim().is_empty(), "expected driver cannot be empty"); + assert!( + driver.driver_name.to_lowercase().contains(&expected.to_lowercase()), + "unexpected actual driver: {}", + driver.driver_name + ); +} + +#[tokio::test] +#[ignore = "requires DBX_TEST_SQLSERVER_* writable endpoint; repeat with native and sqlserver-legacy profiles"] +async fn live_manual_transaction_fixed_connections_batches_commit_and_rollback() { + let (state, config, mut observer, _dir) = setup().await; + let database = config.database.as_deref().unwrap(); + let table = format!("dbo.dbx_manual_{}", uuid::Uuid::new_v4().simple()); + sqlserver::execute_simple_batch_with_max_rows( + &mut observer, + &format!("CREATE TABLE {table} (id int PRIMARY KEY, value int)"), + None, + ) + .await + .unwrap(); + let first = begin_manual_transaction(&state, &config.id, database, None, None).await.unwrap(); + assert_driver(&state, &first).await; + let second = begin_manual_transaction(&state, &config.id, database, None, None).await.unwrap(); + let first_spid = + execute_in_manual_transaction(&state, &first, "SELECT @@SPID AS spid", database, None, Some(10)).await.unwrap(); + let second_spid = execute_in_manual_transaction(&state, &second, "SELECT @@SPID AS spid", database, None, Some(10)) + .await + .unwrap(); + assert_ne!(first_spid[0].rows, second_spid[0].rows); + execute_in_manual_transaction( + &state, + &first, + &format!("INSERT INTO {table} VALUES (1, 10)"), + database, + None, + Some(10), + ) + .await + .unwrap(); + // A separate READ COMMITTED observer may block on uncommitted writes. Bound + // the lock wait and assert that it cannot silently observe a committed row. + let lock_hint = if sqlserver::sql_server_2000() { "" } else { " WITH (READCOMMITTEDLOCK)" }; + let blocked = sqlserver::execute_simple_batch_with_max_rows(&mut observer, + &format!("SET LOCK_TIMEOUT 500; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; SELECT value FROM {table}{lock_hint} WHERE id = 1"), Some(10)).await; + let lock_error = blocked.expect_err("uncommitted row should remain locked"); + assert!( + lock_error.to_lowercase().contains("lock request time out") + || lock_error.to_lowercase().contains("lock request timeout"), + "expected SQL Server lock timeout, got: {lock_error}" + ); + commit_manual_transaction(&state, &first).await.unwrap(); + let visible = + sqlserver::execute_query(&mut observer, &format!("SELECT value FROM {table} WHERE id = 1")).await.unwrap(); + assert_eq!(visible.rows[0][0], serde_json::json!(10)); + execute_in_manual_transaction( + &state, + &second, + &format!("UPDATE {table} SET value = 20 WHERE id = 1"), + database, + None, + Some(10), + ) + .await + .unwrap(); + rollback_manual_transaction(&state, &second).await.unwrap(); + let visible = + sqlserver::execute_query(&mut observer, &format!("SELECT value FROM {table} WHERE id = 1")).await.unwrap(); + assert_eq!(visible.rows[0][0], serde_json::json!(10)); + let batch = begin_manual_transaction(&state, &config.id, database, None, None).await.unwrap(); + execute_in_manual_transaction( + &state, + &batch, + if sqlserver::sql_server_2000() { + "CREATE TABLE #dbx_manual (id int); INSERT INTO #dbx_manual VALUES (1); INSERT INTO #dbx_manual VALUES (2); INSERT INTO #dbx_manual VALUES (3)" + } else { + "CREATE TABLE #dbx_manual (id int); INSERT INTO #dbx_manual VALUES (1), (2), (3)" + }, + database, + None, + Some(10), + ) + .await + .unwrap(); + let results = execute_in_manual_transaction(&state, &batch, + if sqlserver::sql_server_2000() { + "DECLARE @v int; SET @v = 4; SELECT @v AS variable;\nGO\nSELECT id FROM #dbx_manual ORDER BY id; PRINT 'SQL2000 manual batch'; SELECT 99 AS last_result" + } else { + "DECLARE @v int = 4; SELECT @v AS variable;\nGO\nBEGIN TRY SELECT id FROM #dbx_manual ORDER BY id; END TRY BEGIN CATCH SELECT ERROR_MESSAGE(); END CATCH; SELECT 99 AS last_result" + }, database, None, Some(1)).await.unwrap(); + assert!(results.iter().any(|r| r.columns == vec!["variable"])); + assert!(results.iter().any(|r| r.truncated)); + assert!(results.iter().any(|r| r.columns == vec!["last_result"])); + let spid_again = + execute_in_manual_transaction(&state, &batch, "SELECT @@SPID AS spid", database, None, Some(10)).await.unwrap(); + let spid_last = + execute_in_manual_transaction(&state, &batch, "SELECT @@SPID AS spid", database, None, Some(10)).await.unwrap(); + assert_eq!(spid_again[0].rows, spid_last[0].rows); + rollback_manual_transaction(&state, &batch).await.unwrap(); + sqlserver::execute_simple_batch_with_max_rows(&mut observer, &format!("DROP TABLE {table}"), None).await.unwrap(); + assert!(state.transaction_sessions.read().await.is_empty()); +} + +#[tokio::test] +#[ignore = "requires DBX_TEST_SQLSERVER_* writable endpoint"] +async fn live_manual_transaction_timeout_invalidates_session_without_replay() { + let (state, config, mut observer, _dir) = setup().await; + let database = config.database.as_deref().unwrap(); + let session = begin_manual_transaction(&state, &config.id, database, None, None).await.unwrap(); + assert_driver(&state, &session).await; + let spid = execute_in_manual_transaction(&state, &session, "SELECT @@SPID", database, None, Some(1)).await.unwrap() + [0] + .rows[0][0] + .as_i64() + .unwrap(); + let identity = sqlserver::log_old_session(&mut observer, spid, "before timeout").await; + let error = execute_in_manual_transaction_with_options( + &state, + &session, + "WAITFOR DELAY '00:00:10'; SELECT 1", + database, + None, + ManualTransactionExecutionOptions { + timeout_secs: Some(1), + execution_id: Some("live-sqlserver-timeout".to_owned()), + ..Default::default() + }, + ) + .await + .unwrap_err(); + let error = dbx_core::query::sqlserver_manual_transaction::backend_error(&error).unwrap(); + assert_eq!(serde_json::to_value(error).unwrap()["transactionOutcome"], "unknown"); + assert!(!state.transaction_sessions.read().await.contains_key(&session)); + assert!(execute_in_manual_transaction(&state, &session, "SELECT 2", database, None, Some(10)).await.is_err()); + sqlserver::wait_for_session_release(&mut observer, spid, identity.as_deref()).await; +} + +#[tokio::test] +#[ignore = "requires DBX_TEST_SQLSERVER_* writable endpoint"] +async fn live_manual_transaction_failures_and_idle_expiry_acknowledge_rollback() { + let (state, config, _observer, _dir) = setup().await; + let database = config.database.as_deref().unwrap(); + for idle in [false, true] { + let session = begin_manual_transaction(&state, &config.id, database, None, None).await.unwrap(); + assert_driver(&state, &session).await; + execute_in_manual_transaction( + &state, + &session, + "CREATE TABLE #pending (id int); INSERT INTO #pending VALUES (1)", + database, + None, + Some(10), + ) + .await + .unwrap(); + if idle { + state.transaction_sessions.write().await.get_mut(&session).unwrap().last_activity = + std::time::Instant::now() - Duration::from_secs(301); + } + let error = execute_in_manual_transaction( + &state, + &session, + if idle { "SELECT 2" } else { "SELECT * FROM dbx_missing_table_7542" }, + database, + None, + Some(10), + ) + .await + .unwrap_err(); + let error = dbx_core::query::sqlserver_manual_transaction::backend_error(&error).unwrap(); + assert_eq!(serde_json::to_value(error).unwrap()["transactionOutcome"], "rolled_back"); + assert!(!state.transaction_sessions.read().await.contains_key(&session)); + assert!(execute_in_manual_transaction(&state, &session, "SELECT 3", database, None, Some(10)).await.is_err()); + } + state.agent_manager.stop_daemons().await; +} + +#[tokio::test] +#[ignore = "requires DBX_TEST_SQLSERVER_* writable endpoint"] +async fn live_manual_transaction_killed_connection_has_unknown_outcome_without_replay() { + let (state, config, mut observer, _dir) = setup().await; + let database = config.database.as_deref().unwrap(); + let session = begin_manual_transaction(&state, &config.id, database, None, None).await.unwrap(); + assert_driver(&state, &session).await; + let spid = execute_in_manual_transaction(&state, &session, "SELECT @@SPID", database, None, Some(1)).await.unwrap() + [0] + .rows[0][0] + .as_i64() + .unwrap(); + sqlserver::execute_simple_batch_with_max_rows(&mut observer, &format!("KILL {spid}"), None).await.unwrap(); + let error = + execute_in_manual_transaction(&state, &session, "SELECT 1", database, None, Some(10)).await.unwrap_err(); + let error = dbx_core::query::sqlserver_manual_transaction::backend_error(&error).unwrap(); + assert_eq!(serde_json::to_value(error).unwrap()["transactionOutcome"], "unknown"); + assert!(!state.transaction_sessions.read().await.contains_key(&session)); + assert!(execute_in_manual_transaction(&state, &session, "SELECT 2", database, None, Some(10)).await.is_err()); + state.agent_manager.stop_daemons().await; +} + +#[tokio::test] +#[ignore = "requires DBX_TEST_SQLSERVER_* writable endpoint"] +async fn live_manual_transaction_cancel_confirms_terminal_and_rejects_busy_end() { + let (state, config, mut observer, _dir) = setup().await; + let state = std::sync::Arc::new(state); + let database = config.database.as_deref().unwrap(); + let session = begin_manual_transaction(&state, &config.id, database, None, None).await.unwrap(); + assert_driver(&state, &session).await; + let spid = execute_in_manual_transaction(&state, &session, "SELECT @@SPID", database, None, Some(1)).await.unwrap() + [0] + .rows[0][0] + .as_i64() + .unwrap(); + let identity = sqlserver::log_old_session(&mut observer, spid, "before execution").await; + let execution = { + let state = state.clone(); + let session = session.clone(); + let database = database.to_owned(); + tokio::spawn(async move { + execute_in_manual_transaction_with_options( + &state, + &session, + "WAITFOR DELAY '00:00:20'; SELECT 1", + &database, + None, + ManualTransactionExecutionOptions { + timeout_secs: Some(30), + execution_id: Some("live-sqlserver-cancel".to_owned()), + ..Default::default() + }, + ) + .await + }) + }; + // Confirm the actual WAITFOR reached SQL Server before sending cancellation. + tokio::time::timeout(Duration::from_secs(8), async { + loop { + let requests = sqlserver::execute_query(&mut observer, &sqlserver::wait_query(spid)).await.unwrap(); + if requests.rows[0][0] == serde_json::json!(1) { + break; + } + tokio::time::sleep(Duration::from_millis(30)).await; + } + }) + .await + .expect("long query never reached SQL Server"); + assert!(state.running_queries.diagnostics().active_execution_ids.contains(&"live-sqlserver-cancel".to_owned())); + assert!(commit_manual_transaction(&state, &session).await.unwrap_err().contains("busy")); + assert!(rollback_manual_transaction(&state, &session).await.unwrap_err().contains("busy")); + let cancelled = state.running_queries.cancel_and_wait("live-sqlserver-cancel", Duration::from_secs(8)).await; + assert!(cancelled.requested && cancelled.terminal, "cancellation did not confirm terminal state"); + let error = execution.await.unwrap().unwrap_err(); + let error = dbx_core::query::sqlserver_manual_transaction::backend_error(&error).unwrap(); + assert_eq!(serde_json::to_value(error).unwrap()["transactionOutcome"], "unknown"); + assert!(!state.transaction_sessions.read().await.contains_key(&session)); + assert!(!state.running_queries.diagnostics().active_execution_ids.contains(&"live-sqlserver-cancel".to_owned())); + assert!(execute_in_manual_transaction(&state, &session, "SELECT 2", database, None, Some(10)).await.is_err()); + sqlserver::wait_for_session_release(&mut observer, spid, identity.as_deref()).await; + state.agent_manager.stop_daemons().await; +} + +#[tokio::test] +#[ignore = "requires DBX_TEST_SQLSERVER_* writable endpoint"] +async fn live_manual_transaction_control_conflicts_and_hidden_commit_end_session() { + let (state, config, mut observer, _dir) = setup().await; + let database = config.database.as_deref().unwrap(); + let procedure = format!("dbo.dbx_manual_proc_{}", uuid::Uuid::new_v4().simple()); + sqlserver::execute_simple_batch_with_max_rows( + &mut observer, + &format!("CREATE PROCEDURE {procedure} AS BEGIN COMMIT TRANSACTION; SELECT @@TRANCOUNT END"), + None, + ) + .await + .unwrap(); + let session = begin_manual_transaction(&state, &config.id, database, None, None).await.unwrap(); + assert_driver(&state, &session).await; + for sql in ["COMMIT TRANSACTION", "BEGIN TRANSACTION", "USE master", "SET IMPLICIT_TRANSACTIONS ON"] { + let error = execute_in_manual_transaction(&state, &session, sql, database, None, Some(10)).await.unwrap_err(); + assert!(error.contains("control_conflict"), "wrong conflict error: {error}"); + assert!(state.transaction_sessions.read().await.contains_key(&session)); + } + let count = + execute_in_manual_transaction(&state, &session, "SELECT @@TRANCOUNT", database, None, Some(1)).await.unwrap(); + assert_eq!(count[0].rows[0][0], serde_json::json!(1)); + let error = execute_in_manual_transaction(&state, &session, &format!("EXEC {procedure}"), database, None, Some(10)) + .await + .unwrap_err(); + let error = dbx_core::query::sqlserver_manual_transaction::backend_error(&error).unwrap(); + assert_eq!(serde_json::to_value(error).unwrap()["transactionOutcome"], "unknown"); + assert!(!state.transaction_sessions.read().await.contains_key(&session)); + assert!(execute_in_manual_transaction(&state, &session, "SELECT 2", database, None, Some(10)).await.is_err()); + sqlserver::execute_simple_batch_with_max_rows(&mut observer, &format!("DROP PROCEDURE {procedure}"), None) + .await + .unwrap(); + state.agent_manager.stop_daemons().await; +} + +#[tokio::test] +#[ignore = "requires DBX_TEST_SQLSERVER_* writable endpoint"] +async fn live_manual_transaction_active_disconnect_cancels_and_releases_session() { + let (state, config, mut observer, _dir) = setup().await; + let state = std::sync::Arc::new(state); + let database = config.database.as_deref().unwrap(); + let session = begin_manual_transaction(&state, &config.id, database, None, None).await.unwrap(); + assert_driver(&state, &session).await; + let spid = execute_in_manual_transaction(&state, &session, "SELECT @@SPID", database, None, Some(1)).await.unwrap() + [0] + .rows[0][0] + .as_i64() + .unwrap(); + let identity = sqlserver::log_old_session(&mut observer, spid, "before execution").await; + let execution = { + let state = state.clone(); + let session = session.clone(); + let database = database.to_owned(); + tokio::spawn(async move { + execute_in_manual_transaction_with_options( + &state, + &session, + "WAITFOR DELAY '00:00:20'; SELECT 1", + &database, + None, + ManualTransactionExecutionOptions { + timeout_secs: Some(30), + execution_id: Some("live-sqlserver-disconnect".to_owned()), + ..Default::default() + }, + ) + .await + }) + }; + tokio::time::timeout(Duration::from_secs(8), async { + loop { + let requests = sqlserver::execute_query(&mut observer, &sqlserver::wait_query(spid)).await.unwrap(); + if requests.rows[0][0] == serde_json::json!(1) { + break; + } + tokio::time::sleep(Duration::from_millis(30)).await; + } + }) + .await + .expect("long query never reached SQL Server"); + tokio::time::timeout(Duration::from_secs(10), state.remove_connection_pools(&config.id)) + .await + .expect("disconnect cleanup did not finish"); + let error = execution.await.unwrap().unwrap_err(); + let error = dbx_core::query::sqlserver_manual_transaction::backend_error(&error).unwrap(); + assert_eq!(serde_json::to_value(error).unwrap()["transactionOutcome"], "unknown"); + assert!(state.transaction_sessions.read().await.is_empty()); + assert!(state.running_queries.diagnostics().active_execution_ids.is_empty()); + assert!(execute_in_manual_transaction(&state, &session, "SELECT 2", database, None, Some(10)).await.is_err()); + sqlserver::wait_for_session_release(&mut observer, spid, identity.as_deref()).await; + state.agent_manager.stop_daemons().await; +} + +async fn fault_setup( + mode: &str, +) -> (AppState, ConnectionConfig, sqlserver::SqlServerClient, tempfile::TempDir, FaultProxy) { + let (state, mut config, observer, dir) = setup().await; + let proxy = FaultProxy::start(&config.host, config.port).await; + config.port = proxy.port; + config.host = "127.0.0.1".to_owned(); + if config.driver_profile.as_deref() == Some("sqlserver-legacy") { + let delegate = config.jdbc_driver_class.clone().expect("explicit actual JDBC delegate"); + let original = config.connection_string.as_ref().unwrap(); + let authority = original.find("://").expect("SQL Server JDBC URL authority") + 3; + let suffix = original[authority..].find([';', '/']).map(|offset| authority + offset).unwrap_or(original.len()); + let url = format!("{}127.0.0.1:{}{}", &original[..authority], proxy.port, &original[suffix..]); + config.connection_string = Some(format!( + "jdbc:dbx-fault:{url};dbxFaultDriver={delegate};dbxFaultControlPort={};dbxFaultMode={mode}", + proxy.control_port + )); + config.jdbc_driver_class = Some("com.dbx.agent.testing.SqlServerFaultDriver".to_owned()); + config.jdbc_driver_paths = + vec![std::env::var("DBX_TEST_SQLSERVER_FAULT_DRIVER_JAR").expect("built test-support.jar")]; + } + state.configs.write().await.insert(config.id.clone(), config.clone()); + (state, config, observer, dir, proxy) +} + +async fn response_loss(commit: bool) { + let mode = if commit { "commit_response_loss" } else { "rollback_response_loss" }; + let (state, config, mut observer, _dir, proxy) = fault_setup(mode).await; + let database = config.database.as_deref().unwrap(); + let table = format!("dbo.dbx_manual_fault_{}", uuid::Uuid::new_v4().simple()); + sqlserver::execute_simple_batch_with_max_rows( + &mut observer, + &format!("CREATE TABLE {table} (id int PRIMARY KEY)"), + None, + ) + .await + .unwrap(); + let session = begin_manual_transaction(&state, &config.id, database, None, None).await.unwrap(); + assert_driver(&state, &session).await; + execute_in_manual_transaction( + &state, + &session, + &format!("INSERT INTO {table} VALUES (1)"), + database, + None, + Some(1), + ) + .await + .unwrap(); + // TLS negotiation can open several sockets before the dedicated workload + // connection exists (SQL2000 rejects TLS before accepting plain TDS). + // Only a connection opened after this point would be a forbidden reconnect. + let established_connections = proxy.stats.connections.load(Ordering::SeqCst); + let _hook = if config.driver_profile.is_none() { + let hook_id = if commit { + session.clone() + } else { + let sessions = state.transaction_sessions.read().await; + let connection = sessions[&session].connection.lock().await; + match &*connection { + dbx_core::connection::TxnConnection::SqlServer { client_session_id, .. } => client_session_id.clone(), + _ => panic!("expected fixed native connection"), + } + }; + let stats = proxy.stats.clone(); + Some(live_test_hooks::install( + &hook_id, + if commit { Phase::BeforeCommit } else { Phase::BeforeRollback }, + move || stats.armed.store(true, Ordering::SeqCst), + )) + } else { + None + }; + let error = if commit { + commit_manual_transaction(&state, &session).await + } else { + rollback_manual_transaction(&state, &session).await + } + .unwrap_err(); + let parsed = dbx_core::query::sqlserver_manual_transaction::backend_error(&error).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap()["transactionOutcome"], "unknown"); + assert_eq!(proxy.stats.dropped_responses.load(Ordering::SeqCst), 1, "no actual server response was discarded"); + assert!(proxy.stats.request_bytes_after_arm.load(Ordering::SeqCst) > 0, "end request was not forwarded"); + assert!(!state.transaction_sessions.read().await.contains_key(&session)); + // The independent observer proves which operation really reached SQL Server. + let visible = + sqlserver::execute_query(&mut observer, &format!("SET LOCK_TIMEOUT 5000; SELECT COUNT(*) FROM {table}")) + .await + .unwrap(); + assert_eq!(visible.rows[0][0], serde_json::json!(if commit { 1 } else { 0 })); + assert!(commit_manual_transaction(&state, &session).await.is_err()); + assert!(rollback_manual_transaction(&state, &session).await.is_err()); + assert!(execute_in_manual_transaction(&state, &session, "SELECT 1", database, None, Some(1)).await.is_err()); + assert_eq!(proxy.stats.connections.load(Ordering::SeqCst), established_connections, "client silently reconnected"); + sqlserver::execute_simple_batch_with_max_rows(&mut observer, &format!("DROP TABLE {table}"), None).await.unwrap(); + state.agent_manager.stop_daemons().await; + println!( + "{} response discarded; independently verified final data; outcome unknown; no reconnect/replay", + if commit { "COMMIT" } else { "ROLLBACK" } + ); +} +#[tokio::test] +#[ignore = "requires writable SQL Server and live fault driver"] +async fn live_manual_transaction_fault_commit_response_lost_after_real_commit() { + response_loss(true).await; +} +#[tokio::test] +#[ignore = "requires writable SQL Server and live fault driver"] +async fn live_manual_transaction_fault_rollback_response_loss_remains_unknown() { + response_loss(false).await; +} + +#[tokio::test] +#[ignore = "requires writable SQL Server and live fault driver"] +async fn live_manual_transaction_fault_acknowledged_commit_survives_terminal_transport_or_close_fault() { + let (state, config, mut observer, _dir, proxy) = fault_setup("cleanup_failure").await; + let database = config.database.as_deref().unwrap(); + let table = format!("dbo.dbx_manual_fault_{}", uuid::Uuid::new_v4().simple()); + sqlserver::execute_simple_batch_with_max_rows( + &mut observer, + &format!("CREATE TABLE {table} (id int PRIMARY KEY)"), + None, + ) + .await + .unwrap(); + let session = begin_manual_transaction(&state, &config.id, database, None, None).await.unwrap(); + assert_driver(&state, &session).await; + execute_in_manual_transaction( + &state, + &session, + &format!("INSERT INTO {table} VALUES (1)"), + database, + None, + Some(1), + ) + .await + .unwrap(); + // TLS negotiation can open several sockets before the dedicated workload + // connection exists (SQL2000 rejects TLS before accepting plain TDS). + // Only a connection opened after this point would be a forbidden reconnect. + let established_connections = proxy.stats.connections.load(Ordering::SeqCst); + let _hook = if config.driver_profile.is_none() { + let stats = proxy.stats.clone(); + Some(live_test_hooks::install(&session, Phase::AfterCommitAck, move || stats.cut.cancel())) + } else { + None + }; + // Keep the legacy single-process client alive until its asynchronous Java + // close callback runs. Otherwise process fail-stop can precede fault injection. + let close_guard = { + let sessions = state.transaction_sessions.read().await; + let connection = sessions[&session].connection.lock().await; + match &*connection { + dbx_core::connection::TxnConnection::Agent { client, .. } => Some(client.clone()), + _ => None, + } + }; + commit_manual_transaction(&state, &session).await.unwrap(); + assert!(state.connection_pools_snapshot().await.is_empty()); + if config.driver_profile.is_some() { + tokio::time::timeout(Duration::from_secs(8), async { + while proxy.stats.close_failures.load(Ordering::SeqCst) == 0 { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("actual JDBC close error was not injected"); + assert_eq!(proxy.stats.close_entered.load(Ordering::SeqCst), 1); + } else { + assert!(proxy.stats.cut.is_cancelled()); + } + drop(close_guard); + tokio::time::timeout(Duration::from_secs(3), async { + while proxy.stats.closed_connections.load(Ordering::SeqCst) < established_connections { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("terminal TCP socket handles were not released"); + let error = commit_manual_transaction(&state, &session).await.unwrap_err(); + let parsed = dbx_core::query::sqlserver_manual_transaction::backend_error(&error).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap()["transactionOutcome"], "committed"); + let visible = sqlserver::execute_query(&mut observer, &format!("SELECT COUNT(*) FROM {table}")).await.unwrap(); + assert_eq!(visible.rows[0][0], serde_json::json!(1)); + assert!(state.transaction_sessions.read().await.is_empty()); + assert_eq!(proxy.stats.connections.load(Ordering::SeqCst), established_connections); + sqlserver::execute_simple_batch_with_max_rows(&mut observer, &format!("DROP TABLE {table}"), None).await.unwrap(); + state.agent_manager.stop_daemons().await; + println!( + "Real COMMIT acknowledged; {} did not change committed outcome", + if config.driver_profile.is_some() { "injected JDBC close error" } else { "confirmed terminal TCP closure" } + ); +} + +#[tokio::test] +#[ignore = "requires writable SQL Server and live fault driver"] +async fn live_manual_transaction_fault_network_break_during_execution_discards_session() { + let (state, config, mut observer, _dir, proxy) = fault_setup("network_break").await; + let state = std::sync::Arc::new(state); + let database = config.database.as_deref().unwrap(); + let table = format!("dbo.dbx_manual_fault_{}", uuid::Uuid::new_v4().simple()); + sqlserver::execute_simple_batch_with_max_rows( + &mut observer, + &format!("CREATE TABLE {table} (id int PRIMARY KEY)"), + None, + ) + .await + .unwrap(); + let session = begin_manual_transaction(&state, &config.id, database, None, None).await.unwrap(); + assert_driver(&state, &session).await; + let spid = execute_in_manual_transaction(&state, &session, "SELECT @@SPID", database, None, Some(1)).await.unwrap() + [0] + .rows[0][0] + .as_i64() + .unwrap(); + execute_in_manual_transaction( + &state, + &session, + &format!("INSERT INTO {table} VALUES (1)"), + database, + None, + Some(1), + ) + .await + .unwrap(); + let executing = { + let state = state.clone(); + let session = session.clone(); + let database = database.to_owned(); + tokio::spawn(async move { + execute_in_manual_transaction_with_options( + &state, + &session, + "WAITFOR DELAY '00:00:10'; SELECT 1", + &database, + None, + ManualTransactionExecutionOptions { + timeout_secs: Some(15), + execution_id: Some("live-network-fault".to_owned()), + ..Default::default() + }, + ) + .await + }) + }; + tokio::time::timeout(Duration::from_secs(8), async { + loop { + let waiting = sqlserver::execute_query(&mut observer, &sqlserver::wait_query(spid)).await.unwrap(); + if waiting.rows[0][0] == serde_json::json!(1) { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .unwrap(); + let established_connections = proxy.stats.connections.load(Ordering::SeqCst); + proxy.stats.cut.cancel(); + let error = tokio::time::timeout(Duration::from_secs(20), executing).await.unwrap().unwrap().unwrap_err(); + let parsed = dbx_core::query::sqlserver_manual_transaction::backend_error(&error).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap()["transactionOutcome"], "unknown"); + assert!(state.transaction_sessions.read().await.is_empty()); + assert!(execute_in_manual_transaction(&state, &session, "SELECT 2", database, None, Some(1)).await.is_err()); + let visible = + sqlserver::execute_query(&mut observer, &format!("SET LOCK_TIMEOUT 15000; SELECT COUNT(*) FROM {table}")) + .await + .unwrap(); + assert_eq!(visible.rows[0][0], serde_json::json!(0)); + assert_eq!( + proxy.stats.connections.load(Ordering::SeqCst), + established_connections, + "network failure triggered reconnect" + ); + assert!(state.running_queries.diagnostics().active_execution_ids.is_empty()); + sqlserver::execute_simple_batch_with_max_rows(&mut observer, &format!("DROP TABLE {table}"), None).await.unwrap(); + state.agent_manager.stop_daemons().await; + println!("Real TCP connection interrupted during WAITFOR; pending data absent; no reconnect/replay"); +} + +#[tokio::test] +#[ignore = "requires writable SQL Server; waits for the real five-minute idle watchdog"] +async fn live_manual_transaction_wall_clock_idle_watchdog_rolls_back_without_request() { + let (state, config, mut observer, _dir) = setup().await; + let database = config.database.as_deref().unwrap(); + let table = format!("dbo.dbx_manual_idle_{}", uuid::Uuid::new_v4().simple()); + sqlserver::execute_simple_batch_with_max_rows( + &mut observer, + &format!("CREATE TABLE {table} (id int PRIMARY KEY)"), + None, + ) + .await + .unwrap(); + let session = begin_manual_transaction(&state, &config.id, database, None, None).await.unwrap(); + assert_driver(&state, &session).await; + execute_in_manual_transaction( + &state, + &session, + &format!("INSERT INTO {table} VALUES (1)"), + database, + None, + Some(1), + ) + .await + .unwrap(); + let started = std::time::Instant::now(); + // Observe the registry only: do not execute another request or alter activity timestamps. + tokio::time::timeout(Duration::from_secs(330), async { + loop { + if !state.transaction_sessions.read().await.contains_key(&session) { + break; + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + }) + .await + .expect("real idle watchdog did not remove session within 330 seconds"); + assert!(started.elapsed() >= Duration::from_secs(299), "watchdog expired before configured idle period"); + tokio::time::timeout(Duration::from_secs(15), async { + loop { + let error = commit_manual_transaction(&state, &session).await.unwrap_err(); + let parsed = dbx_core::query::sqlserver_manual_transaction::backend_error(&error).unwrap(); + let value = serde_json::to_value(parsed).unwrap(); + if value["transactionOutcome"] == "rolled_back" { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("watchdog rollback acknowledgement never arrived"); + let visible = + sqlserver::execute_query(&mut observer, &format!("SET LOCK_TIMEOUT 3000; SELECT COUNT(*) FROM {table}")) + .await + .unwrap(); + assert_eq!(visible.rows[0][0], serde_json::json!(0)); + assert!(state.connection_pools_snapshot().await.is_empty(), "watchdog left a routed dedicated pool"); + assert!(execute_in_manual_transaction(&state, &session, "SELECT 1", database, None, Some(1)).await.is_err()); + sqlserver::execute_simple_batch_with_max_rows(&mut observer, &format!("DROP TABLE {table}"), None).await.unwrap(); + state.agent_manager.stop_daemons().await; + println!( + "Real idle duration {:?}; background rollback acknowledged; data absent; no request replay", + started.elapsed() + ); +} + +async fn aborted_request_probe(disconnecting: bool) { + let (state, config, mut observer, _dir) = setup().await; + let state = std::sync::Arc::new(state); + let database = config.database.as_deref().unwrap(); + let table = format!("dbo.dbx_manual_aborted_{}", uuid::Uuid::new_v4().simple()); + sqlserver::execute_simple_batch_with_max_rows( + &mut observer, + &format!("CREATE TABLE {table} (id int PRIMARY KEY)"), + None, + ) + .await + .unwrap(); + let session = begin_manual_transaction(&state, &config.id, database, None, None).await.unwrap(); + assert_driver(&state, &session).await; + let spid = execute_in_manual_transaction(&state, &session, "SELECT @@SPID", database, None, Some(1)).await.unwrap() + [0] + .rows[0][0] + .as_i64() + .unwrap(); + execute_in_manual_transaction( + &state, + &session, + &format!("INSERT INTO {table} VALUES (1)"), + database, + None, + Some(1), + ) + .await + .unwrap(); + let identity = sqlserver::log_old_session(&mut observer, spid, "before request abort").await; + let execution = { + let state = state.clone(); + let session = session.clone(); + let database = database.to_owned(); + tokio::spawn(async move { + execute_in_manual_transaction_with_options( + &state, + &session, + "WAITFOR DELAY '00:00:10'; SELECT 1", + &database, + None, + ManualTransactionExecutionOptions { + timeout_secs: Some(20), + execution_id: Some("live-request-abort".to_owned()), + ..Default::default() + }, + ) + .await + }) + }; + tokio::time::timeout(Duration::from_secs(8), async { + loop { + if sqlserver::execute_query(&mut observer, &sqlserver::wait_query(spid)).await.unwrap().rows[0][0] + == serde_json::json!(1) + { + break; + } + tokio::time::sleep(Duration::from_millis(30)).await; + } + }) + .await + .expect("request never reached server"); + // Transfer cleanup ownership before dropping the future. This avoids relying + // on scheduler timing or lifecycle cancellation to reproduce the race. + let disconnect_task = if disconnecting { + let removed = state.transaction_sessions.write().await.remove(&session).unwrap(); + assert!(removed.busy); + let state = state.clone(); + let session = session.clone(); + Some(tokio::spawn(async move { + dbx_core::query::sqlserver_manual_transaction::disconnect(&state, &session, removed).await; + })) + } else { + None + }; + execution.abort(); + assert!(execution.await.unwrap_err().is_cancelled()); + if let Some(task) = disconnect_task { + task.await.unwrap(); + } + tokio::time::timeout(Duration::from_secs(8), async { + while state.transaction_sessions.read().await.contains_key(&session) { + tokio::time::sleep(Duration::from_millis(30)).await; + } + }) + .await + .expect("dropped request left a registered session"); + sqlserver::wait_for_session_release(&mut observer, spid, identity.as_deref()).await; + let visible = + sqlserver::execute_query(&mut observer, &format!("SET LOCK_TIMEOUT 3000; SELECT COUNT(*) FROM {table}")) + .await + .unwrap(); + assert_eq!(visible.rows[0][0], serde_json::json!(0)); + assert!(state.running_queries.diagnostics().active_execution_ids.is_empty()); + assert!(execute_in_manual_transaction(&state, &session, "SELECT 2", database, None, Some(1)).await.is_err()); + sqlserver::execute_simple_batch_with_max_rows(&mut observer, &format!("DROP TABLE {table}"), None).await.unwrap(); + state.agent_manager.stop_daemons().await; +} +#[tokio::test] +#[ignore = "requires writable SQL Server; dropping a running request must stop its dedicated session"] +async fn live_manual_transaction_aborted_request_releases_original_server_transaction() { + aborted_request_probe(false).await; +} + +#[tokio::test] +#[ignore = "requires writable SQL Server; disconnect must own cleanup when the request is dropped"] +async fn live_manual_transaction_aborted_request_after_disconnect_releases_original_server_transaction() { + aborted_request_probe(true).await; +} diff --git a/crates/dbx-core/tests/support/sqlserver_fault_proxy.rs b/crates/dbx-core/tests/support/sqlserver_fault_proxy.rs new file mode 100644 index 0000000000..0eb6e9d669 --- /dev/null +++ b/crates/dbx-core/tests/support/sqlserver_fault_proxy.rs @@ -0,0 +1,140 @@ +//! TCP fault injector: forwards real TDS/TCP bytes (including TLS ciphertext), never SQL mocks. +use std::sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + task::JoinSet, +}; +use tokio_util::sync::CancellationToken; +#[derive(Default)] +pub struct Stats { + pub armed: AtomicBool, + pub dropped_responses: AtomicUsize, + pub request_bytes_after_arm: AtomicUsize, + pub connections: AtomicUsize, + pub close_failures: AtomicUsize, + pub close_entered: AtomicUsize, + pub closed_connections: AtomicUsize, + pub cut: CancellationToken, + shutdown: CancellationToken, +} +pub struct FaultProxy { + pub port: u16, + pub control_port: u16, + pub stats: Arc, +} +impl FaultProxy { + pub async fn start(host: &str, port: u16) -> Self { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let control = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let proxy = Self { + port: listener.local_addr().unwrap().port(), + control_port: control.local_addr().unwrap().port(), + stats: Arc::default(), + }; + let stats = proxy.stats.clone(); + let host = host.to_owned(); + tokio::spawn(async move { + let mut tasks = JoinSet::new(); + loop { + tokio::select! { + _ = stats.shutdown.cancelled() => break, + accepted = listener.accept() => { + let (client, _) = accepted.unwrap(); + stats.connections.fetch_add(1, Ordering::SeqCst); + let stats = stats.clone(); let host = host.clone(); + tasks.spawn(async move { relay(client, &host, port, stats).await }); + } + Some(_) = tasks.join_next(), if !tasks.is_empty() => {} + } + } + }); + let stats = proxy.stats.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + _ = stats.shutdown.cancelled() => break, + accepted = control.accept() => { + let Ok((mut socket, _)) = accepted else { break; }; + let read = async { + let mut command = Vec::new(); + loop { + let ch = socket.read_u8().await?; + if ch == b'\n' { return Ok::<_, std::io::Error>(command); } + command.push(ch); + if command.len() >= 64 { return Err(std::io::ErrorKind::InvalidData.into()); } + } + }; + let command = tokio::select! { + _ = stats.shutdown.cancelled() => break, + result = tokio::time::timeout(std::time::Duration::from_secs(3), read) => { + match result { Ok(Ok(command)) => command, _ => continue } + } + }; + match command.as_slice() { + b"ARM" => stats.armed.store(true, Ordering::SeqCst), + b"CLOSE_ENTERED" => { stats.close_entered.fetch_add(1, Ordering::SeqCst); }, + b"CLOSE_FAILED" => { stats.close_failures.fetch_add(1, Ordering::SeqCst); }, + _ => panic!("unknown fault command"), + } + let _ = socket.write_all(b"K").await; + } + } + } + }); + proxy + } +} +impl Drop for FaultProxy { + fn drop(&mut self) { + self.stats.shutdown.cancel(); + self.stats.cut.cancel(); + } +} +async fn relay(client: TcpStream, host: &str, port: u16, stats: Arc) { + let server = TcpStream::connect((host, port)).await.unwrap(); + let (mut client_read, mut client_write) = client.into_split(); + let (mut server_read, mut server_write) = server.into_split(); + let send = async { + let mut buffer = [0u8; 8192]; + loop { + let size = client_read.read(&mut buffer).await?; + if size == 0 { + return server_write.shutdown().await; + } + let armed = stats.armed.load(Ordering::SeqCst); + server_write.write_all(&buffer[..size]).await?; + if armed { + stats.request_bytes_after_arm.fetch_add(size, Ordering::SeqCst); + } + } + }; + let receive = async { + let mut buffer = [0u8; 8192]; + loop { + let size = server_read.read(&mut buffer).await?; + if size == 0 { + return client_write.shutdown().await; + } + if stats.armed.load(Ordering::SeqCst) { + stats.dropped_responses.fetch_add(1, Ordering::SeqCst); + stats.cut.cancel(); + return Ok::<(), std::io::Error>(()); + } + client_write.write_all(&buffer[..size]).await?; + } + }; + tokio::select! { + _ = stats.cut.cancelled() => {}, + _ = stats.shutdown.cancelled() => {}, + _ = async { let _ = tokio::try_join!(send, receive); } => {}, + } + drop(client_read); + drop(client_write); + drop(server_read); + drop(server_write); + stats.closed_connections.fetch_add(1, Ordering::SeqCst); +} diff --git a/crates/dbx-core/tests/support/sqlserver_live_observer.rs b/crates/dbx-core/tests/support/sqlserver_live_observer.rs new file mode 100644 index 0000000000..a344531f15 --- /dev/null +++ b/crates/dbx-core/tests/support/sqlserver_live_observer.rs @@ -0,0 +1,124 @@ +//! Independent observers for the opt-in SQL Server live suite. +//! SQL Server 2000 uses an independent JDBC observer, including for native TDS workloads. +use dbx_core::db::{ + agent_driver::{AgentDriverClient, AgentLaunchSpec}, + sqlserver as native, QueryResult, +}; +use std::time::Duration; + +pub fn sql_server_2000() -> bool { + std::env::var("DBX_TEST_SQLSERVER_COMPAT_2000").as_deref() == Ok("1") +} + +pub enum SqlServerClient { + Native(Box), + Agent(Box), +} + +pub async fn connect_with_port_explicit( + host: &str, + port: u16, + port_explicit: bool, + user: &str, + password: &str, + database: Option<&str>, + timeout: Duration, +) -> Result { + if !sql_server_2000() { + return native::connect_with_port_explicit(host, port, port_explicit, user, password, database, timeout) + .await + .map(|client| SqlServerClient::Native(Box::new(client))); + } + let mut client = AgentDriverClient::spawn(AgentLaunchSpec::java_jar( + std::env::var("DBX_TEST_SQLSERVER_JAVA").expect("Java 21 executable"), + std::env::var("DBX_TEST_SQLSERVER_AGENT_JAR").expect("built SQL Server legacy Agent"), + )) + .await?; + client + .connect(serde_json::json!({ + "host":host, "port":port, "port_explicit":port_explicit, "username":user, + "password":password, "database":database, + "jdbc_driver_class":std::env::var("DBX_TEST_SQLSERVER_JDBC_CLASS").ok(), + "connection_string":std::env::var("DBX_TEST_SQLSERVER_JDBC_URL").ok(), + "ssl":false, + })) + .await?; + Ok(SqlServerClient::Agent(Box::new(client))) +} + +pub async fn execute_simple_batch_with_max_rows( + client: &mut SqlServerClient, + sql: &str, + max_rows: Option, +) -> Result, String> { + match client { + SqlServerClient::Native(client) => native::execute_simple_batch_with_max_rows(client, sql, max_rows).await, + SqlServerClient::Agent(client) => client + .execute_query_with_timeout( + serde_json::json!({"sql":sql,"maxRows":max_rows}), + Some(Duration::from_secs(40)), + ) + .await + .map(|result: QueryResult| vec![result]), + } +} + +pub async fn execute_query(client: &mut SqlServerClient, sql: &str) -> Result { + match client { + SqlServerClient::Native(client) => native::execute_query(client, sql).await, + SqlServerClient::Agent(_) => execute_simple_batch_with_max_rows(client, sql, None) + .await? + .into_iter() + .find(|result| !result.columns.is_empty()) + .ok_or_else(|| "Observer query returned no result set".to_owned()), + } +} + +pub fn wait_query(spid: i64) -> String { + if sql_server_2000() { + format!("SELECT COUNT(*) FROM master.dbo.sysprocesses WHERE spid={spid} AND LTRIM(RTRIM(cmd))='WAITFOR'") + } else { + format!("SELECT COUNT(*) FROM sys.dm_exec_requests WHERE session_id={spid} AND wait_type='WAITFOR'") + } +} + +pub fn active_query(spid: i64) -> String { + if sql_server_2000() { + format!("SELECT COUNT(*) FROM master.dbo.sysprocesses WHERE spid={spid}") + } else { + format!("SELECT COUNT(*) FROM sys.dm_exec_requests WHERE session_id={spid}") + } +} + +pub async fn log_old_session(client: &mut SqlServerClient, spid: i64, phase: &str) -> Option { + if !sql_server_2000() { + return None; + } + let sql = format!("SELECT spid, status, cmd, open_tran, login_time, hostname, program_name FROM master.dbo.sysprocesses WHERE spid={spid}"); + let result = execute_query(client, &sql).await.expect("old session diagnostic"); + println!("SQL2000 session {spid} {phase}: {:?}", result.rows); + result.rows.first().and_then(|row| row.get(4)).and_then(serde_json::Value::as_str).map(str::to_owned) +} + +/// Server cleanup is asynchronous. Poll fresh evidence rather than assert a +/// stale snapshot taken before logging; login_time prevents SPID reuse from +/// making a newly opened, unrelated session look like a leaked transaction. +pub async fn wait_for_session_release(client: &mut SqlServerClient, spid: i64, identity: Option<&str>) { + let sql = if sql_server_2000() { + let identity = identity.expect("actual SQL2000 login_time must be recorded").replace(char::from(39), "''"); + format!("SELECT COUNT(*) FROM master.dbo.sysprocesses WHERE spid={spid} AND login_time=CONVERT(datetime, '{identity}', 121)") + } else { + active_query(spid) + }; + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let active = execute_query(client, &sql).await.unwrap(); + if active.rows[0][0] == serde_json::json!(0) { + break; + } + tokio::time::sleep(Duration::from_millis(30)).await; + } + }) + .await + .expect("the original server task/transaction was not released"); +} diff --git a/crates/dbx-driver-agent/src/agent_driver.rs b/crates/dbx-driver-agent/src/agent_driver.rs index 1b58661930..0fe2d1f0b6 100644 --- a/crates/dbx-driver-agent/src/agent_driver.rs +++ b/crates/dbx-driver-agent/src/agent_driver.rs @@ -852,6 +852,15 @@ const AGENT_STARTUP_RETRY_DELAY_MS: u64 = 100; const SHARED_RUNTIME_IDLE_GRACE_SECS: u64 = 30; const AGENT_JAVA_OPTS_ENV: &str = "DBX_AGENT_JAVA_OPTS"; +fn agent_json_rpc_response_id(response: &Value) -> Option { + if response.get("jsonrpc").and_then(Value::as_str) != Some("2.0") + || (response.get("result").is_none() && response.get("error").is_none()) + { + return None; + } + response.get("id").and_then(Value::as_u64) +} + pub struct AgentDriverClient { child: Option, stdin: Option>, @@ -865,6 +874,7 @@ pub struct AgentDriverClient { /// Driver-reported identifier quoting, captured once after connect. /// Keeping this on the client avoids a connection-info RPC on every query. identifier_quote: Option, + dedicated_transaction_query_cancel: bool, } /// Keeps serialized session RPC access separate from the process-level fail-stop handle. @@ -1680,6 +1690,10 @@ fn is_retryable_agent_startup_error(error: &str) -> bool { && !error.contains(AGENT_JAVA_TOO_OLD_MESSAGE) } +/// Outcome of one blocking agent RPC read: the restored stdout reader, the +/// deserialized result (or error) and the JSON-RPC id the reply carried. +type AgentResponseOutcome = (BufReader, Result, Option); + impl AgentDriverClient { /// Spawn an agent process and wait for it to signal readiness. /// @@ -1700,6 +1714,7 @@ impl AgentDriverClient { agent_session_id: None, cached_query: None, identifier_quote: None, + dedicated_transaction_query_cancel: false, }) } @@ -1715,6 +1730,7 @@ impl AgentDriverClient { agent_session_id: Some(agent_session_id), cached_query: None, identifier_quote: None, + dedicated_transaction_query_cancel: false, } } @@ -1814,8 +1830,65 @@ impl AgentDriverClient { let request_line = serde_json::to_string(&request).map_err(|e| format!("Failed to serialize JSON-RPC request: {e}"))?; - // Write request to stdin - let write_result = { + // A dedicated SQL Server query must remain cancellable even before + // the Agent consumes its JSON request. Keep other clients unchanged. + let mut timeout_duration = timeout_duration; + let write_result = if self.dedicated_transaction_query_cancel && method == AgentMethod::ExecuteQuery.as_str() { + if cancel_token.as_ref().is_some_and(CancellationToken::is_cancelled) { + self.kill(); + return Err(AgentCallError::Canceled { + stage: agent_error_stage(method), + operation_outcome: agent_operation_outcome(method), + }); + } + let started = Instant::now(); + let mut writer = self.stdin.take().ok_or("Agent stdin not available")?; + let mut write_task = tokio::task::spawn_blocking(move || { + let result = writer + .write_all(request_line.as_bytes()) + .and_then(|_| writer.write_all(b"\n")) + .and_then(|_| writer.flush()) + .map_err(|e| format!("Failed to write to agent stdin: {e}")); + (writer, result) + }); + let result = tokio::select! { + // If the entire request is known to have been sent, restore the + // writer so cancellation can use JDBC cancel_session below. + biased; + result = &mut write_task => result, + _ = async { + if let Some(token) = &cancel_token { token.cancelled().await; } + else { std::future::pending::<()>().await; } + } => { + // A partial JSON frame cannot be followed by another RPC. + self.kill(); + return Err(AgentCallError::Canceled { + stage: agent_error_stage(method), operation_outcome: agent_operation_outcome(method), + }); + }, + _ = async { + if let Some(duration) = timeout_duration { tokio::time::sleep(duration).await; } + else { std::future::pending::<()>().await; } + } => { + self.kill(); + return Err(AgentCallError::Timeout { + stage: agent_error_stage(method), operation_outcome: agent_operation_outcome(method), + }); + }, + }; + let (writer, result) = match result { + Ok(result) => result, + Err(error) => { + self.kill(); + return Err(AgentCallError::Transport { message: format!("Agent write task failed: {error}") }); + } + }; + self.stdin = Some(writer); + // Sending and receiving share one deadline, without granting a + // second timeout after a slow but successful write. + timeout_duration = timeout_duration.map(|duration| duration.saturating_sub(started.elapsed())); + result + } else { let writer = self.stdin.as_mut().ok_or("Agent stdin not available")?; writer .write_all(request_line.as_bytes()) @@ -1832,12 +1905,13 @@ impl AgentDriverClient { // Read response from stdout (blocking, with timeout) let mut reader = self.stdout.take().ok_or("Agent stdout not available")?; - let response_task = tokio::task::spawn_blocking(move || { + let mut response_task = tokio::task::spawn_blocking(move || { let (line, resp) = match read_agent_json_response(&mut reader) { Ok(response) => response, - Err(e) => return (reader, Err(e)), + Err(e) => return (reader, Err(e), None), }; + let response_id = agent_json_rpc_response_id(&resp); let result = if let Some(err) = resp.get("error") { Err(format_agent_rpc_error(err)) } else if let Some(result_val) = resp.get("result") { @@ -1847,23 +1921,23 @@ impl AgentDriverClient { Err(format!("Agent response missing both 'result' and 'error': {line}")) }; - (reader, result) + (reader, result, response_id) }); - let (returned_reader, result) = match (timeout_duration, cancel_token) { + let (returned_reader, result, _) = match (timeout_duration, cancel_token) { (Some(duration), Some(token)) => { tokio::select! { biased; _ = token.cancelled() => { - self.kill(); + self.kill_after_dedicated_transaction_query_cancel(method, id, &mut response_task).await; return Err(AgentCallError::Canceled { stage: agent_error_stage(method), operation_outcome: agent_operation_outcome(method), }); } - result = tokio::time::timeout(duration, response_task) => match result { + result = tokio::time::timeout(duration, &mut response_task) => match result { Ok(result) => result, Err(_) => { - self.kill(); + self.kill_after_dedicated_transaction_query_cancel(method, id, &mut response_task).await; return Err(AgentCallError::Timeout { stage: agent_error_stage(method), operation_outcome: agent_operation_outcome(method), @@ -1872,10 +1946,10 @@ impl AgentDriverClient { }, } } - (Some(duration), None) => match tokio::time::timeout(duration, response_task).await { + (Some(duration), None) => match tokio::time::timeout(duration, &mut response_task).await { Ok(result) => result, Err(_) => { - self.kill(); + self.kill_after_dedicated_transaction_query_cancel(method, id, &mut response_task).await; return Err(AgentCallError::Timeout { stage: agent_error_stage(method), operation_outcome: agent_operation_outcome(method), @@ -1886,13 +1960,13 @@ impl AgentDriverClient { tokio::select! { biased; _ = token.cancelled() => { - self.kill(); + self.kill_after_dedicated_transaction_query_cancel(method, id, &mut response_task).await; return Err(AgentCallError::Canceled { stage: agent_error_stage(method), operation_outcome: agent_operation_outcome(method), }); } - result = response_task => result, + result = &mut response_task => result, } } (None, None) => response_task.await, @@ -1913,6 +1987,73 @@ impl AgentDriverClient { .map_err(|error| legacy_agent_call_error(error, self.agent_session_id.as_deref())) } + /// Opt in only for isolated transaction clients using the v2 JDBC server. + /// Ordinary clients keep their existing immediate fail-stop behavior. + pub fn enable_dedicated_transaction_query_cancellation(&mut self) { + self.dedicated_transaction_query_cancel = true; + } + + async fn kill_after_dedicated_transaction_query_cancel( + &mut self, + method: &str, + query_id: u64, + response_task: &mut tokio::task::JoinHandle>, + ) { + if self.dedicated_transaction_query_cancel + && method == AgentMethod::ExecuteQuery.as_str() + && self.handshake.as_ref().is_some_and(|handshake| { + handshake.protocol_version >= 2 && handshake.supports(AgentCapability::MultiSession) + }) + { + self.next_id += 1; + let cancel_id = self.next_id; + // MultiSessionJsonRpcServer routes connect() clients to this singleton + // session. Its cancellation path does not acquire the executing RPC lock. + let request = serde_json::json!({ + "jsonrpc": "2.0", "id": cancel_id, "method": "cancel_session", + "params": { "agentSessionId": "__legacy__" }, + }); + if let Some(mut writer) = self.stdin.take() { + // Moving the writer lets the independent child handle force kill + // even if a full stdin pipe blocks the write. Never restore stdin. + let _ = tokio::time::timeout(Duration::from_secs(5), async { + let writer = tokio::task::spawn_blocking(move || { + writeln!(writer, "{request}").and_then(|_| writer.flush())?; + Ok::<_, std::io::Error>(writer) + }) + .await + .map_err(|_| ())? + .map_err(|_| ())?; + // Keep stdin open until the replies arrive: EOF can make the + // Agent exit before JDBC cancellation reaches the server. + let (mut reader, _, first_id) = (&mut *response_task).await.map_err(|_| ())?; + tokio::task::spawn_blocking(move || { + let mut query_seen = first_id == Some(query_id); + let mut cancel_seen = first_id == Some(cancel_id); + while !query_seen || !cancel_seen { + let (_, response) = read_agent_json_response(&mut reader)?; + match agent_json_rpc_response_id(&response) { + Some(id) if id == query_id => query_seen = true, + Some(id) if id == cancel_id => cancel_seen = true, + _ => {} // JSON logs and duplicate/unrelated ACKs cannot end the grace. + } + } + Ok::<(), String>(()) + }) + .await + .map_err(|_| ())? + .map_err(|_| ())?; + drop(writer); + Ok::<(), ()>(()) + }) + .await; + } + } + // Cancellation always discards this connection, even after both replies. + // On an unresponsive Agent, killing also unblocks the stdio worker tasks. + self.kill(); + } + pub async fn call_method( &mut self, method: AgentMethod, @@ -3573,6 +3714,7 @@ impl AgentDriverClient { agent_session_id: None, cached_query: None, identifier_quote: None, + dedicated_transaction_query_cancel: false, } } @@ -4401,6 +4543,7 @@ for line in sys.stdin: agent_session_id: None, cached_query: None, identifier_quote: None, + dedicated_transaction_query_cancel: false, }; let started_at = std::time::Instant::now(); @@ -4841,6 +4984,231 @@ for line in sys.stdin: let _ = std::fs::remove_file(script_path); } + async fn dedicated_transaction_cancel_probe(order: &str, enabled: bool, timeout: bool) { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("agent.py"); + let started = dir.path().join("started"); + let marker = dir.path().join("cancel"); + std::fs::write(&script, r#"import json, pathlib, sys +root = pathlib.Path(sys.argv[1]); order = sys.argv[2] +print(json.dumps({'ready': True}), flush=True) +pending = None +for line in sys.stdin: + req = json.loads(line) + method = req['method'] + if method == 'handshake': + print(json.dumps({'jsonrpc':'2.0','id':req['id'],'result':{'protocolVersion':2,'agentProtocolVersion':2,'capabilities':['multi_session']}}),flush=True) + elif method == 'execute_query': + pending = req['id']; (root/'started').write_text('executing') + elif method == 'cancel_session': + assert req['params']['agentSessionId'] == '__legacy__' + assert req['id'] != pending + (root/'cancel').write_text('cancel_session:__legacy__') + query = {'jsonrpc':'2.0','id':pending,'error':{'code':-1,'message':'Query was cancelled'}} + ack = {'jsonrpc':'2.0','id':req['id'],'result':{'ok':True}} + frames = [query,ack] if order == 'query_first' else [ack,query] + if order == 'ack_only': frames = [ack] + if order == 'duplicate_ack': frames = [ack,ack] + if order == 'json_log': frames = [ack,{'id':pending,'message':'not a query response'}] + for frame in frames: print(json.dumps(frame),flush=True) +"#).unwrap(); + let mut client = AgentDriverClient::spawn(AgentLaunchSpec::new(test_python()).with_args([ + script.to_string_lossy().to_string(), + dir.path().to_string_lossy().to_string(), + order.to_owned(), + ])) + .await + .unwrap(); + client.try_optional_handshake("test").await.unwrap(); + if enabled { + client.enable_dedicated_transaction_query_cancellation(); + } + let token = CancellationToken::new(); + let cancel_task = if timeout { + None + } else { + let cancellation = token.clone(); + Some(tokio::spawn(async move { + tokio::time::timeout(Duration::from_secs(2), async { + while !started.exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + cancellation.cancel(); + })) + }; + let began = Instant::now(); + let error = client + .call_typed_with_timeout_and_cancel::( + "execute_query", + serde_json::json!({"sql":"WAITFOR"}), + Some(if timeout { Duration::from_millis(100) } else { Duration::from_secs(3) }), + if timeout { None } else { Some(token) }, + ) + .await + .unwrap_err(); + if timeout { + assert!(matches!(error, AgentCallError::Timeout { .. })); + assert!(began.elapsed() >= Duration::from_secs(5), "cancel ACK alone incorrectly confirmed completion"); + } else { + assert!(matches!(error, AgentCallError::Canceled { .. })); + } + assert!(began.elapsed() < Duration::from_secs(7), "cancel grace did not remain bounded"); + if enabled { + assert_eq!(std::fs::read_to_string(&marker).unwrap(), "cancel_session:__legacy__"); + } else { + assert!(!marker.exists(), "ordinary clients changed cancellation behavior"); + } + assert!( + client.call::("execute_query", serde_json::json!({})).await.is_err(), + "disposed transaction was reused" + ); + if let Some(task) = cancel_task { + task.await.unwrap(); + } + } + + #[tokio::test] + async fn dedicated_transaction_query_cancel_accepts_ack_before_query_response() { + dedicated_transaction_cancel_probe("ack_first", true, false).await; + } + + #[tokio::test] + async fn dedicated_transaction_query_cancel_accepts_query_response_before_ack() { + dedicated_transaction_cancel_probe("query_first", true, false).await; + } + + #[tokio::test] + async fn dedicated_transaction_query_cancel_timeout_does_not_treat_ack_as_completion() { + dedicated_transaction_cancel_probe("ack_only", true, true).await; + } + + #[tokio::test] + async fn dedicated_transaction_query_cancel_timeout_ignores_duplicate_ack() { + dedicated_transaction_cancel_probe("duplicate_ack", true, true).await; + } + + #[tokio::test] + async fn dedicated_transaction_query_cancel_timeout_ignores_json_logs() { + dedicated_transaction_cancel_probe("json_log", true, true).await; + } + + async fn dedicated_transaction_initial_write_probe(timeout: bool) { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("unresponsive-agent.py"); + std::fs::write(&script, r#"import json, sys, time +print(json.dumps({'ready':True}),flush=True) +req=json.loads(sys.stdin.readline()) +assert req['method']=='handshake' +print(json.dumps({'jsonrpc':'2.0','id':req['id'],'result':{'protocolVersion':2,'agentProtocolVersion':2,'capabilities':['multi_session']}}),flush=True) +time.sleep(60) +"#).unwrap(); + let mut client = AgentDriverClient::spawn( + AgentLaunchSpec::new(test_python()).with_args([script.to_string_lossy().to_string()]), + ) + .await + .unwrap(); + client.try_optional_handshake("test").await.unwrap(); + client.enable_dedicated_transaction_query_cancellation(); + let token = CancellationToken::new(); + let cancel_task = if timeout { + None + } else { + let cancellation = token.clone(); + Some(tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + cancellation.cancel(); + })) + }; + let began = Instant::now(); + let error = client + .call_typed_with_timeout_and_cancel::( + "execute_query", + serde_json::json!({"sql":" ".repeat(2 * 1024 * 1024)}), + Some(if timeout { Duration::from_millis(100) } else { Duration::from_secs(3) }), + if timeout { None } else { Some(token) }, + ) + .await + .unwrap_err(); + if timeout { + assert!(matches!(error, AgentCallError::Timeout { operation_outcome: AgentOperationOutcome::Unknown, .. })); + } else { + assert!(matches!( + error, + AgentCallError::Canceled { operation_outcome: AgentOperationOutcome::Unknown, .. } + )); + } + assert!(began.elapsed() < Duration::from_secs(2), "initial request write blocked cancellation/deadline"); + assert!(client.child.as_mut().unwrap().try_wait().unwrap().is_some()); + assert!(client.stdin.is_none()); + assert!(client.call::("execute_query", serde_json::json!({})).await.is_err()); + if let Some(task) = cancel_task { + task.await.unwrap(); + } + } + + #[tokio::test] + async fn dedicated_transaction_query_cancel_interrupts_blocked_initial_write() { + dedicated_transaction_initial_write_probe(false).await; + } + + #[tokio::test] + async fn dedicated_transaction_query_cancel_timeout_interrupts_blocked_initial_write() { + dedicated_transaction_initial_write_probe(true).await; + } + + #[cfg(windows)] + #[tokio::test] + async fn dedicated_transaction_query_cancel_bounds_a_blocked_stdin_write() { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("blocked-agent.py"); + std::fs::write(&script, r#"import json, sys, time +print(json.dumps({'ready':True}),flush=True) +req=json.loads(sys.stdin.readline()) +assert req['method']=='handshake' +print(json.dumps({'jsonrpc':'2.0','id':req['id'],'result':{'protocolVersion':2,'agentProtocolVersion':2,'capabilities':['multi_session']}}),flush=True) +# Never consume the next request. Windows' default anonymous pipe holds 4096 bytes. +time.sleep(60) +"#).unwrap(); + let mut client = AgentDriverClient::spawn( + AgentLaunchSpec::new(test_python()).with_args([script.to_string_lossy().to_string()]), + ) + .await + .unwrap(); + client.try_optional_handshake("test").await.unwrap(); + client.enable_dedicated_transaction_query_cancellation(); + let empty_request = serde_json::json!({ + "jsonrpc":"2.0", "id":client.next_id + 1, + "method":"execute_query", "params":{"sql":""}, + }); + let sql = " ".repeat(4096 - empty_request.to_string().len() - 1); + let began = Instant::now(); + let error = client + .call_typed_with_timeout_and_cancel::( + "execute_query", + serde_json::json!({"sql":sql}), + Some(Duration::from_millis(100)), + None, + ) + .await + .unwrap_err(); + assert!(matches!(error, AgentCallError::Timeout { .. })); + assert!(began.elapsed() >= Duration::from_secs(5)); + assert!(began.elapsed() < Duration::from_secs(7), "blocked cancel write prevented force kill"); + assert!( + client.child.as_mut().unwrap().try_wait().unwrap().is_some(), + "unresponsive dedicated Agent was retained" + ); + assert!(client.stdin.is_none()); + } + + #[tokio::test] + async fn dedicated_transaction_query_cancel_remains_opt_in() { + dedicated_transaction_cancel_probe("ack_first", false, false).await; + } + #[tokio::test] async fn shared_runtime_tolerates_post_startup_driver_stdout_noise() { let script_path = diff --git a/crates/dbx-driver-agent/src/backend_error.rs b/crates/dbx-driver-agent/src/backend_error.rs index 6e1467ad5f..2968e21946 100644 --- a/crates/dbx-driver-agent/src/backend_error.rs +++ b/crates/dbx-driver-agent/src/backend_error.rs @@ -105,6 +105,28 @@ pub struct BackendErrorDiagnostics { adapter_code: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TransactionOutcome { + Committed, + RolledBack, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ManualTransactionFailureKind { + Unsupported, + Busy, + ContextChanged, + ConnectionLost, + RollbackFailed, + CommitUnknown, + ControlConflict, + IdleTimeout, + ExecutionFailed, +} + /// Public v1 backend error envelope. /// /// The fields are private on purpose: callers create envelopes through the @@ -130,9 +152,49 @@ pub struct BackendError { /// envelope version; clients that do not understand it simply ignore it. #[serde(skip_serializing_if = "Option::is_none")] error_position: Option, + #[serde(skip_serializing_if = "Option::is_none")] + transaction_outcome: Option, } impl BackendError { + pub fn from_manual_transaction_failure( + kind: ManualTransactionFailureKind, + outcome: TransactionOutcome, + detail: &str, + ) -> Self { + let code = match kind { + ManualTransactionFailureKind::Unsupported => CatalogCode::TransactionUnsupported, + ManualTransactionFailureKind::Busy => CatalogCode::TransactionBusy, + ManualTransactionFailureKind::ContextChanged => CatalogCode::TransactionContextChanged, + ManualTransactionFailureKind::ConnectionLost => CatalogCode::TransactionConnectionLost, + ManualTransactionFailureKind::RollbackFailed => CatalogCode::TransactionRollbackFailed, + ManualTransactionFailureKind::CommitUnknown => CatalogCode::TransactionCommitUnknown, + ManualTransactionFailureKind::ControlConflict => CatalogCode::TransactionControlConflict, + ManualTransactionFailureKind::IdleTimeout => CatalogCode::TransactionIdleTimeout, + ManualTransactionFailureKind::ExecutionFailed => CatalogCode::TransactionExecutionFailed, + }; + let mut error = Self::new( + catalog_entry(code), + BackendErrorSource::LegacyBackend, + BackendErrorOrigin::database(BackendErrorAdapter::Native), + if matches!( + kind, + ManualTransactionFailureKind::Unsupported + | ManualTransactionFailureKind::Busy + | ManualTransactionFailureKind::ControlConflict + ) { + BackendOperationOutcome::NotStarted + } else { + BackendOperationOutcome::Unknown + }, + BTreeMap::new(), + bounded_detail(detail), + None, + ); + error.transaction_outcome = Some(outcome); + error + } + /// Convert a typed Agent error into the stable v1 JDBC catalog envelope. pub fn from_agent_call_error(error: &AgentCallError) -> Self { match error { @@ -390,6 +452,7 @@ impl BackendError { diagnostics, help_url: entry.help_url.map(str::to_string), error_position: None, + transaction_outcome: None, } } } @@ -401,6 +464,15 @@ enum CatalogCode { TimeoutNotStarted, TimeoutUnknown, TransactionSessionExpired, + TransactionUnsupported, + TransactionBusy, + TransactionContextChanged, + TransactionConnectionLost, + TransactionRollbackFailed, + TransactionCommitUnknown, + TransactionControlConflict, + TransactionIdleTimeout, + TransactionExecutionFailed, Canceled, BusyRetryLater, RuntimeReplaced, @@ -438,6 +510,87 @@ const NO_PARAMS: &[ParamSpec] = &[]; fn catalog_entry(code: CatalogCode) -> &'static CatalogEntry { const ENTRIES: &[(CatalogCode, CatalogEntry)] = &[ + ( + CatalogCode::TransactionUnsupported, + CatalogEntry { + code: "DBX-TXN-1002", + message_key: "backendErrors.transaction.unsupported", + params: NO_PARAMS, + help_url: None, + }, + ), + ( + CatalogCode::TransactionBusy, + CatalogEntry { + code: "DBX-TXN-1003", + message_key: "backendErrors.transaction.busy", + params: NO_PARAMS, + help_url: None, + }, + ), + ( + CatalogCode::TransactionContextChanged, + CatalogEntry { + code: "DBX-TXN-1004", + message_key: "backendErrors.transaction.contextChanged", + params: NO_PARAMS, + help_url: None, + }, + ), + ( + CatalogCode::TransactionConnectionLost, + CatalogEntry { + code: "DBX-TXN-1005", + message_key: "backendErrors.transaction.connectionLost", + params: NO_PARAMS, + help_url: None, + }, + ), + ( + CatalogCode::TransactionRollbackFailed, + CatalogEntry { + code: "DBX-TXN-1006", + message_key: "backendErrors.transaction.rollbackFailed", + params: NO_PARAMS, + help_url: None, + }, + ), + ( + CatalogCode::TransactionCommitUnknown, + CatalogEntry { + code: "DBX-TXN-1007", + message_key: "backendErrors.transaction.commitUnknown", + params: NO_PARAMS, + help_url: None, + }, + ), + ( + CatalogCode::TransactionControlConflict, + CatalogEntry { + code: "DBX-TXN-1008", + message_key: "backendErrors.transaction.controlConflict", + params: NO_PARAMS, + help_url: None, + }, + ), + ( + CatalogCode::TransactionIdleTimeout, + CatalogEntry { + code: "DBX-TXN-1009", + message_key: "backendErrors.transaction.idleTimeout", + params: NO_PARAMS, + help_url: None, + }, + ), + ( + CatalogCode::TransactionExecutionFailed, + CatalogEntry { + code: "DBX-TXN-1010", + message_key: "backendErrors.transaction.executionFailed", + params: NO_PARAMS, + help_url: None, + }, + ), ( CatalogCode::ConnectionFailed, CatalogEntry { @@ -1442,6 +1595,27 @@ mod tests { assert_eq!(error.detail(), Some("无效的表或视图名\n错误码: -2106")); } + #[test] + fn manual_transaction_outcomes_are_optional_and_preserve_confirmed_commit() { + let legacy = serde_json::to_value(BackendError::from_legacy_backend("failure")).unwrap(); + assert!(legacy.get("transactionOutcome").is_none()); + let committed = BackendError::from_manual_transaction_failure( + ManualTransactionFailureKind::ContextChanged, + TransactionOutcome::Committed, + "Commit acknowledged before connection cleanup failed", + ); + let value = serde_json::to_value(committed).unwrap(); + assert_eq!(value["transactionOutcome"], "committed"); + let unknown = BackendError::from_manual_transaction_failure( + ManualTransactionFailureKind::CommitUnknown, + TransactionOutcome::Unknown, + "Commit response lost", + ); + let value = serde_json::to_value(unknown).unwrap(); + assert_eq!(value["code"], "DBX-TXN-1007"); + assert_eq!(value["transactionOutcome"], "unknown"); + } + #[test] fn strict_marker_round_trip_keeps_catalog_code() { let error = AgentCallError::Structured { diff --git a/crates/dbx-driver-agent/tests/dedicated_transaction_deadline.rs b/crates/dbx-driver-agent/tests/dedicated_transaction_deadline.rs new file mode 100644 index 0000000000..15fdbe59c9 --- /dev/null +++ b/crates/dbx-driver-agent/tests/dedicated_transaction_deadline.rs @@ -0,0 +1,49 @@ +//! The SQL Server opt-in query deadline includes sending a large request. +use dbx_driver_agent::agent_driver::{AgentCallError, AgentDriverClient, AgentLaunchSpec, AgentOperationOutcome}; +use std::time::{Duration, Instant}; + +#[tokio::test] +async fn slow_request_write_does_not_restart_the_query_deadline() { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("slow-reader.py"); + std::fs::write(&script, r#"import json, sys, time +print(json.dumps({'ready':True}),flush=True) +req=json.loads(sys.stdin.readline()) +assert req['method']=='handshake' +print(json.dumps({'jsonrpc':'2.0','id':req['id'],'result':{'protocolVersion':2,'agentProtocolVersion':2,'capabilities':['multi_session']}}),flush=True) +time.sleep(1) +pending=None +for line in sys.stdin: + req=json.loads(line) + if req['method']=='execute_query': pending=req['id'] + elif req['method']=='cancel_session': + assert pending is not None + print(json.dumps({'jsonrpc':'2.0','id':pending,'error':{'code':-1,'message':'Query was cancelled'}}),flush=True) + print(json.dumps({'jsonrpc':'2.0','id':req['id'],'result':{'ok':True}}),flush=True) +"#).unwrap(); + let python = if cfg!(windows) { "python" } else { "python3" }; + let mut client = + AgentDriverClient::spawn(AgentLaunchSpec::new(python).with_args([script.to_string_lossy().to_string()])) + .await + .unwrap(); + client.try_optional_handshake("deadline-test").await.unwrap(); + client.enable_dedicated_transaction_query_cancellation(); + let began = Instant::now(); + let error = client + .call_typed_with_timeout_and_cancel::( + "execute_query", + serde_json::json!({"sql":" ".repeat(2 * 1024 * 1024)}), + Some(Duration::from_millis(1600)), + None, + ) + .await + .unwrap_err(); + assert!(matches!(error, AgentCallError::Timeout { operation_outcome: AgentOperationOutcome::Unknown, .. })); + assert!(began.elapsed() >= Duration::from_millis(1500)); + assert!( + began.elapsed() < Duration::from_millis(2400), + "request write granted a second timeout: {:?}", + began.elapsed() + ); + assert!(client.call::("execute_query", serde_json::json!({})).await.is_err()); +} diff --git a/crates/dbx-driver-sqlserver/src/sqlserver.rs b/crates/dbx-driver-sqlserver/src/sqlserver.rs index 3957b75303..4cfaa43e8f 100644 --- a/crates/dbx-driver-sqlserver/src/sqlserver.rs +++ b/crates/dbx-driver-sqlserver/src/sqlserver.rs @@ -40,11 +40,12 @@ enum SqlServerQueryTransport { pub struct SqlServerClient { inner: SqlServerTdsClient, query_transport: SqlServerQueryTransport, + server_major_version: Option, } impl SqlServerClient { fn new(inner: SqlServerTdsClient) -> Self { - Self { inner, query_transport: SqlServerQueryTransport::Unknown } + Self { inner, query_transport: SqlServerQueryTransport::Unknown, server_major_version: None } } async fn ensure_query_transport(&mut self) -> SqlServerQueryTransport { @@ -1630,14 +1631,19 @@ fn top_level_sqlserver_tokens(sql: &str) -> Vec { } if ch == '/' && next == Some('*') { i += 2; - while i < sql.len() { + let mut comment_depth = 1usize; + while i < sql.len() && comment_depth > 0 { let current = next_char(sql, i); let following = next_char_at(sql, i + current.len_utf8()); - if current == '*' && following == Some('/') { + if current == '/' && following == Some('*') { + comment_depth += 1; i += 2; - break; + } else if current == '*' && following == Some('/') { + comment_depth -= 1; + i += 2; + } else { + i += current.len_utf8(); } - i += current.len_utf8(); } continue; } @@ -3958,6 +3964,98 @@ fn sqlserver_dml_output_returns_rows(sql: &str) -> bool { }) } +/// Transaction controls cannot be mixed with DBX-owned interactive transactions. +/// The existing lexer skips comments, literals and delimited identifiers. +pub fn manual_transaction_control_conflict(sql: &str) -> bool { + let tokens = top_level_sqlserver_tokens(sql); + tokens.iter().enumerate().any(|(index, token)| { + matches!(token.text.as_str(), "COMMIT" | "ROLLBACK" | "USE" | "SAVE") + || (token.text == "BEGIN" + && tokens + .get(index + 1) + .is_some_and(|next| matches!(next.text.as_str(), "TRAN" | "TRANSACTION" | "DISTRIBUTED"))) + || (token.text == "SET" && tokens.get(index + 1).is_some_and(|next| next.text == "IMPLICIT_TRANSACTIONS")) + }) +} + +fn manual_transaction_status_error(error: String) -> String { + // Inspect the capability probe's server error, not a version allowlist or + // a failed login/transport. Preserve unrelated errors for their caller. + if sqlserver_error_number(&error) == Some(195) && error.to_ascii_uppercase().contains("XACT_STATE") { + format!( + "DBX_MANUAL_TRANSACTION_UNSUPPORTED: The native SQL Server driver requires XACT_STATE() \ + to verify transaction safety. Use the SQL Server legacy compatibility driver \ + (jTDS for SQL Server 2000). Server error: {error}" + ) + } else { + error + } +} + +/// SQL Server 2000 has no XACT_STATE(). Keep that absence explicit rather +/// than infer committability from transaction count. Any execution error ends +/// the owning session; COMMIT/ROLLBACK acknowledgement remains authoritative. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ManualTransactionStatus { + pub count: i64, + pub xact_state: Option, +} +impl ManualTransactionStatus { + pub fn is_active(self) -> bool { + self.count == 1 && self.xact_state.is_none_or(|state| state == 1) + } +} +/// SQL Server 2000 (product major 8) never offers XACT_STATE() and this driver +/// negotiates TDS 7.1 with it, so the product major identifies the legacy +/// count-only status check without reaching into a patched-only tiberius API. +fn uses_legacy_transaction_status(server_major: Option) -> bool { + server_major == Some(8) +} + +/// Consume the full response, including transaction descriptor ENVCHANGE tokens. +pub async fn manual_transaction_status(client: &mut SqlServerClient) -> Result { + if client.server_major_version.is_none() { + let version = execute_simple_batch_with_max_rows_metadata( + client, + "SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128))", + Some(1), + ) + .await?; + client.server_major_version = version + .iter() + .find_map(|result| result.result.rows.first()) + .and_then(|row| row.first()) + .and_then(serde_json::Value::as_str) + .and_then(|version| version.split('.').next()) + .and_then(|major| major.parse().ok()); + if client.server_major_version.is_none() { + return Err("SQL Server did not return its product version for transaction capability detection".to_owned()); + } + } + let legacy = uses_legacy_transaction_status(client.server_major_version); + let results = execute_simple_batch_with_max_rows_metadata( + client, + if legacy { + "SELECT @@TRANCOUNT AS dbx_transaction_count" + } else { + "SELECT @@TRANCOUNT AS dbx_transaction_count, XACT_STATE() AS dbx_transaction_state" + }, + Some(1), + ) + .await + .map_err(manual_transaction_status_error)?; + results + .iter() + .find_map(|result| result.result.rows.first()) + .and_then(|row| { + Some(ManualTransactionStatus { + count: row.first()?.as_i64()?, + xact_state: if legacy { None } else { Some(row.get(1)?.as_i64()?) }, + }) + }) + .ok_or_else(|| "SQL Server did not return transaction status".to_owned()) +} + fn contains_transaction_control(sql: &str) -> bool { let tokens = top_level_sqlserver_tokens(sql); tokens.iter().enumerate().any(|(index, token)| { @@ -4075,6 +4173,75 @@ fn first_sql_tokens(sql: &str, limit: usize) -> Vec { #[cfg(test)] mod tests { + #[test] + fn manual_transaction_status_keeps_legacy_and_modern_safety_explicit() { + use super::{uses_legacy_transaction_status as legacy, ManualTransactionStatus as Status}; + assert!(legacy(Some(8))); + for major in [None, Some(9), Some(13), Some(16)] { + assert!(!legacy(major)); + } + assert!(Status { count: 1, xact_state: None }.is_active()); + assert!(Status { count: 1, xact_state: Some(1) }.is_active()); + for count in [0, 2, -1] { + assert!(!Status { count, xact_state: None }.is_active()); + assert!(!Status { count, xact_state: Some(1) }.is_active()); + } + for state in [-1, 0, 2] { + assert!(!Status { count: 1, xact_state: Some(state) }.is_active()); + } + } + + #[test] + fn manual_transaction_status_missing_xact_state_is_unsupported() { + for message in [ + "'XACT_STATE' is not a recognized built-in function name. (code: 195, state: 10, class: 15)", + "无法识别函数 'xact_state'。 (code: 195, state: 10, class: 15)", + ] { + let error = super::manual_transaction_status_error(message.to_owned()); + assert!(error.starts_with("DBX_MANUAL_TRANSACTION_UNSUPPORTED:"), "{error}"); + assert!(error.contains("legacy")); + assert!(error.contains(message)); + } + } + + #[test] + fn manual_transaction_status_other_errors_keep_their_original_category() { + for message in [ + "Permission denied for XACT_STATE. (code: 229, state: 1, class: 14)", + "'OTHER_FUNCTION' is not a recognized function name. (code: 195, state: 10, class: 15)", + "Connection reset while querying XACT_STATE", + "XACT_STATE query timed out", + ] { + assert_eq!(super::manual_transaction_status_error(message.to_owned()), message); + } + } + #[test] + fn manual_transaction_controls_are_detected_without_false_positive_literals_or_try_blocks() { + for sql in [ + "BEGIN TRAN", + "BEGIN DISTRIBUTED TRANSACTION", + "COMMIT", + "ROLLBACK TRANSACTION", + "SAVE TRANSACTION x", + "SET IMPLICIT_TRANSACTIONS OFF", + "USE [other_db]", + "IF 1=1 BEGIN COMMIT; END", + ] { + assert!(super::manual_transaction_control_conflict(sql), "{sql}"); + } + for sql in [ + "-- COMMIT\nSELECT 1", + "/* BEGIN TRANSACTION */ SELECT 1", + "/* outer /* inner */ COMMIT; USE other */ SELECT 1", + "SELECT 'ROLLBACK; USE db'", + "SELECT [COMMIT], [USE] FROM t", + "BEGIN TRY SELECT 1; END TRY BEGIN CATCH SELECT ERROR_MESSAGE(); END CATCH", + "DECLARE @message nvarchar(50) = N'SET IMPLICIT_TRANSACTIONS ON'", + ] { + assert!(!super::manual_transaction_control_conflict(sql), "{sql}"); + } + } + use super::{ build_sqlserver_unsafe_type_query, capture_sqlserver_messages, completion_context_from_query_result, decode_sqlserver_spatial_values, format_sqlserver_numeric, is_blocking_sqlserver_unsafe_probe_error, diff --git a/crates/dbx-driver-sqlserver/tests/live_sqlserver_2000_native.rs b/crates/dbx-driver-sqlserver/tests/live_sqlserver_2000_native.rs new file mode 100644 index 0000000000..f2df5671e6 --- /dev/null +++ b/crates/dbx-driver-sqlserver/tests/live_sqlserver_2000_native.rs @@ -0,0 +1,97 @@ +//! Opt-in native SQL Server 2000 compatibility acceptance; never routes through JDBC. +use dbx_driver_sqlserver as native; +use futures::FutureExt; +use std::panic::AssertUnwindSafe; +use std::time::Duration; + +#[tokio::test] +#[ignore = "requires an explicit SQL Server 2000 test endpoint"] +async fn native_sqlserver_2000_connects_and_checks_manual_transaction_state() { + let host = std::env::var("DBX_TEST_SQLSERVER_HOST").unwrap(); + let port = std::env::var("DBX_TEST_SQLSERVER_PORT").unwrap().parse().unwrap(); + let password = std::env::var("DBX_TEST_SQLSERVER_PASSWORD").unwrap(); + let connected = AssertUnwindSafe(native::connect_with_port_explicit( + &host, + port, + true, + "sa", + &password, + Some("dbx_manual_test"), + Duration::from_secs(10), + )) + .catch_unwind() + .await; + let mut client = connected.expect("native TDS handshake must not panic").expect("native TDS handshake failed"); + let info = native::execute_simple_batch_with_max_rows( + &mut client, + "SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)), @@TRANCOUNT", + Some(1), + ) + .await + .unwrap(); + assert!(info[0].rows[0][0].as_str().unwrap().starts_with("8.")); + println!("Native server diagnostics: {:?}", info[0].rows); + let rpc = client.query("SELECT @P1 AS value", &[&42i32]).await.unwrap().into_row().await.unwrap().unwrap(); + assert_eq!(rpc.get::(0), Some(42)); + assert_eq!( + native::manual_transaction_status(&mut client).await.unwrap(), + native::ManualTransactionStatus { count: 0, xact_state: None } + ); + native::execute_simple_batch_with_max_rows(&mut client, "BEGIN TRANSACTION", Some(1)).await.unwrap(); + assert_eq!( + native::manual_transaction_status(&mut client).await.unwrap(), + native::ManualTransactionStatus { count: 1, xact_state: None } + ); + native::execute_simple_batch_with_max_rows(&mut client, "ROLLBACK TRANSACTION", Some(1)).await.unwrap(); + assert_eq!( + native::manual_transaction_status(&mut client).await.unwrap(), + native::ManualTransactionStatus { count: 0, xact_state: None } + ); +} +#[tokio::test] +#[ignore = "requires an explicit SQL Server 2000 test endpoint"] +async fn native_sqlserver_2000_rpc_handles_long_unicode_binary_and_nulls() { + let host = std::env::var("DBX_TEST_SQLSERVER_HOST").unwrap(); + let port = std::env::var("DBX_TEST_SQLSERVER_PORT").unwrap().parse().unwrap(); + let password = std::env::var("DBX_TEST_SQLSERVER_PASSWORD").unwrap(); + let mut client = native::connect_with_port_explicit( + &host, + port, + true, + "sa", + &password, + Some("dbx_manual_test"), + Duration::from_secs(10), + ) + .await + .unwrap(); + for count in [1400, 4001] { + let text = "\u{4e2d}".repeat(count); + let row = client.query("SELECT DATALENGTH(@P1)", &[&text]).await.unwrap().into_row().await.unwrap().unwrap(); + assert_eq!(row.get::(0), Some((count * 2) as i32)); + } + let binary = vec![0x5au8; 8001]; + let row = + client.query("SELECT DATALENGTH(@P1)", &[&binary.as_slice()]).await.unwrap().into_row().await.unwrap().unwrap(); + assert_eq!(row.get::(0), Some(8001)); + let null_text: Option<&str> = None; + let null_binary: Option<&[u8]> = None; + let row = client + .query("SELECT CASE WHEN @P1 IS NULL AND @P2 IS NULL THEN 1 ELSE 0 END", &[&null_text, &null_binary]) + .await + .unwrap() + .into_row() + .await + .unwrap() + .unwrap(); + assert_eq!(row.get::(0), Some(1)); + let sql = format!("/*{}*/ SELECT 7", "x".repeat(5000)); + let row = client.query(sql, &[]).await.unwrap().into_row().await.unwrap().unwrap(); + assert_eq!(row.get::(0), Some(7)); + let results = native::execute_simple_batch_with_max_rows(&mut client, + "CREATE TABLE #lob (a text, b ntext, c image); INSERT INTO #lob VALUES ('abc', N'xyz', 0x0102); SELECT a,b,c FROM #lob; DROP TABLE #lob", Some(2)).await.unwrap(); + let result = results.iter().find(|result| result.columns == vec!["a", "b", "c"]).unwrap(); + assert_eq!(result.rows[0][0], serde_json::json!("abc")); + assert_eq!(result.rows[0][1], serde_json::json!("xyz")); + assert!(!result.rows[0][2].is_null()); +} diff --git a/docs/images/sqlserver-manual-transactions-toolbar.png b/docs/images/sqlserver-manual-transactions-toolbar.png new file mode 100644 index 0000000000..e74aee93ff Binary files /dev/null and b/docs/images/sqlserver-manual-transactions-toolbar.png differ diff --git a/docs/sqlserver-manual-transactions-7542.md b/docs/sqlserver-manual-transactions-7542.md new file mode 100644 index 0000000000..d9cdb83272 --- /dev/null +++ b/docs/sqlserver-manual-transactions-7542.md @@ -0,0 +1,167 @@ +# SQL Server 手动事务(#7542) + +实现基线:247eb2b0b(0.6.31),分支 codex/sqlserver-manual-transactions;2026-10-05 合并 upstream/main 38ce7b5dd(0.6.34)并重新做提交前验证。 + +桌面查询标签页使用独立专用连接。事务句柄、生命周期 generation、实际驱动诊断和结束原因仅保留在内存;自动提交连接的临时表与 SET 状态不继承。所有执行按 GO 分 batch,完整消费响应;结果行数限制只影响保留的行。同一事务串行,执行时提交/回滚返回 Busy。结束原因保留 5 分钟,定期清理,最多保留 1024 条。 + +原生路径采用普通 TDS batch 开启事务,检查 @@TRANCOUNT 与 XACT_STATE;SQL Server 2000(主版本 8.x 且 TDS 功能级别 7.1)缺少 XACT_STATE(),改用仅读取 @@TRANCOUNT 的旧版状态检查:无法区分可提交与不可提交事务,因此任何执行错误直接废弃所属会话,COMMIT/ROLLBACK 的确认响应仍是结束终局的唯一依据。SQL Server legacy 路由使用专用 Agent 进程。JDBC 采用 setAutoCommit(false),在首次用户 batch 前确保一个物理事务,使用 JDBC commit/rollback;手动会话禁止自动重连。旧 legacy 组件缺少 manualTransactionBatch 能力时拒绝执行,需更新组件。没有服务器版本白名单。 + +执行失败尝试回滚;取消、超时、连接丢失和事务状态异常丢弃会话。只有明确回滚响应才能标记 rolled_back。提交确认立即记录 committed,连接清理不改变该结论。提交响应丢失记为 unknown,要求核对数据库,禁止重试提交和自动重跑 SQL。SQL Server 会话失效保留手动模式,仅下一次用户主动执行建立新事务。 + +## 按版本和实际驱动确认的范围 + +- SQL Server 2019 Developer 15.0.4490.9、2022 Developer 16.0.4295.3:原生 Tiberius、Microsoft JDBC 13.2、jTDS 1.3.1 的手动事务均有实库验收。 +- SQL Server 2000:已在 MSDE SP4 Desktop Engine 8.00.2039 实例上验证原生 Tiberius 与 legacy Agent 的 jTDS 1.3.1 两条路径。显式选择 jTDS 与 legacy Agent 内 Microsoft JDBC 尝试后回退到 jTDS 均有覆盖;回退后实际驱动仍是 jTDS,不能标为 Microsoft JDBC 通过。 +- 原生路径对 SQL Server 2000 的支持方式:2026-10-04 在上述真实 8.00.2039 实例上只读执行 SELECT XACT_STATE(),服务器返回 SQLState 42000 / vendorCode 195(不识别函数),探测前后 @@TRANCOUNT=0。该证据促成旧版状态检查分支而非排除支持:客户端用 SERVERPROPERTY('ProductVersion') 取得主版本,8.x(该服务器经本驱动协商出的 TDS 功能级别即为 SqlServer2000/2000Sp1)时状态探测只读 @@TRANCOUNT。主版本探测失败直接报错,不猜测服务器能力;错误码 195 且明确指向 XACT_STATE 的服务器错误仍归类为 Unsupported;认证、权限、网络和超时保留原有错误分类,开启失败不会执行用户 SQL。8.x 实库另要求并已修复 tiberius 登录响应解码(invalid token type 0)、TDS 7.1 功能级别协商、旧版 RPC ntext/image 编码与 ATTENTION 取消信号。 +- 原生 TDS 不会因为手动事务开启失败自动切换为 JDBC;需要 JDBC 兼容路径时用户须显式选择 legacy 兼容驱动。其他未实测服务器版本不列为已验证;运行时仍按实际驱动与开启/状态探测结果判断,没有单一服务器版本白名单。 +- 用户反馈本次客户端手工测试未发现问题;未记录的具体版本/实际驱动组合不据此增加到实库矩阵。 + +## 验证入口 + +- Rust:dbx-core::query::sqlserver_manual_transaction 协议故障测试;dbx-driver-sqlserver 事务控制词法测试;已有 PostgreSQL/MySQL/Agent 手动事务测试。 +- Agent:common 的 JdbcExecutorTest,以及 sqlserver-legacy 的 SqlServerLegacyAgentTest;构建 sqlserver-legacy:shadowJar。 +- 前端:queryStore.manualTransactionExpiry、queryStore.defaultTransactionMode、databaseFeatureSupport、EditorToolbar.transactionMode;vue-tsc 与 Vite 构建。 +- 真实数据库:crates/dbx-core/tests/live_sqlserver_manual_transactions.rs(默认 ignored)。需要 DBX_TEST_SQLSERVER_HOST、PASSWORD、DATABASE、EXPECT_DRIVER,可选 USER、PORT、DRIVER_PROFILE。DATABASE 必须是可写的专用测试库。 + +legacy 实库测试使用 DRIVER_PROFILE=sqlserver-legacy,同时提供 DBX_TEST_SQLSERVER_AGENT_JAR、DBX_TEST_SQLSERVER_JAVA、DBX_TEST_SQLSERVER_JDBC_CLASS、DBX_TEST_SQLSERVER_JDBC_URL。测试向临时 Agent 目录导入此次 shadowJar,并使用指定 Java;不会覆盖用户安装的组件。EXPECT_DRIVER 必須非空并匹配服务器返回的实际驱动名,防止强制路径静默变成另一驱动。 + +2019/2022 的观察连接使用独立原生 TDS 连接及有界锁等待。SQL Server 2000 使用 DBX_TEST_SQLSERVER_COMPAT_2000=1,观察连接为独立、自动提交的 JDBC Agent 进程;旧版使用 master.dbo.sysprocesses 检查 WAITFOR 和会话释放,适配 DECLARE/SET、INSERT 及锁提示语法。锁验证必须取得明确的 lock request timeout,不能把语法错误当成隔离性成功。 + +## 2026-10-03 远端实库验收 + +通过 remote-shell MCP 连接 aliyun,使用服务器既有 Docker。数据库端口仅绑定远端 127.0.0.1:17542,本地测试经 MCP SSH 转发访问;本地没有启动 Docker。两个版本顺序运行,容器内存上限 2 GiB、MSSQL_MEMORY_LIMIT_MB=1536、CPU 上限 1.5。远端既有 campus 容器保持运行。 + +测试数据库均为 dbx_manual_test,服务器使用 Developer Edition。固定镜像摘要用于复现,不以 mutable 的 latest 标签标记验证版本: + +- SQL Server 2022:16.0.4295.3,CU27;容器 dbx-sqlserver-7542-2022;镜像 mcr.microsoft.com/mssql/server@sha256:4402d880dd4c34bfa7d8705e56a86cd6c88da80a1f6bbbe741f999e76264a090。 +- SQL Server 2019:15.0.4490.9,CU32;容器 dbx-sqlserver-7542-2019;镜像 mcr.microsoft.com/mssql/server@sha256:ef0b8db33970ecd01bed49c3a84a1d083c435a9891718df619298b67b352e74a。 + +首轮每个版本分别强制以下路径,7 项基础用例在六种组合中全部通过,共 42 次用例执行: + +- 原生 TDS:实际驱动 tiberius (native TDS),EXPECT_DRIVER=tiberius。 +- Microsoft JDBC:实际驱动 Microsoft JDBC Driver 13.2 for SQL Server,13.2.0.0,EXPECT_DRIVER=Microsoft;显式配置 sslProtocol=TLSv1.2。 +- jTDS:实际驱动 jTDS Type 4 JDBC Driver for MS SQL Server and Sybase,1.3.1,EXPECT_DRIVER=jTDS;显式 jTDS URL 使用 ssl=off,传输由 SSH 隧道保护。 + +七项真实用例覆盖: + +1. 两个标签页独立 SPID,同一事务固定 SPID;提交可见性、回滚恢复、临时表跨执行复用、GO/变量/TRY CATCH、多结果集与截断后的后续结果消费。 +2. WAITFOR 执行超时后丢弃会话,旧 ID 不再执行 SQL,结果报告 unknown。 +3. SQL 执行错误及空闲到期后取得回滚确认,结果报告 rolled_back;空闲测试注入 last_activity=301 秒前,验证实际回滚路径,不等同于五分钟墙钟计时器验收。 +4. 独立观察连接 KILL 被测 SPID,后续执行报告 unknown 并丢弃会话,旧 ID 不再执行 SQL。 +5. 先从 sys.dm_exec_requests 确认 WAITFOR 已到达服务器,再取消;执行时提交和回滚均返回 busy;取消确认 terminal,后台请求与会话得到释放。 +6. 用户 COMMIT、BEGIN TRANSACTION、USE、SET IMPLICIT_TRANSACTIONS 被拒绝且保持原事务;调用实际存储过程执行隐藏 COMMIT 后检测异常,结束会话并报告 unknown。 +7. 在实际 WAITFOR 期间调用应用 remove_connection_pools,确认先取消执行,再完成连接清理;会话及运行查询登记为空,服务器已无该 SPID 的执行请求。 + +基础及补验完成后两个实例均检查:测试表 0、测试存储过程 0、其他用户会话的活动事务 0,DBCC OPENTRAN 无活动事务。失败诊断过程遗留的 fixture 已按实际查询返回的准确名称清理。两个测试容器最终均停止;本轮 2022 在 45 秒停止宽限后以 137 退出,Docker State.OOMKilled=false,发生于全部验收和事务残留检查完成之后;2019 正常以 0 退出。镜像和停止的容器保留以便复验;远端既有 campus 容器持续运行。 + +六组完整日志保存在本地 target/sqlserver-7542-{native,microsoft,jtds}-{2019,2022}.log。可在已有 Rust 构建环境下执行: + +```powershell +cargo test -p dbx-core --test live_sqlserver_manual_transactions --no-default-features --features sqlite-bundled,test-support -- --ignored --nocapture --test-threads=1 +``` + +运行前设置上述环境变量,并先启动一个测试容器、建立 SSH 转发。测试使用 sa 执行专用库 fixture、DMV 检查及 KILL 自己创建的会话;不要指向业务库。 + +## 真实网络和清理故障补验 + +2026-10-03 补充 4 项故障测试,在 SQL Server 2019/2022 × 原生/Microsoft JDBC/jTDS 六种组合中全部通过,共 24 次执行。测试代理仅转发真实 TDS/TCP 字节,不解析或伪造 SQL 结果;TLS 路径保持密文,jTDS ssl=off 路径由 SSH 隧道保护远端传输;独立观察连接绕过代理核对数据。每次故障后检查旧会话不可再执行、事务登记为空、被测连接仅建立一次,禁止用重新提交来确认结果。 + +1. COMMIT 发出前精确开启响应截断:服务端收到请求并完成提交,代理丢弃响应后断开;观察连接确认数据已持久化,DBX 返回 transactionOutcome=unknown。 +2. ROLLBACK 请求阶段截断响应:DBX 返回 unknown,观察连接确认待提交数据不存在。连接中断也可能触发服务端自动回滚,因此数据为零不能单独证明显式 ROLLBACK 已完成;此用例验证响应中断时不虚报回滚成功。 +3. 执行中真实断网:先从 DMV 确认 WAITFOR 正在服务器执行,再关闭代理 TCP 连接;DBX 报 unknown、废弃会话,观察连接确认待提交数据消失,无重连或 SQL 重放。 +4. 已确认提交后的结束故障:原生路径在收到 COMMIT 确认后断开 TCP,并确认本地 socket handles 已释放;JDBC 包装器先调用真实 Connection.close,再抛 SQLException。两者均保留 committed,观察连接确认数据已持久化。原生连接 Drop 没有可返回的关闭错误,此路径不能称为原生 close 返回失败测试。 + +SQL Server 现有 legacy 路由使用单进程 Agent,兼容层的断开 RPC 可先确认后异步执行 JDBC close。测试在第 4 项临时持有 AgentClient 的 Arc,直到真实 close 错误被观察,再释放并确认 TCP 句柄关闭;否则进程 fail-stop 可能先于故障回调结束 Java。此 guard 不持有客户端锁、不改变生产路由,也不代表共享 Agent runtime 验证。正式 SQL Server Agent jar 不包含故障包装器;包装器仅位于 test-support.jar,原生精确阶段 hook 仅在 Rust test-support feature 下编译。 + +故障测试需要额外设置 DBX_TEST_SQLSERVER_FAULT_DRIVER_JAR 为 agents/test-support/build/libs/test-support.jar。可分别用 fault_ 与 wall_clock_idle 过滤器运行新增用例。完整日志在本地 target/sqlserver-7542-{native,microsoft,jtds}-fault-{2019,2022}-final.log。 + +真实墙钟空闲验证:2019/2022 的三个实际驱动均在约 301.3~301.5 秒后由后台 watchdog 主动回滚,收到 rolled_back 确认,待提交数据为零、路由池为空。此测试只读取会话登记,不修改时间戳、不发送新执行请求;等待期间保留真实服务器连接。六次用例全部通过,日志位于本地 target/sqlserver-7542-{native,microsoft,jtds}-idle-{2019,2022}.log。基础 42 次 + 故障 24 次 + 墙钟空闲 6 次,共 72 次最终实库用例通过,覆盖 12 项测试 × 6 种版本/驱动组合;重复诊断运行不计入该总数。 + +## 2026-10-04 最终取消修复的现代版本回归 + +最终发送/取消代码在 SQL Server 2019、2022 × Microsoft JDBC、jTDS 四种组合中补跑了每条 11 项即时用例,共 44 次全部通过。保留此前六种组合均通过的真实墙钟空闲证据,不重复累计为新的独立验收项。原生路径没有受到此次 Agent 写入/取消增量影响;先前原生实库结果仍保留。 + +四份最终日志为 target/sqlserver-7542-{microsoft,jtds}-regression-{2019,2022}.log。2022 首次补跑因暂停后 SSH 本地转发缺失而在连接阶段失败;恢复转发后从头重跑,两条路径均通过。环境连接失败的日志单独保留,不计为通过。 + +两个现代版本的最后 DBCC OPENTRAN 均显示 No active open transactions,专用测试库没有遗留本任务测试对象。测试结束后停止本任务容器,关闭本任务辅助 shell 与 SSH 转发,服务器既有 campus 业务容器保持运行。2019 停止退出码为 0;2022 在 45 秒停止期限后退出码为 137、OOMKilled=false,停止前已确认无遗留事务,并非测试期间 OOM。最终默认 features Windows debug 构建完成(10 分 44 秒),产出 target/debug/dbx.exe;构建重写且无实质差异的 manifest/自动生成权限文件已还原,未生成发布安装包或安装应用。 + +## 2026-10-04 SQL Server 2000 服务器实库验收 + +遵照用户要求,旧版数据库安装和运行均在 aliyun 上完成;本机不运行本任务的数据库容器或虚拟机,仅作为 DBX/Java 测试客户端。远端没有可用 KVM,使用专用 QEMU TCG 客体(1 vCPU、512 MiB RAM、4 GiB 专用可增长系统盘)。客体为 Microsoft 官方 POSReady 2009 评估版,安装经 Microsoft 签名校验的 MSDE 2000 SP4。 + +实际服务器返回:Microsoft SQL Server 2000 Desktop Engine 8.00.2039,Windows NT 5.1 Build 2600 SP3。SQL 端口仅监听远端 127.0.0.1:17540,MCP SSH 转发至本机 5566;测试库为独立创建的 dbx_manual_test。实际 JDBC 驱动均为 jTDS Type 4 1.3.1。 + +完整验证结果: + +- 显式指定 jTDS:12 passed、0 failed,包含四项真实故障注入。 +- legacy 组件默认 JDBC 驱动选择/兼容回退:8 passed、0 failed,实际使用 jTDS;跳过要求显式故障包装驱动的四项,不能标为这条路径的故障测试通过。 +- 两条路径的真实空闲 watchdog 分别在 301.465 秒和 301.395 秒后确认回滚,待提交数据消失,无请求重放。 +- 补齐发送阶段取消/总超时后,最终实库客户端重新运行所有即时用例:jTDS 11 passed、回退 7 passed。已通过的两次墙钟空闲验证保留;诊断和重复回归不叠加为独立验收项数。 +- 最后独立检查:DBCC OPENTRAN 显示 No active open transactions,专用测试库没有遗留本任务事务、测试表或存储过程。 + +日志位于本地 target/sqlserver-7542-jtds-2000-final.log、sqlserver-7542-fallback-2000-final-no-faults.log,以及对应 *-2000-write-final*.log。最终清理证据为 target/sqlserver-7542-sql2000-cleanup.log。 + +首轮旧版实库测试发现取消与主动断开后,原 Agent 进程直接退出,SQL Server 2000 的 WAITFOR 及未提交事务仍保留。诊断同时检查 SPID、login_time、cmd 和 open_tran,确认是同一个遗留查询,并非 SPID 复用。修复只对 SQL Server 的专用事务查询启用取消走廊:显式 v2/multi_session 握手下,向 __legacy__ 会话发送 cancel_session,保持 stdin 打开,并按两个请求 ID 等待原查询响应及取消响应;整个取消写入和等待最多五秒,随后始终释放进程、废弃连接。重复确认、无关响应和普通 JSON 日志不能代替查询结束。 + +初始 execute_query 写入同样使用可取消的阻塞工作任务;发送与读取共享一次期限。请求尚未完整发送时取消/超时,只能强制释放进程,不追加 RPC、不重放或复用会话;结果保持 unknown。完整请求已经发送时继续使用 JDBC 取消走廊。自动化覆盖两种响应顺序、只有确认而无查询结果、重复确认、JSON 日志、取消请求的满管道、大请求发送时取消/超时、非 opt-in 行为和慢速写入后的总期限。其他数据库及 shared runtime 保持原取消策略。 + +旧版客体环境与系统盘保留。再次启动仅使用服务器 /root/dbx-sqlserver-7542/sql2000/start-installed.py 从已有系统盘启动;不要复用安装/恢复介质启动脚本。 + +原生 Tiberius 路径在同一 8.00.2039 实例上的最终矩阵:12 passed、0 failed,含执行中止释放服务器事务用例,日志 target/sqlserver-7542-native-2000-cleanup-final.log;当时唯一按过滤器排除的真实墙钟空闲用例已于 2026-10-04 晚补跑通过:301.395 秒真实空闲后后台 watchdog 主动回滚并确认 rolled_back,待提交数据为零、无请求重放,日志 target/sqlserver-7542-native-2000-wallclock-idle-final.log。至此 SQL2000 原生 13 项实库用例全部通过。 + +## 2026-10-04 原生协议最终增量的现代版本回归 + +最终原生协议增量(ExecutionGuard 丢弃执行 future 时发送 ATTENTION、8.x 旧版状态检查、新增执行中止用例)完成后,原生路径对两个现代版本重跑全部 13 项实库用例:SQL Server 2022 16.0.4295.3 用时 310.66 秒、SQL Server 2019 15.0.4490.9 用时 309.42 秒,均 13 passed、0 failed;两版本的真实墙钟空闲分别在 301.3 秒与 301.267 秒后确认回滚、待提交数据为零。日志 target/sqlserver-7542-native-{2019,2022}-regression-final.log。最终检查两个实例 DBCC OPENTRAN 均 No active open transactions,专用测试库无遗留测试表或存储过程;测试容器已停止,镜像与容器保留复验。最终代码的 tiberius 协议单元测试 147 项全部通过(复跑确认)。 + +## 实库发现并修复的 TLS 配置问题 + +原 legacy Agent 无条件将显式 sslProtocol 覆盖为 TLSv1。使用相同 Java 21、Microsoft JDBC 13.2 和 TLS 放宽设置进行只读连接对照时,本次 2022 实例上 TLSv1 连接失败,TLSv1.2 成功;失败发生于连接阶段,尚未执行用户 SQL。 + +修复保留未配置时的 TLSv1 默认值,但尊重显式 JDBC URL 或 url_params 中的 sslProtocol,url_params 优先。支持驱动接受的 TLS、TLSv1、TLSv1.1、TLSv1.2、TLSv1.3 规范值;大小写归一化,非法值明确失败,不静默降级。JDBC brace 转义、内部 ;/& 和 }} 使用同一属性拆分逻辑,避免将转义值内的协议文本当作外部设置。失败诊断包含实际配置的协议。 + +没有降低测试服务器的 TLS 策略。TLSv1.2 实库连接及事务矩阵已通过;TLSv1.3 参数解析经过单元测试,不代表本次实例完成 TLSv1.3 握手验证。 + +## 自动化验证结果 + +- Rust 核心手动事务回归:33 passed(包含 SQL Server 协议故障用例和既有 Agent/SQL 文件事务回归)。 +- Rust dbx-driver-agent 最终回归:330 passed;另一个慢速请求写入的总期限集成测试 1 passed。新取消边界测试共 10 项。dbx-driver-sqlserver:142 passed、4 个已有实库用例 ignored。 +- Rust dbx-core 与桌面 dbx:裁剪功能 cargo check 通过;补验默认 features 的 pnpm exec tauri build --debug --no-bundle --ci 通过,产出 target/debug/dbx.exe。复用已通过 Vite 构建的前端 dist;未生成或安装发布安装包。 +- Java common:test 与 sqlserver-legacy:test:通过;sqlserver-legacy:shadowJar 构建通过。JdbcExecutorTest 25 项、SqlServerLegacyAgentTest 最终 37 项。 +- 前端事务/能力/工具栏回归:5 个文件、108 passed。事务结果字段解析及文案回归:2 个文件、553 passed(两组包含重复的错误解析用例,不能相加作为独立测试总数)。 +- vue-tsc --noEmit 与前端 Vite 构建:通过。 +- 2026-10-04 时实库测试共 13 项:2019/2022 × 三个实际驱动的初次完整矩阵 72 次通过;最终 Agent 增量另补验现代 JDBC 四种组合的 44 次即时用例,通过。SQL2000 legacy 两条适用路径的完整 20 次及最终即时 18 次通过;SQL2000 原生 13 项全部通过(12 项即时矩阵 + 真实墙钟空闲)。最终原生协议增量后,2019/2022 原生各重跑 13 项全部通过。重复回归不合并成独立测试数量。正常 CI 默认 ignored,必须显式指向可写隔离测试库。 +- 2026-10-04 的以上验证记录针对当时的代码;2026-10-05 提交前复核另外发现并修复请求丢弃与断开连接交叉的取消所有权问题,以及 JDBC 请求丢弃后过早结束取消走廊的问题。修复后检查结果单独记录。pnpm check 的 connection-types 与 packages/app-tests 发布签名用例在本机失败,与本次改动无关:connection-types 因 node_modules/.bin/node.CMD 启动失败,macOS 签名流程测试在基线 247eb2b0b 纯净检出同样 35/39 失败(Windows 环境缺少 macOS 签名工具链 mock 前置条件)。 + +Windows 的默认 GNU Rust 工具链缺少本地 C/OpenSSL 编译依赖,本次使用 target 下的便携编译依赖完成检查与测试,没有修改仓库 Cargo 配置或全局工具链。 + +## 尚未覆盖及发布要求 + +SQL Server 2000 的实际验证范围为 MSDE 2000 SP4 Desktop Engine 8.00.2039 + 原生 Tiberius 与 jTDS 1.3.1;未验证 SQL Server 2000 Enterprise/Developer Edition 与其他 Service Pack,也不能声称 Microsoft JDBC 13.2 支持 SQL Server 2000(legacy 路径回退后实际驱动为 jTDS)。默认 features 的 Windows debug 应用已构建,发布安装包生成与安装仍未验证。 + +范围不包括 Web 手动事务、嵌套事务、保存点或备份一致性快照。无法从事务计数检测到的存储过程自行提交并重新开启事务,不应被视为受 DBX 完全控制;可检测到的结束/计数异常会废弃会话。 + +legacy 发布时必须同时发布此次构建的 SQL Server Agent 组件,并按组件仓库流程更新可下载安装的版本;仅更新桌面应用而保留旧 Agent 会得到明确的组件更新提示。manualTransactionBatch 是运行时握手能力标识,不根据安装版本号猜测安全性。本次没有发布可下载组件;合并发布时需按组件仓库流程同步交付。 + +## 2026-10-05 提交前复核与上游合并 + +合并 upstream/main 38ce7b5dd(0.6.34)。JsonRpcServer 保留新增 deferLobs 选项以及 SQL Server returnAllResults 路由;queryStore 保留 SQL Server executionId/timeout 参数,兼容上游 useLargeValuePreview。新增 18 个文案键已覆盖 en、zh-CN、zh-TW、es、it、ja、ko、pt-BR、ru。 + +复核发现两个中断清理竞态并完成修复:disconnect 已移除 busy 会话时,后续执行 future 被丢弃,原 ExecutionGuard 无法取得清理所有权,disconnect 又只关闭 TCP,遗漏原生 ATTENTION;现在接管 busy 会话的 disconnect 同样负责有界中断。JDBC 执行 future 被丢弃时,直接追加 cancel_session 会因为原 reader 持有 stdout 而立即失败,随后过早关闭进程;现在每个 Agent batch 在持有客户端 Arc/锁的 owned task 内执行,外层 Drop 只取消 token,后台继续完成现有有界取消走廊。后台同时持有查询登记,直到取消结束才确认 terminal;正常 GO batch 完成后解除 Drop 取消 guard,不取消后续 batch。 + +修复前 SQL2000 真实 jTDS 的 aborted_request 用例失败,原服务器任务/事务未能及时释放;延迟取消响应的协议测试同样失败。修复后增加确定性的 disconnect 先取得会话所有权、随后 abort 请求的用例。协议测试不通过额外连接 Arc 保活,验证取消完成前登记仍活动、完成后释放;实库按 SPID + login_time 检查原服务器事务释放、未提交数据消失,且失效会话不会再次执行 SQL。 + +本次最终验证结果: + +- Rust 核心手动事务相关回归 35 passed;Agent 339 passed、1 个原有外部制品测试 ignored;发送总期限集成测试 1 passed;SQL Server 驱动 145 passed、4 个原有实库测试 ignored。 +- Java common 232 passed、sqlserver-legacy 37 passed,两个组件及故障测试 JAR 构建通过。 +- 前端相关 5 文件 111 passed;vue-tsc 使用与上游 CI 相同的 8 GiB Node 堆配置通过;oxfmt、oxlint(无 error)、连接类型生成校验和 i18n dry-run 全部通过;Vite 生产构建通过。 +- cargo fmt --check、git diff --check 通过。cargo check -p dbx --no-default-features --features sqlite-bundled 通过,包含桌面命令接口;不将单包检查标为整个工作区 make cargo-check-fast 通过。 +- SQL2000 8.00.2039 的原生、jTDS 各 13 项即时实库用例通过;2019 15.0.4490.9 与 2022 16.0.4295.3 的原生、Microsoft JDBC 13.2、jTDS 各 13 项即时实库用例通过。上述每组排除且仅排除真实 5 分钟墙钟空闲测试;此前 2026-10-04 的真实墙钟结果保留,本次没有把这些历史结果标为重新执行。 +- 最新实库测试集共 14 项(增加 disconnect + abort 交叉用例)。日志为 target/sqlserver-7542-pr-{2000,2019,2022}-{native,jtds,microsoft}-green.log 的适用组合;重复增量结果不累加为独立用例总数。 +- 复核没有未解决的 P1/P2。三个测试实例均确认无遗留事务或测试对象;现代测试容器恢复停止状态。 + +本次未在最新上游合并后全量重跑 make check / pnpm check,不能标为全量检查通过;2026-10-04 的 Windows 签名测试基线结果见上文。当前安装包仍是此前构建的产物;本次桌面编译检查与前端构建不等同于重新产出安装包。上游 PR CI 结果需另行核对。 + +工具栏截图由真实 EditorToolbar 组件与隔离状态 fixture 渲染,展示活动、执行中、结果未知三个状态,不把组件截图当作实库证明: + +![SQL Server 手动事务工具栏状态](images/sqlserver-manual-transactions-toolbar.png) diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index 8d37197fae..4161382041 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -30,6 +30,19 @@ pub enum ManualTransactionCommandError { Legacy(String), } +fn manual_transaction_command_error(error: String) -> ManualTransactionCommandError { + if let Some(error) = dbx_core::query::sqlserver_manual_transaction::backend_error(&error) { + return ManualTransactionCommandError::Structured(Box::new(error)); + } + if dbx_core::query::is_manual_transaction_session_expired_error(&error) { + ManualTransactionCommandError::Structured(Box::new(BackendError::from_manual_transaction_session_expired( + dbx_core::query::MANUAL_TRANSACTION_IDLE_TIMEOUT_SECS, + ))) + } else { + ManualTransactionCommandError::Legacy(error) + } +} + #[tauri::command] #[allow(clippy::too_many_arguments)] pub async fn execute_query( @@ -462,12 +475,14 @@ pub async fn begin_manual_transaction( database: String, schema: Option, catalog: Option, -) -> Result { +) -> Result { dbx_core::query::begin_manual_transaction(&state, &connection_id, &database, schema.as_deref(), catalog.as_deref()) .await + .map_err(manual_transaction_command_error) } #[tauri::command] +#[allow(clippy::too_many_arguments)] pub async fn execute_in_manual_transaction( state: State<'_, Arc>, txn_session_id: String, @@ -479,6 +494,8 @@ pub async fn execute_in_manual_transaction( page_size: Option, result_session_id: Option, classification_sql: Option, + execution_id: Option, + timeout_secs: Option, ) -> Result, ManualTransactionCommandError> { dbx_core::query::execute_in_manual_transaction_with_options( &state, @@ -492,34 +509,30 @@ pub async fn execute_in_manual_transaction( page_size, result_session_id, classification_sql, + execution_id, + timeout_secs, }, ) .await - .map_err(|error| { - if dbx_core::query::is_manual_transaction_session_expired_error(&error) { - ManualTransactionCommandError::Structured(Box::new(BackendError::from_manual_transaction_session_expired( - dbx_core::query::MANUAL_TRANSACTION_IDLE_TIMEOUT_SECS, - ))) - } else { - ManualTransactionCommandError::Legacy(error) - } - }) + .map_err(manual_transaction_command_error) } #[tauri::command] pub async fn commit_manual_transaction( state: State<'_, Arc>, txn_session_id: String, -) -> Result { - dbx_core::query::commit_manual_transaction(&state, &txn_session_id).await +) -> Result { + dbx_core::query::commit_manual_transaction(&state, &txn_session_id).await.map_err(manual_transaction_command_error) } #[tauri::command] pub async fn rollback_manual_transaction( state: State<'_, Arc>, txn_session_id: String, -) -> Result { - dbx_core::query::rollback_manual_transaction(&state, &txn_session_id).await +) -> Result { + dbx_core::query::rollback_manual_transaction(&state, &txn_session_id) + .await + .map_err(manual_transaction_command_error) } #[tauri::command] diff --git a/vendor/tiberius/src/client.rs b/vendor/tiberius/src/client.rs index 688721d10c..d14a4077d6 100644 --- a/vendor/tiberius/src/client.rs +++ b/vendor/tiberius/src/client.rs @@ -75,6 +75,11 @@ impl Client { }) } + /// The wire dialect negotiated in LOGINACK. + pub fn tds_version(&self) -> crate::FeatureLevel { + self.connection.context().version() + } + /// Executes SQL statements in the SQL Server, returning the number rows /// affected. Useful for `INSERT`, `UPDATE` and `DELETE` statements. The /// `query` can define the parameter placement by annotating them with @@ -238,7 +243,8 @@ impl Client { { self.connection.flush_stream().await?; - let req = BatchRequest::new(query, self.connection.context().transaction_descriptor()); + let req = BatchRequest::new(query, self.connection.context().transaction_descriptor()) + .with_tds_version(self.connection.context().version()); let id = self.connection.context_mut().next_packet_id(); self.connection.send(PacketHeader::batch(id), req).await?; @@ -306,7 +312,8 @@ impl Client { // retrieve column metadata from server let query = format!("SELECT TOP 0 * FROM {}", table); - let req = BatchRequest::new(query, self.connection.context().transaction_descriptor()); + let req = BatchRequest::new(query, self.connection.context().transaction_descriptor()) + .with_tds_version(self.connection.context().version()); let id = self.connection.context_mut().next_packet_id(); self.connection.send(PacketHeader::batch(id), req).await?; @@ -336,7 +343,8 @@ impl Client { let col_data = columns.iter().map(|c| format!("{}", c)).join(", "); let query = format!("INSERT BULK {} ({})", table, col_data); - let req = BatchRequest::new(query, self.connection.context().transaction_descriptor()); + let req = BatchRequest::new(query, self.connection.context().transaction_descriptor()) + .with_tds_version(self.connection.context().version()); let id = self.connection.context_mut().next_packet_id(); self.connection.send(PacketHeader::batch(id), req).await?; @@ -347,6 +355,15 @@ impl Client { BulkLoadRequest::new(&mut self.connection, columns) } + /// Send an ATTENTION signal without reading or reusing an interrupted response. + /// This only acknowledges transmission, not rollback. The caller must discard + /// this connection after interrupting its token stream and bound this operation. + pub async fn send_attention(&mut self) -> crate::Result<()> { + let id = self.connection.context_mut().next_packet_id(); + self.connection.write_to_wire(PacketHeader::attention(id), bytes::BytesMut::new()).await?; + self.connection.flush_sink().await + } + /// Closes this database connection explicitly. pub async fn close(self) -> crate::Result<()> { self.connection.close().await @@ -383,7 +400,7 @@ impl Client { param_str.push(',') } param_str.push_str(&format!("@P{} ", i + 1)); - param_str.push_str(¶m.type_name()); + param_str.push_str(¶m.type_name_for_version(self.connection.context().version())); rpc_params.push(RpcParam { name: Cow::Owned(format!("@P{}", i + 1)), @@ -400,7 +417,7 @@ impl Client { proc_id, rpc_params, self.connection.context().transaction_descriptor(), - ); + ).with_tds_version(self.connection.context().version()); let id = self.connection.context_mut().next_packet_id(); self.connection.send(PacketHeader::rpc(id), req).await?; diff --git a/vendor/tiberius/src/lib.rs b/vendor/tiberius/src/lib.rs index 882f5ad365..7120c2ca78 100644 --- a/vendor/tiberius/src/lib.rs +++ b/vendor/tiberius/src/lib.rs @@ -287,6 +287,7 @@ pub use uuid::Uuid; use sql_read_bytes::*; use tds::codec::*; +pub use tds::codec::FeatureLevel; /// An alias for a result that holds crate's error type as the error. pub type Result = std::result::Result; diff --git a/vendor/tiberius/src/sql_read_bytes.rs b/vendor/tiberius/src/sql_read_bytes.rs index 0455a1ce76..0569ee0fc2 100644 --- a/vendor/tiberius/src/sql_read_bytes.rs +++ b/vendor/tiberius/src/sql_read_bytes.rs @@ -352,12 +352,13 @@ pub(crate) mod test_utils { type T = BytesMutReader; fn into_sql_read_bytes(self) -> Self::T { - BytesMutReader { buf: self } + BytesMutReader { buf: self, context: Context::new() } } } pub(crate) struct BytesMutReader { buf: BytesMut, + context: Context, } impl AsyncRead for BytesMutReader { @@ -388,11 +389,11 @@ pub(crate) mod test_utils { } fn context(&self) -> &Context { - todo!() + &self.context } fn context_mut(&mut self) -> &mut Context { - todo!() + &mut self.context } } } diff --git a/vendor/tiberius/src/tds/codec.rs b/vendor/tiberius/src/tds/codec.rs index 07f1331068..1560a8b374 100644 --- a/vendor/tiberius/src/tds/codec.rs +++ b/vendor/tiberius/src/tds/codec.rs @@ -62,3 +62,6 @@ where T::decode(&mut buf) } + +#[cfg(test)] +mod legacy_tests; diff --git a/vendor/tiberius/src/tds/codec/batch_request.rs b/vendor/tiberius/src/tds/codec/batch_request.rs index f68da0e2ec..55eb1ce4e8 100644 --- a/vendor/tiberius/src/tds/codec/batch_request.rs +++ b/vendor/tiberius/src/tds/codec/batch_request.rs @@ -5,6 +5,7 @@ use std::borrow::Cow; pub struct BatchRequest<'a> { queries: Cow<'a, str>, transaction_descriptor: [u8; 8], + tds_version: crate::FeatureLevel, } impl<'a> BatchRequest<'a> { @@ -12,17 +13,25 @@ impl<'a> BatchRequest<'a> { Self { queries: queries.into(), transaction_descriptor, + tds_version: crate::FeatureLevel::default(), } } + + pub fn with_tds_version(mut self, version: crate::FeatureLevel) -> Self { + self.tds_version = version; + self + } } impl<'a> Encode for BatchRequest<'a> { fn encode(self, dst: &mut BytesMut) -> crate::Result<()> { - dst.put_u32_le(ALL_HEADERS_LEN_TX as u32); - dst.put_u32_le(ALL_HEADERS_LEN_TX as u32 - 4); - dst.put_u16_le(AllHeaderTy::TransactionDescriptor as u16); - dst.put_slice(&self.transaction_descriptor); - dst.put_u32_le(1); + if self.tds_version >= crate::FeatureLevel::SqlServer2005 { + dst.put_u32_le(ALL_HEADERS_LEN_TX as u32); + dst.put_u32_le(ALL_HEADERS_LEN_TX as u32 - 4); + dst.put_u16_le(AllHeaderTy::TransactionDescriptor as u16); + dst.put_slice(&self.transaction_descriptor); + dst.put_u32_le(1); + } for c in self.queries.encode_utf16() { dst.put_u16_le(c); diff --git a/vendor/tiberius/src/tds/codec/column_data.rs b/vendor/tiberius/src/tds/codec/column_data.rs index 03fa6c9a92..4455db554c 100644 --- a/vendor/tiberius/src/tds/codec/column_data.rs +++ b/vendor/tiberius/src/tds/codec/column_data.rs @@ -86,6 +86,18 @@ pub enum ColumnData<'a> { } impl<'a> ColumnData<'a> { + pub(crate) fn type_name_for_version(&self, version: crate::FeatureLevel) -> Cow<'static, str> { + if version < crate::FeatureLevel::SqlServer2005 { + match self { + Self::String(Some(s)) if s.encode_utf16().count() > 4000 => return "ntext".into(), + Self::Binary(None) => return "varbinary(8000)".into(), + Self::Binary(Some(b)) if b.len() > 8000 => return "image".into(), + _ => {} + } + } + self.type_name() + } + pub(crate) fn type_name(&self) -> Cow<'static, str> { match self { ColumnData::U8(_) => "tinyint".into(), @@ -96,7 +108,7 @@ impl<'a> ColumnData<'a> { ColumnData::F64(_) => "float(53)".into(), ColumnData::Bit(_) => "bit".into(), ColumnData::String(None) => "nvarchar(4000)".into(), - ColumnData::String(Some(ref s)) if s.len() <= 4000 => "nvarchar(4000)".into(), + ColumnData::String(Some(ref s)) if s.encode_utf16().count() <= 4000 => "nvarchar(4000)".into(), ColumnData::String(Some(ref s)) if s.len() <= MAX_NVARCHAR_SIZE => { "nvarchar(max)".into() } @@ -418,7 +430,7 @@ impl<'a> Encode> for ColumnData<'a> { dst.put_u64_le(0xffffffffffffffff) } } - (ColumnData::String(Some(ref s)), None) if s.len() <= 4000 => { + (ColumnData::String(Some(ref s)), None) if s.encode_utf16().count() <= 4000 => { dst.put_u8(VarLenType::NVarchar as u8); dst.put_u16_le(8000); dst.extend_from_slice(&[0u8; 5][..]); diff --git a/vendor/tiberius/src/tds/codec/header.rs b/vendor/tiberius/src/tds/codec/header.rs index 719fc158ba..e545357c22 100644 --- a/vendor/tiberius/src/tds/codec/header.rs +++ b/vendor/tiberius/src/tds/codec/header.rs @@ -68,6 +68,11 @@ impl PacketHeader { } } + pub fn attention(id: u8) -> Self { + Self { ty: PacketType::AttentionSignal, status: PacketStatus::EndOfMessage, + ..Self::new(8, id) } + } + pub fn rpc(id: u8) -> Self { Self { ty: PacketType::Rpc, diff --git a/vendor/tiberius/src/tds/codec/legacy_tests.rs b/vendor/tiberius/src/tds/codec/legacy_tests.rs new file mode 100644 index 0000000000..bce4168f03 --- /dev/null +++ b/vendor/tiberius/src/tds/codec/legacy_tests.rs @@ -0,0 +1,143 @@ +use super::*; +use crate::{sql_read_bytes::test_utils::IntoSqlReadBytes, SqlReadBytes}; +use bytes::{BufMut, BytesMut}; + +fn message(width: usize) -> BytesMut { + let text = "A\u{1f600}"; + let mut buf = BytesMut::new(); + buf.put_u16_le((10 + 2 * (text.encode_utf16().count() + 1) + width) as u16); + buf.put_u32_le(195); + buf.put_u8(10); + buf.put_u8(15); + buf.put_u16_le(text.encode_utf16().count() as u16); + for c in text.encode_utf16() { buf.put_u16_le(c); } + buf.put_u8(1); buf.put_u16_le('S' as u16); + buf.put_u8(0); + if width == 2 { buf.put_u16_le(321); } else { buf.put_u32_le(70000); } + buf.put_u8(0xfd); // next token must stay untouched + buf +} + +#[tokio::test] +async fn legacy_info_and_error_preserve_next_token_before_loginack() { + for width in [2, 4] { + let mut info = message(width).into_sql_read_bytes(); + let decoded = TokenInfo::decode(&mut info).await.unwrap(); + assert_eq!(decoded.line, if width == 2 { 321 } else { 70000 }); + assert_eq!(decoded.message, "A\u{1f600}"); + assert_eq!(info.read_u8().await.unwrap(), 0xfd); + let mut error = message(width).into_sql_read_bytes(); + let decoded = TokenError::decode(&mut error).await.unwrap(); + assert_eq!(decoded.code(), 195); + assert_eq!(decoded.line(), if width == 2 { 321 } else { 70000 }); + assert_eq!(error.read_u8().await.unwrap(), 0xfd); + } +} + +#[tokio::test] +async fn legacy_message_rejects_invalid_length_without_consuming_line() { + let mut buf = message(2); + buf[0] = 1; buf[1] = 0; + let mut reader = buf.into_sql_read_bytes(); + assert!(TokenInfo::decode(&mut reader).await.is_err()); + assert_eq!(reader.read_u16_le().await.unwrap(), 321); +} + +#[tokio::test] +async fn legacy_done_and_column_width_follow_negotiated_context() { + for version in [FeatureLevel::SqlServer2000Sp1, FeatureLevel::SqlServer2005, FeatureLevel::SqlServerN] { + let mut done = BytesMut::new(); + done.put_u16_le(0x10); done.put_u16_le(0); + if version < FeatureLevel::SqlServer2005 { done.put_u32_le(42); } else { done.put_u64_le(42); } + done.put_u8(0xfd); + let mut reader = done.into_sql_read_bytes(); + reader.context_mut().set_version(version); + assert_eq!(TokenDone::decode(&mut reader).await.unwrap().rows(), 42); + assert_eq!(reader.read_u8().await.unwrap(), 0xfd); + let mut col = BytesMut::new(); + col.put_u16_le(1); // one column + if version < FeatureLevel::SqlServer2005 { col.put_u16_le(0); } else { col.put_u32_le(0); } + col.put_u16_le(0); col.put_u8(0x38); // fixed int + col.put_u8(1); col.put_u16_le('v' as u16); col.put_u8(0xd1); + let mut reader = col.into_sql_read_bytes(); + reader.context_mut().set_version(version); + let columns = TokenColMetaData::decode(&mut reader).await.unwrap(); + assert_eq!(columns.columns[0].col_name, "v"); + assert_eq!(reader.read_u8().await.unwrap(), 0xd1); + } +} + +#[test] +fn legacy_batch_and_rpc_omit_only_tds72_headers() { + let descriptor = [9u8; 8]; + let mut legacy = BytesMut::new(); + let mut modern = BytesMut::new(); + BatchRequest::new("SELECT 1", descriptor).with_tds_version(FeatureLevel::SqlServer2000Sp1).encode(&mut legacy).unwrap(); + BatchRequest::new("SELECT 1", descriptor).with_tds_version(FeatureLevel::SqlServer2005).encode(&mut modern).unwrap(); + assert_eq!(legacy.as_ref(), "SELECT 1".encode_utf16().flat_map(u16::to_le_bytes).collect::>()); + assert_eq!(modern.len(), legacy.len() + ALL_HEADERS_LEN_TX); + assert_eq!(&modern[10..18], &descriptor); + assert_eq!(&modern[ALL_HEADERS_LEN_TX..], legacy.as_ref()); + legacy.clear(); modern.clear(); + TokenRpcRequest::new(RpcProcId::ExecuteSQL, vec![], descriptor).with_tds_version(FeatureLevel::SqlServer2000Sp1).encode(&mut legacy).unwrap(); + TokenRpcRequest::new(RpcProcId::ExecuteSQL, vec![], descriptor).with_tds_version(FeatureLevel::SqlServerN).encode(&mut modern).unwrap(); + assert_eq!(legacy.as_ref(), &[0xff, 0xff, 10, 0, 0, 0]); + assert_eq!(&modern[10..18], &descriptor); + assert_eq!(&modern[ALL_HEADERS_LEN_TX..], legacy.as_ref()); +} +#[tokio::test] +async fn legacy_lob_table_names_preserve_next_row_token() { + for version in [FeatureLevel::SqlServer2000Sp1, FeatureLevel::SqlServerN] { + for ty in [0x23, 0x63, 0x22] { + let mut col = BytesMut::new(); col.put_u16_le(1); + if version < FeatureLevel::SqlServer2005 { col.put_u16_le(0); } else { col.put_u32_le(0); } + col.put_u16_le(0); col.put_u8(ty); col.put_u32_le(100); + if ty != 0x22 { col.extend_from_slice(&[0x09, 0x04, 0, 0, 0]); } + let names: &[&str] = if version < FeatureLevel::SqlServer2005 { &["dbo.test"] } else { &["db", "dbo", "test"] }; + if version >= FeatureLevel::SqlServer2005 { col.put_u8(names.len() as u8); } + for name in names { + col.put_u16_le(name.encode_utf16().count() as u16); + for unit in name.encode_utf16() { col.put_u16_le(unit); } + } + col.put_u8(1); col.put_u16_le('v' as u16); col.put_u8(0xd1); + let mut reader = col.into_sql_read_bytes(); reader.context_mut().set_version(version); + assert_eq!(TokenColMetaData::decode(&mut reader).await.unwrap().columns[0].col_name, "v"); + assert_eq!(reader.read_u8().await.unwrap(), 0xd1); + } + } +} + +#[test] +fn legacy_rpc_lob_and_unicode_encodings_match_declared_types() { + use std::borrow::Cow; + use enumflags2::BitFlags; + let legacy = FeatureLevel::SqlServer2000Sp1; + let modern = FeatureLevel::SqlServerN; + let encode = |value, version| { + let mut dst = BytesMut::new(); + TokenRpcRequest::new(RpcProcId::ExecuteSQL, vec![RpcParam { name: Cow::Borrowed(""), flags: BitFlags::empty(), value }], [0;8]) + .with_tds_version(version).encode(&mut dst).unwrap(); + let offset = if version < FeatureLevel::SqlServer2005 { 6 } else { 6 + ALL_HEADERS_LEN_TX }; + dst[offset + 2..].to_vec() // skip empty name + flags + }; + let unicode = ColumnData::String(Some("\u{4e2d}".repeat(1400).into())); + assert_eq!(unicode.type_name_for_version(legacy), "nvarchar(4000)"); + assert_eq!(encode(unicode, legacy)[0], 0xe7); + let large = ColumnData::String(Some("x".repeat(4001).into())); + assert_eq!(large.type_name_for_version(legacy), "ntext"); + assert_eq!(large.type_name_for_version(modern), "nvarchar(max)"); + let old = encode(large.clone(), legacy); let new = encode(large, modern); + assert_eq!(old[0], 0x63); assert_eq!(&old[10..14], &8002u32.to_le_bytes()); + assert_eq!(new[0], 0xe7); assert_eq!(&new[1..3], &[0xff,0xff]); + let binary = ColumnData::Binary(Some(vec![0x5a;8001].into())); + assert_eq!(binary.type_name_for_version(legacy), "image"); + assert_eq!(encode(binary, legacy)[0], 0x22); + assert_eq!(ColumnData::Binary(None).type_name_for_version(legacy), "varbinary(8000)"); +} + +#[test] +fn legacy_attention_is_an_empty_end_of_message_packet() { + let mut dst = BytesMut::new(); + PacketHeader::attention(3).encode(&mut dst).unwrap(); + assert_eq!(dst.as_ref(), &[6, 1, 0, 8, 0, 0, 3, 0]); +} \ No newline at end of file diff --git a/vendor/tiberius/src/tds/codec/login.rs b/vendor/tiberius/src/tds/codec/login.rs index 265db381ed..5fa33babed 100644 --- a/vendor/tiberius/src/tds/codec/login.rs +++ b/vendor/tiberius/src/tds/codec/login.rs @@ -28,7 +28,7 @@ impl Default for FeatureLevel { } impl FeatureLevel { - pub fn done_row_count_bytes(self) -> u8 { + pub(crate) fn done_row_count_bytes(self) -> u8 { if self as u32 >= FeatureLevel::SqlServer2005 as u32 { 8 } else { diff --git a/vendor/tiberius/src/tds/codec/rpc_request.rs b/vendor/tiberius/src/tds/codec/rpc_request.rs index 368cbd3c80..9732911141 100644 --- a/vendor/tiberius/src/tds/codec/rpc_request.rs +++ b/vendor/tiberius/src/tds/codec/rpc_request.rs @@ -30,6 +30,7 @@ pub struct TokenRpcRequest<'a> { flags: BitFlags, params: Vec>, transaction_desc: [u8; 8], + tds_version: crate::FeatureLevel, } impl<'a> TokenRpcRequest<'a> { @@ -42,8 +43,13 @@ impl<'a> TokenRpcRequest<'a> { flags: BitFlags::empty(), params, transaction_desc, + tds_version: crate::FeatureLevel::default(), } } + pub fn with_tds_version(mut self, version: crate::FeatureLevel) -> Self { + self.tds_version = version; + self + } } #[derive(Debug)] @@ -92,11 +98,13 @@ impl<'a> From for RpcProcIdValue<'a> { impl<'a> Encode for TokenRpcRequest<'a> { fn encode(self, dst: &mut BytesMut) -> Result<()> { - dst.put_u32_le(ALL_HEADERS_LEN_TX as u32); - dst.put_u32_le(ALL_HEADERS_LEN_TX as u32 - 4); - dst.put_u16_le(AllHeaderTy::TransactionDescriptor as u16); - dst.put_slice(&self.transaction_desc); - dst.put_u32_le(1); + if self.tds_version >= crate::FeatureLevel::SqlServer2005 { + dst.put_u32_le(ALL_HEADERS_LEN_TX as u32); + dst.put_u32_le(ALL_HEADERS_LEN_TX as u32 - 4); + dst.put_u16_le(AllHeaderTy::TransactionDescriptor as u16); + dst.put_slice(&self.transaction_desc); + dst.put_u32_le(1); + } match self.proc_id { RpcProcIdValue::Id(ref id) => { @@ -113,7 +121,7 @@ impl<'a> Encode for TokenRpcRequest<'a> { dst.put_u16_le(self.flags.bits()); for param in self.params.into_iter() { - param.encode(dst)?; + param.encode_for_version(dst, self.tds_version)?; } Ok(()) @@ -122,6 +130,11 @@ impl<'a> Encode for TokenRpcRequest<'a> { impl<'a> Encode for RpcParam<'a> { fn encode(self, dst: &mut BytesMut) -> Result<()> { + self.encode_for_version(dst, crate::FeatureLevel::default()) + } +} +impl<'a> RpcParam<'a> { + fn encode_for_version(self, dst: &mut BytesMut, version: crate::FeatureLevel) -> Result<()> { let len_pos = dst.len(); let mut length = 0u8; @@ -134,8 +147,31 @@ impl<'a> Encode for RpcParam<'a> { dst.put_u8(self.flags.bits()); - let mut dst_fi = BytesMutWithTypeInfo::new(dst); - self.value.encode(&mut dst_fi)?; + let legacy_lob = if version < crate::FeatureLevel::SqlServer2005 { + match &self.value { + ColumnData::String(Some(s)) if s.encode_utf16().count() > 4000 => { + let units = u32::try_from(s.encode_utf16().count()).map_err(|_| crate::Error::Protocol("NTEXT parameter too large".into()))?; + let bytes = units.checked_mul(2).ok_or_else(|| crate::Error::Protocol("NTEXT parameter too large".into()))?; + dst.put_u8(0x63); // NTEXT TYPE_INFO, followed by L_VARBYTE + dst.put_u32_le(bytes); + dst.extend_from_slice(&[0; 5]); // default collation + dst.put_u32_le(bytes); + for unit in s.encode_utf16() { dst.put_u16_le(unit); } + true + } + ColumnData::Binary(Some(b)) if b.len() > 8000 => { + let len = u32::try_from(b.len()).map_err(|_| crate::Error::Protocol("IMAGE parameter too large".into()))?; + dst.put_u8(0x22); // IMAGE TYPE_INFO, followed by L_VARBYTE + dst.put_u32_le(len); dst.put_u32_le(len); dst.extend_from_slice(b); + true + } + _ => false, + } + } else { false }; + if !legacy_lob { + let mut dst_fi = BytesMutWithTypeInfo::new(dst); + self.value.encode(&mut dst_fi)?; + } let dst: &mut [u8] = dst.borrow_mut(); dst[len_pos] = length; diff --git a/vendor/tiberius/src/tds/codec/token.rs b/vendor/tiberius/src/tds/codec/token.rs index 706d03710b..90b360ca99 100644 --- a/vendor/tiberius/src/tds/codec/token.rs +++ b/vendor/tiberius/src/tds/codec/token.rs @@ -11,6 +11,14 @@ mod token_row; mod token_sspi; mod token_type; +fn message_line_number_bytes(length: usize, message: &str, server: &str, procedure: &str) -> crate::Result { + let fixed_bytes = 10 + 2 * (message.encode_utf16().count() + server.encode_utf16().count() + procedure.encode_utf16().count()); + match length.checked_sub(fixed_bytes) { + Some(width @ (2 | 4)) => Ok(width), + _ => Err(crate::Error::Protocol("Invalid INFO/ERROR token length".into())), + } +} + pub use token_col_metadata::*; pub use token_done::*; pub use token_env_change::*; diff --git a/vendor/tiberius/src/tds/codec/token/token_col_metadata.rs b/vendor/tiberius/src/tds/codec/token/token_col_metadata.rs index 53ffdf1c68..12bcb2d93e 100644 --- a/vendor/tiberius/src/tds/codec/token/token_col_metadata.rs +++ b/vendor/tiberius/src/tds/codec/token/token_col_metadata.rs @@ -326,7 +326,11 @@ impl BaseMetaDataColumn { { use VarLenType::*; - let _user_ty = src.read_u32_le().await?; + let _user_ty = if src.context().version() < crate::FeatureLevel::SqlServer2005 { + src.read_u16_le().await? as u32 + } else { + src.read_u32_le().await? + }; let flags = BitFlags::from_bits(src.read_u16_le().await?) .map_err(|_| Error::Protocol("column metadata: invalid flags".into()))?; @@ -335,11 +339,13 @@ impl BaseMetaDataColumn { if let TypeInfo::VarLenSized(cx) = ty { if let Text | NText | Image = cx.r#type() { - let num_of_parts = src.read_u8().await?; - - // table name - for _ in 0..num_of_parts { + if src.context().version() < crate::FeatureLevel::SqlServer2005 { src.read_us_varchar().await?; + } else { + let num_of_parts = src.read_u8().await?; + for _ in 0..num_of_parts { + src.read_us_varchar().await?; + } } }; }; diff --git a/vendor/tiberius/src/tds/codec/token/token_error.rs b/vendor/tiberius/src/tds/codec/token/token_error.rs index d1e435a773..39a2d5ff57 100644 --- a/vendor/tiberius/src/tds/codec/token/token_error.rs +++ b/vendor/tiberius/src/tds/codec/token/token_error.rs @@ -1,4 +1,4 @@ -use crate::{tds::codec::FeatureLevel, SqlReadBytes}; +use crate::SqlReadBytes; use std::fmt; #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] @@ -22,7 +22,7 @@ impl TokenError { where R: SqlReadBytes + Unpin, { - let _length = src.read_u16_le().await? as usize; + let length = src.read_u16_le().await? as usize; let code = src.read_u32_le().await?; let state = src.read_u8().await?; @@ -32,7 +32,8 @@ impl TokenError { let server = src.read_b_varchar().await?; let procedure = src.read_b_varchar().await?; - let line = if src.context().version() > FeatureLevel::SqlServer2005 { + let width = super::message_line_number_bytes(length, &message, &server, &procedure)?; + let line = if width == 4 { src.read_u32_le().await? } else { src.read_u16_le().await? as u32 diff --git a/vendor/tiberius/src/tds/codec/token/token_info.rs b/vendor/tiberius/src/tds/codec/token/token_info.rs index 96c4eca53a..98c9f5ee85 100644 --- a/vendor/tiberius/src/tds/codec/token/token_info.rs +++ b/vendor/tiberius/src/tds/codec/token/token_info.rs @@ -20,7 +20,7 @@ impl TokenInfo { where R: SqlReadBytes + Unpin, { - let _length = src.read_u16_le().await?; + let length = src.read_u16_le().await? as usize; let number = src.read_u32_le().await?; let state = src.read_u8().await?; @@ -28,7 +28,10 @@ impl TokenInfo { let message = src.read_us_varchar().await?; let server = src.read_b_varchar().await?; let procedure = src.read_b_varchar().await?; - let line = src.read_u32_le().await?; + // INFO can precede LOGINACK, so the negotiated dialect is not yet + // available. Its declared length disambiguates 7.1 and 7.2+ safely. + let width = super::message_line_number_bytes(length, &message, &server, &procedure)?; + let line = if width == 2 { src.read_u16_le().await? as u32 } else { src.read_u32_le().await? }; Ok(TokenInfo { number, diff --git a/vendor/tiberius/src/tds/context.rs b/vendor/tiberius/src/tds/context.rs index 732bac15cb..79cf69f4e0 100644 --- a/vendor/tiberius/src/tds/context.rs +++ b/vendor/tiberius/src/tds/context.rs @@ -54,6 +54,10 @@ impl Context { self.transaction_desc = desc; } + pub fn set_version(&mut self, version: FeatureLevel) { + self.version = version; + } + pub fn version(&self) -> FeatureLevel { self.version } diff --git a/vendor/tiberius/src/tds/stream/token.rs b/vendor/tiberius/src/tds/stream/token.rs index 35ce0658b4..e37ac94d8d 100644 --- a/vendor/tiberius/src/tds/stream/token.rs +++ b/vendor/tiberius/src/tds/stream/token.rs @@ -197,6 +197,7 @@ where async fn get_login_ack(&mut self) -> crate::Result { let ack = TokenLoginAck::decode(self.conn).await?; + self.conn.context_mut().set_version(ack.tds_version); event!(Level::INFO, "{} version {}", ack.prog_name, ack.version); Ok(ReceivedToken::LoginAck(ack)) }