Files
prism/test/test_orm_pessimistic.rb

162 lines
5.4 KiB
Ruby

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