Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,9 @@ If both are present for a method, **runOnly takes precedence**: argument combina
| `NEGATIVE_STATEMENT_BATCH` | executeBatch partial/full failure + per-element BatchUpdateException counts |
| `NEGATIVE_CONNECTION_STATE` | setCatalog/setSchema/setClientInfo/USE to nonexistent targets (own fresh connections) |
| `NEGATIVE_TRANSACTION` | commit/rollback with autocommit on; DDL inside a manual transaction (own fresh connections) |
| `NEGATIVE_CONNECTION` | connect failures — bad token, unknown host, bad warehouse, bad ConnCatalog/ConnSchema (own broken connections) |
| `NEGATIVE_CANCEL_TIMEOUT` | cancel() mid/after/double + setQueryTimeout(1) on a slow query (own fresh connections) |
| `NEGATIVE_VOLUME` | GET/PUT/REMOVE failures + op-after-close (runs under the `VOLUME_OPERATIONS` config) |

Negative suites compare each endpoint's **error behavior** (exception class, SQLState, vendor code,
message) via the `ERROR_COMPARISON_MODE` gate (default `shadow`). See
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,28 @@ static Stream<Arguments> provideAllTests() {
// config's resolved LEFT/RIGHT URLs; the returned connections are NOT cached, so the suite
// owns closing them and cannot poison the shared connections above.
ConnectionFactory factory =
side -> {
if ("LEFT".equalsIgnoreCase(side)) return connectionManager.openUncached(leftUrl);
if ("RIGHT".equalsIgnoreCase(side)) return connectionManager.openUncached(rightUrl);
throw new IllegalArgumentException("side must be LEFT or RIGHT, got: " + side);
new ConnectionFactory() {
@Override
public Connection openFresh(String side) throws SQLException {
return connectionManager.openUncached(urlFor(side));
}

@Override
public String urlFor(String side) {
if ("LEFT".equalsIgnoreCase(side)) return leftUrl;
if ("RIGHT".equalsIgnoreCase(side)) return rightUrl;
throw new IllegalArgumentException("side must be LEFT or RIGHT, got: " + side);
}

@Override
public String token() {
return connectionManager.getToken();
}

@Override
public Connection open(String url, String token) throws SQLException {
return connectionManager.openUncached(url, token);
}
};

for (TestSuite suite : config.getApplicableSuites()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ public enum ConnectionConfig {
"Default params",
Map.of(),
null,
allExcept(TestSuite.COMPLEX_TYPES, TestSuite.GEOSPATIAL, TestSuite.VOLUME_OPERATIONS)),
allExcept(
TestSuite.COMPLEX_TYPES,
TestSuite.GEOSPATIAL,
TestSuite.VOLUME_OPERATIONS,
TestSuite.NEGATIVE_VOLUME)),

COMPRESSION_DISABLED(
"Compression disabled",
Expand Down Expand Up @@ -69,7 +73,7 @@ public enum ConnectionConfig {
"Volume operations",
Map.of("VolumeOperationAllowedLocalPaths", "/tmp"),
null,
EnumSet.of(TestSuite.VOLUME_OPERATIONS)),
EnumSet.of(TestSuite.VOLUME_OPERATIONS, TestSuite.NEGATIVE_VOLUME)),

PRO_WAREHOUSE(
"Pro warehouse",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,23 @@ public interface ConnectionFactory {
* @param side "LEFT" or "RIGHT" (case-insensitive)
*/
Connection openFresh(String side) throws SQLException;

/**
* The resolved (healthy) JDBC URL for the named side. Exposed so suites that deliberately open
* broken connections (e.g. NEGATIVE_CONNECTION) can start from a good URL and corrupt one piece
* of it (host, warehouse, a connection param) while leaving the rest valid.
*
* @param side "LEFT" or "RIGHT" (case-insensitive)
*/
String urlFor(String side);

/** The shared PAT used for all connections. Exposed for suites that test bad-token auth. */
String token();

/**
* Opens a new, uncached connection for an arbitrary URL + token — the escape hatch for suites
* that build a deliberately-broken URL/token and need to capture the resulting failure. The
* caller owns closing whatever is returned.
*/
Connection open(String url, String token) throws SQLException;
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ public Connection openUncached(String url) throws SQLException {
return DriverManager.getConnection(url, "token", token);
}

/**
* Opens a NEW, uncached connection for an arbitrary URL and token — used by suites that build a
* deliberately-broken URL/token to capture the resulting failure. The caller owns closing it.
*/
public Connection openUncached(String url, String tokenOverride) throws SQLException {
return DriverManager.getConnection(url, "token", tokenOverride);
}

/** The shared PAT. */
public String getToken() {
return token;
}

/** Returns all active connection URLs, useful for report headers. */
public List<String> getActiveUrls() {
return new ArrayList<>(cache.keySet());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,15 @@ public enum TestSuite {
// Negative suites that mutate connection/session state — each opens its OWN fresh connections
// via ConnectionFactory (closed in a finally), so they never poison the shared connections.
NEGATIVE_CONNECTION_STATE(new NegativeConnectionStateProvider()),
NEGATIVE_TRANSACTION(new NegativeTransactionProvider());
NEGATIVE_TRANSACTION(new NegativeTransactionProvider()),

// Negative suites that open their own deliberately-broken or slow connections via
// ConnectionFactory. NEGATIVE_VOLUME runs under the VOLUME_OPERATIONS config (needs
// VolumeOperationAllowedLocalPaths), so it is excluded from DEFAULT_PARAMS like the positive
// VOLUME_OPERATIONS suite.
NEGATIVE_CONNECTION(new NegativeConnectionProvider()),
NEGATIVE_CANCEL_TIMEOUT(new NegativeCancelTimeoutProvider()),
NEGATIVE_VOLUME(new NegativeVolumeProvider());

private final SuiteProvider provider;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package com.databricks.jdbc.comparator.suite;

import com.databricks.jdbc.comparator.ComparisonResult;
import com.databricks.jdbc.comparator.config.ConnectionFactory;
import com.databricks.jdbc.comparator.error.CapturedOutcome;
import com.databricks.jdbc.comparator.error.Captures;
import com.databricks.jdbc.comparator.error.ErrorDiffs;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Arrays;
import java.util.List;

/**
* Negative cancel/timeout cases — {@code cancel()} mid-query, {@code cancel()} after completion,
* double {@code cancel()}, and {@code setQueryTimeout(1)} on a slow query. Compares how the two
* endpoints surface each outcome via {@link ErrorDiffs}.
*
* <p>Isolation: cancel/timeout affect statement/connection state, so the suite opens its OWN fresh
* connections per side (closed in a {@code finally}). The "slow query" is a bounded range
* aggregation (~a few seconds) — no server-side sleep is available, so it is intentionally modest
* to keep load and flakiness low; {@code setQueryTimeout(1)} and mid-flight {@code cancel()} still
* act well within it.
*/
public class NegativeCancelTimeoutProvider implements SuiteProvider {

// Bounded slow query: a cross-join aggregation that runs a few seconds on a small warehouse
// without any explicit sleep. Kept modest to limit shared-warehouse load.
private static final String SLOW_QUERY =
"SELECT COUNT(*) FROM range(0, 100000000) a CROSS JOIN range(0, 20) b";

private static final String CANCEL_MID = "CANCEL_MID_QUERY";
private static final String CANCEL_AFTER = "CANCEL_AFTER_COMPLETE";
private static final String CANCEL_TWICE = "CANCEL_ALREADY_CANCELLED";
private static final String QUERY_TIMEOUT = "SET_QUERY_TIMEOUT_1";

@Override
public List<TestCase> getTestCases() {
return Arrays.asList(
new TestCase(CANCEL_MID, "cancel() mid slow query"),
new TestCase(CANCEL_AFTER, "cancel() after the query completed"),
new TestCase(CANCEL_TWICE, "cancel() on an already-cancelled statement"),
new TestCase(QUERY_TIMEOUT, "setQueryTimeout(1) on a slow query"));
}

/** Runs one case's action against a single connection; returns a value or throws. */
private Object runCase(String id, Connection conn) throws Exception {
switch (id) {
case CANCEL_MID:
return cancelMid(conn);
case CANCEL_AFTER:
return cancelAfterComplete(conn);
case CANCEL_TWICE:
return cancelTwice(conn);
case QUERY_TIMEOUT:
return queryTimeout(conn);
default:
throw new IllegalArgumentException("Unknown case: " + id);
}
}

/** Starts the slow query on a background thread, cancels it after a short delay, joins. */
private Object cancelMid(Connection conn) throws Exception {
try (Statement stmt = conn.createStatement()) {
final Object[] box = new Object[1];
Thread runner =
new Thread(
() -> {
try {
stmt.executeQuery(SLOW_QUERY);
box[0] = "completed";
} catch (Throwable t) {
box[0] = t;
}
});
runner.start();
Thread.sleep(1500); // let the query start, then cancel it mid-flight
stmt.cancel();
runner.join(60000);
if (box[0] instanceof Throwable) {
throw (Exception) box[0]; // surface the cancellation error for comparison
}
return box[0];
}
}

/** Runs the query to completion, then cancels the (finished) statement. */
private Object cancelAfterComplete(Connection conn) throws Exception {
try (Statement stmt = conn.createStatement()) {
stmt.executeQuery("SELECT 1").close();
stmt.cancel();
return "cancelled after complete";
}
}

/** Cancels a statement twice in a row. */
private Object cancelTwice(Connection conn) throws Exception {
try (Statement stmt = conn.createStatement()) {
stmt.executeQuery("SELECT 1").close();
stmt.cancel();
stmt.cancel();
return "double cancel ok";
}
}

/** setQueryTimeout(1) on the slow query — expect a timeout error. */
private Object queryTimeout(Connection conn) throws Exception {
try (Statement stmt = conn.createStatement()) {
stmt.setQueryTimeout(1);
stmt.executeQuery(SLOW_QUERY).close();
return "completed within timeout";
}
}

/** Not used — this suite requires the ConnectionFactory overload below. */
@Override
public ComparisonResult execute(
Connection conn1, Connection conn2, TestCase testCase, String label) throws Exception {
throw new UnsupportedOperationException(
"NEGATIVE_CANCEL_TIMEOUT requires the ConnectionFactory overload");
}

@Override
public ComparisonResult execute(
Connection conn1,
Connection conn2,
ConnectionFactory factory,
TestCase testCase,
String label)
throws Exception {
String id = testCase.getIdentifier();
Connection left = factory.openFresh("LEFT");
Connection right = factory.openFresh("RIGHT");
try {
CapturedOutcome lo = Captures.capture(() -> runCase(id, left));
CapturedOutcome ro = Captures.capture(() -> runCase(id, right));
ComparisonResult result =
new ComparisonResult(label, testCase.getDescription(), testCase.getArgs());
ErrorDiffs.foldInto(result, lo, ro, "result ", "");
return result;
} finally {
closeQuietly(left);
closeQuietly(right);
}
}

private static void closeQuietly(Connection conn) {
try {
if (conn != null) conn.close();
} catch (SQLException ignored) {
// best-effort
}
}
}
Loading
Loading