Files
prism/test/test_orm_locking.rb
T

180 lines
6.5 KiB
Ruby

require 'minitest/autorun'
require 'prism'
require 'tmpdir'
require 'fileutils'
class OrmLockingTest < Minitest::Test
def setup
@dir = Dir.mktmpdir('prism-locking')
@store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore'))
@store.create_table(:widgets, %w[id name lock_version])
@store.create_table(:plain_things, %w[id name])
widget_class = Class.new(Prism::Orm::Model) { table_name :widgets }
plain_class = Class.new(Prism::Orm::Model) { table_name :plain_things }
[widget_class, plain_class].each { |klass| klass.store = @store }
Object.const_set(:Widget, widget_class)
Object.const_set(:PlainThing, plain_class)
end
def teardown
FileUtils.remove_entry(@dir)
%i[Widget PlainThing].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) }
end
def test_locking_enabled_when_lock_version_column_present
assert Widget.locking_enabled?
refute PlainThing.locking_enabled?
end
def test_create_initializes_lock_version_to_zero
widget = Widget.create!(name: 'a')
assert_equal 0, widget.lock_version
end
def test_create_does_not_overwrite_an_explicitly_assigned_lock_version
widget = Widget.create!(name: 'a', lock_version: 5)
assert_equal 5, widget.lock_version
end
def test_update_increments_lock_version
widget = Widget.create!(name: 'a')
widget.update!(name: 'b')
assert_equal 1, widget.lock_version
widget.update!(name: 'c')
assert_equal 2, widget.lock_version
assert_equal 2, Widget.find(widget.id).lock_version
end
def test_stale_update_raises_stale_object_error
original = Widget.create!(name: 'a')
stale_copy = Widget.find(original.id)
original.update!(name: 'fresh')
stale_copy.name = 'stale'
error = assert_raises(Prism::Orm::StaleObjectError) { stale_copy.save! }
assert_same stale_copy, error.record
assert_equal :update, error.attempted_action
reloaded = Widget.find(original.id)
assert_equal 'fresh', reloaded.name, 'the stale write must not have overwritten the fresh row'
end
def test_reloading_after_a_stale_error_allows_a_later_save_to_succeed
original = Widget.create!(name: 'a')
stale_copy = Widget.find(original.id)
original.update!(name: 'fresh')
stale_copy.name = 'stale'
assert_raises(Prism::Orm::StaleObjectError) { stale_copy.save! }
stale_copy.reload
stale_copy.name = 'retried'
assert stale_copy.save!
assert_equal 'retried', Widget.find(original.id).name
end
def test_stale_destroy_raises_stale_object_error
original = Widget.create!(name: 'a')
stale_copy = Widget.find(original.id)
original.update!(name: 'changed')
error = assert_raises(Prism::Orm::StaleObjectError) { stale_copy.destroy }
assert_equal :destroy, error.attempted_action
refute_nil Widget.find(original.id), 'stale destroy must not have deleted the row'
end
def test_current_destroy_succeeds
widget = Widget.create!(name: 'a')
widget.destroy
assert_raises(Prism::Orm::RecordNotFound) { Widget.find(widget.id) }
end
def test_model_without_lock_version_column_ignores_locking_entirely
thing = PlainThing.create!(name: 'a')
stale = PlainThing.find(thing.id)
thing.update!(name: 'first writer')
stale.update!(name: 'second writer wins, no locking')
assert_equal 'second writer wins, no locking', PlainThing.find(thing.id).name
end
def test_locking_column_can_be_customized
@store.create_table(:gizmos, %w[id name version_number])
gizmo_class = Class.new(Prism::Orm::Model) do
table_name :gizmos
locking_column :version_number
end
gizmo_class.store = @store
gizmo = gizmo_class.create!(name: 'a')
assert_equal 0, gizmo.version_number
gizmo.update!(name: 'b')
assert_equal 1, gizmo.version_number
stale = gizmo_class.find(gizmo.id)
gizmo.update!(name: 'c')
stale.name = 'stale'
assert_raises(Prism::Orm::StaleObjectError) { stale.save! }
end
# Real cross-process proof: N processes each retry a load -> increment ->
# save loop against the SAME row until their own increment sticks. If the
# version check and the write were not inside the same PStore transaction
# (or if Model silently swallowed StaleObjectError instead of raising),
# concurrent writers would stomp on each other's increments and the final
# counter would fall short of the true total - the classic optimistic
# locking lost-update problem this feature exists to prevent.
def test_prevents_lost_updates_across_real_concurrent_processes
store_path = File.join(@dir, 'shared.pstore')
shared_store = Prism::Orm::Store.new(store_path)
shared_store.create_table(:counters, %w[id counter lock_version])
shared_store.insert(:counters, id: 1, counter: 0, lock_version: 0)
process_count = 4
increments_per_process = 20
racer_script = File.join(@dir, 'racer.rb')
File.write(racer_script, <<~RUBY)
require 'prism'
store = Prism::Orm::Store.new(#{store_path.inspect})
Counter = Class.new(Prism::Orm::Model) { table_name :counters }
Counter.store = store
successes = 0
retries = 0
while successes < #{increments_per_process}
counter = Counter.find(1)
counter.counter = counter.counter + 1
sleep(rand * 0.005) # widen the read-then-write window so processes actually overlap
begin
counter.save!
successes += 1
rescue Prism::Orm::StaleObjectError
retries += 1
end
end
File.write(File.join(#{@dir.inspect}, "\#{Process.pid}.retries"), retries.to_s)
RUBY
pids = process_count.times.map do
Process.spawn(RbConfig.ruby, '-I', File.expand_path('../lib', __dir__), racer_script,
out: File::NULL, err: File::NULL)
end
pids.each { |pid| Process.waitpid(pid) }
expected_total = process_count * increments_per_process
final = shared_store.find(:counters, 1)
assert_equal expected_total, final['counter'],
'every successful save must have applied its own +1 - a lost update means ' \
'this falls short of the true total'
assert_equal expected_total, final['lock_version']
total_retries = pids.sum { |pid| Integer(File.read(File.join(@dir, "#{pid}.retries"))) }
assert total_retries.positive?,
'with 4 processes racing on one row, at least one save should have collided and ' \
'had to retry - a suspiciously zero count would suggest the processes never ' \
'actually overlapped'
end
end