Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions benchmark/component_delivery.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# rbs_inline: enabled

require_relative "support"

SolidObjectsBenchmark.component_delivery
131 changes: 131 additions & 0 deletions benchmark/support.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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" => "<p><%= actor.player %></p>",
"actors/solid_objects_benchmark/playmat_actor/_player_controls.html.erb" => "<ul><% actor.player_controls.each do |c| %><li><%= c %></li><% end %></ul>",
"actors/solid_objects_benchmark/playmat_actor/_library_search.html.erb" => "<ul><% actor.library_search.each do |c| %><li><%= c %></li><% end %></ul>"
)
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"]
Expand Down
22 changes: 22 additions & 0 deletions docs/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions lib/solid_objects/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,6 +58,7 @@ class Configuration
:max_result_bytes,
:max_attempts,
:retry_delay,
:lock_retry_attempts,
:process_heartbeat_interval,
:process_alive_threshold,
:shutdown_timeout,
Expand Down Expand Up @@ -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
Expand Down
39 changes: 37 additions & 2 deletions lib/solid_objects/database_adapters/sqlite.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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/)
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/solid_objects/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# rbs_inline: enabled

module SolidObjects
VERSION = "0.7.0"
VERSION = "0.7.1"
end
Loading
Loading