Fix aborted transaction when a duplicate visit is rescued on Postgres - #583
Open
sevab wants to merge 1 commit into
Open
Fix aborted transaction when a duplicate visit is rescued on Postgres#583sevab wants to merge 1 commit into
sevab wants to merge 1 commit into
Conversation
Postgres aborts the entire transaction when any statement fails, so rescuing the unique violation on visit_token is not enough when the caller is already inside a transaction. The `visitable` macro creates visits from a before_create hook, so a concurrent duplicate visit left the enclosing transaction unusable and the record failed to save with: ActiveRecord::StatementInvalid: PG::InFailedSqlTransaction: ERROR: current transaction is aborted, commands ignored until end of transaction block Save inside a savepoint so the failed insert can be rolled back on its own. The exception has to escape the block for Active Record to issue ROLLBACK TO SAVEPOINT, so the rescue stays outside of it. MySQL and SQLite only fail the individual statement, which is why this went unnoticed. Active Record collapses the savepoint into the parent when there is nothing to preserve, so the common path emits no extra statements. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sevab
marked this pull request as ready for review
July 9, 2026 11:45
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.
Problem
DatabaseStore#track_visitrelies on the unique index onvisit_tokenas concurrency control, and rescues the resulting violation:On Postgres, that rescue is not sufficient. Postgres aborts the entire transaction when any statement fails, so catching the Ruby exception leaves the connection in an aborted state. Every subsequent statement fails with
current transaction is aborted.This matters because the
visitablemacro creates visits from abefore_createhook — that is, inside the record's own transaction:So when two concurrent requests share a visit token and one loses the race, the host application's record fails to save:
Rails does not open a savepoint for a nested
save, so there is nothing to roll back to. In our app this silently cost us lead records — the visit insert lost a race, and the lead insert behind it died with the transaction.track_eventrescuesevent.save!the same way and has the same hazard for anyone who adds a unique index toahoy_events.Why this went unnoticed
It's Postgres-only. MySQL and SQLite fail just the offending statement and leave the transaction usable. I added the regression test before the fix and confirmed it fails on
ADAPTER=postgresqland passes onADAPTER=sqlite3unchanged.Fix
Save inside a savepoint (
transaction(requires_new: true)) so the failed insert can be rolled back on its own.The subtlety worth calling out: the exception has to escape the block for Active Record to issue
ROLLBACK TO SAVEPOINT. Wrapping the existing rescue would not work — the block would exit normally and Active Record would issueRELEASE SAVEPOINT, which itself fails on an aborted transaction. Hencesave_record!raises and the callers rescue outside of it.Mongoid is branched around the savepoint and behaves exactly as before. I extracted the existing inline
visit_model < Mongoid::Documentcheck ingeocodeinto amongoid?helper rather than duplicate it.Cost
None on the common path. Active Record collapses the savepoint into the parent when there is nothing to preserve:
A real savepoint is only emitted when the enclosing transaction has writes to protect, and I verified those writes survive the rollback:
Testing
test_duplicate_visit_token_does_not_abort_transactionintest/tracker_test.rb, skipped on Mongoid.Full suite green locally on
sqlite3(91 runs) andpostgresql(133 runs). I could not buildmysql2locally, so MySQL, Trilogy and Mongoid are covered only by CI here — opening as a draft so the matrix can run.