Files
once-campfire/test/lib/sqlite_wal_checkpoint_test.rb
T

314 lines
10 KiB
Ruby

require "test_helper"
class SqliteWalCheckpointTest < ActiveSupport::TestCase
setup do
@tmpdir = Dir.mktmpdir("sqlite-wal-checkpoint")
SqliteWalCheckpoint.reset!
SqliteWalCheckpoint.lock_path = File.join(@tmpdir, "checkpoint.lock")
end
teardown do
SqliteWalCheckpoint.reset!
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.directory?(@tmpdir)
end
test "connections disable WAL auto-checkpoint so commits do not fsync on the writer" do
assert_equal 0, ActiveRecord::Base.connection.select_value("PRAGMA wal_autocheckpoint").to_i
end
test "a passive checkpoint against the primary database does not raise" do
assert_nothing_raised { SqliteWalCheckpoint.checkpoint }
end
test "does not start a background thread in test by default" do
assert_nil SqliteWalCheckpoint.start
assert_empty checkpoint_threads
end
test "start with enabled: true runs a named contender thread that stop joins" do
thread = SqliteWalCheckpoint.start(interval: 0.05, enabled: true)
assert thread.alive?
wait_until { thread.name == "sqlite-wal-checkpoint" }
SqliteWalCheckpoint.stop
assert_not thread.alive?
assert_empty checkpoint_threads
end
test "stop before start again mimics a fork-safe worker boot" do
db_path = build_wal_database(rows: 10)
SqliteWalCheckpoint.database_path_override = db_path
first = SqliteWalCheckpoint.start(interval: 0.05, enabled: true)
wait_until { first.name == "sqlite-wal-checkpoint" }
SqliteWalCheckpoint.stop
assert_not first.alive?
second = SqliteWalCheckpoint.start(interval: 0.05, enabled: true)
wait_until { second.name == "sqlite-wal-checkpoint" }
assert second.alive?
SqliteWalCheckpoint.stop
assert_not second.alive?
assert_equal :checkpointed, SqliteWalCheckpoint.tick
end
test "stop interrupts a long checkpoint interval" do
waits = observe_waits
contender = SqliteWalCheckpoint.start(interval: 30, enabled: true)
assert_equal 30, receive(waits)
SqliteWalCheckpoint.stop(timeout: 0.5)
assert_not contender.alive?
assert_empty checkpoint_threads
end
test "stop interrupts checkpoint error backoff" do
SqliteWalCheckpoint.database_path_override = build_wal_database(rows: 10)
SqliteWalCheckpoint.stubs(:checkpoint_on).raises(SQLite3::Exception, "checkpoint failed")
waits = observe_waits
contender = SqliteWalCheckpoint.start(interval: 30, enabled: true)
assert_equal 30, receive(waits)
SqliteWalCheckpoint.stop(timeout: 0.5)
assert_not contender.alive?
assert_not lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
end
test "timed out stop prevents replacement until the checkpoint releases its resources" do
entered, release = block_checkpoint
contender = SqliteWalCheckpoint.start(enabled: true)
database = receive(entered)
assert lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
assert_raises(SqliteWalCheckpoint::StopTimeout) { SqliteWalCheckpoint.stop(timeout: 0.01) }
assert contender.alive?
assert lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
assert_raises(SqliteWalCheckpoint::StopTimeout) { SqliteWalCheckpoint.start(enabled: true) }
assert_equal [ contender ], checkpoint_threads
release << true
SqliteWalCheckpoint.stop
assert_not contender.alive?
assert database.closed?
assert_not lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
ensure
release&.push(true)
SqliteWalCheckpoint.stop
end
test "start waits for a concurrent stop to release the previous contender" do
entered, release = block_checkpoint
contender = SqliteWalCheckpoint.start(enabled: true)
receive(entered)
stopping = Thread.new { SqliteWalCheckpoint.stop }
wait_until { stopping.status == "sleep" }
starting = Thread.new { SqliteWalCheckpoint.start(enabled: true) }
wait_until { starting.status == "sleep" }
assert contender.alive?
release << true
stopping.value
replacement = starting.value
assert_not contender.alive?
assert replacement.alive?
assert_not_equal contender, replacement
ensure
release&.push(true)
stopping&.join
starting&.join
SqliteWalCheckpoint.stop
end
test "a contender cannot release a concurrent tick's lock" do
entered, release = block_checkpoint
ticking = Thread.new { SqliteWalCheckpoint.tick }
receive(entered)
waits = observe_waits
contender = SqliteWalCheckpoint.start(interval: 30, enabled: true)
assert_equal 30, receive(waits)
SqliteWalCheckpoint.stop(timeout: 0.5)
assert_not contender.alive?
assert lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
release << true
assert_equal :checkpointed, ticking.value
assert_not lock_held_by_other_process?(SqliteWalCheckpoint.lock_path)
ensure
release&.push(true)
ticking&.join
SqliteWalCheckpoint.stop
end
test "reset does not reinstall the process exit checkpoint" do
SqliteWalCheckpoint.start(enabled: true)
SqliteWalCheckpoint.reset!
SqliteWalCheckpoint.expects(:at_exit).never
SqliteWalCheckpoint.start(enabled: true)
SqliteWalCheckpoint.stop
end
test "tick checkpoints through the elected lock holder" do
db_path = build_wal_database(rows: 50)
SqliteWalCheckpoint.database_path_override = db_path
assert_equal :checkpointed, SqliteWalCheckpoint.tick
end
test "tick reports busy while another process holds the lock and succeeds after it exits" do
db_path = build_wal_database(rows: 20)
SqliteWalCheckpoint.database_path_override = db_path
lock = SqliteWalCheckpoint.lock_path
ready = File.join(@tmpdir, "holder-ready")
holder = spawn_lock_holder(lock, ready_path: ready, hold_for: 0.4)
begin
wait_until { File.exist?(ready) }
assert_equal :busy, SqliteWalCheckpoint.tick
ensure
Process.wait(holder)
end
assert_equal :checkpointed, SqliteWalCheckpoint.tick
end
test "passive checkpoint moves WAL pages after writes with autocheckpoint disabled" do
db_path = File.join(@tmpdir, "writer-#{SecureRandom.hex(4)}.sqlite3")
SQLite3::Database.new(db_path) do |writer|
writer.execute("PRAGMA journal_mode=WAL")
writer.execute("PRAGMA wal_autocheckpoint=0")
writer.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, body TEXT)")
200.times do |i|
writer.execute("INSERT INTO items (body) VALUES (?)", "row-#{i}-#{"x" * 200}")
end
wal_path = "#{db_path}-wal"
assert File.exist?(wal_path), "expected a WAL file after inserts"
assert File.size(wal_path) > 0
SqliteWalCheckpoint.database_path_override = db_path
lock = SqliteWalCheckpoint.send(:acquire_lock)
assert lock
begin
_busy, _log, checkpointed = SqliteWalCheckpoint.checkpoint
assert checkpointed.to_i > 0, "expected PASSIVE checkpoint to copy WAL pages, got #{checkpointed.inspect}"
ensure
SqliteWalCheckpoint.send(:release_lock, lock)
end
end
end
test "a contender thread takes over checkpointing after the lock holder stops" do
db_path = build_wal_database(rows: 30)
SqliteWalCheckpoint.database_path_override = db_path
lock = SqliteWalCheckpoint.lock_path
ready = File.join(@tmpdir, "holder-ready")
holder = spawn_lock_holder(lock, ready_path: ready, hold_for: 0.25)
wait_until { File.exist?(ready) }
contender = SqliteWalCheckpoint.start(interval: 0.05, enabled: true)
begin
assert_equal :busy, SqliteWalCheckpoint.tick
Process.wait(holder)
holder = nil
wait_until(timeout: 2) { lock_held_by_other_process?(lock) }
ensure
Process.wait(holder) if holder
SqliteWalCheckpoint.stop
assert_not contender.alive?
end
end
private
def observe_waits
waits = Thread::Queue.new
SqliteWalCheckpoint.stubs(:wait).with do |wakeup, timeout|
waits << timeout
wakeup.pop(timeout: timeout)
true
end
waits
end
def block_checkpoint
SqliteWalCheckpoint.database_path_override = build_wal_database(rows: 10)
entered = Thread::Queue.new
release = Thread::Queue.new
blocked = false
SqliteWalCheckpoint.stubs(:checkpoint_on).returns([ 0, 0, 0 ]).with do |database|
unless blocked
blocked = true
entered << database
release.pop
end
true
end
[ entered, release ]
end
def receive(queue)
queue.pop(timeout: 2).tap { |value| assert_not_nil value, "expected checkpoint barrier" }
end
def checkpoint_threads
Thread.list.select { |thread| thread.name == "sqlite-wal-checkpoint" }
end
def build_wal_database(rows:)
path = File.join(@tmpdir, "writer-#{SecureRandom.hex(4)}.sqlite3")
SQLite3::Database.new(path) do |database|
database.execute("PRAGMA journal_mode=WAL")
database.execute("PRAGMA wal_autocheckpoint=0")
database.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, body TEXT)")
rows.times do |i|
database.execute("INSERT INTO items (body) VALUES (?)", "row-#{i}-#{"x" * 200}")
end
end
path
end
def spawn_lock_holder(lock, ready_path:, hold_for:)
Process.spawn(
RbConfig.ruby, "-e", <<~RUBY
require "fileutils"
lock = #{lock.inspect}
ready = #{ready_path.inspect}
FileUtils.mkdir_p(File.dirname(lock))
file = File.open(lock, File::RDWR | File::CREAT, 0644)
abort "lock failed" unless file.flock(File::LOCK_EX | File::LOCK_NB)
File.write(ready, "1")
sleep #{hold_for}
RUBY
)
end
def lock_held_by_other_process?(lock)
return false unless File.exist?(lock)
probe = File.open(lock, File::RDWR | File::CREAT, 0644)
if probe.flock(File::LOCK_EX | File::LOCK_NB)
probe.flock(File::LOCK_UN)
probe.close
false
else
probe.close
true
end
end
def wait_until(timeout: 1)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
until yield
raise "condition not met within #{timeout}s" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
sleep 0.02
end
end
end