fix(data): fire transaction synchronizations, and scope them to their transaction

Two defects in the same mechanism, both silent.

1. HibernateTxManager#commit fired synchronizations *after* its finally block,
   where cleanupIfIdle() had already called ResourceRegistry.cleanup() and
   removed the ThreadLocal list holding them. fireSynchronizations() then read
   a freshly initialized empty list and did nothing. No afterCommit callback
   had ever run on the Hibernate path: no exception, no log, just silence.
   rollback() twenty lines below had the order right, which is what makes this
   an ordering slip rather than a design choice.

   Found in production, from the far end: an admin write landed in Postgres
   while the in-memory cache it was registered to refresh never heard about it,
   so the change only took effect when the process restarted and re-read the
   database at boot.

2. Synchronizations were a single flat per-thread list, fired from index 0 by
   whichever transaction completed first. A REQUIRES_NEW inner transaction
   therefore fired the *suspended* outer transaction's callbacks too — early,
   with the inner transaction's outcome, for a transaction that might still
   roll back. Each new transaction now records how many synchronizations were
   already registered when it began, and fires only its own tail.

Both managers get the fix and the same callback ordering: unbind the session or
connection first, so a callback that opens its own transaction (a cache reload,
an outbox drain) gets a fresh one instead of joining the transaction that just
committed, then fire, then clean up.

Also fixes JdbcTxStatus rejecting a null connection, which turned the two
propagations that deliberately produce a connectionless status — SUPPORTS with
no active transaction, and NOT_SUPPORTED — into an NPE inside begin(). The
Hibernate manager always allowed it, and resource() already reports the real
mistake with a message that names it.

