Skip to content

Commit 7f99d1e

Browse files
author
Philip Z
committed
Keep nested JDBC save failures rollback-only
1 parent 7cfe9ff commit 7f99d1e

3 files changed

Lines changed: 60 additions & 1 deletion

File tree

‎examples/committed-events/src/test/java/io/teaql/examples/committed/CommittedEventsExampleTest.java‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,21 @@ record -> {
203203
new Object[] {42L}).get(0).get("name"));
204204
assertEquals(2L, ((Number) sql.queryForList("SELECT version FROM order_data WHERE id = ?",
205205
new Object[] {42L}).get(0).get("version")).longValue());
206+
207+
// Even if application code catches the inner save failure, the shared
208+
// JDBC transaction must not commit a business row without its outbox.
209+
order.updateProperty("name", "caught failure must roll back");
210+
assertThrows(IllegalStateException.class, () -> sql.executeInTransaction(() -> {
211+
try {
212+
order.auditAs("reject caught outbox failure").save(context);
213+
} catch (RuntimeException expected) {
214+
// Simulate a caller that handles the failed save and returns.
215+
}
216+
}));
217+
assertEquals("submitted", sql.queryForList("SELECT name FROM order_data WHERE id = ?",
218+
new Object[] {42L}).get(0).get("name"));
219+
assertEquals(2L, ((Number) sql.queryForList("SELECT version FROM order_data WHERE id = ?",
220+
new Object[] {42L}).get(0).get("version")).longValue());
206221
runtime.close();
207222
}
208223
}

‎teaql-provider-jdbc/src/main/java/io/teaql/provider/jdbc/JdbcSqlExecutor.java‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public class JdbcSqlExecutor implements SqlExecutionAdapter {
2121
// A held view retains only this token, not the executor, connection or DataSource.
2222
private final Object storageIdentity = new Object();
2323
private final ThreadLocal<Connection> transactionConnection = new ThreadLocal<>();
24+
private final ThreadLocal<Boolean> transactionRollbackOnly = new ThreadLocal<>();
2425
private final List<Consumer<Connection>> connectionInitializers = new CopyOnWriteArrayList<>();
2526

2627
public JdbcSqlExecutor(DataSource dataSource) {
@@ -395,21 +396,35 @@ public int[] batchUpdate(String sql, List<Object[]> paramsList) {
395396
@Override
396397
public void executeInTransaction(Runnable action) {
397398
if (transactionConnection.get() != null) {
398-
action.run();
399+
try {
400+
action.run();
401+
} catch (RuntimeException | Error failure) {
402+
// The nested action shares the outer JDBC transaction. Without a
403+
// savepoint, its partial writes cannot be committed safely even if
404+
// application code catches the exception before the outer returns.
405+
transactionRollbackOnly.set(true);
406+
throw failure;
407+
}
399408
return;
400409
}
401410
try (Connection connection = openConnection()) {
402411
boolean autoCommit = connection.getAutoCommit();
403412
connection.setAutoCommit(false);
404413
transactionConnection.set(connection);
414+
transactionRollbackOnly.set(false);
405415
try {
406416
action.run();
417+
if (Boolean.TRUE.equals(transactionRollbackOnly.get())) {
418+
throw new IllegalStateException(
419+
"JDBC transaction is rollback-only after a nested failure");
420+
}
407421
connection.commit();
408422
} catch (RuntimeException | Error failure) {
409423
connection.rollback();
410424
throw failure;
411425
} finally {
412426
transactionConnection.remove();
427+
transactionRollbackOnly.remove();
413428
connection.setAutoCommit(autoCommit);
414429
}
415430
} catch (SQLException e) {

‎teaql-provider-jdbc/src/test/java/io/teaql/provider/jdbc/JdbcSqlExecutorTest.java‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import java.util.logging.Logger;
1919

2020
import static org.junit.Assert.assertEquals;
21+
import static org.junit.Assert.assertThrows;
2122
import static org.junit.Assert.assertTrue;
2223

2324
public class JdbcSqlExecutorTest {
@@ -62,6 +63,34 @@ public void testExecuteAndInsert() throws Exception {
6263
}
6364
}
6465

66+
@Test
67+
public void nestedFailureCaughtByCallerStillRollsBackOuterTransaction() {
68+
assertThrows(IllegalStateException.class, () -> sqlExecutor.executeInTransaction(() -> {
69+
sqlExecutor.update("INSERT INTO test_user (id, name, age) VALUES (?, ?, ?)",
70+
new Object[] {91, "before failure", 20});
71+
try {
72+
sqlExecutor.executeInTransaction(() -> {
73+
sqlExecutor.update("INSERT INTO test_user (id, name, age) VALUES (?, ?, ?)",
74+
new Object[] {92, "failed save", 20});
75+
throw new IllegalStateException("simulated outbox append failure");
76+
});
77+
} catch (IllegalStateException expected) {
78+
// Application code may log a failed save and continue its outer callback.
79+
}
80+
}));
81+
82+
assertEquals(0, ((Number) sqlExecutor.queryForList(
83+
"SELECT count(*) AS total FROM test_user", new Object[0])
84+
.get(0).get("total")).intValue());
85+
86+
sqlExecutor.executeInTransaction(() -> sqlExecutor.executeInTransaction(() ->
87+
sqlExecutor.update("INSERT INTO test_user (id, name, age) VALUES (?, ?, ?)",
88+
new Object[] {93, "next transaction", 20})));
89+
assertEquals(1, ((Number) sqlExecutor.queryForList(
90+
"SELECT count(*) AS total FROM test_user", new Object[0])
91+
.get(0).get("total")).intValue());
92+
}
93+
6594
@Test
6695
public void testQueryForListNormalizesAliasesForPortableMapping() {
6796
List<Map<String, Object>> rows = sqlExecutor.queryForList(

0 commit comments

Comments
 (0)