From 5d18a77a9ddfe9966b20f03545b77a1f91cb67a3 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 9 Aug 2026 08:54:31 -0700 Subject: [PATCH 1/2] fix: retry contended SQLite writes outside deadlines Asynchronous enqueue had no Ruby-level retry budget, so a contended write depended entirely on SQLite's busy handler and surfaced SQLite3::BusyException once concurrent writers exhausted it. This is the intermittent EnqueueTest failure that has cost re-runs on three pull requests. Retry busy errors with bounded exponential backoff, capped by lock_retry_attempts. Also pin every GitHub Actions reference to a commit SHA, and add a benchmark for the three reactive delivery paths. --- .github/workflows/ci.yml | 22 +-- CHANGELOG.md | 10 ++ benchmark/component_delivery.rb | 5 + benchmark/support.rb | 131 ++++++++++++++++ docs/benchmarks.md | 22 +++ lib/solid_objects/configuration.rb | 3 + lib/solid_objects/database_adapters/sqlite.rb | 39 ++++- .../lib/solid_objects/configuration.rbs | 10 +- .../database_adapters/sqlite.rbs | 14 ++ test/integration/enqueue_lock_retry_test.rb | 142 ++++++++++++++++++ 10 files changed, 382 insertions(+), 16 deletions(-) create mode 100644 benchmark/component_delivery.rb create mode 100644 test/integration/enqueue_lock_retry_test.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 596cc4d..228b896 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,8 @@ jobs: sqlite: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: ruby/setup-ruby@v1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 with: ruby-version: "3.3" bundler-cache: true @@ -45,8 +45,8 @@ jobs: env: SOLID_OBJECTS_DATABASE_URL: postgresql://solid_objects:solid_objects@127.0.0.1:5432/solid_objects_test steps: - - uses: actions/checkout@v7 - - uses: ruby/setup-ruby@v1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 with: ruby-version: "3.3" bundler-cache: true @@ -72,8 +72,8 @@ jobs: env: SOLID_OBJECTS_DATABASE_URL: mysql2://solid_objects:solid_objects@127.0.0.1:3306/solid_objects_test steps: - - uses: actions/checkout@v7 - - uses: ruby/setup-ruby@v1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 with: ruby-version: "3.3" bundler-cache: true @@ -82,8 +82,8 @@ jobs: static: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: ruby/setup-ruby@v1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 with: ruby-version: "3.3" bundler-cache: true @@ -102,10 +102,10 @@ jobs: contents: write id-token: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - uses: ruby/setup-ruby@v1 + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 with: ruby-version: "3.3" bundler-cache: true @@ -120,7 +120,7 @@ jobs: - name: Push to RubyGems run: gem push solid_objects-*.gem - name: Create GitHub release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: files: solid_objects-*.gem generate_release_notes: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 578975f..56b9a99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +- Retry a contended SQLite write outside a synchronous deadline. Asynchronous + enqueue had no Ruby-level retry budget, so it depended entirely on SQLite's + busy handler and raised `SQLite3::BusyException` once concurrent writers + exhausted it. Bounded by the new `lock_retry_attempts` setting. +- Pin every GitHub Actions reference to a commit SHA. +- Add a benchmark comparing individual, batched, and payload delivery for one + mutation that changes three components. + ## 0.7.0 - 2026-08-09 - Add `batch:` to reactive components. Components sharing a batch in one actor diff --git a/benchmark/component_delivery.rb b/benchmark/component_delivery.rb new file mode 100644 index 0000000..f99496c --- /dev/null +++ b/benchmark/component_delivery.rb @@ -0,0 +1,5 @@ +# rbs_inline: enabled + +require_relative "support" + +SolidObjectsBenchmark.component_delivery diff --git a/benchmark/support.rb b/benchmark/support.rb index cbdc0b9..7776870 100644 --- a/benchmark/support.rb +++ b/benchmark/support.rb @@ -22,6 +22,43 @@ class CounterActor < SolidObjects::Actor end end + class BenchmarkConnection + attr_reader :session_id + + def initialize(session_id) + @session_id = session_id + end + end + + class PlaymatActor < SolidObjects::Actor + actor_type "benchmark-playmat" + + attribute :player, default: "unseated" + attribute :player_controls, default: -> { [] } + attribute :library_search, default: -> { [] } + attribute :hands, default: -> { {} } + + observable :player + observable :player_controls + observable :library_search + + broadcast_payload :playmat_state do |actor, context| + { + "player" => actor.player, + "controls" => actor.player_controls, + "library" => actor.library_search, + "hand" => actor.hands.fetch(context.session_id, []) + } + end + + def seat(player:) + self.player = player + self.player_controls = %w[untap draw] + self.library_search = %w[Island Forest] + self.hands = hands.merge(player => %w[Island]) + end + end + class << self # @rbs () -> Integer def count @@ -189,6 +226,69 @@ def activation_cache worker&.stop end + # Compares how many browser requests one actor mutation costs across the + # three delivery paths, and how long the server spends producing them. + # @rbs () -> void + def component_delivery + require "action_controller" + require "action_view" + require "action_view/testing/resolvers" + + SolidObjects.configuration.stream_signing_secret = "benchmark-secret" + SolidObjects.configuration.authorize_query = ->(**) { true } + reference = PlaymatActor.ref("table") + reference.seat(player: "alice") + + view_context = benchmark_view_context + snapshot = SolidObjects::ActorSnapshot.new(reference) + registrations = %w[player player_controls library_search].map do |name| + SolidObjects::ComponentRegistration.issue( + reference:, + component_name: name, + component_key: nil, + dependencies: [ name ], + locals: {}, + refresh_method: "morph", + snapshot:, + refresh_path: "/solid_objects/components", + batch: "playmat" + ) + end + + individual = measure_delivery(count) do + registrations.each do |registration| + render_component(registration, view_context) + end + end + batched = measure_delivery(count) do + current = SolidObjects::ActorSnapshot.new(reference) + registrations.each do |registration| + render_component(registration, view_context, snapshot: current) + end + end + payload = measure_delivery(count) do + SolidObjects::PayloadBroadcast.new( + snapshot: SolidObjects::ActorSnapshot.new(reference), + name: "playmat_state", + authorization_context: BenchmarkConnection.new("alice") + ).call + end + + puts "three components changing in one mutation, #{count} iterations" + puts format( + " individual refreshes: 3 requests, %.3fms per mutation", + individual + ) + puts format( + " batched refresh: 1 request, %.3fms per mutation", + batched + ) + puts format( + " state payload: 0 requests, %.3fms per mutation", + payload + ) + end + # @rbs () -> void def query_count CounterActor.ref("queries").async(:increment) @@ -209,6 +309,37 @@ def query_count private + # @rbs (ComponentRegistration, untyped, ?snapshot: ActorSnapshot?) -> untyped + def render_component(registration, view_context, snapshot: nil) + SolidObjects::ComponentRenderer.new( + snapshot: snapshot || SolidObjects::ActorSnapshot.new(registration.reference), + registration:, + view_context:, + authorization_context: nil + ).call + end + + # @rbs (Integer) { () -> untyped } -> Float + def measure_delivery(iterations) + yield + elapsed = Benchmark.realtime { iterations.times { yield } } + (elapsed / iterations) * 1_000 + end + + # @rbs () -> untyped + def benchmark_view_context + resolver = ActionView::FixtureResolver.new( + "actors/solid_objects_benchmark/playmat_actor/_player.html.erb" => "

