skip to content

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?

level: principalimportance: nice to knowfreq 25%

answer

  1. programmatic def → rollback on ANY error
  2. @Transactional keeps rule-based (commit on checked)
  3. no savepoint NESTED for R2DBC
  4. big Flux = long-open tx = pool/lock pressure
  5. no reactive XA/distributed tx

basics

~20 s

A 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 s

The 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 lines
java
import 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

for a junior

Not expected — this is advanced/operational territory.

for a middle

Know you can pass a TransactionDefinition for isolation/read-only/timeout.

for a senior

Explain propagation support, the missing NESTED/savepoint, and connection-pool implications of long transactions.

for a principal

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

context