Transactions — @Transaction, Rollback, and Transparent Retry¶
Overview and Motivation¶
Every unit of work that modifies the database must be atomic: it either commits as a whole or
leaves no trace. Tentackle expresses this declaratively — a domain or persistence method is
annotated with @Transaction, and the
interceptor behind it wraps the
invocation in begin/commit, rolls back on any exception, and can transparently retry the whole
unit when the failure was temporary — an optimistic-lock conflict, a deadlock, a serialization
failure.
@Transaction
@Override
public void invoiceShipment(Shipment shipment) {
... // everything in here commits or rolls back as one unit
}
Because @Transaction is a PUBLIC interception,
it sits on the declared interface method — the transactional contract is part of the API, not
an implementation detail. And because the interceptor is remote-aware, the same annotated method
runs correctly in every tier: with a remote session the
transaction executes on the server (see below).
Basic Behavior¶
For a method annotated with plain @Transaction, the interceptor:
- begins a transaction on the session — unless one is already running (see nesting);
- invokes the method;
- commits on normal return, or rolls back on any exception, which is then rethrown.
The transaction gets a name and is visible in logs.
By default, it is derived from the method: DeclaringClass#methodName. Set it explicitly via the
value attribute when several methods form one logical unit or when a
retry policy should treat certain transactions specially:
Nesting: inner methods join the outer transaction¶
Session.begin(...) does nothing when a transaction is already running — it hands out a
voucher of 0 instead of a real one. An inner @Transaction method therefore simply joins
the surrounding transaction: it neither commits nor rolls back by itself, and never
retries. Only the outermost method — the holder of the real voucher — ends
the transaction. Composing transactional methods into larger transactional methods is thus safe
and requires no ceremony.
Two consistency rules apply at the join point:
- If the inner annotation requests an
isolationorwritabilitythat differs from what the running transaction was started with, aPersistenceExceptionis thrown — the inner method's requirements cannot be silently ignored. - An inner annotation with
running = trueadditionally asserts its expectation (see below).
The Attributes¶
| Attribute | Default | Meaning |
|---|---|---|
value |
derived | The transaction name; defaults to DeclaringClass#methodName. |
running |
false |
Assert that a transaction is already running instead of beginning one. |
rollbackSilently |
false |
Never log rollback details for this method. |
isolation |
DEFAULT |
Transaction isolation: READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE, NONE. |
writability |
DEFAULT |
READ_WRITE or READ_ONLY (a hint the database may use to optimize). |
retry |
false |
Retry the transaction after a temporary failure. |
retryPolicy |
"" (the default) |
Name of the TransactionRetryPolicy to use. |
retryLogLevel |
WARNING |
Level at which each retry is logged. |
DEFAULT for isolation and writability means: whatever the session (and ultimately the
database backend) is configured with — most applications never need to override either.
Asserting an open transaction with running¶
Some methods are only meaningful as part of a larger unit — a step in a workflow that must never commit on its own. Annotating them with
turns the interceptor into an assertion: a PersistenceException is thrown if no transaction
is running. If both the annotation and the running transaction are named, the names must match —
guarding against the method being invoked from the wrong workflow.
Rollback logging and rollbackSilently¶
Not every rollback is worth a stack trace. The interceptor distinguishes:
- Silent rollback — for temporary exceptions (an optimistic-lock conflict about to be retried) and application-level domain exceptions: the rollback happens without detailed logging, because the exception is either handled upstream or part of normal business flow.
- Logged rollback — for non-temporary persistence errors (e.g. a constraint violation): the details go to the log.
rollbackSilently = true forces the silent variant for methods where rollbacks are expected
behavior and would only clutter the log.
Remote sessions¶
When the session is remote and the implementation is a
@RemoteMethod, the interceptor does
not begin a transaction locally: it forwards the invocation, and the server's own
TransactionInterceptor runs begin, method and commit on the server — saving the two network
round trips for begin and commit, and keeping the transaction (including any
retry) close to the database, where it belongs.
Transparent Retry¶
Why retry belongs to the framework¶
Tentackle's optimistic locking turns
a lost update into an exception at UPDATE time, and databases running at higher isolation levels
report deadlocks and serialization failures the same way: the transaction fails, but retrying it
from the start would very likely succeed. Scattering hand-written retry loops across the domain
logic is error-prone — so the @Transaction interceptor offers the loop as a one-word feature:
@Transaction(retry = true)
@Override
public void transferStock(StorageBin from, StorageBin to, int amount) {
...
}
What is retried — temporary exceptions¶
A failed transaction is re-attempted only when the exception chain contains a
TentackleRuntimeException flagged temporary — the framework's marker for "the cause may
disappear if you try again". The flag is set by:
- the optimistic
serialcheck: the row was changed under the method by a concurrent, committed transaction (see locking); - the backend's error classification:
SQLExceptions the backend recognizes as transient — deadlock victim, serialization failure — are wrapped in a temporaryPersistenceException; - any application code that throws a
TentackleRuntimeExceptionwithsetTemporary(true).
Everything else — constraint violations, domain exceptions, programming errors — fails
immediately, exactly as without retry.
Only the outermost transaction retries. An inner @Transaction(retry = true) method that
joined a surrounding transaction rethrows instead: the unit of work that must be re-executed is
the whole transaction, and only its owner can do that.
The retry loop and the default policy¶
On a temporary failure the interceptor rolls back (silently), asks the
TransactionRetryPolicy how long to
wait, sleeps, and re-invokes the method — beginning a fresh transaction. The policy's contract is
a single method:
Each retry is logged at retryLogLevel (default WARNING), so contention becomes visible in the
logs without failing the operation.
The DefaultTransactionRetryPolicy
implements randomized exponential backoff: up to 7 retries, waiting a random 250–750 ms
doubled on each attempt — roughly 0.5 s, 1 s, 2 s, 4 s, 8 s, 16 s and 32 s. The randomization
de-synchronizes competing transactions that failed on the same row; the growing intervals give
longer outages (a failing-over database) a chance to clear. After the 7th unsuccessful retry the
temporary exception is rethrown.
Custom retry policies¶
A policy is a mapped service: implement
the interface and annotate it with
@TransactionRetryPolicyService:
@TransactionRetryPolicyService("aggressive")
public class AggressiveRetryPolicy implements TransactionRetryPolicy {
@Override
public long waitMillis(String txName, int count) {
return count > 20 ? 0 : 100; // 20 fast retries, then give up
}
}
Select it per method via the annotation:
Registering a policy under the empty name (@TransactionRetryPolicyService("")) replaces the
default policy application-wide. Referencing a name that no policy is registered under fails with
a PersistenceException on first use — a misspelled policy name cannot silently disable backoff.
Writing retryable methods¶
Retry re-executes the method body, not just the database statements. That is safe as long as everything the method does is transactional — reads, modifications, and PDO state changes all roll back with the transaction. Two things to watch:
- No external side effects. A method that sends an e-mail, writes a file, or mutates shared
in-memory state before the commit will repeat those effects on every retry. Move such work after
the transaction — for example into a
@PostCommitmethod, which runs only once, after the commit actually succeeded. - Re-read what you check. The retried invocation must reload the PDOs it operates on (or receive them fresh), so the second attempt sees the concurrent modification instead of colliding with it again. Methods that select their working set inside the transaction get this for free.
Related Documentation¶
- Locking — the optimistic
serialcheck whose temporaryNotFoundExceptionpowers the retry, and token locks for long interactions. - Interceptors — the interception
framework
@Transactionis built on, and the other built-in interceptors (@PreCommit/@PostCommit,@NoTransaction, …). - Correctness First — why a detected conflict plus an automatic retry beats silent overwriting.
- Session — the persistence context that owns the physical transaction.
- Modification Tracking — how committed transactions become change notifications.