Testing Complex Race Conditions and Concurrency in RSpec
The trap: a “concurrent” test that never races
The naive recipe – spawn two threads, join, assert – almost always passes even when the code is broken. MRI’s GVL serializes Ruby code, so two threads rarely interleave inside a critical section at all. What actually runs concurrently is anything that releases the GVL: I/O, and especially database calls. The race only shows up when threads block in the database at the same moment, and unless you force that moment, your test is theater.
The fix has two halves. Force the interleaving deterministically with a barrier, and make the database the arbiter that serializes the mutation.
Make the race deterministic
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
require "concurrent"
it "charges a balance exactly once under contention" do
user = User.create!(balance: 100)
gate = Concurrent::CyclicBarrier.new(2)
outcomes = Queue.new
threads = Array.new(2) do
Thread.new do
ActiveRecord::Base.connection_pool.with_connection do
gate.wait # both threads cross together -> real contention
begin
outcomes << ChargeService.charge(user.id, 60)
rescue StandardError => e
outcomes << e
end
end
end
end
threads.each(&:join)
results = Array.new(2) { outcomes.pop }
expect(results.count(:success)).to eq(1)
expect(user.reload.balance).to eq(40)
end
The barrier guarantees both threads are inside the critical section before either executes the charge. Without it, thread A can finish before thread B even starts, and the test passes forever while the production bug waits for a Friday incident.
The service under test must be correct against that contention. The version that reads then writes is a lost-update race:
1
2
3
4
5
6
7
8
9
class ChargeService
def self.charge(user_id, amount)
charged = User.where(id: user_id)
.where("balance >= ?", amount)
.update_all(["balance = balance - ?", amount])
raise InsufficientBalance unless charged == 1
:success
end
end
The conditional UPDATE ... WHERE balance >= ? returns the number of rows changed. Under two concurrent charges of 60 from a balance of 100, exactly one update matches, so exactly one :success and one InsufficientBalance. The database – not Ruby, not a mutex – is what serializes the mutation, and that is exactly what you want to assert.
The database setup that matters
- Turn off transactional tests. Rails wraps each spec in a transaction on one connection by default. Threads each need their own connection, and a test transaction on the main connection is invisible to them anyway. Set
self.use_transactional_tests = false(RSpec Rails:use_transactional_fixturesisfalsein the config for these specs) and truncate tables in abeforehook so nothing leaks between specs. - Size the pool. The default connection pool is 5; you need threads + 1 for the main thread. Two threads means at least 3. When threads starve, you see
ActiveRecord::ConnectionTimeoutError– a symptom of the pool being too small, not your bug. - Keep thread counts small. Two to five threads is plenty. The barrier makes the race deterministic, so you do not need hundreds of threads to reproduce it – you need one correctly timed interleaving.
- Catch everything in the thread. Wrap the thread body in an explicit
begin/rescueand funnel outcomes through theQueue. An uncaught exception in a thread only surfaces as a confusingThread killedmessage or disappears entirely.Thread.report_on_exception = truehelps locally, but theQueueis what the spec can actually assert on.
Assert the invariant, not the interleaving
Even with a barrier you cannot predict which thread wins, and you should not try. Assert that exactly one charge succeeded and the balance is consistent. That invariant is robust to scheduler changes, to CI load, to a different Ruby implementation. A test that asserts thread A specifically wins is a flake factory waiting for a faster machine.
The war story
We shipped an increment-based balance -= amount that “always worked” in tests for months. The first time we barrier-synchronized the threads, it failed on every single run – deterministic, reproducible, fixed by switching to the conditional UPDATE. The barrier is what turned a quarterly incident into a CI failure, which is the entire point of writing these tests. A concurrency test that cannot fail is worse than none: it builds confidence in code that is already broken.
- Use a barrier, never
sleep. Sleeping “synchronization” is how flaky tests are born. - Let the database arbitrate; test the invariant, not the winner.
- Pool size must exceed thread count, or you are testing your pool, not your code.
- Keep transactional tests off and truncate manually for multithreaded specs.
- Funnel all thread outcomes through a
Queue; let nothing die silently inside a thread.