Skip to content

Commit e015c6a

Browse files
committed
fix: restore the SQLite busy wait after sync
Rails installs SQLite's busy wait as a Ruby busy handler through the sqlite3 timeout configuration. PRAGMA busy_timeout neither reports that handler nor preserves it: sqlite3_busy_timeout replaces any registered handler, so reading zero and writing zero back stripped the handler permanently. Every later writer on that pooled connection, inside or outside Solid Objects, then failed immediately with SQLite3::BusyException instead of waiting for the lock. Reinstall the configured handler when restoring, and stop the test helper from simulating contention the same broken way.
1 parent 9ab4ed0 commit e015c6a

5 files changed

Lines changed: 126 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
## 0.5.1 - 2026-08-07
44

5+
- Restore the SQLite busy wait that a synchronous invocation suspends for its
6+
deadline. Rails installs the busy wait as a Ruby busy handler through the
7+
sqlite3 `timeout` configuration, which `PRAGMA busy_timeout` reports as zero
8+
and silently replaces, so the previous save and restore left pooled
9+
connections with no busy handler at all. Every later writer on that
10+
connection, inside or outside Solid Objects, then failed immediately with
11+
`SQLite3::BusyException` instead of waiting for the lock.
12+
513
- Run the doctor round-trip probe on a dedicated caller process, and accept an
614
explicit process registry in `SynchronousInvocation`, so the probe can no
715
longer stop and delete a shared application caller process, release its

docs/correctness.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,13 @@ result. Adapter lock/query deadlines cover the durable enqueue, caller-process
167167
registration and heartbeat, activation coordination, and result observation.
168168
SQLite retries busy coordination operations only within the original call
169169
deadline and reports `waiting_on=database_contention` when the database cannot
170-
be inspected at timeout. If enqueue cannot commit, `SyncEnqueueTimeout` is
170+
be inspected at timeout. To keep those retries in Ruby, the SQLite adapter
171+
suspends the connection's busy wait for the duration of each deadline-bound
172+
transaction and restores it afterwards. Restoration reinstalls the Ruby busy
173+
handler Rails configures from the sqlite3 `timeout` setting, which
174+
`PRAGMA busy_timeout` neither reports nor preserves, so a synchronous call
175+
leaves the connection's lock waiting behaviour exactly as it found it for
176+
later writers inside and outside Solid Objects. If enqueue cannot commit, `SyncEnqueueTimeout` is
171177
raised and no message reference exists. MySQL lock waits have one-second InnoDB
172178
granularity. Ruby handlers that already started are not preempted.
173179

lib/solid_objects/database_adapters/sqlite.rb

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,46 @@ def with_lock_probe
6767
def with_transaction_deadline(connection)
6868
return yield unless SyncDeadline.active?
6969

70-
previous_timeout = connection.select_value("PRAGMA busy_timeout").to_i
70+
busy_wait = suspend_busy_wait(connection)
71+
begin
72+
yield
73+
ensure
74+
restore_busy_wait(connection, busy_wait)
75+
end
76+
end
77+
78+
# @rbs (untyped) -> Hash[Symbol, untyped]
79+
def suspend_busy_wait(connection)
80+
busy_wait = {
81+
pragma_timeout: connection.select_value("PRAGMA busy_timeout").to_i,
82+
handler_timeout: configured_busy_handler_timeout(connection)
83+
}
7184
connection.execute("PRAGMA busy_timeout = 0")
72-
yield
73-
ensure
74-
connection.execute("PRAGMA busy_timeout = #{previous_timeout}") if previous_timeout
85+
busy_wait
86+
end
87+
88+
# @rbs (untyped, Hash[Symbol, untyped]) -> void
89+
def restore_busy_wait(connection, busy_wait)
90+
handler_timeout = busy_wait.fetch(:handler_timeout)
91+
pragma_timeout = busy_wait.fetch(:pragma_timeout)
92+
if handler_timeout && pragma_timeout.zero?
93+
connection.raw_connection.busy_handler_timeout = handler_timeout
94+
return
95+
end
96+
97+
connection.execute("PRAGMA busy_timeout = #{pragma_timeout}")
98+
end
99+
100+
# @rbs (untyped) -> Integer?
101+
def configured_busy_handler_timeout(connection)
102+
return nil unless connection.respond_to?(:raw_connection)
103+
return nil unless connection.raw_connection.respond_to?(:busy_handler_timeout=)
104+
105+
pool = connection.respond_to?(:pool) ? connection.pool : nil
106+
return nil unless pool.respond_to?(:db_config)
107+
108+
timeout = pool.db_config.configuration_hash[:timeout]
109+
timeout&.to_i
75110
end
76111

77112
# @rbs (Exception) -> bool

sig/generated/lib/solid_objects/database_adapters/sqlite.rbs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ module SolidObjects
2626
# @rbs (untyped) { () -> untyped } -> untyped
2727
def with_transaction_deadline: (untyped) { () -> untyped } -> untyped
2828

29+
# @rbs (untyped) -> Hash[Symbol, untyped]
30+
def suspend_busy_wait: (untyped) -> Hash[Symbol, untyped]
31+
32+
# @rbs (untyped, Hash[Symbol, untyped]) -> void
33+
def restore_busy_wait: (untyped, Hash[Symbol, untyped]) -> void
34+
35+
# @rbs (untyped) -> Integer?
36+
def configured_busy_handler_timeout: (untyped) -> Integer?
37+
2938
# @rbs (Exception) -> bool
3039
def deadline_error?: (Exception) -> bool
3140

test/integration/synchronous_invocation_test.rb

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,18 @@ def wait(timeout:)
502502
release_sqlite_write_lock(lock) if lock
503503
end
504504

505+
test "sync restores the SQLite busy handler it suspended for the deadline" do
506+
skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i)
507+
508+
SolidObjects::Record.connection_pool.with_connection do
509+
CounterActor.ref("busy-handler").increment
510+
511+
assert_nothing_raised do
512+
write_while_write_lock_is_briefly_held
513+
end
514+
end
515+
end
516+
505517
test "sync bounds SQLite contention while reusing and heartbeating its caller process" do
506518
skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i)
507519

@@ -758,6 +770,32 @@ def release_sqlite_write_lock(lock)
758770
thread.join
759771
end
760772

773+
BRIEF_LOCK_HOLD = 0.2
774+
775+
def write_while_write_lock_is_briefly_held
776+
lock = hold_sqlite_write_lock
777+
releaser = Thread.new do
778+
mutex = Thread::Mutex.new
779+
mutex.synchronize { Thread::ConditionVariable.new.wait(mutex, BRIEF_LOCK_HOLD) }
780+
release_sqlite_write_lock(lock)
781+
lock = nil
782+
end
783+
784+
SolidObjects::Process.create!(
785+
id: SecureRandom.uuid,
786+
kind: "busy-handler-probe",
787+
hostname: "test-host",
788+
pid: ::Process.pid,
789+
started_at: Time.current,
790+
last_heartbeat_at: Time.current,
791+
metadata: {}
792+
)
793+
releaser.join
794+
ensure
795+
releaser&.join
796+
release_sqlite_write_lock(lock) if lock
797+
end
798+
761799
def invoke_with_immediate_sqlite_lock_failure(message_reference)
762800
result = Queue.new
763801
invocation = Thread.new do
@@ -768,15 +806,14 @@ def invoke_with_immediate_sqlite_lock_failure(message_reference)
768806
attempts += 1 if process_write?(event.payload)
769807
end
770808
SolidObjects::Record.connection_pool.with_connection do |connection|
771-
previous_timeout = connection.select_value("PRAGMA busy_timeout").to_i
772-
connection.execute("PRAGMA busy_timeout = 0")
773-
started_at = monotonic_now
774-
error = capture_exception do
775-
SolidObjects::SynchronousInvocation.new.call(message_reference, timeout: 0.1)
809+
with_immediate_sqlite_lock_failure(connection) do
810+
started_at = monotonic_now
811+
error = capture_exception do
812+
SolidObjects::SynchronousInvocation.new.call(message_reference, timeout: 0.1)
813+
end
814+
elapsed = monotonic_now - started_at
776815
end
777-
elapsed = monotonic_now - started_at
778816
ensure
779-
connection.execute("PRAGMA busy_timeout = #{previous_timeout}")
780817
ActiveSupport::Notifications.unsubscribe(subscription)
781818
end
782819
result << [ error, elapsed, attempts ]
@@ -786,6 +823,25 @@ def invoke_with_immediate_sqlite_lock_failure(message_reference)
786823
captured
787824
end
788825

826+
def with_immediate_sqlite_lock_failure(connection)
827+
database_adapter = SolidObjects.database_adapter
828+
database_adapter.define_singleton_method(:configured_busy_handler_timeout) { |_connection| 0 }
829+
connection.raw_connection.busy_handler_timeout = 0
830+
yield
831+
ensure
832+
database_adapter.singleton_class.send(:remove_method, :configured_busy_handler_timeout)
833+
connection.raw_connection.busy_handler_timeout = configured_busy_handler_timeout
834+
end
835+
836+
def configured_busy_handler_timeout
837+
SolidObjects::Record
838+
.connection_pool
839+
.db_config
840+
.configuration_hash
841+
.fetch(:timeout, 5_000)
842+
.to_i
843+
end
844+
789845
def process_write?(payload)
790846
payload.fetch(:sql).match?(/\A(?:INSERT|UPDATE)/) &&
791847
payload.fetch(:sql).include?(SolidObjects::Process.table_name)

0 commit comments

Comments
 (0)