From 8704baa8603516785645b14f2b3e2072ebfc4e28 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 19:05:44 +0000 Subject: [PATCH] Load WAL checkpointer outside Zeitwerk reload; harden fork stop Keep the module under lib/rails_ext, use before_worker_boot, shorten the SQLite busy timeout under stop's join, and PASSIVE-checkpoint on exit for short-lived writers. Co-authored-by: Thomas Klemm --- config/initializers/sqlite_wal_checkpoint.rb | 7 +++++-- config/puma.rb | 2 +- lib/{ => rails_ext}/sqlite_wal_checkpoint.rb | 15 ++++++++++++++- test/lib/sqlite_wal_checkpoint_test.rb | 18 ++++++++++++++++++ 4 files changed, 38 insertions(+), 4 deletions(-) rename lib/{ => rails_ext}/sqlite_wal_checkpoint.rb (86%) diff --git a/config/initializers/sqlite_wal_checkpoint.rb b/config/initializers/sqlite_wal_checkpoint.rb index 3e72802..aa14b80 100644 --- a/config/initializers/sqlite_wal_checkpoint.rb +++ b/config/initializers/sqlite_wal_checkpoint.rb @@ -1,5 +1,8 @@ -# Start a checkpoint contender in every non-test process. Puma and Resque pool -# stop before fork and start again in the child so the flock is never inherited. +# Loaded from lib/rails_ext (autoload_lib ignore list) so reloads do not orphan +# the contender thread. Non-test processes start here; Puma/Resque stop before +# fork and start again in the child. +require Rails.root.join("lib/rails_ext/sqlite_wal_checkpoint") + Rails.application.config.after_initialize do SqliteWalCheckpoint.start unless Rails.env.test? end diff --git a/config/puma.rb b/config/puma.rb index e626470..06220b8 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -54,7 +54,7 @@ Membership.disconnect_all # do not inherit the flock; each worker starts its own contender. Single-process # mode never forks, so the initializer's contender keeps running. before_fork { SqliteWalCheckpoint.stop } -on_worker_boot { SqliteWalCheckpoint.start } +before_worker_boot { SqliteWalCheckpoint.start } Signal.trap :SIGPROF do Thread.list.each do |t| diff --git a/lib/sqlite_wal_checkpoint.rb b/lib/rails_ext/sqlite_wal_checkpoint.rb similarity index 86% rename from lib/sqlite_wal_checkpoint.rb rename to lib/rails_ext/sqlite_wal_checkpoint.rb index 4fc3e94..75103bc 100644 --- a/lib/sqlite_wal_checkpoint.rb +++ b/lib/rails_ext/sqlite_wal_checkpoint.rb @@ -21,6 +21,7 @@ module SqliteWalCheckpoint return if @thread&.alive? @stop = false + install_exit_checkpoint @thread = Thread.new { run(interval) } @thread.report_on_exception = false end @@ -33,6 +34,7 @@ module SqliteWalCheckpoint def stop @stop = true thread = @mutex&.synchronize { @thread } + # Join longer than busy_handler_timeout so before_fork does not race a PRAGMA. thread&.join(2) @mutex&.synchronize { @thread = nil if @thread && !@thread.alive? } end @@ -63,6 +65,7 @@ module SqliteWalCheckpoint @lock_path = nil @database_path_override = nil @stop = false + @exit_checkpoint_installed = false end private @@ -113,7 +116,8 @@ module SqliteWalCheckpoint result = nil SQLite3::Database.new(path) do |database| - database.busy_handler_timeout = 5_000 + # Keep below stop's join timeout so before_fork can finish cleanly. + database.busy_handler_timeout = 1_000 result = yield database end result @@ -148,5 +152,14 @@ module SqliteWalCheckpoint ensure @lock_file = nil end + + # Best-effort PASSIVE for short-lived console/rake writers that exit before + # the contender acquires the flock. Does not touch lock ownership. + def install_exit_checkpoint + return if @exit_checkpoint_installed + + @exit_checkpoint_installed = true + at_exit { checkpoint rescue nil } + end end end diff --git a/test/lib/sqlite_wal_checkpoint_test.rb b/test/lib/sqlite_wal_checkpoint_test.rb index 2e973a3..508fddd 100644 --- a/test/lib/sqlite_wal_checkpoint_test.rb +++ b/test/lib/sqlite_wal_checkpoint_test.rb @@ -35,6 +35,24 @@ class SqliteWalCheckpointTest < ActiveSupport::TestCase 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 "tick checkpoints through the elected lock holder" do db_path = build_wal_database(rows: 50) SqliteWalCheckpoint.database_path_override = db_path