diff --git a/spec/concurrency_spec.rb b/spec/concurrency_spec.rb new file mode 100644 index 0000000..10422bc --- /dev/null +++ b/spec/concurrency_spec.rb @@ -0,0 +1,109 @@ +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 diff --git a/spec/store_contract_spec.rb b/spec/store_contract_spec.rb new file mode 100644 index 0000000..d2f71b3 --- /dev/null +++ b/spec/store_contract_spec.rb @@ -0,0 +1,8 @@ +require 'spec_helper' + +# The adapter contract shared examples (spec/support/adapter_contract.rb) were +# shipped with the gem but never actually included by any spec — the parity +# proof they document did not run. This spec makes the Store honour it. +RSpec.describe SimplyBased::Store do + it_behaves_like 'a simply_couch adapter' +end