From d3bd7cf930c1deafc4f7ea9ebe8456119a7dc930 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Fri, 7 Aug 2026 15:46:30 -0700 Subject: [PATCH 1/5] fix: scope doctor probe cleanup Stop the round-trip probe from deregistering and deleting a caller process the application registered, which released its activations and unclaimed its messages. Report cleanup failures as a failed or warned check instead of raising a database lock error out of the command. Instrument component refreshes with identity, key, dependencies, revision, and outcome. --- CHANGELOG.md | 11 +++ Gemfile.lock | 4 +- .../solid_objects/components_controller.rb | 31 ++++++- docs/operations.md | 21 ++++- lib/solid_objects/caller_process.rb | 22 +++++ lib/solid_objects/doctor.rb | 48 ++++++++-- lib/solid_objects/version.rb | 2 +- .../solid_objects/components_controller.rbs | 6 ++ .../lib/solid_objects/caller_process.rbs | 6 ++ sig/generated/lib/solid_objects/doctor.rbs | 12 +++ .../integration/components_controller_test.rb | 88 +++++++++++++++++++ test/integration/doctor_test.rb | 69 +++++++++++++++ 12 files changed, 306 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e81029a..2b599d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.5.1 - 2026-08-07 + +- Keep the doctor round-trip probe from stopping and deleting a caller process + the application registered, which released its activations and unclaimed its + messages. +- Report doctor probe cleanup failures as a failed or warned check instead of + raising a database lock error out of the command and leaking the probe + caller process. +- Instrument component refreshes with actor identity, component name, key, + dependencies, refresh method, revision, and outcome, excluding locals. + ## 0.5.0 - 2026-08-07 - Add repeatable reactive components with signed string or integer keys and diff --git a/Gemfile.lock b/Gemfile.lock index 00cf7a9..77cc4ad 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - solid_objects (0.5.0) + solid_objects (0.5.1) actioncable (>= 8.0) actionpack (>= 8.0) actionview (>= 8.0) @@ -373,7 +373,7 @@ CHECKSUMS rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 - solid_objects (0.5.0) + solid_objects (0.5.1) sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b diff --git a/app/controllers/solid_objects/components_controller.rb b/app/controllers/solid_objects/components_controller.rb index d593051..688953c 100644 --- a/app/controllers/solid_objects/components_controller.rb +++ b/app/controllers/solid_objects/components_controller.rb @@ -8,12 +8,25 @@ class ComponentsController < ActionController::Base # @rbs () -> void def show + SolidObjects.instrument(:"component.refreshed") { |payload| refresh(payload) } + end + + private + + # @rbs (Hash[Symbol, untyped]) -> void + def refresh(payload) registration = ComponentRegistration.from_token( params.require(:token) ) + payload.merge!(registration_payload(registration)) requested_revision = requested_revision_key snapshot = ActorSnapshot.new(registration.reference) - return head :conflict if newer_than_snapshot?(requested_revision, snapshot) + payload[:instance_id] = snapshot.instance_id + payload[:revision] = snapshot.revision + if newer_than_snapshot?(requested_revision, snapshot) + payload[:outcome] = "conflict" + return head :conflict + end authorization_context = SolidObjects .configuration @@ -26,18 +39,32 @@ def show authorization_context: ).call response.headers["Cache-Control"] = "private, no-store" + payload[:outcome] = "rendered" render html: component_frame(registration, snapshot, rendered) rescue Unauthorized + payload[:outcome] = "unauthorized" head :forbidden rescue UnknownComponent + payload[:outcome] = "unknown_component" head :not_found rescue ActionController::ParameterMissing, ArgumentError, InvalidComponentToken + payload[:outcome] = "invalid_token" head :bad_request end - private + # @rbs (ComponentRegistration) -> Hash[Symbol, untyped] + def registration_payload(registration) + { + actor_type: registration.reference.actor_type, + actor_id: registration.reference.actor_id, + component_name: registration.component_name, + component_key: registration.component_key, + dependencies: registration.dependencies, + refresh_method: registration.refresh_method + } + end # @rbs () -> Array[Integer] def requested_revision_key diff --git a/docs/operations.md b/docs/operations.md index 7c597d1..c06b47e 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -15,6 +15,13 @@ so the schema check compares the required shape instead of a fixed timestamp. Warnings such as an all-deny neutral policy do not fail the command because a context-aware production policy may correctly deny the probe. +The round-trip probe cleans up after itself. It removes its temporary actor, +and removes the caller process record only when the probe registered it, so +running the doctor inside a process that already serves synchronous calls +leaves that caller process and its activations untouched. A database busy +enough to block cleanup reports a failed or warned check rather than raising +out of the command. + ## Runtime Start all configured roles: @@ -147,10 +154,20 @@ transaction rejection, commit-action start/completion/failure, effect and broadcast enqueue/completion, reminder enqueue, actor destruction/expiration, retention pruning, process cleanup, and supervisor lifecycle. +`solid_objects.component.refreshed` covers every authorized component refresh +request. Its payload carries the actor identity, `component_name`, +`component_key`, declared `dependencies`, `refresh_method`, the rendered +`instance_id` and `revision`, and an `outcome` of `rendered`, `conflict`, +`unauthorized`, `unknown_component`, or `invalid_token`. Use it to watch +refresh rate per key, authorization denials, superseded requests, and render +duration. A rejected token reports only the outcome, since no signed identity +was recovered. + Payloads contain stable runtime identifiers, actor identity, sequence, attempts, ownership generations, and safe exception summaries where relevant. -Arguments, actor state, results, and outbox payloads are excluded. The bundled -log subscriber turns the same notifications into structured logger hashes. +Arguments, component locals, actor state, results, and outbox payloads are +excluded. The bundled log subscriber turns the same notifications into +structured logger hashes. ## Retention and backups diff --git a/lib/solid_objects/caller_process.rb b/lib/solid_objects/caller_process.rb index 203bd8b..c24e126 100644 --- a/lib/solid_objects/caller_process.rb +++ b/lib/solid_objects/caller_process.rb @@ -26,6 +26,14 @@ def process_registry end end + # @rbs () -> String? + def process_record_id + mutex.synchronize do + reset_after_fork + registry&.process_record&.id + end + end + # @rbs () -> bool def stop mutex.synchronize do @@ -36,6 +44,20 @@ def stop end end + # @rbs (String) -> bool + def delete_process_record(process_id) + mutex.synchronize do + reset_after_fork + process_record = registry&.process_record + return false unless process_record&.id == process_id + + registry.stop + process_record.delete + @registry = nil + true + end + end + private attr_reader :mutex, :registry diff --git a/lib/solid_objects/doctor.rb b/lib/solid_objects/doctor.rb index 5966aec..59b7650 100644 --- a/lib/solid_objects/doctor.rb +++ b/lib/solid_objects/doctor.rb @@ -215,11 +215,22 @@ def check_runtime # @rbs () -> Check def check_sync_round_trip actor_id = SecureRandom.uuid + caller_process_id = SolidObjects.caller_process.process_record_id + check = run_sync_probe(actor_id) + leftovers = remove_probe_records(actor_id:, caller_process_id:) + return check if leftovers.empty? || check.failed? + + warn_check( + :sync_round_trip, + "#{check.message}; could not remove the #{leftovers.join(" and ")}" + ) + end + + # @rbs (String) -> Check + def run_sync_probe(actor_id) value = SecureRandom.hex(8) - process_registry = SolidObjects.caller_process.process_registry - reference = ProbeActor.ref(actor_id) message_reference = Mailbox.new.enqueue( - reference, + ProbeActor.ref(actor_id), :ping, { value: }, kind: "sync" @@ -230,10 +241,33 @@ def check_sync_round_trip pass(:sync_round_trip, "durable synchronous actor call completed without a worker") rescue => error fail_check(:sync_round_trip, "#{error.class}: #{error.message}") - ensure - Instance.where(actor_type: ProbeActor.actor_type, actor_id:).delete_all if actor_id - process_registry&.stop - process_registry&.process_record&.delete + end + + # @rbs (actor_id: String, caller_process_id: String?) -> Array[String] + def remove_probe_records(actor_id:, caller_process_id:) + leftovers = [] + leftovers << "probe actor" unless delete_probe_actor(actor_id) + probe_process_id = SolidObjects.caller_process.process_record_id + return leftovers if probe_process_id.nil? || probe_process_id == caller_process_id + + leftovers << "probe caller process" unless delete_probe_caller_process(probe_process_id) + leftovers + end + + # @rbs (String) -> bool + def delete_probe_actor(actor_id) + Instance.where(actor_type: ProbeActor.actor_type, actor_id:).delete_all + true + rescue + false + end + + # @rbs (String) -> bool + def delete_probe_caller_process(process_id) + SolidObjects.caller_process.delete_process_record(process_id) + true + rescue + false end # @rbs (Check, Check) -> bool diff --git a/lib/solid_objects/version.rb b/lib/solid_objects/version.rb index 2942531..4e07a1e 100644 --- a/lib/solid_objects/version.rb +++ b/lib/solid_objects/version.rb @@ -1,5 +1,5 @@ # rbs_inline: enabled module SolidObjects - VERSION = "0.5.0" + VERSION = "0.5.1" end diff --git a/sig/generated/controllers/solid_objects/components_controller.rbs b/sig/generated/controllers/solid_objects/components_controller.rbs index 87b582e..09d4239 100644 --- a/sig/generated/controllers/solid_objects/components_controller.rbs +++ b/sig/generated/controllers/solid_objects/components_controller.rbs @@ -7,6 +7,12 @@ module SolidObjects private + # @rbs (Hash[Symbol, untyped]) -> void + def refresh: (Hash[Symbol, untyped]) -> void + + # @rbs (ComponentRegistration) -> Hash[Symbol, untyped] + def registration_payload: (ComponentRegistration) -> Hash[Symbol, untyped] + # @rbs () -> Array[Integer] def requested_revision_key: () -> Array[Integer] diff --git a/sig/generated/lib/solid_objects/caller_process.rbs b/sig/generated/lib/solid_objects/caller_process.rbs index c68199e..faf4eb7 100644 --- a/sig/generated/lib/solid_objects/caller_process.rbs +++ b/sig/generated/lib/solid_objects/caller_process.rbs @@ -16,9 +16,15 @@ module SolidObjects # @rbs () -> ProcessRegistry def process_registry: () -> ProcessRegistry + # @rbs () -> String? + def process_record_id: () -> String? + # @rbs () -> bool def stop: () -> bool + # @rbs (String) -> bool + def delete_process_record: (String) -> bool + private attr_reader mutex: untyped diff --git a/sig/generated/lib/solid_objects/doctor.rbs b/sig/generated/lib/solid_objects/doctor.rbs index b5e52ba..92d49a5 100644 --- a/sig/generated/lib/solid_objects/doctor.rbs +++ b/sig/generated/lib/solid_objects/doctor.rbs @@ -78,6 +78,18 @@ module SolidObjects # @rbs () -> Check def check_sync_round_trip: () -> Check + # @rbs (String) -> Check + def run_sync_probe: (String) -> Check + + # @rbs (actor_id: String, caller_process_id: String?) -> Array[String] + def remove_probe_records: (actor_id: String, caller_process_id: String?) -> Array[String] + + # @rbs (String) -> bool + def delete_probe_actor: (String) -> bool + + # @rbs (String) -> bool + def delete_probe_caller_process: (String) -> bool + # @rbs (Check, Check) -> bool def ready_for_round_trip?: (Check, Check) -> bool diff --git a/test/integration/components_controller_test.rb b/test/integration/components_controller_test.rb index a47ba97..1b85955 100644 --- a/test/integration/components_controller_test.rb +++ b/test/integration/components_controller_test.rb @@ -292,8 +292,96 @@ def update_room(messages:, status:) assert_includes @response.body, %(data-solid-objects-refresh="morph") end + test "instruments an authorized component refresh without its locals" do + reference = RoomActor.ref("general") + reference.replace_messages(messages: [ { id: "1", body: "First" } ]) + token = component_token( + reference, + component_name: "player", + component_key: "alice", + dependencies: %w[status], + locals: { player_id: "alice", label: "You" }, + refresh_method: "morph" + ) + event = capture_component_event { render_component(token, viewer: "alice") } + + assert_response :success + assert_equal "component-room", event.payload.fetch(:actor_type) + assert_equal "general", event.payload.fetch(:actor_id) + assert_equal "player", event.payload.fetch(:component_name) + assert_equal "alice", event.payload.fetch(:component_key) + assert_equal %w[status], event.payload.fetch(:dependencies) + assert_equal "morph", event.payload.fetch(:refresh_method) + assert_equal "rendered", event.payload.fetch(:outcome) + assert_equal( + SolidObjects::Instance.find_by(actor_type: "component-room", actor_id: "general").state_revision, + event.payload.fetch(:revision) + ) + assert event.payload.fetch(:instance_id) + assert event.duration + refute event.payload.key?(:locals) + refute event.payload.key?(:token) + end + + test "instruments a denied component refresh" do + reference = RoomActor.ref("general") + SolidObjects.configuration.authorize_query = ->(**) { false } + token = component_token( + reference, + component_name: "messages", + dependencies: %w[recent_messages] + ) + + event = capture_component_event { render_component(token, viewer: "mallory") } + + assert_response :forbidden + assert_equal "unauthorized", event.payload.fetch(:outcome) + assert_equal "messages", event.payload.fetch(:component_name) + end + + test "instruments a superseded component refresh" do + reference = RoomActor.ref("general") + token = component_token( + reference, + component_name: "messages", + dependencies: %w[recent_messages] + ) + registration = SolidObjects::ComponentRegistration.from_token(token) + @request.headers["HTTP_X_VIEWER"] = "alice" + + event = capture_component_event do + get :show, params: { + token:, + instance_id: registration.instance_id + 1, + revision: registration.revision + } + end + + assert_response :conflict + assert_equal "conflict", event.payload.fetch(:outcome) + end + + test "instruments a rejected component token" do + event = capture_component_event { render_component("malformed", viewer: "alice") } + + assert_response :bad_request + assert_equal "invalid_token", event.payload.fetch(:outcome) + refute event.payload.key?(:component_name) + end + private + def capture_component_event + event = nil + subscription = ActiveSupport::Notifications.subscribe( + "solid_objects.component.refreshed" + ) { |notification| event = notification } + yield + event + ensure + ActiveSupport::Notifications.unsubscribe(subscription) if subscription + end + def component_token( reference, component_name:, diff --git a/test/integration/doctor_test.rb b/test/integration/doctor_test.rb index eb99af3..519d230 100644 --- a/test/integration/doctor_test.rb +++ b/test/integration/doctor_test.rb @@ -21,6 +21,43 @@ class DoctorTest < ActiveSupport::TestCase assert_empty SolidObjects::Process.where(kind: "caller") end + test "preserves a caller process the application registered before the probe" do + existing_record = SolidObjects.caller_process.process_registry.process_record + + report = SolidObjects::Doctor.new.call + + assert_equal :pass, report.check(:sync_round_trip).status + assert SolidObjects::Process.exists?(id: existing_record.id), + "doctor deleted the caller process the application already registered" + assert_equal "running", existing_record.reload.shutdown_state + assert_empty SolidObjects::Instance.where(actor_type: "solid_objects_doctor") + ensure + SolidObjects.reset_caller_process! + end + + test "reports a failed round trip instead of raising while the database stays locked" do + skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i) + lock = hold_sqlite_write_lock + + report = SolidObjects::Doctor.new.call + + refute report.healthy? + assert_equal :fail, report.check(:sync_round_trip).status + ensure + release_sqlite_write_lock(lock) if lock + end + + test "warns when probe records outlive a passing round trip" do + doctor = SolidObjects::Doctor.new + doctor.define_singleton_method(:delete_probe_actor) { |_actor_id| false } + + report = doctor.call + + assert report.healthy? + assert_equal :warn, report.check(:sync_round_trip).status + assert_match(/probe actor/, report.check(:sync_round_trip).message) + end + test "warns when every policy denies a neutral context without changing policies" do deny = ->(**) { false } SolidObjects.configuration.authorize_message = deny @@ -93,4 +130,36 @@ class DoctorTest < ActiveSupport::TestCase ensure Rake.application = original_application end + + private + + def hold_sqlite_write_lock + locked = Queue.new + release = Queue.new + thread = Thread.new do + SolidObjects::Record.connection_pool.with_connection do + SolidObjects::Record.transaction do + SolidObjects::Process.create!( + id: SecureRandom.uuid, + kind: "lock-holder", + hostname: "test-host", + pid: ::Process.pid, + started_at: Time.current, + last_heartbeat_at: Time.current, + metadata: {} + ) + locked << true + release.pop + end + end + end + Timeout.timeout(2) { locked.pop } + [ thread, release ] + end + + def release_sqlite_write_lock(lock) + thread, release = lock + release << true + thread.join + end end From 9ab4ed01167d595fdb08bba50507645dbddbfdcd Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Fri, 7 Aug 2026 16:01:45 -0700 Subject: [PATCH 2/5] fix: isolate doctor probe caller process Register a dedicated caller process for the round-trip probe and pass it to SynchronousInvocation, so an overlapping application call can no longer adopt the probe registry and have its activation ownership and claimed messages released when the probe cleans up. Removes the caller process accessors the previous ownership check needed. --- CHANGELOG.md | 7 ++-- docs/operations.md | 13 +++---- lib/solid_objects/caller_process.rb | 22 ------------ lib/solid_objects/doctor.rb | 34 +++++++++++-------- lib/solid_objects/synchronous_invocation.rb | 17 +++++++++- .../lib/solid_objects/caller_process.rbs | 6 ---- sig/generated/lib/solid_objects/doctor.rbs | 12 +++---- .../solid_objects/synchronous_invocation.rbs | 8 +++++ test/integration/doctor_test.rb | 26 +++++++++++++- 9 files changed, 85 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b599d7..801ba1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,10 @@ ## 0.5.1 - 2026-08-07 -- Keep the doctor round-trip probe from stopping and deleting a caller process - the application registered, which released its activations and unclaimed its - messages. +- Run the doctor round-trip probe on a dedicated caller process, and accept an + explicit process registry in `SynchronousInvocation`, so the probe can no + longer stop and delete a shared application caller process, release its + activations, and unclaim its messages. - Report doctor probe cleanup failures as a failed or warned check instead of raising a database lock error out of the command and leaking the probe caller process. diff --git a/docs/operations.md b/docs/operations.md index c06b47e..afd8931 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -15,12 +15,13 @@ so the schema check compares the required shape instead of a fixed timestamp. Warnings such as an all-deny neutral policy do not fail the command because a context-aware production policy may correctly deny the probe. -The round-trip probe cleans up after itself. It removes its temporary actor, -and removes the caller process record only when the probe registered it, so -running the doctor inside a process that already serves synchronous calls -leaves that caller process and its activations untouched. A database busy -enough to block cleanup reports a failed or warned check rather than raising -out of the command. +The round-trip probe runs on its own dedicated caller process rather than the +shared application caller process, and removes that record together with its +temporary actor when it finishes. Running the doctor inside a process that +already serves synchronous calls therefore leaves the application caller +process, its activations, and its claimed messages untouched, including when an +application call overlaps the probe. A database busy enough to block cleanup +reports a failed or warned check rather than raising out of the command. ## Runtime diff --git a/lib/solid_objects/caller_process.rb b/lib/solid_objects/caller_process.rb index c24e126..203bd8b 100644 --- a/lib/solid_objects/caller_process.rb +++ b/lib/solid_objects/caller_process.rb @@ -26,14 +26,6 @@ def process_registry end end - # @rbs () -> String? - def process_record_id - mutex.synchronize do - reset_after_fork - registry&.process_record&.id - end - end - # @rbs () -> bool def stop mutex.synchronize do @@ -44,20 +36,6 @@ def stop end end - # @rbs (String) -> bool - def delete_process_record(process_id) - mutex.synchronize do - reset_after_fork - process_record = registry&.process_record - return false unless process_record&.id == process_id - - registry.stop - process_record.delete - @registry = nil - true - end - end - private attr_reader :mutex, :registry diff --git a/lib/solid_objects/doctor.rb b/lib/solid_objects/doctor.rb index 59b7650..c13dc4d 100644 --- a/lib/solid_objects/doctor.rb +++ b/lib/solid_objects/doctor.rb @@ -215,9 +215,9 @@ def check_runtime # @rbs () -> Check def check_sync_round_trip actor_id = SecureRandom.uuid - caller_process_id = SolidObjects.caller_process.process_record_id - check = run_sync_probe(actor_id) - leftovers = remove_probe_records(actor_id:, caller_process_id:) + probe_registry = ProcessRegistry.new + check = run_sync_probe(actor_id, probe_registry) + leftovers = remove_probe_records(actor_id:, probe_registry:) return check if leftovers.empty? || check.failed? warn_check( @@ -226,8 +226,9 @@ def check_sync_round_trip ) end - # @rbs (String) -> Check - def run_sync_probe(actor_id) + # @rbs (String, ProcessRegistry) -> Check + def run_sync_probe(actor_id, probe_registry) + probe_registry.register(kind: "caller", metadata: { execution: "doctor" }) value = SecureRandom.hex(8) message_reference = Mailbox.new.enqueue( ProbeActor.ref(actor_id), @@ -235,7 +236,9 @@ def run_sync_probe(actor_id) { value: }, kind: "sync" ) - result = SynchronousInvocation.new.call(message_reference, timeout: 5.seconds) + result = SynchronousInvocation + .new(process_registry: probe_registry) + .call(message_reference, timeout: 5.seconds) raise Error, "unexpected round-trip result" unless result == value pass(:sync_round_trip, "durable synchronous actor call completed without a worker") @@ -243,14 +246,11 @@ def run_sync_probe(actor_id) fail_check(:sync_round_trip, "#{error.class}: #{error.message}") end - # @rbs (actor_id: String, caller_process_id: String?) -> Array[String] - def remove_probe_records(actor_id:, caller_process_id:) + # @rbs (actor_id: String, probe_registry: ProcessRegistry) -> Array[String] + def remove_probe_records(actor_id:, probe_registry:) leftovers = [] leftovers << "probe actor" unless delete_probe_actor(actor_id) - probe_process_id = SolidObjects.caller_process.process_record_id - return leftovers if probe_process_id.nil? || probe_process_id == caller_process_id - - leftovers << "probe caller process" unless delete_probe_caller_process(probe_process_id) + leftovers << "probe caller process" unless delete_probe_caller_process(probe_registry) leftovers end @@ -262,9 +262,13 @@ def delete_probe_actor(actor_id) false end - # @rbs (String) -> bool - def delete_probe_caller_process(process_id) - SolidObjects.caller_process.delete_process_record(process_id) + # @rbs (ProcessRegistry) -> bool + def delete_probe_caller_process(probe_registry) + process_record = probe_registry.process_record + return true unless process_record + + probe_registry.stop + process_record.delete true rescue false diff --git a/lib/solid_objects/synchronous_invocation.rb b/lib/solid_objects/synchronous_invocation.rb index 41b5a45..df81bc8 100644 --- a/lib/solid_objects/synchronous_invocation.rb +++ b/lib/solid_objects/synchronous_invocation.rb @@ -6,6 +6,13 @@ module SolidObjects class SynchronousInvocation + # @rbs @dedicated_process_registry: ProcessRegistry? + + # @rbs (?process_registry: ProcessRegistry?) -> void + def initialize(process_registry: nil) + @dedicated_process_registry = process_registry + end + # @rbs (MessageReference, timeout: Numeric) -> untyped def call(message_reference, timeout:) return call_before_deadline(message_reference, timeout:) if SyncDeadline.active? @@ -92,9 +99,17 @@ def raise_rejection(message) ) end + # @rbs () -> ProcessRegistry + def process_registry + dedicated_registry = @dedicated_process_registry + return SolidObjects.caller_process.process_registry unless dedicated_registry + + dedicated_registry.tap(&:heartbeat) + end + # @rbs (Message, deadline: Float) -> Integer def assist(message, deadline:) - process_registry = SolidObjects.caller_process.process_registry + process_registry = self.process_registry activation = ActivationManager .new(owner_id: process_registry.process_record.id) .claim(instance_id: message.instance_id) diff --git a/sig/generated/lib/solid_objects/caller_process.rbs b/sig/generated/lib/solid_objects/caller_process.rbs index faf4eb7..c68199e 100644 --- a/sig/generated/lib/solid_objects/caller_process.rbs +++ b/sig/generated/lib/solid_objects/caller_process.rbs @@ -16,15 +16,9 @@ module SolidObjects # @rbs () -> ProcessRegistry def process_registry: () -> ProcessRegistry - # @rbs () -> String? - def process_record_id: () -> String? - # @rbs () -> bool def stop: () -> bool - # @rbs (String) -> bool - def delete_process_record: (String) -> bool - private attr_reader mutex: untyped diff --git a/sig/generated/lib/solid_objects/doctor.rbs b/sig/generated/lib/solid_objects/doctor.rbs index 92d49a5..e8d4342 100644 --- a/sig/generated/lib/solid_objects/doctor.rbs +++ b/sig/generated/lib/solid_objects/doctor.rbs @@ -78,17 +78,17 @@ module SolidObjects # @rbs () -> Check def check_sync_round_trip: () -> Check - # @rbs (String) -> Check - def run_sync_probe: (String) -> Check + # @rbs (String, ProcessRegistry) -> Check + def run_sync_probe: (String, ProcessRegistry) -> Check - # @rbs (actor_id: String, caller_process_id: String?) -> Array[String] - def remove_probe_records: (actor_id: String, caller_process_id: String?) -> Array[String] + # @rbs (actor_id: String, probe_registry: ProcessRegistry) -> Array[String] + def remove_probe_records: (actor_id: String, probe_registry: ProcessRegistry) -> Array[String] # @rbs (String) -> bool def delete_probe_actor: (String) -> bool - # @rbs (String) -> bool - def delete_probe_caller_process: (String) -> bool + # @rbs (ProcessRegistry) -> bool + def delete_probe_caller_process: (ProcessRegistry) -> bool # @rbs (Check, Check) -> bool def ready_for_round_trip?: (Check, Check) -> bool diff --git a/sig/generated/lib/solid_objects/synchronous_invocation.rbs b/sig/generated/lib/solid_objects/synchronous_invocation.rbs index 15e11fc..ab11534 100644 --- a/sig/generated/lib/solid_objects/synchronous_invocation.rbs +++ b/sig/generated/lib/solid_objects/synchronous_invocation.rbs @@ -2,6 +2,11 @@ module SolidObjects class SynchronousInvocation + @dedicated_process_registry: ProcessRegistry? + + # @rbs (?process_registry: ProcessRegistry?) -> void + def initialize: (?process_registry: ProcessRegistry?) -> void + # @rbs (MessageReference, timeout: Numeric) -> untyped def call: (MessageReference, timeout: Numeric) -> untyped @@ -22,6 +27,9 @@ module SolidObjects # @rbs (Message) -> bot def raise_rejection: (Message) -> bot + # @rbs () -> ProcessRegistry + def process_registry: () -> ProcessRegistry + # @rbs (Message, deadline: Float) -> Integer def assist: (Message, deadline: Float) -> Integer diff --git a/test/integration/doctor_test.rb b/test/integration/doctor_test.rb index 519d230..72cee9c 100644 --- a/test/integration/doctor_test.rb +++ b/test/integration/doctor_test.rb @@ -21,6 +21,19 @@ class DoctorTest < ActiveSupport::TestCase assert_empty SolidObjects::Process.where(kind: "caller") end + test "runs its probe on a caller process the application cannot adopt" do + SolidObjects.caller_process.define_singleton_method(:process_registry) do + raise "the doctor probe must not share the application caller process" + end + + report = SolidObjects::Doctor.new.call + + assert_equal :pass, report.check(:sync_round_trip).status + assert_empty SolidObjects::Process.where(kind: "caller") + ensure + SolidObjects.reset_caller_process! + end + test "preserves a caller process the application registered before the probe" do existing_record = SolidObjects.caller_process.process_registry.process_record @@ -39,10 +52,11 @@ class DoctorTest < ActiveSupport::TestCase skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i) lock = hold_sqlite_write_lock - report = SolidObjects::Doctor.new.call + report = without_sqlite_busy_wait { SolidObjects::Doctor.new.call } refute report.healthy? assert_equal :fail, report.check(:sync_round_trip).status + assert_match(/database is locked/, report.check(:sync_round_trip).message) ensure release_sqlite_write_lock(lock) if lock end @@ -157,6 +171,16 @@ def hold_sqlite_write_lock [ thread, release ] end + def without_sqlite_busy_wait + SolidObjects::Record.connection_pool.with_connection do |connection| + previous_timeout = connection.select_value("PRAGMA busy_timeout").to_i + connection.execute("PRAGMA busy_timeout = 0") + yield + ensure + connection.execute("PRAGMA busy_timeout = #{previous_timeout}") if previous_timeout + end + end + def release_sqlite_write_lock(lock) thread, release = lock release << true From e015c6a8ba2356deafd6604f7e37042ff3fbcb8f Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Fri, 7 Aug 2026 16:22:50 -0700 Subject: [PATCH 3/5] 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. --- CHANGELOG.md | 8 +++ docs/correctness.md | 8 ++- lib/solid_objects/database_adapters/sqlite.rb | 43 ++++++++++-- .../database_adapters/sqlite.rbs | 9 +++ .../synchronous_invocation_test.rb | 70 +++++++++++++++++-- 5 files changed, 126 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 801ba1b..f1dc7dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## 0.5.1 - 2026-08-07 +- Restore the SQLite busy wait that a synchronous invocation suspends for its + deadline. Rails installs the busy wait as a Ruby busy handler through the + sqlite3 `timeout` configuration, which `PRAGMA busy_timeout` reports as zero + and silently replaces, so the previous save and restore left pooled + connections with no busy handler at all. Every later writer on that + connection, inside or outside Solid Objects, then failed immediately with + `SQLite3::BusyException` instead of waiting for the lock. + - Run the doctor round-trip probe on a dedicated caller process, and accept an explicit process registry in `SynchronousInvocation`, so the probe can no longer stop and delete a shared application caller process, release its diff --git a/docs/correctness.md b/docs/correctness.md index 39f4571..964e823 100644 --- a/docs/correctness.md +++ b/docs/correctness.md @@ -167,7 +167,13 @@ result. Adapter lock/query deadlines cover the durable enqueue, caller-process registration and heartbeat, activation coordination, and result observation. SQLite retries busy coordination operations only within the original call deadline and reports `waiting_on=database_contention` when the database cannot -be inspected at timeout. If enqueue cannot commit, `SyncEnqueueTimeout` is +be inspected at timeout. To keep those retries in Ruby, the SQLite adapter +suspends the connection's busy wait for the duration of each deadline-bound +transaction and restores it afterwards. Restoration reinstalls the Ruby busy +handler Rails configures from the sqlite3 `timeout` setting, which +`PRAGMA busy_timeout` neither reports nor preserves, so a synchronous call +leaves the connection's lock waiting behaviour exactly as it found it for +later writers inside and outside Solid Objects. If enqueue cannot commit, `SyncEnqueueTimeout` is raised and no message reference exists. MySQL lock waits have one-second InnoDB granularity. Ruby handlers that already started are not preempted. diff --git a/lib/solid_objects/database_adapters/sqlite.rb b/lib/solid_objects/database_adapters/sqlite.rb index aea77b6..d1a6c92 100644 --- a/lib/solid_objects/database_adapters/sqlite.rb +++ b/lib/solid_objects/database_adapters/sqlite.rb @@ -67,11 +67,46 @@ def with_lock_probe def with_transaction_deadline(connection) return yield unless SyncDeadline.active? - previous_timeout = connection.select_value("PRAGMA busy_timeout").to_i + busy_wait = suspend_busy_wait(connection) + begin + yield + ensure + restore_busy_wait(connection, busy_wait) + end + end + + # @rbs (untyped) -> Hash[Symbol, untyped] + def suspend_busy_wait(connection) + busy_wait = { + pragma_timeout: connection.select_value("PRAGMA busy_timeout").to_i, + handler_timeout: configured_busy_handler_timeout(connection) + } connection.execute("PRAGMA busy_timeout = 0") - yield - ensure - connection.execute("PRAGMA busy_timeout = #{previous_timeout}") if previous_timeout + busy_wait + end + + # @rbs (untyped, Hash[Symbol, untyped]) -> void + def restore_busy_wait(connection, busy_wait) + handler_timeout = busy_wait.fetch(:handler_timeout) + pragma_timeout = busy_wait.fetch(:pragma_timeout) + if handler_timeout && pragma_timeout.zero? + connection.raw_connection.busy_handler_timeout = handler_timeout + return + end + + connection.execute("PRAGMA busy_timeout = #{pragma_timeout}") + end + + # @rbs (untyped) -> Integer? + def configured_busy_handler_timeout(connection) + return nil unless connection.respond_to?(:raw_connection) + return nil unless connection.raw_connection.respond_to?(:busy_handler_timeout=) + + pool = connection.respond_to?(:pool) ? connection.pool : nil + return nil unless pool.respond_to?(:db_config) + + timeout = pool.db_config.configuration_hash[:timeout] + timeout&.to_i end # @rbs (Exception) -> bool diff --git a/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs b/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs index e186b12..510fa54 100644 --- a/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +++ b/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs @@ -26,6 +26,15 @@ module SolidObjects # @rbs (untyped) { () -> untyped } -> untyped def with_transaction_deadline: (untyped) { () -> untyped } -> untyped + # @rbs (untyped) -> Hash[Symbol, untyped] + def suspend_busy_wait: (untyped) -> Hash[Symbol, untyped] + + # @rbs (untyped, Hash[Symbol, untyped]) -> void + def restore_busy_wait: (untyped, Hash[Symbol, untyped]) -> void + + # @rbs (untyped) -> Integer? + def configured_busy_handler_timeout: (untyped) -> Integer? + # @rbs (Exception) -> bool def deadline_error?: (Exception) -> bool diff --git a/test/integration/synchronous_invocation_test.rb b/test/integration/synchronous_invocation_test.rb index 408a968..26e4b67 100644 --- a/test/integration/synchronous_invocation_test.rb +++ b/test/integration/synchronous_invocation_test.rb @@ -502,6 +502,18 @@ def wait(timeout:) release_sqlite_write_lock(lock) if lock end + test "sync restores the SQLite busy handler it suspended for the deadline" do + skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i) + + SolidObjects::Record.connection_pool.with_connection do + CounterActor.ref("busy-handler").increment + + assert_nothing_raised do + write_while_write_lock_is_briefly_held + end + end + end + test "sync bounds SQLite contention while reusing and heartbeating its caller process" do skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i) @@ -758,6 +770,32 @@ def release_sqlite_write_lock(lock) thread.join end + BRIEF_LOCK_HOLD = 0.2 + + def write_while_write_lock_is_briefly_held + lock = hold_sqlite_write_lock + releaser = Thread.new do + mutex = Thread::Mutex.new + mutex.synchronize { Thread::ConditionVariable.new.wait(mutex, BRIEF_LOCK_HOLD) } + release_sqlite_write_lock(lock) + lock = nil + end + + SolidObjects::Process.create!( + id: SecureRandom.uuid, + kind: "busy-handler-probe", + hostname: "test-host", + pid: ::Process.pid, + started_at: Time.current, + last_heartbeat_at: Time.current, + metadata: {} + ) + releaser.join + ensure + releaser&.join + release_sqlite_write_lock(lock) if lock + end + def invoke_with_immediate_sqlite_lock_failure(message_reference) result = Queue.new invocation = Thread.new do @@ -768,15 +806,14 @@ def invoke_with_immediate_sqlite_lock_failure(message_reference) attempts += 1 if process_write?(event.payload) end SolidObjects::Record.connection_pool.with_connection do |connection| - previous_timeout = connection.select_value("PRAGMA busy_timeout").to_i - connection.execute("PRAGMA busy_timeout = 0") - started_at = monotonic_now - error = capture_exception do - SolidObjects::SynchronousInvocation.new.call(message_reference, timeout: 0.1) + with_immediate_sqlite_lock_failure(connection) do + started_at = monotonic_now + error = capture_exception do + SolidObjects::SynchronousInvocation.new.call(message_reference, timeout: 0.1) + end + elapsed = monotonic_now - started_at end - elapsed = monotonic_now - started_at ensure - connection.execute("PRAGMA busy_timeout = #{previous_timeout}") ActiveSupport::Notifications.unsubscribe(subscription) end result << [ error, elapsed, attempts ] @@ -786,6 +823,25 @@ def invoke_with_immediate_sqlite_lock_failure(message_reference) captured end + def with_immediate_sqlite_lock_failure(connection) + database_adapter = SolidObjects.database_adapter + database_adapter.define_singleton_method(:configured_busy_handler_timeout) { |_connection| 0 } + connection.raw_connection.busy_handler_timeout = 0 + yield + ensure + database_adapter.singleton_class.send(:remove_method, :configured_busy_handler_timeout) + connection.raw_connection.busy_handler_timeout = configured_busy_handler_timeout + end + + def configured_busy_handler_timeout + SolidObjects::Record + .connection_pool + .db_config + .configuration_hash + .fetch(:timeout, 5_000) + .to_i + end + def process_write?(payload) payload.fetch(:sql).match?(/\A(?:INSERT|UPDATE)/) && payload.fetch(:sql).include?(SolidObjects::Process.table_name) From c661a030ce1d786370895805f687cfc0e5eca249 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Fri, 7 Aug 2026 16:28:00 -0700 Subject: [PATCH 4/5] test: stop stripping the SQLite busy handler The doctor and synchronous invocation suites each simulated immediate lock failure by writing PRAGMA busy_timeout back over itself, which left the pooled connection with no busy handler and made later concurrent tests fail with SQLite3::BusyException. Share one helper that suspends and correctly restores the configured busy wait. --- test/database_test_helper.rb | 25 +++++++++++++++++++ test/integration/doctor_test.rb | 12 +-------- .../synchronous_invocation_test.rb | 21 +--------------- 3 files changed, 27 insertions(+), 31 deletions(-) diff --git a/test/database_test_helper.rb b/test/database_test_helper.rb index fddca8e..4de539d 100644 --- a/test/database_test_helper.rb +++ b/test/database_test_helper.rb @@ -56,6 +56,31 @@ class ActiveSupport::TestCase SolidObjects::Process.delete_all SolidObjectsTestDomainRecord.delete_all end + + def with_immediate_sqlite_lock_failure(&block) + SolidObjects::Record.connection_pool.with_connection do |connection| + suspend_sqlite_busy_wait(connection, &block) + end + end + + def suspend_sqlite_busy_wait(connection) + database_adapter = SolidObjects.database_adapter + database_adapter.define_singleton_method(:configured_busy_handler_timeout) { |_connection| 0 } + connection.raw_connection.busy_handler_timeout = 0 + yield + ensure + database_adapter.singleton_class.send(:remove_method, :configured_busy_handler_timeout) + connection.raw_connection.busy_handler_timeout = configured_sqlite_busy_handler_timeout + end + + def configured_sqlite_busy_handler_timeout + SolidObjects::Record + .connection_pool + .db_config + .configuration_hash + .fetch(:timeout, 5_000) + .to_i + end end Minitest.after_run do diff --git a/test/integration/doctor_test.rb b/test/integration/doctor_test.rb index 72cee9c..12e4f45 100644 --- a/test/integration/doctor_test.rb +++ b/test/integration/doctor_test.rb @@ -52,7 +52,7 @@ class DoctorTest < ActiveSupport::TestCase skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i) lock = hold_sqlite_write_lock - report = without_sqlite_busy_wait { SolidObjects::Doctor.new.call } + report = with_immediate_sqlite_lock_failure { SolidObjects::Doctor.new.call } refute report.healthy? assert_equal :fail, report.check(:sync_round_trip).status @@ -171,16 +171,6 @@ def hold_sqlite_write_lock [ thread, release ] end - def without_sqlite_busy_wait - SolidObjects::Record.connection_pool.with_connection do |connection| - previous_timeout = connection.select_value("PRAGMA busy_timeout").to_i - connection.execute("PRAGMA busy_timeout = 0") - yield - ensure - connection.execute("PRAGMA busy_timeout = #{previous_timeout}") if previous_timeout - end - end - def release_sqlite_write_lock(lock) thread, release = lock release << true diff --git a/test/integration/synchronous_invocation_test.rb b/test/integration/synchronous_invocation_test.rb index 26e4b67..df7238c 100644 --- a/test/integration/synchronous_invocation_test.rb +++ b/test/integration/synchronous_invocation_test.rb @@ -806,7 +806,7 @@ def invoke_with_immediate_sqlite_lock_failure(message_reference) attempts += 1 if process_write?(event.payload) end SolidObjects::Record.connection_pool.with_connection do |connection| - with_immediate_sqlite_lock_failure(connection) do + suspend_sqlite_busy_wait(connection) do started_at = monotonic_now error = capture_exception do SolidObjects::SynchronousInvocation.new.call(message_reference, timeout: 0.1) @@ -823,25 +823,6 @@ def invoke_with_immediate_sqlite_lock_failure(message_reference) captured end - def with_immediate_sqlite_lock_failure(connection) - database_adapter = SolidObjects.database_adapter - database_adapter.define_singleton_method(:configured_busy_handler_timeout) { |_connection| 0 } - connection.raw_connection.busy_handler_timeout = 0 - yield - ensure - database_adapter.singleton_class.send(:remove_method, :configured_busy_handler_timeout) - connection.raw_connection.busy_handler_timeout = configured_busy_handler_timeout - end - - def configured_busy_handler_timeout - SolidObjects::Record - .connection_pool - .db_config - .configuration_hash - .fetch(:timeout, 5_000) - .to_i - end - def process_write?(payload) payload.fetch(:sql).match?(/\A(?:INSERT|UPDATE)/) && payload.fetch(:sql).include?(SolidObjects::Process.table_name) From 16d495074992338e2955dfb0e20e24af156c8130 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Fri, 7 Aug 2026 16:35:35 -0700 Subject: [PATCH 5/5] fix: suspend SQLite busy waits only when restorable The adapter reads the configured busy wait through the connection pool's database configuration. If a future Active Record release stops exposing it, restoration would silently fall back to writing a zero pragma and strip lock waiting from a pooled connection the whole application shares. Skip the suspension instead, which only loosens the synchronous deadline bound, and assert the timeout stays discoverable so the looser bound cannot be adopted unnoticed. --- CHANGELOG.md | 6 +++- docs/correctness.md | 10 ++++++- lib/solid_objects/database_adapters/sqlite.rb | 29 ++++++++++-------- .../database_adapters/sqlite.rbs | 4 +-- .../synchronous_invocation_test.rb | 30 +++++++++++++++++++ 5 files changed, 62 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1dc7dc..6b22b15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,11 @@ and silently replaces, so the previous save and restore left pooled connections with no busy handler at all. Every later writer on that connection, inside or outside Solid Objects, then failed immediately with - `SQLite3::BusyException` instead of waiting for the lock. + `SQLite3::BusyException` instead of waiting for the lock. Suspend the busy + wait only when the adapter can identify how to restore it, so an Active + Record release that stops exposing the configured timeout loosens + synchronous deadline bounds instead of stripping lock waiting from a shared + pooled connection. - Run the doctor round-trip probe on a dedicated caller process, and accept an explicit process registry in `SynchronousInvocation`, so the probe can no diff --git a/docs/correctness.md b/docs/correctness.md index 964e823..ad26e28 100644 --- a/docs/correctness.md +++ b/docs/correctness.md @@ -173,7 +173,15 @@ transaction and restores it afterwards. Restoration reinstalls the Ruby busy handler Rails configures from the sqlite3 `timeout` setting, which `PRAGMA busy_timeout` neither reports nor preserves, so a synchronous call leaves the connection's lock waiting behaviour exactly as it found it for -later writers inside and outside Solid Objects. If enqueue cannot commit, `SyncEnqueueTimeout` is +later writers inside and outside Solid Objects. + +The adapter suspends the busy wait only when it can identify how to restore +it. When a future Active Record release stops exposing the configured +timeout, the adapter leaves the connection untouched: synchronous deadlines +lose their tight bound and wait as long as the configured busy wait allows, +rather than stripping lock waiting from a pooled connection the rest of the +application shares. A test asserts the timeout stays discoverable so the +looser bound cannot be adopted silently. If enqueue cannot commit, `SyncEnqueueTimeout` is raised and no message reference exists. MySQL lock waits have one-second InnoDB granularity. Ruby handlers that already started are not preempted. diff --git a/lib/solid_objects/database_adapters/sqlite.rb b/lib/solid_objects/database_adapters/sqlite.rb index d1a6c92..01e9bfe 100644 --- a/lib/solid_objects/database_adapters/sqlite.rb +++ b/lib/solid_objects/database_adapters/sqlite.rb @@ -67,34 +67,37 @@ def with_lock_probe def with_transaction_deadline(connection) return yield unless SyncDeadline.active? - busy_wait = suspend_busy_wait(connection) + busy_wait = restorable_busy_wait(connection) + return yield unless busy_wait + begin + connection.execute("PRAGMA busy_timeout = 0") yield ensure restore_busy_wait(connection, busy_wait) end end - # @rbs (untyped) -> Hash[Symbol, untyped] - def suspend_busy_wait(connection) - busy_wait = { - pragma_timeout: connection.select_value("PRAGMA busy_timeout").to_i, - handler_timeout: configured_busy_handler_timeout(connection) - } - connection.execute("PRAGMA busy_timeout = 0") - busy_wait + # @rbs (untyped) -> Hash[Symbol, untyped]? + def restorable_busy_wait(connection) + pragma_timeout = connection.select_value("PRAGMA busy_timeout").to_i + return { pragma_timeout: } if pragma_timeout.positive? + + handler_timeout = configured_busy_handler_timeout(connection) + return nil unless handler_timeout + + { pragma_timeout:, handler_timeout: } end # @rbs (untyped, Hash[Symbol, untyped]) -> void def restore_busy_wait(connection, busy_wait) - handler_timeout = busy_wait.fetch(:handler_timeout) - pragma_timeout = busy_wait.fetch(:pragma_timeout) - if handler_timeout && pragma_timeout.zero? + handler_timeout = busy_wait[:handler_timeout] + if handler_timeout connection.raw_connection.busy_handler_timeout = handler_timeout return end - connection.execute("PRAGMA busy_timeout = #{pragma_timeout}") + connection.execute("PRAGMA busy_timeout = #{busy_wait.fetch(:pragma_timeout)}") end # @rbs (untyped) -> Integer? diff --git a/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs b/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs index 510fa54..225bef9 100644 --- a/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +++ b/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs @@ -26,8 +26,8 @@ module SolidObjects # @rbs (untyped) { () -> untyped } -> untyped def with_transaction_deadline: (untyped) { () -> untyped } -> untyped - # @rbs (untyped) -> Hash[Symbol, untyped] - def suspend_busy_wait: (untyped) -> Hash[Symbol, untyped] + # @rbs (untyped) -> Hash[Symbol, untyped]? + def restorable_busy_wait: (untyped) -> Hash[Symbol, untyped]? # @rbs (untyped, Hash[Symbol, untyped]) -> void def restore_busy_wait: (untyped, Hash[Symbol, untyped]) -> void diff --git a/test/integration/synchronous_invocation_test.rb b/test/integration/synchronous_invocation_test.rb index df7238c..4031162 100644 --- a/test/integration/synchronous_invocation_test.rb +++ b/test/integration/synchronous_invocation_test.rb @@ -502,6 +502,36 @@ def wait(timeout:) release_sqlite_write_lock(lock) if lock end + test "sync discovers the configured SQLite busy wait it has to restore" do + skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i) + + SolidObjects::Record.connection_pool.with_connection do |connection| + discovered = SolidObjects + .database_adapter + .send(:configured_busy_handler_timeout, connection) + + assert_equal configured_sqlite_busy_handler_timeout, discovered, + "the adapter can no longer read the configured busy wait, so it stops " \ + "suspending lock waits and synchronous deadlines lose their bound" + end + end + + test "sync leaves an unrestorable busy wait alone" do + skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i) + database_adapter = SolidObjects.database_adapter + database_adapter.define_singleton_method(:configured_busy_handler_timeout) { |_connection| nil } + + SolidObjects::Record.connection_pool.with_connection do + CounterActor.ref("unrestorable").increment + + assert_nothing_raised do + write_while_write_lock_is_briefly_held + end + end + ensure + database_adapter&.singleton_class&.send(:remove_method, :configured_busy_handler_timeout) + end + test "sync restores the SQLite busy handler it suspended for the deadline" do skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i)