Bring back the transactional server - #191
Merged
Merged
Conversation
rosston
added this pull request to stack #189
September 17, 2026 17:35
This was referenced Sep 17, 2026
Merged
rosston
force-pushed
the
undo-breaking-changes
branch
from
September 17, 2026 18:01
0777245 to
84922c5
Compare
rosston
force-pushed
the
undo-breaking-changes
branch
from
September 17, 2026 18:10
84922c5 to
f583be7
Compare
0.8.0 removed CYPRESS_RAILS_TRANSACTIONAL_SERVER and told apps to reset state with database_cleaner's :transaction strategy. That can't work under cypress-rails' multi-threaded Puma server: the strategy opens a transaction on one pooled connection, so requests served on other connections commit for real and survive the reset. The example app went flaky as soon as request timing changed. Restore the transactional server (on by default) and the after_transaction_start hook, and undo the after_state_reset rename. This time ManagesTransactions drives ActiveRecord::TestFixtures through its before_setup/after_teardown hooks, which Minitest and rspec-rails both depend on, instead of copying Rails' connection-pinning internals, which is what broke on Rails 7.2. A dedicated thread owns the transaction, because on Rails 7.1 a Puma thread's end-of-request cleanup would check the pinned connection back in. /cypress_rails_reset_state now finishes the reset before responding, runs one reset at a time, and returns a 500 with the error when a hook raises. Previously it flagged a reset for whichever request happened to come next. The example app exercises both modes. With the transactional server off, it truncates and reloads its fixtures itself.
The transactional server relies on ActiveRecord::TestFixtures' before_setup/after_teardown hooks, which don't exist in Rails 5.2, and CI only covers Rails 7.1 and newer.
Also cover turning the transactional server off, including the database_cleaner pitfalls that made 0.8.0's recommended setup unreliable.
The extra jobs with the transactional server turned off keep the documented non-transactional reset pattern from silently breaking.
The unit tests now install sqlite3, whose Linux builds are only published for platforms like x86_64-linux-gnu. The RubyGems and Bundler that ship with Ruby 3.0 predate that platform naming, so Bundler tried to compile sqlite3 from source instead, and that build failed.
When the Ruby 3.0 job failed, fail-fast cancelled the rest, so there was no way to tell whether they would have passed. example-app-tests already works this way.
This will be the in depth doc that the shorter CHANGELOG entry will point to.
rosston
force-pushed
the
undo-breaking-changes
branch
from
September 17, 2026 18:12
f583be7 to
6504db1
Compare
This was referenced Sep 17, 2026
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
0.8.0 removed
CYPRESS_RAILS_TRANSACTIONAL_SERVERand told apps to reset their own test data with database_cleaner. That was a mistake. The setup the README recommended doesn't reliably reset data under cypress-rails' multi-threaded Puma server, so data can leak from one test into the next.This PR restores the transactional server, built this time on a stable Rails contract instead of copied Rails internals, and makes the reset endpoint finish its work before responding. For apps on Rails 7.1 or newer, the public API is back to exactly what 0.7.1 had.
This is stacked on #187, #188, and #190.
Why 0.8.0's reasoning was wrong
The commit that removed the transactional server gave three reasons:
Each of those turned out to be wrong, or right about the problem but wrong about the fix.
"cypress-rails has no special knowledge of an app's database"
cypress-rails doesn't need to know anything about an app's schema or data, but it does own the thing that decides how the database gets used: the server. cypress-rails boots the app under Puma with multiple threads, and each thread can use its own database connection. Whether test data resets cleanly depends on how those connections share, or don't share, a transaction. That's a consequence of running the server, so it's cypress-rails' concern, not something each app can reasonably solve on its own.
"database_cleaner already does this well"
database_cleaner is built for tests that run in the same thread as the code they exercise. Its
:transactionstrategy opens a transaction on one database connection and rolls it back later. Under cypress-rails' server, a request handled by another Puma thread uses a different connection, outside that transaction. Its writes commit for real and survive the reset, and data written inside the transaction is invisible to it. On SQLite, the open transaction also makes other connections' writes fail with "database is locked".The README's example made this worse in a way that's easy to miss. It set
DatabaseCleaner.strategy = :transactionat the top of an initializer. If ActiveRecord hasn't loaded yet when that line runs, as in the example app, the assignment is silently ignored, and database_cleaner falls back to its default, which is also:transaction. So the example appeared to work as written, but anyone who changed it to a strategy that does work in this setup, like:truncation, could silently end up with:transactionanyway.The strategies that do work, truncation and deletion, wipe all data on every reset, so every app has to rebuild its fixtures and seed data in the hook, with its own tradeoffs. Recommending database_cleaner handed a hard concurrency problem to every user, along with guidance that didn't work.
"chasing Rails internals isn't sustainable"
This part was true: the old implementation copied connection-pinning code from Rails, and Rails 7.2 changed it (
lock_thread=becamepin_connection!). But the fix was to stop copying internals, not to drop the feature.Rails already has a stable contract for exactly this problem.
ActiveRecord::TestFixtureswraps each test in a transaction shared across threads, and it's driven through itsbefore_setupandafter_teardownhooks. Minitest'sActiveSupport::TestCasecalls those hooks, and rspec-rails has called them around every example since at least 3.5.0 (2016). They've been essentially unchanged from Rails 6.0 through Rails'mainbranch, and Rails picks the right pinning mechanism for each version behind them. Rails can't change that contract without breaking both test frameworks, so it's a far safer foundation than the code 0.7.1 copied.Why it looked fine at first
After 0.8.0, CI on
mainstayed green, which made the change look safe. The reason was luck: Rails' connection pool hands out the most recently returned connection first, so as long as the example app's requests never overlapped, each one reused the connection holding database_cleaner's transaction.Changing request timing was enough to break that. After the example app moved to shakapacker (#187), the Rails 7.1 jobs started failing intermittently with "database is locked". After it moved to Postgres (#188), the real symptom showed up: an edit from one test survived into the next. Locally, it failed 3 of 5 and 6 of 8 runs on Rails 7.1. Apps with more concurrent requests, such as XHR on page load, Turbo frames, or ActiveStorage images, are far more likely to hit this than the example app was.
What this PR does
CYPRESS_RAILS_TRANSACTIONAL_SERVER(on by default),CypressRails::Config#transactional_server, theafter_transaction_starthook, and theafter_state_resethook name, all exactly as they were in 0.7.1.ManagesTransactionsonActiveRecord::TestFixtures' lifecycle hooks. A dedicated thread owns the transaction, because on Rails 7.1 a Puma thread's end-of-request cleanup would hand the shared connection back to the pool./cypress_rails_reset_statefinish the reset before responding, and return a 500 with the error when a hook raises. It used to defer the reset to whichever request came next.Why the reset endpoint is synchronous now
Deferring the reset to the next request isn't what caused the flaky resets. Bringing back the shared transaction fixes that. But the deferred design had problems of its own:
cy.requestthat asked for the reset fails, with the exception message, and the next reset starts clean.The deferred design existed so the old rollback could run on a request thread where the pinned connection lived. With a dedicated thread owning the transaction, that reason is gone.
Compatibility
Relative to 0.7.1, the only breaking change is the Rails 7.1 minimum. The reset endpoint's behavior change is compatible with the documented usage. Relative to 0.8.0, this reverses 0.8.0's breaking changes, which is breaking for apps that adopted it. For example,
after_reset_requestedis gone, so an initializer still calling it raises at boot. The full comparison is indocs/changes-in-0.9.0.md.How this was verified
ManagesTransactionsagainst a real SQLite database on ActiveRecord 7.1, 7.2, 8.0, and 8.1: other threads see the shared transaction, rollback undoes every thread's writes, and repeated resets stay pinned. They also cover reset ordering and concurrent resets. I deliberately broke the code three ways (removing the reset lock, getting theTestFixturesinclude order wrong, and skipping a connection release), and each time the test meant to catch it failed.before_server_stopstill runs even if that rollback raises.Related issues
before_server_stopnot running with the transactional server. I couldn't reproduce the hang with this implementation, andbefore_server_stopnow runs even if the final rollback raises, but I haven't confirmed the original cause, so this PR doesn't close it.after_server_starthooks run once per boot poll instead of once #186:after_server_startruns once per boot poll. Pre-existing; fixed separately.