Skip to content
AITroveRead. Build. Understand.
Make this comfortable

Spring R2DBC reactive transaction: roll back the event marker with a rejected debit

Last updated: 1 Oct 20264 min read
tutorial
IntermediateBy AITrove Editorial

TransactionalOperator can bind multiple R2DBC publishers to one reactive transaction and roll them back when the composed pipeline fails.

Download Spring source kit

Compose both writes before applying the operator

The fixture inserts EVT-47 into applied_reservation, then executes a conditional stock debit in the same reactive chain. TransactionalOperator wraps the chain. On success, the marker exists and SKU-47 moves from 19 to 12 with version 4. If a 20-unit debit affects zero rows, the pipeline converts that result into an error; the marker rolls back and stock stays at 19. The consumer ledger explains why the marker and mutation must share a commit.

This uses R2dbcTransactionManager and one R2DBC ConnectionFactory. A JDBC TransactionTemplate in a different datasource does not automatically join the reactive transaction. Reactor Context carries the transaction association through the subscribed pipeline; splitting work into an unmanaged subscribe call can lose that boundary. The JDBC transaction lesson is a separate model.

Specify the failure policy

An error signal triggers rollback here. A caught exception converted to a normal value can instead lead to commit. The method therefore turns a zero-row update into Mono.error rather than swallowing it. Cancellation, timeout, and retry semantics need tests against the selected driver and production database; this embedded fixture checks two sequential outcomes only.

Checked excerpt

Java
return database.sql("insert into applied_reservation(event_id) values (:eventId)")
    .bind("eventId", eventId).fetch().rowsUpdated()
    .then(database.sql(
        "update stock_balance set available = available - :units, version = version + 1 "
            + "where sku = :sku and tenant = :tenant "
            + "and version = :expectedVersion and available >= :units")
        .bind("units", units).bind("sku", sku).bind("tenant", tenant)
        .bind("expectedVersion", expectedVersion).fetch().rowsUpdated())
    .flatMap(changed -> changed == 1 ? Mono.<Void>empty()
        : Mono.error(new IllegalStateException("reservation rejected")))
    .as(transactions::transactional);

Verification boundary

The Java 21 / Spring Boot 4 source kit checks transactionCommitsMarkerAndVersionedDebitTogether and transactionRollsBackMarkerAfterRejectedDebit.

Cost and limits

One transaction holds a connection until completion and adds an indexed marker write. Long reactive chains inside the transaction can hold database resources for too long; keep remote calls and slow work outside the database commit window.

Common Mistakes

  • Do not put the marker outside the transactional pipeline.
  • Do not convert a failed row count into a normal completion if rollback is required.
  • Do not assume a JDBC transaction joins a R2DBC transaction automatically.

Read next

Spring R2DBC conditional update: test tenant, version and stock floor in one statement, Spring consumer idempotency: reserve an event ID with the stock mutation, Spring TransactionTemplate: roll back a failed multi-row change, Spring transaction propagation: joined rollback and independent commit, Spring R2DBC fixture isolation: keep incompatible H2 versions out of one test classpath.

Related boundary

Spring reactive transaction: subscription owns the context, not a thread

spring
spring-boot
r2dbc
r2dbc-reactive-transaction
Storage details