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:
co-authored by
Claude Opus 5
parent
88ac3c3d1f
commit
7299490d0d
+22
-6
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -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; }
|
||||
}
|
||||
|
||||
+195
@@ -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);
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user