Skip to content

Pin the transaction strategy's connection on Rails 7.2+ - #134

Open
rywall wants to merge 1 commit into
DatabaseCleaner:mainfrom
rywall:pin-transaction-connection
Open

rywall wants to merge 1 commit into
DatabaseCleaner:mainfrom
rywall:pin-transaction-connection

Conversation

@rywall

@rywall rywall commented Oct 23, 2026

Copy link
Copy Markdown

On Rails 7.2+, a test using the transaction strategy can lose sight of its own uncommitted records partway through.

Transaction#start opens a transaction with joinable: false. Rails' ConnectionPool::ExecutorHooks.complete releases a thread's connection back to the pool whenever its current transaction isn't joinable, and every executor wrap runs that hook: an inline job (perform_enqueued_jobs, deliver_later), or each request in a request spec. So after the first job or request, the test's connection, with its open transaction, goes back to the pool. Usually the next lease gets the same connection back. But if another thread has it checked out at that moment (on Rails 8.1 the pool reaper's keepalive briefly checks out idle connections), the test leases a different connection. That connection can't see the test's records, which fails with things like ActiveJob::DeserializationError: Couldn't find Model with 'id'=.... We hit this as an intermittent failure on a Rails 8.1 suite.

ActiveRecord::TestFixtures avoids this by pinning the connection (pool.pin_connection!(lock_threads) in setup, unpin_connection! in teardown). This PR does the same in the transaction strategy:

  • On Rails 7.2+, start pins the connection and clean unpins it, rolling back the transaction. A pinned connection is never checked back in, so executor wraps can't release it, and every thread gets it, so other threads see the test's data the way they do under Rails' transactional tests.
  • New :lock_threads option, default true, matching Rails' lock_threads. It makes threads take turns on the shared connection.
  • Rails < 7.2 is unchanged.

The new specs reproduce the race deterministically. After ExecutorHooks.complete, another thread checks out the released connection and holds it while the test queries. They fail on main and pass with this change.

Behavior change to note: a test that needs a genuinely separate database session from a second thread (for example, to check that a table lock blocks another session) now shares the pinned connection, as it would under Rails' transactional tests. Such tests need the deletion or truncation strategy.

Tested locally on Rails 7.1, 7.2, 8.0 and 8.1 against MySQL, Trilogy, Postgres and SQLite: full suite green on each.

Rails' executor releases a thread's connection when its transaction is
not joinable, so an inline job or a request in a request spec hands the
test's connection back to the pool. If another thread (the pool reaper's
keepalive, for one) checks it out before the test queries again, the test
leases a different connection that can't see its uncommitted records.

Pin the connection the way ActiveRecord::TestFixtures does, and add a
:lock_threads option matching Rails' lock_threads.
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