Sijin T V
Sijin T V A passionate Software Engineer who contributes to the wonders happenning on the internet

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_fixtures is false in the config for these specs) and truncate tables in a before hook 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/rescue and funnel outcomes through the Queue. An uncaught exception in a thread only surfaces as a confusing Thread killed message or disappears entirely. Thread.report_on_exception = true helps locally, but the Queue is 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.

comments powered by Disqus