- 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>
110 lines
4.1 KiB
Ruby
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
|