Skip to content

Fix connection returned to pool without rollback after failed commit#228

Open
rkurka wants to merge 1 commit into
r2dbc:mainfrom
rkurka:fix/commit-failure-skips-rollback
Open

Fix connection returned to pool without rollback after failed commit#228
rkurka wants to merge 1 commit into
r2dbc:mainfrom
rkurka:fix/commit-failure-skips-rollback

Conversation

@rkurka

@rkurka rkurka commented Mar 14, 2026

Copy link
Copy Markdown

Summary

PooledConnection.commitTransaction() uses doOnSubscribe to clear the inTransaction flag, which fires before the database processes the commit. If the commit subsequently fails, the flag is already false. When close() is called, the safety-net rollback is skipped and the connection is returned to the pool with an open transaction.

Root cause

// Before (PooledConnection.java:117)
return Mono.from(this.connection.commitTransaction())
    .doOnSubscribe(ignore -> this.inTransaction = false);  // clears flag immediately on subscribe

doOnSubscribe fires at subscription time, not on successful completion. Compare with beginTransaction(), which correctly uses the pessimistic approach:

return Mono.from(this.connection.beginTransaction())
    .doOnSubscribe(ignore -> this.inTransaction = true)   // set true early (safe: unnecessary rollback is harmless)
    .doOnError(e -> this.inTransaction = false);           // revert on failure

For commitTransaction, the semantics are reversed: clearing the flag early is unsafe, because a failed commit leaves the transaction open.

Failure scenario

Step-by-step trace through the code:

  1. beginTransaction() is called → doOnSubscribe sets inTransaction = true → DB executes BEGINonComplete. State: inTransaction == true

  2. Application executes statements (e.g. INSERT INTO orders ...)

  3. commitTransaction() is called → doOnSubscribe immediately sets inTransaction = false → DB attempts COMMIT → fails (serialization failure, deadlock, transient network error) → onError propagates to caller. State: inTransaction == false, but the transaction is still open on the database side

  4. Caller handles the error and calls close() to return the connection to the pool

  5. close() triggers the release chain:

    • Validation.validate(this, ValidationDepth.LOCAL) — checks if the TCP connection is alive. It is (the error was a logical failure, not a connection loss). Validation passes.
    • if (this.inTransaction) (line 74) — inTransaction is false. Rollback is skipped.
    • this.ref.release() — connection is returned to the pool
  6. Next consumer acquires the same physical connection. The previous transaction is still open on the database. The new consumer's statements execute within the previous session's uncommitted transaction. A subsequent COMMIT commits both sessions' data; a ROLLBACK rolls back both.

Concrete example

User A: BEGIN
User A: INSERT INTO orders (amount) VALUES (10000)
User A: COMMIT  →  DB: "ERROR: could not serialize access"
                    ↑ doOnSubscribe already set inTransaction = false
User A: close() →  validation OK → no rollback → ref.release()

User B: pool.acquire() → gets the same connection
User B: INSERT INTO orders (amount) VALUES (5)
User B: COMMIT  →  DB commits BOTH inserts (10000 + 5)

User A's failed order (10,000) is silently committed alongside User B's order.

Fix

Change doOnSubscribe to doOnSuccess:

return Mono.from(this.connection.commitTransaction())
    .doOnSuccess(ignore -> this.inTransaction = false);

The flag is only cleared after the commit succeeds. On failure, inTransaction remains true, so close() issues a rollback before returning the connection to the pool.

Why this is safe

  • Successful commit: doOnSuccess fires → inTransaction = falseclose() skips rollback (correct, same behavior as before)
  • Failed commit: doOnSuccess does not fire → inTransaction stays trueclose() issues rollback → connection returned clean (fixed behavior)
  • Comparison with beginTransaction(): beginTransaction uses doOnSubscribe to set true early, which is the correct pessimistic direction — an unnecessary rollback on close is harmless. For commitTransaction, the pessimistic direction is to keep the flag true until success.

Test plan

  • Added failedCommitShouldRollbackOnClose test: begin → failed commit → close → verify rollback was called
  • All 98 existing tests pass without modification

🤖 Generated with Claude Code

PooledConnection.commitTransaction() used doOnSubscribe to set
inTransaction=false, which fires immediately when the commit Mono is
subscribed — before the database has actually committed. If the commit
then fails (e.g. serialization failure, deadlock, transient network
error), inTransaction is already false. When close() runs afterwards,
it skips the safety-net rollback (line 74: if this.inTransaction) and
returns the connection to the pool with an open transaction.

The next consumer that acquires this connection inherits the
uncommitted transaction state, which can lead to data corruption:
their writes become part of the previous transaction, and a subsequent
commit or rollback affects both sessions' data.

Change doOnSubscribe to doOnSuccess so that inTransaction is only
cleared after the commit actually succeeds. On failure, the flag
remains true, and close() will issue a rollback before releasing the
connection back to the pool.

Add a unit test that reproduces the scenario: begin → failed commit →
close, and verifies that rollbackTransaction() is called.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant