Stop WAL checkpointer before fork; one thread owns the flock

Unify startup: initializer starts every non-test process, Puma and Resque
stop before fork and start again in the child. Only the contender thread
releases the lock. Drop Puma::CLI / single_puma_process? special cases.

Co-authored-by: Thomas Klemm <github@tklemm.eu>
This commit is contained in:
Cursor Agent
2026-10-07 19:00:40 +00:00
parent 88acc4e026
commit 61631ea5f9
7 changed files with 24 additions and 71 deletions
+9 -38
View File
@@ -2,9 +2,9 @@
# writer and fsyncs during the request. database.yml sets wal_autocheckpoint=0;
# this module copies pages off the request thread with PASSIVE checkpoints.
#
# Every non-test writer process starts a contender thread (initializer, Puma,
# Resque). A file lock elects one leader; if that process exits, another takes
# over on the next interval so auto-checkpoint stays off safely.
# Every non-test process starts a contender. Callers that fork (Puma, Resque pool)
# must stop before fork and start again in the child so the flock is never shared
# across an inherited file descriptor.
module SqliteWalCheckpoint
INTERVAL = 0.25
MAX_BACKOFF = 30.0
@@ -21,7 +21,6 @@ module SqliteWalCheckpoint
return if @thread&.alive?
@stop = false
install_exit_handler
@thread = Thread.new { run(interval) }
@thread.report_on_exception = false
end
@@ -29,13 +28,13 @@ module SqliteWalCheckpoint
@thread
end
# Signal the contender to exit and wait for it. Only the contender thread
# acquires or releases the flock (see run's ensure).
def stop
@stop = true
thread = @mutex&.synchronize { @thread }
thread&.join(2)
ensure
@mutex&.synchronize { @thread = nil if @thread && !@thread.alive? }
release_lock
end
def checkpoint
@@ -64,13 +63,6 @@ module SqliteWalCheckpoint
@lock_path = nil
@database_path_override = nil
@stop = false
@exit_handler_installed = false
end
# Puma's WEB_CONCURRENCY=auto must not use to_i (that is 0). Only the literal
# 0 means single-process mode; everything else forks workers.
def single_puma_process?(configured_workers)
configured_workers.to_s == "0"
end
private
@@ -81,12 +73,12 @@ module SqliteWalCheckpoint
until @stop
begin
unless database_path
interruptible_sleep interval
sleep interval
next
end
unless acquire_lock
interruptible_sleep interval
sleep interval
next
end
@@ -95,7 +87,7 @@ module SqliteWalCheckpoint
until @stop
checkpoint_on(database)
backoff = interval
interruptible_sleep interval
sleep interval
end
end
ensure
@@ -103,7 +95,7 @@ module SqliteWalCheckpoint
end
rescue => error
Rails.logger.warn "SQLite WAL checkpoint failed: #{error.class}: #{error.message}"
interruptible_sleep backoff
sleep backoff
backoff = [ backoff * 2, MAX_BACKOFF ].min
end
end
@@ -153,29 +145,8 @@ module SqliteWalCheckpoint
@lock_file.flock(File::LOCK_UN)
@lock_file.close
rescue Errno::EBADF, IOError
# Already closed by a racing ensure / exit handler.
ensure
@lock_file = nil
end
def interruptible_sleep(seconds)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
while !@stop && (remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)) > 0
sleep [ remaining, 0.05 ].min
end
end
def install_exit_handler
return if @exit_handler_installed
@exit_handler_installed = true
at_exit do
if @lock_file
checkpoint rescue nil
end
stop
end
end
end
end
+3
View File
@@ -4,6 +4,9 @@ end
task "resque:pool:setup" do
ActiveRecord::Base.connection.disconnect!
# Initializer may have started a contender in the pool master; drop it before
# workers fork so they do not inherit the flock.
SqliteWalCheckpoint.stop
Resque::Pool.after_prefork do |job|
ActiveRecord::Base.establish_connection