Files
simply_based/spec/concurrency_spec.rb
bterkuileandClaude Sonnet 5 9ce5aea23d Add real concurrency coverage and actually run the adapter contract
- spec/support/adapter_contract.rb shipped shared examples ('a simply_couch
  adapter') that no spec ever included — the parity proof they document never
  ran. store_contract_spec.rb now includes them against the live Store.
- concurrency_spec.rb: the suite had zero multi-threaded examples despite the
  gem's core claims being CAS optimistic concurrency and Redis-replacement
  primitives. Adds: counter increments under 8 concurrent writers, lock
  granted to exactly one contender, with_lock mutual exclusion, stale-handle
  release after TTL expiry (must not unlock the new holder), same-attribute
  CAS conflict surfacing (no lost update), different-attribute auto-merge, and
  a 3-thread disjoint-attribute write storm.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 18:42:17 -05:00

110 lines
4.1 KiB
Ruby

require 'spec_helper'
# Real-concurrency coverage. The suite had no multi-threaded examples at all,
# yet the gem's core claims are about CAS-based optimistic concurrency and
# Redis-replacement primitives (atomic counters, distributed locks). These
# examples exercise those claims against the live cluster.
RSpec.describe 'Concurrency' do
describe SimplyBased::Counter do
it 'loses no increments under concurrent writers' do
counter = SimplyBased::Counter.new
threads = 8.times.map do
Thread.new do
25.times { counter.increment('conc:hits') }
end
end
threads.each(&:join)
expect(counter.get('conc:hits')).to eq(8 * 25)
end
end
describe SimplyBased::Lock do
it 'grants the lock to exactly one of many concurrent contenders' do
lock = SimplyBased::Lock.new
grants = 8.times.map do
Thread.new { lock.acquire('conc:job', ttl: 30) }
end.map(&:value)
expect(grants.compact.size).to eq(1)
end
it 'mutually excludes critical sections under with_lock' do
lock = SimplyBased::Lock.new
active = 0
max_active = 0
mutex = Mutex.new
threads = 4.times.map do
Thread.new do
lock.with_lock('conc:crit', ttl: 30, retries: 200, wait: 0.05) do
mutex.synchronize { active += 1; max_active = [max_active, active].max }
sleep 0.05
mutex.synchronize { active -= 1 }
end
end
end
threads.each(&:join)
expect(max_active).to eq(1)
end
it 'does not release a lock re-acquired by someone else after expiry' do
lock = SimplyBased::Lock.new
stale_handle = lock.acquire('conc:expiring', ttl: 1)
expect(stale_handle).not_to be_nil
sleep 2.5 # let it expire server-side
new_handle = lock.acquire('conc:expiring', ttl: 30)
expect(new_handle).not_to be_nil
# the stale holder's release must be a no-op (CAS mismatch), not an unlock
expect(lock.release('conc:expiring', stale_handle)).to be(false)
expect(lock.acquire('conc:expiring', ttl: 30)).to be_nil # still held
end
end
describe 'CAS optimistic concurrency (documents)' do
it 'does not lose updates when two copies write the same attribute' do
original = User.create(name: 'seed', title: 'Dr.')
copies = 2.times.map { User.find(original.id) }
copies[0].name = 'first-writer'
expect(copies[0].save).to be true
copies[1].name = 'second-writer'
# same attribute changed on both sides — must surface, never silently stomp
expect { copies[1].save }.to raise_error(SimplyBased::Conflict)
expect(User.find(original.id).name).to eq 'first-writer'
end
it 'merges concurrent writers touching different attributes (auto conflict resolution)' do
original = User.create(name: 'seed', title: 'Dr.', homepage: 'a')
a = User.find(original.id)
b = User.find(original.id)
a.name = 'from-a'
expect(a.save).to be true
b.title = 'from-b'
expect { b.save }.not_to raise_error
reloaded = User.find(original.id)
expect(reloaded.name).to eq 'from-a'
expect(reloaded.title).to eq 'from-b'
end
it 'survives a storm of single-attribute writers each bumping their own field' do
original = User.create(name: 'seed', title: 't', homepage: 'h')
results = %w[name title homepage].map do |attr|
Thread.new do
copy = User.find(original.id)
copy.public_send("#{attr}=", "#{attr}-updated")
copy.save
true
rescue SimplyBased::Conflict
:conflict
end
end.map(&:value)
# auto-merge should let disjoint-attribute writers through; when the race
# is too tight the loser must fail loudly with Conflict — never corrupt.
reloaded = User.find(original.id)
%w[name title homepage].each_with_index do |attr, i|
if results[i] == true
expect(reloaded.public_send(attr)).to eq "#{attr}-updated"
end
end
expect(results).to all(satisfy { |r| r == true || r == :conflict })
end
end
end