Skip to content

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

Open
rywall wants to merge 2 commits into
DatabaseCleaner:mainfrom
rywall:pin-transaction-connection
Open

rywall wants to merge 2 commits 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.

Rails edge: Rails main no longer opens a transaction in pin_connection! or rolls it back in unpin_connection!; TestFixtures now does both itself. The strategy opens and rolls back its own transaction whenever pinning didn't, so it works either way.

Tested locally on Rails 7.1, 7.2, 8.0 and 8.1 against MySQL, Trilogy, Postgres and SQLite: full suite green on each. On Rails edge, the same 8 SQLite deletion, truncation and transaction examples fail with and without this change (rows from earlier examples survive into later ones); that's pre-existing on main.

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.
Rails main no longer begins a transaction in pin_connection! or rolls it
back in unpin_connection!; ActiveRecord::TestFixtures now does both itself.
Begin and roll back the transaction when pinning didn't, so the strategy
works on 7.2-8.1 and on edge.

Check the new specs' records by id rather than by table count, so rows
left behind on sqlite3 by other examples don't fail them.
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