Tests: 16 new across the two managers, kept deliberately parallel since the two
are interchangeable behind TxManager — synchronization firing, ordering,
per-transaction scoping, callbacks opening their own transaction, and the
previously untested SUPPORTS/NOT_SUPPORTED/MANDATORY propagations. Nothing
covered afterCommit before, which is how both defects shipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-08-12 23:23:47 +00:00
co-authored by Claude Opus 5
parent 88ac3c3d1f
commit 7299490d0d
10 changed files with 546 additions and 67 deletions
@@ -43,6 +43,9 @@ public class HibernateTxManager implements TxManager {
}
private TxStatus beginNew(TxDefinition definition, HibernateTxStatus suspended) {
// Anything already registered belongs to an enclosing transaction this one is nested
// inside (or suspended over) — see ResourceRegistry#fireSynchronizations.
int synchronizationBaseline = ResourceRegistry.synchronizationCount();
Session s = sf.openSession();
boolean bound = false;
try {
@@ -56,7 +59,8 @@ public class HibernateTxManager implements TxManager {
true,
definition.readOnly(),
suspended,
new HibernateTxStatus.RollbackMarker()
new HibernateTxStatus.RollbackMarker(),
synchronizationBaseline
);
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, status);
bound = true;
@@ -80,12 +84,15 @@ public class HibernateTxManager implements TxManager {
if (definition.readOnly() && !existing.isReadOnly()) {
throw new TxException("Cannot join read-write tx as read-only");
}
// Baseline 0 is never read: a joined status isn't a new transaction, so commit()/rollback()
// hand it straight back to the transaction it joined without firing anything.
return new HibernateTxStatus(
existing.session(),
false,
definition.readOnly(),
null,
existing.rollbackMarker()
existing.rollbackMarker(),
0
);
}
@@ -94,7 +101,7 @@ public class HibernateTxManager implements TxManager {
}
private TxStatus noOp(TxDefinition definition, HibernateTxStatus suspended) {
return new HibernateTxStatus(null, false, definition.readOnly(), suspended, new HibernateTxStatus.RollbackMarker());
return new HibernateTxStatus(null, false, definition.readOnly(), suspended, new HibernateTxStatus.RollbackMarker(), 0);
}
private HibernateTxStatus suspendIfNeeded() {
@@ -114,7 +121,7 @@ public class HibernateTxManager implements TxManager {
cleanupIfIdle();
return;
}
TxOutcome outcome;
TxOutcome outcome = null;
try {
if (s.isRollbackOnly() && s.session().getTransaction().isActive()) {
s.session().getTransaction().rollback();
@@ -124,10 +131,17 @@ public class HibernateTxManager implements TxManager {
outcome = TxOutcome.COMMITTED;
}
} finally {
// Order is load-bearing, and getting it wrong is silent: cleanupIfIdle() calls
// ResourceRegistry.cleanup(), which removes the very ThreadLocal list of
// synchronizations still waiting to be fired — firing afterwards saw a freshly
// initialized empty list and dropped every callback on the floor. cleanupAndResume()
// still has to come first, so a synchronization that opens its own transaction
// (Registry#reload() in Pathway does) starts a fresh one instead of joining the
// session that just committed. rollback() below already had this order right.
cleanupAndResume(s);
if (outcome != null) ResourceRegistry.fireSynchronizations(outcome, s.synchronizationBaseline());
cleanupIfIdle();
}
ResourceRegistry.fireSynchronizations(outcome);
}
@Override
@@ -143,9 +157,11 @@ public class HibernateTxManager implements TxManager {
if (s.session().getTransaction().isActive()) {
s.session().getTransaction().rollback();
}
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
} finally {
// Same order as commit(): unbind the session first so a callback opening its own
// transaction gets a fresh one, fire before cleanupIfIdle() can drop the list.
cleanupAndResume(s);
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK, s.synchronizationBaseline());
cleanupIfIdle();
}
}
@@ -13,19 +13,22 @@ class HibernateTxStatus implements TxStatus {
private final boolean readOnly;
private final HibernateTxStatus suspended;
private final RollbackMarker rollbackMarker;
private final int synchronizationBaseline;
HibernateTxStatus(
Session session,
boolean newTransaction,
boolean readOnly,
HibernateTxStatus suspended,
RollbackMarker rollbackMarker
RollbackMarker rollbackMarker,
int synchronizationBaseline
) {
this.session = session;
this.newTransaction = newTransaction;
this.readOnly = readOnly;
this.suspended = suspended;
this.rollbackMarker = rollbackMarker;
this.synchronizationBaseline = synchronizationBaseline;
}
@Override public boolean isNewTransaction() { return newTransaction; }
@@ -44,4 +47,7 @@ class HibernateTxStatus implements TxStatus {
Session session() { return session; }
HibernateTxStatus suspended() { return suspended; }
RollbackMarker rollbackMarker() { return rollbackMarker; }
/** Index into {@code ResourceRegistry}'s synchronization list where this transaction's own callbacks start. */
int synchronizationBaseline() { return synchronizationBaseline; }
}
@@ -0,0 +1,195 @@
package dev.relism.flash.ext.data.hibernate;
import dev.relism.flash.ext.data.core.*;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Transaction synchronization semantics — the {@code afterCommit}/{@code afterRollback} callbacks
* {@code Data#afterCommit} exposes, and the contract callers build on: "my callback runs once, on
* the outermost commit, after the session is gone".
*
* <p>None of this was covered before, and the gap was not academic: {@link #afterCommit_fires_on_commit}
* failed against the original {@code commit()}, which fired synchronizations only after
* {@code cleanupIfIdle()} had already dropped the ThreadLocal list holding them — so every callback
* was silently discarded, on every commit, with no error and no log. Downstream that meant an admin
* write landing in Postgres while the in-memory cache it was supposed to refresh never heard about
* it until the process restarted.
*/
class HibernateTxManagerSynchronizationTest {
static SessionFactory sf;
static HibernateTxManager manager;
@BeforeAll
static void setup() {
sf = TestHelper.buildSessionFactory();
manager = new HibernateTxManager(sf);
}
@AfterAll
static void teardown() {
if (sf != null) {
sf.close();
}
}
@AfterEach
void cleanup() {
ResourceRegistry.clear();
}
/** Records which callbacks ran, in order — order is part of the contract, not just the fact they ran. */
private static final class Recorder implements TxSynchronization {
final List<String> calls = new ArrayList<>();
@Override public void afterCommit() { calls.add("afterCommit"); }
@Override public void afterRollback() { calls.add("afterRollback"); }
@Override public void afterCompletion(TxOutcome outcome) { calls.add("afterCompletion:" + outcome); }
}
@Test
void afterCommit_fires_on_commit() {
Recorder recorder = new Recorder();
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
ResourceRegistry.addSynchronization(recorder);
manager.commit(tx);
assertEquals(List.of("afterCommit", "afterCompletion:COMMITTED"), recorder.calls);
}
@Test
void afterRollback_fires_on_rollback() {
Recorder recorder = new Recorder();
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
ResourceRegistry.addSynchronization(recorder);
manager.rollback(tx);
assertEquals(List.of("afterRollback", "afterCompletion:ROLLED_BACK"), recorder.calls);
}
/** A commit() call on a tx already marked rollback-only really rolls back — the callbacks must say so. */
@Test
void commit_of_a_rollback_only_tx_fires_the_rollback_callbacks() {
Recorder recorder = new Recorder();
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
tx.markRollbackOnly();
ResourceRegistry.addSynchronization(recorder);
manager.commit(tx);
assertEquals(List.of("afterRollback", "afterCompletion:ROLLED_BACK"), recorder.calls);
}
/**
* The load-bearing ordering detail: callbacks run <em>after</em> the committed session is
* unbound, so a callback that opens its own transaction (a cache reload, an outbox drain)
* gets a fresh one instead of silently joining the transaction that just committed.
*/
@Test
void a_synchronization_may_open_its_own_transaction() {
List<Session> sessionsSeen = new ArrayList<>();
List<Boolean> wasNewTransaction = new ArrayList<>();
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
Session committedSession = outer.resource(Session.class);
ResourceRegistry.addSynchronization(new TxSynchronization() {
@Override
public void afterCommit() {
TxStatus own = manager.begin(TxDefinition.DEFAULTS);
sessionsSeen.add(own.resource(Session.class));
wasNewTransaction.add(own.isNewTransaction());
manager.commit(own);
}
});
manager.commit(outer);
assertEquals(1, sessionsSeen.size(), "the callback must have run");
assertEquals(List.of(true), wasNewTransaction, "must start its own transaction, not join the committed one");
assertNotSame(committedSession, sessionsSeen.get(0));
}
/** A joined (REQUIRED) inner commit is not a real commit — callbacks wait for the outermost one. */
@Test
void a_joined_commit_defers_synchronizations_to_the_outermost_commit() {
Recorder recorder = new Recorder();
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
ResourceRegistry.addSynchronization(recorder);
manager.commit(inner);
assertEquals(List.of(), recorder.calls, "the joined commit did not commit anything yet");
manager.commit(outer);
assertEquals(List.of("afterCommit", "afterCompletion:COMMITTED"), recorder.calls);
}
/** Each callback belongs to one transaction: a second transaction must not re-run the first's. */
@Test
void synchronizations_do_not_leak_into_the_next_transaction() {
Recorder recorder = new Recorder();
TxStatus first = manager.begin(TxDefinition.DEFAULTS);
ResourceRegistry.addSynchronization(recorder);
manager.commit(first);
recorder.calls.clear();
TxStatus second = manager.begin(TxDefinition.DEFAULTS);
manager.commit(second);
assertEquals(List.of(), recorder.calls);
}
/**
* A REQUIRES_NEW inner transaction suspends the outer one; committing the inner must not drag
* the still-pending outer transaction's callbacks along with it. They belong to a transaction
* that has not committed — and may yet roll back, in which case firing {@code afterCommit} for
* it would be a straight lie.
*/
@Test
void a_requires_new_commit_leaves_the_suspended_transactions_synchronizations_alone() {
Recorder outerSync = new Recorder();
Recorder innerSync = new Recorder();
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
ResourceRegistry.addSynchronization(outerSync);
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW));
ResourceRegistry.addSynchronization(innerSync);
manager.commit(inner);
assertEquals(List.of("afterCommit", "afterCompletion: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);
}
/** A rolled-back inner REQUIRES_NEW must not fire the outer's callbacks either — same reason, opposite outcome. */
@Test
void a_requires_new_rollback_leaves_the_suspended_transactions_synchronizations_alone() {
Recorder outerSync = new Recorder();
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
ResourceRegistry.addSynchronization(outerSync);
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW));
manager.rollback(inner);
assertEquals(List.of(), outerSync.calls, "the outer transaction is still open");
manager.commit(outer);
assertEquals(List.of("afterCommit", "afterCompletion:COMMITTED"), outerSync.calls);
}
}
@@ -65,4 +65,44 @@ class HibernateTxManagerTest {
assertTrue(outer.isRollbackOnly());
manager.rollback(outer);
}
/** SUPPORTS without an active transaction yields a sessionless status: not a transaction, no session to hand out. */
@Test
void supports_without_active_transaction_is_a_sessionless_no_op() {
TxStatus s = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.SUPPORTS));
assertFalse(s.isNewTransaction());
assertThrows(IllegalStateException.class, () -> s.resource(Session.class));
assertDoesNotThrow(() -> manager.commit(s));
}
@Test
void not_supported_suspends_the_active_transaction_and_restores_it_on_commit() {
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
Session outerSession = outer.resource(Session.class);
TxStatus suspended = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.NOT_SUPPORTED));
assertFalse(suspended.isNewTransaction());
assertThrows(IllegalStateException.class, () -> suspended.resource(Session.class));
manager.commit(suspended);
TxStatus rejoined = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
assertSame(outerSession, rejoined.resource(Session.class), "the suspended transaction must be back");
manager.rollback(outer);
}
@Test
void mandatory_without_active_transaction_is_rejected() {
assertThrows(IllegalStateException.class,
() -> manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.MANDATORY)));
}
/** A read-only join onto a read-write transaction is a contract violation, not a silent downgrade. */
@Test
void read_only_cannot_join_a_read_write_transaction() {
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
assertThrows(TxException.class,
() -> manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED).asReadOnly()));
manager.rollback(outer);
}
}