test: cover OrmLockingTest and teardown
This commit is contained in:
1 parent
7b3a13a982
commit
66f0ccc21a
1 file changed
+179
@@ -0,0 +1,179 @@
|
||||
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
|
||||
Reference in new issue
Block a user