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
+24
-3
@@ -53,9 +53,30 @@ public final class ResourceRegistry {
|
||||
SYNCHRONIZATIONS.get().add(Objects.requireNonNull(sync));
|
||||
}
|
||||
|
||||
public static void fireSynchronizations(TxOutcome outcome) {
|
||||
List<TxSynchronization> syncs = List.copyOf(SYNCHRONIZATIONS.get());
|
||||
SYNCHRONIZATIONS.get().clear();
|
||||
/**
|
||||
* How many synchronizations are registered right now — captured by a transaction manager when
|
||||
* it opens a new transaction, and handed back to {@link #fireSynchronizations} on completion
|
||||
* so that transaction only fires its own. See there for why that matters.
|
||||
*/
|
||||
public static int synchronizationCount() {
|
||||
return SYNCHRONIZATIONS.get().size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires (and removes) the synchronizations registered from {@code fromIndex} onward — the ones
|
||||
* belonging to the transaction now completing. Everything before that index was registered by
|
||||
* an enclosing transaction that is merely <em>suspended</em>, not finished: a REQUIRES_NEW
|
||||
* inner transaction sets its own baseline, so committing it no longer drags the outer's
|
||||
* pending callbacks along — which fired them early, and with the inner transaction's outcome,
|
||||
* for an outer transaction that might still roll back.
|
||||
*/
|
||||
public static void fireSynchronizations(TxOutcome outcome, int fromIndex) {
|
||||
List<TxSynchronization> pending = SYNCHRONIZATIONS.get();
|
||||
if (fromIndex >= pending.size()) {
|
||||
return;
|
||||
}
|
||||
List<TxSynchronization> syncs = List.copyOf(pending.subList(fromIndex, pending.size()));
|
||||
pending.subList(fromIndex, pending.size()).clear();
|
||||
for (TxSynchronization sync : syncs) {
|
||||
if (outcome == TxOutcome.COMMITTED) {
|
||||
sync.afterCommit();
|
||||
|
||||
Reference in New Issue
Block a user