What are the default rollback rules and propagation limitations of the reactive transaction model compared to the imperative one, and what would you watch for at scale?
answer
- programmatic def → rollback on ANY error
- @Transactional keeps rule-based (commit on checked)
- no savepoint NESTED for R2DBC
- big Flux = long-open tx = pool/lock pressure
- no reactive XA/distributed tx
basics
~20 sA programmatic TransactionalOperator built with a plain TransactionDefinition rolls back on any error signal. Reactive supports common propagations like REQUIRED but not savepoint-based NESTED on R2DBC. At scale, watch long-open transactions from big Flux streams holding connections.
solid answer
~40 sThe programmatic reactive operator is configured from a `TransactionDefinition`, and by default it triggers a rollback on **any** error signal — it doesn't apply the annotation-style rollback rules (`@Transactional(rollbackFor=...)`, the 'commit on checked exceptions' default) unless you use the annotation-driven path with a `RuleBasedTransactionAttribute`. On **propagation**: `R2dbcTransactionManager` supports `REQUIRED`, `SUPPORTS`, `MANDATORY`, `NOT_SUPPORTED`, `NEVER`, and — depending on driver/version — `REQUIRES_NEW`; savepoint-based `NESTED` is generally **not** available for R2DBC the way it is for JDBC. You set isolation and read-only via a `TransactionDefinition` passed to `TransactionalOperator.create(txm, definition)`. At scale I watch: long-lived transactions from wrapping large/unbounded `Flux` (holds a connection and locks for the whole stream), connection-pool sizing under reactive concurrency, and the fact that only one reactive resource enlists — cross-store atomicity isn't provided.
code
java · 15 linesimport org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.transaction.reactive.TransactionalOperator;
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setReadOnly(true);
def.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
// timeout guards against a huge Flux holding a connection forever
def.setTimeout(5);
TransactionalOperator readOnlyRx = TransactionalOperator.create(reactiveTxManager, def);
// Bare programmatic operator: ANY error signal here rolls back,
// including what @Transactional's default would have committed (a 'checked'-style error).
Mono<Snapshot> snapshot = readOnlyRx.transactional(reportRepo.streamRows().collectList().map(Snapshot::of));go deeper
Not expected — this is advanced/operational territory.
Know you can pass a TransactionDefinition for isolation/read-only/timeout.
Explain propagation support, the missing NESTED/savepoint, and connection-pool implications of long transactions.
Contrast programmatic vs rule-based rollback semantics, articulate the absence of reactive XA, and design saga/outbox alternatives with pool-sizing awareness.
## Rollback rules: programmatic vs annotation-driven Two different code paths exist: - **Annotation-driven** `@Transactional` (reactive) uses a `RuleBasedTransactionAttribute`, so it keeps the classic Spring default: roll back on `RuntimeException`/`Error`, **commit** on checked exceptions, customizable via `rollbackFor`/`noRollbackFor`. In reactive there are no checked exceptions in the pipeline per se, but the rule engine still matches the error's type. - **Programmatic** `TransactionalOperator.create(txm)` uses a plain `DefaultTransactionDefinition`, which carries **no** rollback rules. Its behavior is to roll back on **any** error signal reaching the operator. So the 'commit on checked exception' nuance of `@Transactional` does not apply to the bare operator — every `onError` rolls back. This is a subtle but real difference an interviewer may probe. ## Configuring the definition You can pass a `TransactionDefinition` to customize isolation, read-only, timeout, and propagation: ```java DefaultTransactionDefinition def = new DefaultTransactionDefinition(); def.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ); def.setReadOnly(true); def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); TransactionalOperator rxtx = TransactionalOperator.create(txm, def); ``` (Driver support for isolation levels and `REQUIRES_NEW` varies.) ## Propagation limitations `R2dbcTransactionManager` (extends `AbstractReactiveTransactionManager`) supports the standard propagations conceptually, but there are practical gaps vs JDBC: - **NESTED** (savepoint-based nested transactions) is **not** generally supported for R2DBC — the imperative JDBC `DataSourceTransactionManager` supports savepoints, the reactive one historically does not expose the same NESTED semantics. Don't rely on savepoints in reactive. - `REQUIRES_NEW` suspends the current transaction and opens a new connection — support depends on the driver/version; test it. - The others (`REQUIRED`, `SUPPORTS`, `MANDATORY`, `NOT_SUPPORTED`, `NEVER`) behave analogously to imperative, operating over the `TransactionContext` in the Reactor Context. ## Scale / production concerns 1. **Long-open transactions**: `transactional(Flux)` keeps the transaction (and its pooled connection + DB locks) open until the stream completes. Large exports or streaming reads under a transaction can exhaust the R2DBC connection pool and increase lock contention. Prefer bounding the work, or use read-only / no-transaction for pure reads. 2. **Connection-pool sizing**: reactive concurrency can multiply in-flight transactions; the pool (`ConnectionFactory` pool) must be sized for peak concurrent open transactions, or subscribers stall waiting for connections — which can look like a deadlock. 3. **No distributed/XA transactions**: reactive Spring has no reactive JTA. Only a single reactive resource enlists; spanning two datastores atomically isn't provided — use sagas/outbox patterns. 4. **Mixing blocking stores** breaks atomicity (see the propagation-boundary question). 5. **Cancellation semantics under timeouts**: a `timeout()` cancels and rolls back — under load this can mask progress; design idempotent retries. ## Summary for the interview Name the programmatic-rolls-back-on-any-error nuance, the missing savepoint/NESTED support, the connection-pool/long-transaction risk, and the absence of reactive XA. That signals real operational depth.
- Does a bare TransactionalOperator honor @Transactional(rollbackFor=...) style rules?No. Built from a plain TransactionDefinition it has no rollback rules and rolls back on any error signal. Rule-based rollback (commit-on-checked, rollbackFor/noRollbackFor) is the annotation-driven RuleBasedTransactionAttribute path.
- Can you use PROPAGATION_NESTED with R2DBC for savepoint-based partial rollback?Generally no — savepoint-based NESTED is a JDBC feature not exposed by the reactive R2dbcTransactionManager. Design around it (separate transactions, sagas) rather than relying on savepoints.
- How would you keep a streaming read from exhausting the connection pool?Avoid wrapping a large/unbounded Flux in a transaction, use read-only/no-transaction for pure reads, bound the result set, set a timeout, and size the R2DBC pool for peak concurrent open transactions.
saying these in an interview costs you the question
- Claiming reactive Spring supports savepoint-based NESTED like JDBC
- Assuming the bare operator commits on 'checked' errors like @Transactional's default
- Believing reactive Spring provides distributed/XA transactions
- Ignoring that a large Flux holds a connection for the whole stream