Fix connection returned to pool without rollback after failed commit#228
Open
rkurka wants to merge 1 commit into
Open
Fix connection returned to pool without rollback after failed commit#228rkurka wants to merge 1 commit into
rkurka wants to merge 1 commit into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PooledConnection.commitTransaction()usesdoOnSubscribeto clear theinTransactionflag, which fires before the database processes the commit. If the commit subsequently fails, the flag is alreadyfalse. Whenclose()is called, the safety-net rollback is skipped and the connection is returned to the pool with an open transaction.Root cause
doOnSubscribefires at subscription time, not on successful completion. Compare withbeginTransaction(), which correctly uses the pessimistic approach: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:
beginTransaction()is called →doOnSubscribesetsinTransaction = true→ DB executesBEGIN→onComplete. State:inTransaction == trueApplication executes statements (e.g.
INSERT INTO orders ...)commitTransaction()is called →doOnSubscribeimmediately setsinTransaction = false→ DB attemptsCOMMIT→ fails (serialization failure, deadlock, transient network error) →onErrorpropagates to caller. State:inTransaction == false, but the transaction is still open on the database sideCaller handles the error and calls
close()to return the connection to the poolclose()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) —inTransactionisfalse. Rollback is skipped.this.ref.release()— connection is returned to the poolNext 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
COMMITcommits both sessions' data; aROLLBACKrolls back both.Concrete example
User A's failed order (10,000) is silently committed alongside User B's order.
Fix
Change
doOnSubscribetodoOnSuccess:The flag is only cleared after the commit succeeds. On failure,
inTransactionremainstrue, soclose()issues a rollback before returning the connection to the pool.Why this is safe
doOnSuccessfires →inTransaction = false→close()skips rollback (correct, same behavior as before)doOnSuccessdoes not fire →inTransactionstaystrue→close()issues rollback → connection returned clean (fixed behavior)beginTransaction():beginTransactionusesdoOnSubscribeto settrueearly, which is the correct pessimistic direction — an unnecessary rollback on close is harmless. ForcommitTransaction, the pessimistic direction is to keep the flagtrueuntil success.Test plan
failedCommitShouldRollbackOnClosetest: begin → failed commit → close → verify rollback was called🤖 Generated with Claude Code