<%= actor.player %>

", + "actors/solid_objects_benchmark/playmat_actor/_player_controls.html.erb" => "", + "actors/solid_objects_benchmark/playmat_actor/_library_search.html.erb" => "" + ) + ActionView::Base.with_empty_template_cache.new( + ActionView::LookupContext.new([ resolver ]), + {}, + nil + ) + end + # @rbs () -> void def establish_connection database_url = ENV["SOLID_OBJECTS_DATABASE_URL"] diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 0f04306..73f87af 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -54,6 +54,28 @@ result is why Solid Objects does not publish one latency promise. Network topology, adapter behavior, host schema, logging, callbacks, and contention all matter. +## Reactive delivery paths + +Measured 2026-08-09 on an Apple M5 with 200 iterations, for one actor mutation +that changes three components. + +| Delivery path | Browser requests | Server render time | +| --- | ---: | ---: | +| Individual component refreshes | 3 | 0.535 ms | +| Batched refresh | 1 | 0.249 ms | +| State payload broadcast | 0 | 0.100 ms | + +The request column is the headline. Server render time is small in every path, +so the win is not faster rendering, it is fewer round trips: each individual +refresh costs a full HTTP request through the Rails middleware stack, and a +batch replaces three of those with one. A state payload removes the HTTP leg +entirely by travelling on the Action Cable connection the page already holds. + +These are server-side numbers. They do not include network latency, Action +Cable delivery, or browser rendering, which dominate wall-clock time in a real +deployment and make the request-count difference matter more than it appears +here. End-to-end latency against a deployed application has not been measured. + ## Durable row growth The storage cost is deterministic even when latency is not: diff --git a/lib/solid_objects/configuration.rb b/lib/solid_objects/configuration.rb index ee7f09c..d6f83d5 100644 --- a/lib/solid_objects/configuration.rb +++ b/lib/solid_objects/configuration.rb @@ -17,6 +17,7 @@ class Configuration # @rbs @max_result_bytes: Integer # @rbs @max_attempts: Integer # @rbs @retry_delay: Proc + # @rbs @lock_retry_attempts: Integer # @rbs @process_heartbeat_interval: Float # @rbs @process_alive_threshold: Float # @rbs @shutdown_timeout: Float @@ -57,6 +58,7 @@ class Configuration :max_result_bytes, :max_attempts, :retry_delay, + :lock_retry_attempts, :process_heartbeat_interval, :process_alive_threshold, :shutdown_timeout, @@ -99,6 +101,7 @@ def initialize @max_result_bytes = 1.megabyte @max_attempts = 5 @retry_delay = ->(attempt) { [ 2**(attempt - 1), 60 ].min.to_f } + @lock_retry_attempts = 10 @process_heartbeat_interval = 15.0 @process_alive_threshold = 60.0 @shutdown_timeout = 15.0 diff --git a/lib/solid_objects/database_adapters/sqlite.rb b/lib/solid_objects/database_adapters/sqlite.rb index 7bff5bf..b885252 100644 --- a/lib/solid_objects/database_adapters/sqlite.rb +++ b/lib/solid_objects/database_adapters/sqlite.rb @@ -4,6 +4,7 @@ module SolidObjects module DatabaseAdapters class Sqlite < DatabaseAdapter LOCK_RETRY_INTERVAL = 0.001 + MAXIMUM_BUSY_RETRY_INTERVAL = 0.25 LOCK_RETRY_MUTEX = Thread::Mutex.new LOCK_RETRY_CONDITION = Thread::ConditionVariable.new @@ -14,9 +15,28 @@ def current_time_expression # @rbs () { () -> untyped } -> untyped def transaction(&block) - return super unless SyncDeadline.active? + return with_lock_retry { super } if SyncDeadline.active? - with_lock_retry { super } + with_busy_retry { super } + end + + # A write outside a synchronous deadline has no Ruby-level budget, so it + # depends entirely on SQLite's busy handler. Concurrent writers can + # exhaust that, which surfaces as a lock error the caller cannot retry. + # @rbs () { () -> untyped } -> untyped + def with_busy_retry + attempts = 0 + begin + yield + rescue => error + raise unless busy_error?(error) + + attempts += 1 + raise if attempts > SolidObjects.configuration.lock_retry_attempts + + wait_before_busy_retry(attempts) + retry + end end # @rbs () { () -> untyped } -> untyped @@ -116,6 +136,11 @@ def configured_busy_handler_timeout(connection) def deadline_error?(error) return false unless SyncDeadline.active? + busy_error?(error) + end + + # @rbs (Exception) -> bool + def busy_error?(error) cause = error while cause return true if cause.class.name.match?(/BusyException|BusyError/) @@ -125,6 +150,16 @@ def deadline_error?(error) false end + # @rbs (Integer) -> void + def wait_before_busy_retry(attempts) + LOCK_RETRY_MUTEX.synchronize do + LOCK_RETRY_CONDITION.wait( + LOCK_RETRY_MUTEX, + [ LOCK_RETRY_INTERVAL * (2**(attempts - 1)), MAXIMUM_BUSY_RETRY_INTERVAL ].min + ) + end + end + # @rbs () -> void def wait_before_retry LOCK_RETRY_MUTEX.synchronize do diff --git a/sig/generated/lib/solid_objects/configuration.rbs b/sig/generated/lib/solid_objects/configuration.rbs index 75493b0..3597980 100644 --- a/sig/generated/lib/solid_objects/configuration.rbs +++ b/sig/generated/lib/solid_objects/configuration.rbs @@ -2,7 +2,7 @@ module SolidObjects class Configuration - @table_name_prefix: String + @process_alive_threshold: Float @shutdown_timeout: Float @@ -48,6 +48,8 @@ module SolidObjects @authorize_administration: Proc + @table_name_prefix: String + @polling_interval: Float @sync_polling_interval: Float @@ -76,9 +78,9 @@ module SolidObjects @retry_delay: Proc - @process_heartbeat_interval: Float + @lock_retry_attempts: Integer - @process_alive_threshold: Float + @process_heartbeat_interval: Float attr_accessor table_name_prefix: untyped @@ -110,6 +112,8 @@ module SolidObjects attr_accessor retry_delay: untyped + attr_accessor lock_retry_attempts: untyped + attr_accessor process_heartbeat_interval: untyped attr_accessor process_alive_threshold: untyped diff --git a/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs b/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs index 225bef9..ca182a3 100644 --- a/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +++ b/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs @@ -5,6 +5,8 @@ module SolidObjects class Sqlite < DatabaseAdapter LOCK_RETRY_INTERVAL: ::Float + MAXIMUM_BUSY_RETRY_INTERVAL: ::Float + LOCK_RETRY_MUTEX: untyped LOCK_RETRY_CONDITION: untyped @@ -15,6 +17,12 @@ module SolidObjects # @rbs () { () -> untyped } -> untyped def transaction: () { () -> untyped } -> untyped + # A write outside a synchronous deadline has no Ruby-level budget, so it + # depends entirely on SQLite's busy handler. Concurrent writers can + # exhaust that, which surfaces as a lock error the caller cannot retry. + # @rbs () { () -> untyped } -> untyped + def with_busy_retry: () { () -> untyped } -> untyped + # @rbs () { () -> untyped } -> untyped def with_lock_retry: () { () -> untyped } -> untyped @@ -38,6 +46,12 @@ module SolidObjects # @rbs (Exception) -> bool def deadline_error?: (Exception) -> bool + # @rbs (Exception) -> bool + def busy_error?: (Exception) -> bool + + # @rbs (Integer) -> void + def wait_before_busy_retry: (Integer) -> void + # @rbs () -> void def wait_before_retry: () -> void end diff --git a/test/integration/enqueue_lock_retry_test.rb b/test/integration/enqueue_lock_retry_test.rb new file mode 100644 index 0000000..1e64585 --- /dev/null +++ b/test/integration/enqueue_lock_retry_test.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require "database_test_helper" +require "timeout" + +class EnqueueLockRetryTest < ActiveSupport::TestCase + class CartActor < SolidObjects::Actor + actor_type "enqueue-retry-cart" + + attribute :items, default: -> { [] } + + def add(product_id:) + self.items += [ product_id ] + end + end + + setup { CartActor.ensure_registered! } + + # SQLite's own busy handler hides the gap until it is exhausted, so these + # tests disable it. Only a Ruby-level retry can complete the enqueue. + test "an enqueue retries a contended write instead of failing immediately" do + skip unless sqlite? + reference = CartActor.ref("alice") + lock = hold_write_lock_briefly + + message = SolidObjects::Record.connection_pool.with_connection do |connection| + suspend_sqlite_busy_wait(connection) do + reference.async(:add, product_id: "shirt") + end + end + + assert_equal 1, message.sequence + ensure + release_lock(lock) + end + + test "concurrent enqueues all succeed while a lock is contended" do + skip unless sqlite? + reference = CartActor.ref("alice") + start = Queue.new + errors = Queue.new + sequences = Queue.new + lock = hold_write_lock_briefly + + # The adapter is shared, so the busy wait is suspended once for every + # thread rather than stubbed per thread. + SolidObjects::Record.connection_pool.with_connection do |connection| + suspend_sqlite_busy_wait(connection) do + threads = 6.times.map do + Thread.new do + SolidObjects::Record.connection_pool.with_connection do |thread_connection| + thread_connection.raw_connection.busy_handler_timeout = 0 + start.pop + sequences << reference.async(:add, product_id: "shirt").sequence + rescue => error + errors << error + end + end + end + threads.length.times { start << true } + threads.each { |thread| thread.join(30) } + end + end + + assert_empty errors.size.times.map { errors.pop } + assert_equal (1..6).to_a, sequences.size.times.map { sequences.pop }.sort + ensure + release_lock(lock) + end + + test "a permanently locked database still raises rather than hanging" do + skip unless sqlite? + SolidObjects.configuration.lock_retry_attempts = 2 + reference = CartActor.ref("alice") + lock = hold_write_lock + + error = assert_raises(ActiveRecord::StatementTimeout) do + Timeout.timeout(20) do + SolidObjects::Record.connection_pool.with_connection do |connection| + suspend_sqlite_busy_wait(connection) do + reference.async(:add, product_id: "shirt") + end + end + end + end + + assert_match(/database is locked/, error.message) + ensure + release_lock(lock) + end + + private + + def sqlite? + SolidObjects::Record.connection.adapter_name.match?(/sqlite/i) + end + + # Holds the write lock long enough that an enqueue must wait, then releases + # so a correctly retrying enqueue completes. + def hold_write_lock_briefly + hold_write_lock(release_after: 0.25) + end + + def hold_write_lock(release_after: nil) + 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 + if release_after + mutex = Thread::Mutex.new + mutex.synchronize do + Thread::ConditionVariable.new.wait(mutex, release_after) + end + else + release.pop + end + end + end + end + Timeout.timeout(5) { locked.pop } + [ thread, release, release_after ] + end + + def release_lock(lock) + return unless lock + + thread, release, release_after = lock + release << true unless release_after + thread.join(10) + end +end From 826cd5cd1759bbe2e1c886adcd1fa03f90fd837e Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 9 Aug 2026 08:58:13 -0700 Subject: [PATCH 2/2] chore: prepare 0.7.1 release --- CHANGELOG.md | 2 +- Gemfile.lock | 4 ++-- lib/solid_objects/version.rb | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56b9a99..f57ebda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.7.1 - 2026-08-09 - Retry a contended SQLite write outside a synchronous deadline. Asynchronous enqueue had no Ruby-level retry budget, so it depended entirely on SQLite's diff --git a/Gemfile.lock b/Gemfile.lock index 7520c52..4fe8109 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - solid_objects (0.7.0) + solid_objects (0.7.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.7.0) + solid_objects (0.7.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/lib/solid_objects/version.rb b/lib/solid_objects/version.rb index d1d1156..1adf97b 100644 --- a/lib/solid_objects/version.rb +++ b/lib/solid_objects/version.rb @@ -1,5 +1,5 @@ # rbs_inline: enabled module SolidObjects - VERSION = "0.7.0" + VERSION = "0.7.1" end