feat(data): actually invoke TxSynchronization.beforeCommit, and document the contract

beforeCommit(boolean) has been part of the TxSynchronization API from the
start and was invoked by nothing, in either manager — anyone implementing it
got silence, the same failure class as the dropped afterCommit callbacks in
the previous commit. Left out of that one because fixing it is a design
decision rather than a restored behaviour; this is that decision, written
down.

It now runs immediately before the real commit, with the transaction still
active and its session/connection still bound, which is the whole reason to
have a hook on this side of the commit: it can still write through the same
resource and land in the same atomic unit. Skipped when the transaction is
already rollback-only, since there is no commit to precede.

Throwing from it vetoes the commit: the transaction rolls back, the surviving
callbacks hear ROLLED_BACK, and the exception propagates. Without that, a hook
running before the commit would be strictly less useful than one running
after. A commit that fails on its own now takes the same path instead of
completing silently with no callback at all, and a rollback that also fails is
attached as a suppressed exception rather than replacing the one that explains
the failure.

Documented on the interface itself and in flash-ext-data-core/docs/README.md:
which hook sits on which side of the commit, what each may still touch, what
throwing does, and the per-transaction scoping rule from the previous commit.

Tests: 6 more (3 per manager) — runs inside the transaction with the resource
still bound, receives the read-only flag, skipped on a rollback-only
transaction, and vetoes the commit when it throws. 37 across the two managers
now, all green, reactor verify passes the 80% Jacoco gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-08-12 23:33:05 +00:00
co-authored by Claude Opus 5
parent 7299490d0d
commit 0194470c1f
7 changed files with 304 additions and 26 deletions
@@ -123,11 +123,20 @@ public class JdbcTxManager implements TxManager {
s.connection().rollback();
outcome = TxOutcome.ROLLED_BACK;
} else {
// Still inside the transaction, connection still bound: a beforeCommit callback
// can write through it and land in this same commit. Throwing from there vetoes
// the commit — see TxSynchronization.
ResourceRegistry.fireBeforeCommit(s.isReadOnly(), s.synchronizationBaseline());
s.connection().commit();
outcome = TxOutcome.COMMITTED;
}
} catch (SQLException e) {
throw new TxException(e);
TxException wrapped = new TxException(e);
outcome = rollbackAfterFailedCommit(s, wrapped);
throw wrapped;
} catch (RuntimeException e) {
outcome = rollbackAfterFailedCommit(s, e);
throw e;
} finally {
// Unbind the connection before firing, so a callback that opens its own transaction
// gets a fresh one instead of joining the connection that just committed — and fire
@@ -138,6 +147,21 @@ public class JdbcTxManager implements TxManager {
}
}
/**
* Nothing was committed — a vetoing {@code beforeCommit}, or a commit that failed outright —
* so undo whatever the transaction had done and report {@code ROLLED_BACK} to the callbacks
* still queued behind it. A failure to roll back is attached to the exception already on its
* way out rather than replacing it: that one explains what actually went wrong.
*/
private static TxOutcome rollbackAfterFailedCommit(JdbcTxStatus s, Throwable propagating) {
try {
s.connection().rollback();
} catch (SQLException suppressed) {
propagating.addSuppressed(suppressed);
}
return TxOutcome.ROLLED_BACK;
}
@Override
public void rollback(TxStatus status) {
JdbcTxStatus s = (JdbcTxStatus) status;
@@ -29,11 +29,15 @@ class JdbcTxManagerSynchronizationTest {
private static final class Recorder implements TxSynchronization {
final List<String> calls = new ArrayList<>();
@Override public void beforeCommit(boolean readOnly) { calls.add("beforeCommit:" + readOnly); }
@Override public void afterCommit() { calls.add("afterCommit"); }
@Override public void afterRollback() { calls.add("afterRollback"); }
@Override public void afterCompletion(TxOutcome outcome) { calls.add("afterCompletion:" + outcome); }
}
private static final List<String> COMMITTED = List.of("beforeCommit:false", "afterCommit", "afterCompletion:COMMITTED");
private static final List<String> ROLLED_BACK = List.of("afterRollback", "afterCompletion:ROLLED_BACK");
@Test
void afterCommit_fires_on_commit() {
Recorder recorder = new Recorder();
@@ -42,7 +46,7 @@ class JdbcTxManagerSynchronizationTest {
manager.commit(tx);
assertEquals(List.of("afterCommit", "afterCompletion:COMMITTED"), recorder.calls);
assertEquals(COMMITTED, recorder.calls);
}
@Test
@@ -53,11 +57,11 @@ class JdbcTxManagerSynchronizationTest {
manager.rollback(tx);
assertEquals(List.of("afterRollback", "afterCompletion:ROLLED_BACK"), recorder.calls);
assertEquals(ROLLED_BACK, recorder.calls);
}
@Test
void commit_of_a_rollback_only_tx_fires_the_rollback_callbacks() {
void commit_of_a_rollback_only_tx_rolls_back_without_running_beforeCommit() {
Recorder recorder = new Recorder();
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
tx.markRollbackOnly();
@@ -65,10 +69,64 @@ class JdbcTxManagerSynchronizationTest {
manager.commit(tx);
assertEquals(List.of("afterRollback", "afterCompletion:ROLLED_BACK"), recorder.calls);
assertEquals(ROLLED_BACK, recorder.calls);
}
/** Callbacks run after the committed connection is unbound, so opening a transaction gets a fresh one. */
/** beforeCommit runs inside the transaction: connection still bound, nothing committed yet. */
@Test
void beforeCommit_runs_while_the_transaction_is_still_active() {
List<Connection> connectionSeen = new ArrayList<>();
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
Connection connection = tx.resource(Connection.class);
ResourceRegistry.addSynchronization(new TxSynchronization() {
@Override
public void beforeCommit(boolean readOnly) {
// MANDATORY only succeeds while a transaction is bound — proof this runs inside it.
TxStatus joined = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.MANDATORY));
connectionSeen.add(joined.resource(Connection.class));
manager.commit(joined);
}
});
manager.commit(tx);
assertSame(connection, connectionSeen.get(0), "the same connection must still be bound, so writes land in this commit");
}
@Test
void beforeCommit_is_told_whether_the_transaction_is_read_only() {
Recorder readWrite = new Recorder();
TxStatus rw = manager.begin(TxDefinition.DEFAULTS);
ResourceRegistry.addSynchronization(readWrite);
manager.commit(rw);
Recorder readOnly = new Recorder();
TxStatus ro = manager.begin(TxDefinition.DEFAULTS.asReadOnly());
ResourceRegistry.addSynchronization(readOnly);
manager.commit(ro);
assertEquals("beforeCommit:false", readWrite.calls.get(0));
assertEquals("beforeCommit:true", readOnly.calls.get(0));
}
/** Throwing from beforeCommit is a veto: no commit, the rollback callbacks run, the exception propagates. */
@Test
void a_throwing_beforeCommit_vetoes_the_commit() {
Recorder recorder = new Recorder();
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
ResourceRegistry.addSynchronization(new TxSynchronization() {
@Override public void beforeCommit(boolean readOnly) { throw new IllegalStateException("veto"); }
});
ResourceRegistry.addSynchronization(recorder);
IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> manager.commit(tx));
assertEquals("veto", thrown.getMessage());
assertEquals(ROLLED_BACK, recorder.calls, "the surviving callbacks must hear ROLLED_BACK, not silence");
}
/** Post-completion callbacks run after the committed connection is unbound, so opening a transaction gets a fresh one. */
@Test
void a_synchronization_may_open_its_own_transaction() {
List<Connection> connectionsSeen = new ArrayList<>();
@@ -104,7 +162,7 @@ class JdbcTxManagerSynchronizationTest {
assertEquals(List.of(), recorder.calls, "the joined commit did not commit anything yet");
manager.commit(outer);
assertEquals(List.of("afterCommit", "afterCompletion:COMMITTED"), recorder.calls);
assertEquals(COMMITTED, recorder.calls);
}
@Test
@@ -133,11 +191,11 @@ class JdbcTxManagerSynchronizationTest {
ResourceRegistry.addSynchronization(innerSync);
manager.commit(inner);
assertEquals(List.of("afterCommit", "afterCompletion:COMMITTED"), innerSync.calls);
assertEquals(COMMITTED, innerSync.calls);
assertEquals(List.of(), outerSync.calls, "the outer transaction has not committed yet");
manager.commit(outer);
assertEquals(List.of("afterCommit", "afterCompletion:COMMITTED"), outerSync.calls);
assertEquals(COMMITTED, outerSync.calls);
}
@Test
@@ -153,6 +211,6 @@ class JdbcTxManagerSynchronizationTest {
assertEquals(List.of(), outerSync.calls, "the outer transaction is still open");
manager.commit(outer);
assertEquals(List.of("afterCommit", "afterCompletion:COMMITTED"), outerSync.calls);
assertEquals(COMMITTED, outerSync.calls);
}
}