Skip to content
Draft
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 @@ -14,9 +14,12 @@
import java.util.stream.Stream;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
import java.util.logging.Logger;

public class JdbcSqlExecutor implements SqlExecutionAdapter {

private static final Logger LOG = Logger.getLogger(JdbcSqlExecutor.class.getName());

private final DataSource dataSource;
// A held view retains only this token, not the executor, connection or DataSource.
private final Object storageIdentity = new Object();
Expand Down Expand Up @@ -427,7 +430,14 @@ public void executeInTransaction(Runnable action) {
}
return;
}
try (Connection connection = openConnection()) {
Connection connection;
try {
connection = openConnection();
} catch (SQLException failure) {
throw new RuntimeException("JDBC transaction failed", failure);
}
boolean committed = false;
try {
boolean autoCommit = connection.getAutoCommit();
connection.setAutoCommit(false);
transactionConnection.set(connection);
Expand All @@ -439,16 +449,46 @@ public void executeInTransaction(Runnable action) {
"JDBC transaction is rollback-only after a failed operation");
}
connection.commit();
committed = true;
} catch (RuntimeException | Error failure) {
connection.rollback();
try {
connection.rollback();
} catch (SQLException | RuntimeException rollbackFailure) {
// Keep the original failure. The JDBC message may contain
// sensitive connection details and is not safe to expose.
failure.addSuppressed(new IllegalStateException("JDBC rollback failed"));
}
throw failure;
} finally {
transactionConnection.remove();
transactionRollbackOnly.remove();
connection.setAutoCommit(autoCommit);
try {
connection.setAutoCommit(autoCommit);
} catch (SQLException | RuntimeException cleanupFailure) {
// A successful commit is already durable. A cleanup error
// must not make the caller replay that business mutation.
warnTransactionCleanup("autoCommit restoration", committed);
}
}
} catch (SQLException e) {
throw new RuntimeException("JDBC transaction failed", e);
} finally {
try {
connection.close();
} catch (SQLException | RuntimeException cleanupFailure) {
warnTransactionCleanup("connection close", committed);
}
}
}

private static void warnTransactionCleanup(String operation, boolean committed) {
try {
LOG.warning(committed
? "JDBC transaction committed; " + operation + " failed"
: "JDBC transaction unsuccessful; " + operation + " failed");
} catch (RuntimeException ignored) {
// A user-installed logging handler must not turn a committed write
// back into an apparently retryable transaction failure.
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package io.teaql.sqlite;

import io.teaql.provider.jdbc.JdbcSqlExecutor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.nio.file.Files;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Handler;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import javax.sql.DataSource;
import org.junit.Test;
import org.sqlite.SQLiteDataSource;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

/** A committed JDBC write must not look like a rolled-back, retryable save. */
public class JdbcCommittedCleanupSqliteTest {
private static final String DIAGNOSTIC_CANARY = "POST-COMMIT-CLEANUP-SECRET-CANARY";
private enum Fault { AUTO_COMMIT_RESTORE, CLOSE }

@Test public void failedAutoCommitRestoreAfterCommitKeepsCommittedOutcome() throws Exception {
verifyCommittedCleanupFailure(Fault.AUTO_COMMIT_RESTORE);
}

@Test public void failedCloseAfterCommitKeepsCommittedOutcome() throws Exception {
verifyCommittedCleanupFailure(Fault.CLOSE);
}

@Test public void failedWarningHandlerAfterCommitKeepsCommittedOutcome() throws Exception {
verifyCommittedCleanupFailure(Fault.AUTO_COMMIT_RESTORE, true);
}

private static void verifyCommittedCleanupFailure(Fault fault) throws Exception {
verifyCommittedCleanupFailure(fault, false);
}

private static void verifyCommittedCleanupFailure(Fault fault, boolean warningHandlerFails)
throws Exception {
var backing = new SQLiteDataSource();
backing.setUrl("jdbc:sqlite:" + Files.createTempFile("teaql-committed-cleanup-", ".db"));
try (var connection = backing.getConnection(); var statement = connection.createStatement()) {
statement.execute("CREATE TABLE committed_cleanup_probe (id INTEGER PRIMARY KEY)");
}
var committed = new AtomicBoolean();
DataSource injecting = (DataSource) Proxy.newProxyInstance(
DataSource.class.getClassLoader(), new Class<?>[] {DataSource.class},
(proxy, method, args) -> {
Object value = invoke(backing, method, args);
if (!method.getName().equals("getConnection")) return value;
Connection actual = (Connection) value;
return Proxy.newProxyInstance(Connection.class.getClassLoader(),
new Class<?>[] {Connection.class}, (connectionProxy, operation, parameters) -> {
if (operation.getName().equals("setAutoCommit")
&& Boolean.TRUE.equals(parameters[0]) && committed.get()
&& fault == Fault.AUTO_COMMIT_RESTORE) {
throw new SQLException(DIAGNOSTIC_CANARY);
}
Object result = invoke(actual, operation, parameters);
if (operation.getName().equals("commit")) committed.set(true);
if (operation.getName().equals("close") && committed.get()
&& fault == Fault.CLOSE) {
throw new SQLException(DIAGNOSTIC_CANARY);
}
return result;
});
});
var executor = new JdbcSqlExecutor(injecting);
List<String> warnings = new ArrayList<>();
Logger logger = Logger.getLogger(JdbcSqlExecutor.class.getName());
Handler capture = new Handler() {
@Override public void publish(LogRecord record) {
warnings.add(record.getMessage());
if (warningHandlerFails) throw new IllegalStateException(DIAGNOSTIC_CANARY);
}
@Override public void flush() { }
@Override public void close() { }
};
logger.addHandler(capture);

RuntimeException reported = null;
try {
executor.executeInTransaction(() -> executor.update(
"INSERT INTO committed_cleanup_probe (id) VALUES (?)", new Object[] {1L}));
} catch (RuntimeException failure) {
reported = failure;
} finally {
logger.removeHandler(capture);
}

assertTrue("the injected failure must occur after the actual commit", committed.get());
try (var connection = backing.getConnection();
var statement = connection.prepareStatement("SELECT COUNT(*) FROM committed_cleanup_probe");
var rows = statement.executeQuery()) {
assertTrue(rows.next());
assertEquals(1, rows.getInt(1));
}
assertTrue("cleanup failure needs an operational warning", warnings.stream()
.anyMatch(message -> message.contains("JDBC transaction committed")));
assertTrue("the warning must not reveal the JDBC exception", warnings.stream()
.noneMatch(message -> message.contains(DIAGNOSTIC_CANARY)));
if (reported != null) {
throw new AssertionError("committed row was reported as a failed transaction", reported);
}
}

private static Object invoke(Object target, Method method, Object[] args) throws Throwable {
try {
return method.invoke(target, args);
} catch (InvocationTargetException failure) {
throw failure.getCause();
}
}
}
Loading