From 908540b81303ba5cd8e3c811c2476b47fa409f0a Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 29 Jul 2026 12:13:52 +0200 Subject: [PATCH] Roll back transactions leaked by killed job threads in tests Killing the job threads leaked by in-process workers can interrupt a thread in the middle of a database write, skipping the rollback in the transaction's ensure block while still returning the connection to the pool. The open transaction then locks SQLite for every other writer until the pool reaper flushes the idle connection minutes later, cascading "database is locked" errors through the rest of the suite. Wait for the killed threads to die and drop all pool connections, rolling back anything they left open. Transactional tests don't need it: they pin every thread to the test's own connection. Co-Authored-By: Claude Fable 5 --- test/test_helpers/processes_test_helper.rb | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/test/test_helpers/processes_test_helper.rb b/test/test_helpers/processes_test_helper.rb index 4a521b56..0d45ce72 100644 --- a/test/test_helpers/processes_test_helper.rb +++ b/test/test_helpers/processes_test_helper.rb @@ -75,8 +75,22 @@ def terminate_process(pid, timeout: 10, signal: :TERM) # rolling back the AUTOINCREMENT sequence bump too, so a later test's rows # get the same ids and the leaked thread overwrites them when it wakes up. # Kill the leaked threads instead, like a forked worker's exit would. + # + # A thread killed in the middle of a database write can skip the rollback in + # the transaction's ensure block and return its connection to the pool with + # the transaction still open. On SQLite that blocks every other writer until + # the pool reaper flushes the idle connection minutes later, so drop all + # connections to roll leaked transactions back. Transactional tests don't + # need this: they pin all threads to the test's own connection. def kill_running_jobs_in(worker) - worker&.pool&.send(:executor)&.kill + if pool = worker&.pool + pool.send(:executor).kill + pool.wait_for_termination(5) + + unless self.class.use_transactional_tests + [ ActiveRecord::Base, SolidQueue::Record ].each { |base| base.connection_pool.disconnect! } + end + end end def wait_for_process_termination_with_timeout(pid, timeout: 10, exitstatus: 0, signaled: nil)