Skip to content

Fix aborted transaction when a duplicate visit is rescued on Postgres - #583

Open
sevab wants to merge 1 commit into
ankane:masterfrom
sevab:savepoint-duplicate-visit
Open

Fix aborted transaction when a duplicate visit is rescued on Postgres#583
sevab wants to merge 1 commit into
ankane:masterfrom
sevab:savepoint-duplicate-visit

Conversation

@sevab

@sevab sevab commented Jul 9, 2026

Copy link
Copy Markdown

Problem

DatabaseStore#track_visit relies on the unique index on visit_token as concurrency control, and rescues the resulting violation:

def track_visit(data)
  @visit = visit_model.create!(slice_data(visit_model, data))
rescue => e
  raise e unless unique_exception?(e)
  ...
end

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 visitable macro creates visits from a before_create hook — that is, inside the record's own transaction:

def set_ahoy_visit
  self.visit ||= Ahoy.instance.try(:visit_or_create)
end

So when two concurrent requests share a visit token and one loses the race, the host application's record fails to save:

ActiveRecord::StatementInvalid: PG::InFailedSqlTransaction: ERROR:
current transaction is aborted, commands ignored until end of transaction block

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_event rescues event.save! the same way and has the same hazard for anyone who adds a unique index to ahoy_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=postgresql and passes on ADAPTER=sqlite3 unchanged.

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 issue RELEASE SAVEPOINT, which itself fails on an aborted transaction. Hence save_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::Document check in geocode into a mongoid? 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:

# top level, no enclosing transaction -- identical SQL to before
BEGIN
INSERT INTO "ahoy_visits" ...
COMMIT

A real savepoint is only emitted when the enclosing transaction has writes to protect, and I verified those writes survive the rollback:

BEGIN
INSERT INTO "users" ...                  <-- survives
SAVEPOINT active_record_1
INSERT INTO "ahoy_visits" ...            <-- duplicate, fails
ROLLBACK TO SAVEPOINT active_record_1
INSERT INTO "users" ...
COMMIT

Testing

test_duplicate_visit_token_does_not_abort_transaction in test/tracker_test.rb, skipped on Mongoid.

Full suite green locally on sqlite3 (91 runs) and postgresql (133 runs). I could not build mysql2 locally, so MySQL, Trilogy and Mongoid are covered only by CI here — opening as a draft so the matrix can run.

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
sevab marked this pull request as ready for review July 9, 2026 11:45
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