test: cover OrmPessimisticTest and teardown
This commit is contained in:
1 parent
b68c943485
commit
9efcddd66a
1 file changed
+161
@@ -0,0 +1,161 @@
|
|||||||
|
require 'minitest/autorun'
|
||||||
|
require 'prism'
|
||||||
|
require 'tmpdir'
|
||||||
|
require 'fileutils'
|
||||||
|
|
||||||
|
class OrmPessimisticTest < Minitest::Test
|
||||||
|
def setup
|
||||||
|
@dir = Dir.mktmpdir('prism-pessimistic')
|
||||||
|
@store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore'))
|
||||||
|
@store.create_table(:counters, %w[id value])
|
||||||
|
|
||||||
|
counter_class = Class.new(Prism::Orm::Model) { table_name :counters }
|
||||||
|
counter_class.store = @store
|
||||||
|
Object.const_set(:Counter, counter_class)
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
FileUtils.remove_entry(@dir)
|
||||||
|
Object.send(:remove_const, :Counter) if Object.const_defined?(:Counter)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_lock_bang_raises_on_a_record_that_was_never_saved
|
||||||
|
counter = Counter.new(value: 0)
|
||||||
|
assert_raises(Prism::Orm::Pessimistic::UnpersistedRecordError) { counter.lock! }
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_lock_bang_raises_on_a_record_with_unsaved_changes
|
||||||
|
counter = Counter.create!(value: 0)
|
||||||
|
counter.value = 5
|
||||||
|
assert_raises(Prism::Orm::Pessimistic::DirtyRecordError) { counter.lock! }
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_lock_bang_reloads_data_written_by_another_handle_in_the_meantime
|
||||||
|
counter = Counter.create!(value: 0)
|
||||||
|
Counter.find(counter.id).update!(value: 41)
|
||||||
|
|
||||||
|
counter.lock!
|
||||||
|
|
||||||
|
assert_equal 41, counter.value
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_lock_bang_returns_self
|
||||||
|
counter = Counter.create!(value: 0)
|
||||||
|
assert_same counter, counter.lock!
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_with_lock_yields_the_record_with_freshly_reloaded_data
|
||||||
|
counter = Counter.create!(value: 0)
|
||||||
|
Counter.find(counter.id).update!(value: 9)
|
||||||
|
|
||||||
|
yielded = nil
|
||||||
|
counter.with_lock { |c| yielded = c }
|
||||||
|
|
||||||
|
assert_same counter, yielded
|
||||||
|
assert_equal 9, counter.value
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_with_lock_commits_a_write_made_inside_the_block
|
||||||
|
counter = Counter.create!(value: 1)
|
||||||
|
counter.with_lock { |c| c.update!(value: c.value + 1) }
|
||||||
|
assert_equal 2, Counter.find(counter.id).value
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_with_lock_rolls_back_a_write_made_inside_the_block_on_exception
|
||||||
|
counter = Counter.create!(value: 1)
|
||||||
|
assert_raises(RuntimeError) do
|
||||||
|
counter.with_lock do |c|
|
||||||
|
c.update!(value: 99)
|
||||||
|
raise 'boom'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert_equal 1, Counter.find(counter.id).value
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_with_lock_nests_inside_an_already_open_transaction_without_deadlocking
|
||||||
|
counter = Counter.create!(value: 1)
|
||||||
|
Counter.transaction do
|
||||||
|
counter.with_lock { |c| c.update!(value: c.value + 1) }
|
||||||
|
end
|
||||||
|
assert_equal 2, Counter.find(counter.id).value
|
||||||
|
end
|
||||||
|
|
||||||
|
# Everything below forks real child OS processes sharing nothing but the
|
||||||
|
# PStore file on disk - Store#within_transaction opens a brand new file
|
||||||
|
# handle and takes a real File::LOCK_EX/LOCK_SH on it for every single
|
||||||
|
# transaction (stdlib pstore.rb, not something this codebase controls),
|
||||||
|
# so this is the same cross-process guarantee two independent `prism`
|
||||||
|
# server processes pointed at the same database file would get, not a
|
||||||
|
# simulation of it via threads. `exit!` (not `exit`) is required in every
|
||||||
|
# forked child: minitest/autorun installs an at_exit hook, and a plain
|
||||||
|
# `exit` would let the child fall into that hook and re-run the entire
|
||||||
|
# suite a second time from inside the fork.
|
||||||
|
def test_two_processes_updating_without_a_lock_lose_an_update
|
||||||
|
skip 'fork not supported on this platform' unless Process.respond_to?(:fork)
|
||||||
|
|
||||||
|
counter = Counter.create!(value: 0)
|
||||||
|
a_read, a_wrote_read_signal = IO.pipe
|
||||||
|
b_read, b_wrote_read_signal = IO.pipe
|
||||||
|
|
||||||
|
# Two pipes force the full ordering a lost update needs: A reads, THEN
|
||||||
|
# B reads (while the value is still whatever A saw, because A is
|
||||||
|
# blocked waiting on B before it writes anything), THEN both write back
|
||||||
|
# "their own read + 1" as two independent transactions. Only the READS
|
||||||
|
# are ordered here - deliberately NOT the writes, since a real lost
|
||||||
|
# update does not care which write lands last, only that both were
|
||||||
|
# computed from the same stale value.
|
||||||
|
pid_a = Process.fork do
|
||||||
|
a_read.close
|
||||||
|
b_wrote_read_signal.close
|
||||||
|
value = Counter.find(counter.id).value
|
||||||
|
a_wrote_read_signal.write('r')
|
||||||
|
a_wrote_read_signal.close
|
||||||
|
b_read.read(1)
|
||||||
|
b_read.close
|
||||||
|
Counter.find(counter.id).update!(value: value + 1)
|
||||||
|
exit!(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
pid_b = Process.fork do
|
||||||
|
a_wrote_read_signal.close
|
||||||
|
b_read.close
|
||||||
|
a_read.read(1)
|
||||||
|
a_read.close
|
||||||
|
value = Counter.find(counter.id).value
|
||||||
|
b_wrote_read_signal.write('r')
|
||||||
|
b_wrote_read_signal.close
|
||||||
|
Counter.find(counter.id).update!(value: value + 1)
|
||||||
|
exit!(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
[a_read, a_wrote_read_signal, b_read, b_wrote_read_signal].each(&:close)
|
||||||
|
Process.waitpid(pid_a)
|
||||||
|
Process.waitpid(pid_b)
|
||||||
|
|
||||||
|
# Both processes read 0 (forced ordering above only sequences the
|
||||||
|
# reads), so both independently write 1 back - the second write just
|
||||||
|
# clobbers the first with the exact same value, silently losing A's
|
||||||
|
# increment no matter which of the two writes lands last.
|
||||||
|
assert_equal 1, Counter.find(counter.id).value
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_two_processes_incrementing_through_with_lock_never_lose_an_update
|
||||||
|
skip 'fork not supported on this platform' unless Process.respond_to?(:fork)
|
||||||
|
|
||||||
|
counter = Counter.create!(value: 0)
|
||||||
|
increments_per_process = 5
|
||||||
|
|
||||||
|
pids = Array.new(2) do
|
||||||
|
Process.fork do
|
||||||
|
increments_per_process.times do
|
||||||
|
Counter.find(counter.id).with_lock { |c| c.update!(value: c.value + 1) }
|
||||||
|
end
|
||||||
|
exit!(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
pids.each { |pid| Process.waitpid(pid) }
|
||||||
|
|
||||||
|
assert_equal 2 * increments_per_process, Counter.find(counter.id).value
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in new issue
Block a user