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
@@ -21,7 +21,7 @@ Non parla con Hibernate o JDBC direttamente: espone solo astrazioni e un runtime
- `RepositorySupport<T, ID>`: helper interno condiviso.
- `TransactionPropagation`: semantica di propagazione.
- `TransactionIsolation`: livello di isolamento.
- `TxSynchronization`: hook lifecycle.
- `TxSynchronization`: hook lifecycle (vedi sotto).
## Modello di esecuzione
@@ -42,6 +42,46 @@ Il flusso è:
- `NOT_SUPPORTED`: sospende la tx corrente ed esegue senza tx.
- `MANDATORY`: richiede una tx attiva.
## Synchronization (`TxSynchronization`)
Hook sul ciclo di vita di **una** transazione, registrati con `Data.afterCommit(...)` (o
direttamente con `ResourceRegistry.addSynchronization(...)`).
Ogni callback appartiene esattamente alla transazione più interna attiva al momento della
registrazione, e scatta una volta sola quando *quella* transazione completa:
- una tx interna **joined** (`REQUIRED`) non è una transazione a sé, quindi i callback registrati
al suo interno aspettano il commit più esterno;
- una tx `REQUIRES_NEW` lo è, quindi completarla fa scattare solo i propri callback e lascia in
sospeso quelli della transazione esterna sospesa.
### Posizione rispetto al commit
| hook | quando | risorsa |
| --- | --- | --- |
| `beforeCommit(readOnly)` | subito **prima** del commit reale | sessione/connection ancora **bound**, tx ancora attiva |
| `afterCommit()` / `afterRollback()` | dopo il completamento | risorsa già **sganciata** |
| `afterCompletion(outcome)` | dopo i due precedenti | risorsa già sganciata |
`beforeCommit` è l'unico hook che può ancora scrivere sulla stessa risorsa e finire nella stessa
unità atomica: flush di un buffer, riga di audit, valore derivato. Non viene eseguito se la
transazione è già `rollback-only`, perché non c'è nessun commit da precedere.
I callback post-completamento girano invece a risorsa sganciata: uno che apre una propria
transazione ne ottiene una **nuova** invece di agganciarsi a quella appena conclusa. È questo che
li rende il posto giusto per invalidare una cache, accodare un messaggio o notificare qualcosa
fuori dal database.
### Fallimenti
Lanciare da `beforeCommit` **veta il commit**: la transazione va in rollback, scattano
`afterRollback`/`afterCompletion(ROLLED_BACK)` e l'eccezione arriva al chiamante. È il motivo per
cui l'hook gira prima del commit e non dopo — può ancora rifiutare.
Gli hook post-completamento non hanno questo potere: la transazione è già chiusa quando girano,
quindi un'eccezione si propaga ma non cambia nulla di ciò che è stato committato, e blocca i
callback in coda dietro di lei.
## Uso di `Repository`
`Repository` è la base comune per le repository concrete.
@@ -79,3 +119,6 @@ Questo rende il layer dati componibile con il sistema di extension di Flash senz
- Lo stack transazionale è thread-local e viene ripulito quando torna vuoto.
- Le risorse backend sono sospese e ripristinate per `REQUIRES_NEW` e `NOT_SUPPORTED`.
- `TxSynchronization` è il punto di aggancio per hook di commit/rollback/completion.
- Le synchronization sono in una lista thread-local; ogni transazione nuova registra quante ne
esistevano già alla sua apertura e fa scattare solo la propria coda, così una `REQUIRES_NEW`
non trascina con sé quelle della transazione sospesa.
@@ -62,6 +62,28 @@ public final class ResourceRegistry {
return SYNCHRONIZATIONS.get().size();
}
/**
* Runs {@link TxSynchronization#beforeCommit} on the synchronizations registered from
* {@code fromIndex} onward, while their transaction is still active and its resource still
* bound. Unlike {@link #fireSynchronizations} this leaves them registered: they still have
* their post-completion callbacks to come. Exceptions propagate on purpose — a beforeCommit
* that throws vetoes the commit, see {@link TxSynchronization}.
*
* <p>Snapshots before iterating, so a callback that registers further synchronizations (a
* nested {@code Data#afterCommit}) doesn't mutate the list mid-loop. Those new ones join the
* transaction's post-completion callbacks without getting a {@code beforeCommit} of their own,
* which is the only coherent answer once the pass is already running.
*/
public static void fireBeforeCommit(boolean readOnly, int fromIndex) {
List<TxSynchronization> pending = SYNCHRONIZATIONS.get();
if (fromIndex >= pending.size()) {
return;
}
for (TxSynchronization sync : List.copyOf(pending.subList(fromIndex, pending.size()))) {
sync.beforeCommit(readOnly);
}
}
/**
* 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
@@ -1,8 +1,58 @@
package dev.relism.flash.ext.data.core;
/**
* Lifecycle hooks for one transaction, registered through {@code Data#afterCommit} (or
* {@link ResourceRegistry#addSynchronization} directly) and fired by the {@link TxManager} that
* owns the transaction they were registered in.
*
* <p>Every callback belongs to exactly one transaction — the innermost one active at registration
* time — and fires exactly once, when <em>that</em> transaction completes. A joined
* ({@code REQUIRED}) inner transaction is not a transaction of its own, so callbacks registered
* inside one wait for the outermost commit; a {@code REQUIRES_NEW} transaction is, so completing
* it fires only its own callbacks and leaves the suspended outer transaction's alone.
*
* <h3>Where each hook sits relative to the commit</h3>
* <ul>
* <li>{@link #beforeCommit(boolean)} — immediately <b>before</b> the real commit, with the
* transaction still active and its session/connection still bound. This is the only hook
* that can still write through that same resource and have the write land in the same atomic
* unit: flush a buffer, stamp an audit row, materialize a derived value. Skipped entirely
* when the transaction is already rollback-only, since there is no commit to precede.</li>
* <li>{@link #afterCommit()} / {@link #afterRollback()}, then {@link #afterCompletion(TxOutcome)}
* — <b>after</b> the transaction has completed and its resource has been unbound. Nothing
* done here is part of the transaction: a callback that opens its own transaction gets a
* fresh one instead of joining the one that just finished, which is what makes this the
* right place to refresh a cache, enqueue a message, or notify anything outside the
* database.</li>
* </ul>
*
* <h3>Failure</h3>
* Throwing from {@link #beforeCommit(boolean)} <b>vetoes the commit</b>: the transaction is rolled
* back, {@link #afterRollback()}/{@link #afterCompletion(TxOutcome)} fire with
* {@link TxOutcome#ROLLED_BACK}, and the exception propagates to the caller. That is the point of
* this hook running before the commit rather than after — it can still refuse.
*
* <p>The post-completion hooks have no such power: the transaction is over by the time they run,
* so an exception from one propagates to the caller but changes nothing already committed, and
* stops the callbacks queued behind it.
*/
public interface TxSynchronization {
/**
* Runs inside the transaction, immediately before it commits — see the interface javadoc.
* Throwing from here rolls the transaction back instead of committing it.
*
* @param readOnly whether the transaction was opened read-only, so a callback that would
* otherwise write can skip work it is not allowed to do
*/
default void beforeCommit(boolean readOnly) {}
/** Runs after a successful commit, with the transaction's resource already unbound. */
default void afterCommit() {}
/** Runs after a rollback, with the transaction's resource already unbound. */
default void afterRollback() {}
/** Runs after {@link #afterCommit()}/{@link #afterRollback()}, whichever applied. */
default void afterCompletion(TxOutcome outcome) {}
}