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..f57ebda 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog
+## 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
+ 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/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/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" => "<% actor.player_controls.each do |c| %>- <%= c %>
<% end %>
",
+ "actors/solid_objects_benchmark/playmat_actor/_library_search.html.erb" => "<% actor.library_search.each do |c| %>- <%= c %>
<% end %>
"
+ )
+ 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/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
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