Skip to content

Commit e890b7f

Browse files
authored
Merge pull request #10 from cardmagic/agent/enqueue-lock-retry-and-benchmarks
fix: retry contended SQLite writes outside deadlines
2 parents 8df1773 + 826cd5c commit e890b7f

12 files changed

Lines changed: 385 additions & 19 deletions

File tree

.github/workflows/ci.yml

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ jobs:
99
sqlite:
1010
runs-on: ubuntu-latest
1111
steps:
12-
- uses: actions/checkout@v7
13-
- uses: ruby/setup-ruby@v1
12+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
13+
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
1414
with:
1515
ruby-version: "3.3"
1616
bundler-cache: true
@@ -45,8 +45,8 @@ jobs:
4545
env:
4646
SOLID_OBJECTS_DATABASE_URL: postgresql://solid_objects:solid_objects@127.0.0.1:5432/solid_objects_test
4747
steps:
48-
- uses: actions/checkout@v7
49-
- uses: ruby/setup-ruby@v1
48+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
49+
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
5050
with:
5151
ruby-version: "3.3"
5252
bundler-cache: true
@@ -72,8 +72,8 @@ jobs:
7272
env:
7373
SOLID_OBJECTS_DATABASE_URL: mysql2://solid_objects:solid_objects@127.0.0.1:3306/solid_objects_test
7474
steps:
75-
- uses: actions/checkout@v7
76-
- uses: ruby/setup-ruby@v1
75+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
76+
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
7777
with:
7878
ruby-version: "3.3"
7979
bundler-cache: true
@@ -82,8 +82,8 @@ jobs:
8282
static:
8383
runs-on: ubuntu-latest
8484
steps:
85-
- uses: actions/checkout@v7
86-
- uses: ruby/setup-ruby@v1
85+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
86+
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
8787
with:
8888
ruby-version: "3.3"
8989
bundler-cache: true
@@ -102,10 +102,10 @@ jobs:
102102
contents: write
103103
id-token: write
104104
steps:
105-
- uses: actions/checkout@v7
105+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
106106
with:
107107
persist-credentials: false
108-
- uses: ruby/setup-ruby@v1
108+
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
109109
with:
110110
ruby-version: "3.3"
111111
bundler-cache: true
@@ -120,7 +120,7 @@ jobs:
120120
- name: Push to RubyGems
121121
run: gem push solid_objects-*.gem
122122
- name: Create GitHub release
123-
uses: softprops/action-gh-release@v2
123+
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
124124
with:
125125
files: solid_objects-*.gem
126126
generate_release_notes: true

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## 0.7.1 - 2026-08-09
4+
5+
- Retry a contended SQLite write outside a synchronous deadline. Asynchronous
6+
enqueue had no Ruby-level retry budget, so it depended entirely on SQLite's
7+
busy handler and raised `SQLite3::BusyException` once concurrent writers
8+
exhausted it. Bounded by the new `lock_retry_attempts` setting.
9+
- Pin every GitHub Actions reference to a commit SHA.
10+
- Add a benchmark comparing individual, batched, and payload delivery for one
11+
mutation that changes three components.
12+
313
## 0.7.0 - 2026-08-09
414

515
- Add `batch:` to reactive components. Components sharing a batch in one actor

Gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
solid_objects (0.7.0)
4+
solid_objects (0.7.1)
55
actioncable (>= 8.0)
66
actionpack (>= 8.0)
77
actionview (>= 8.0)
@@ -373,7 +373,7 @@ CHECKSUMS
373373
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
374374
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
375375
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
376-
solid_objects (0.7.0)
376+
solid_objects (0.7.1)
377377
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
378378
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
379379
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b

benchmark/component_delivery.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# rbs_inline: enabled
2+
3+
require_relative "support"
4+
5+
SolidObjectsBenchmark.component_delivery

benchmark/support.rb

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,43 @@ class CounterActor < SolidObjects::Actor
2222
end
2323
end
2424

25+
class BenchmarkConnection
26+
attr_reader :session_id
27+
28+
def initialize(session_id)
29+
@session_id = session_id
30+
end
31+
end
32+
33+
class PlaymatActor < SolidObjects::Actor
34+
actor_type "benchmark-playmat"
35+
36+
attribute :player, default: "unseated"
37+
attribute :player_controls, default: -> { [] }
38+
attribute :library_search, default: -> { [] }
39+
attribute :hands, default: -> { {} }
40+
41+
observable :player
42+
observable :player_controls
43+
observable :library_search
44+
45+
broadcast_payload :playmat_state do |actor, context|
46+
{
47+
"player" => actor.player,
48+
"controls" => actor.player_controls,
49+
"library" => actor.library_search,
50+
"hand" => actor.hands.fetch(context.session_id, [])
51+
}
52+
end
53+
54+
def seat(player:)
55+
self.player = player
56+
self.player_controls = %w[untap draw]
57+
self.library_search = %w[Island Forest]
58+
self.hands = hands.merge(player => %w[Island])
59+
end
60+
end
61+
2562
class << self
2663
# @rbs () -> Integer
2764
def count
@@ -189,6 +226,69 @@ def activation_cache
189226
worker&.stop
190227
end
191228

229+
# Compares how many browser requests one actor mutation costs across the
230+
# three delivery paths, and how long the server spends producing them.
231+
# @rbs () -> void
232+
def component_delivery
233+
require "action_controller"
234+
require "action_view"
235+
require "action_view/testing/resolvers"
236+
237+
SolidObjects.configuration.stream_signing_secret = "benchmark-secret"
238+
SolidObjects.configuration.authorize_query = ->(**) { true }
239+
reference = PlaymatActor.ref("table")
240+
reference.seat(player: "alice")
241+
242+
view_context = benchmark_view_context
243+
snapshot = SolidObjects::ActorSnapshot.new(reference)
244+
registrations = %w[player player_controls library_search].map do |name|
245+
SolidObjects::ComponentRegistration.issue(
246+
reference:,
247+
component_name: name,
248+
component_key: nil,
249+
dependencies: [ name ],
250+
locals: {},
251+
refresh_method: "morph",
252+
snapshot:,
253+
refresh_path: "/solid_objects/components",
254+
batch: "playmat"
255+
)
256+
end
257+
258+
individual = measure_delivery(count) do
259+
registrations.each do |registration|
260+
render_component(registration, view_context)
261+
end
262+
end
263+
batched = measure_delivery(count) do
264+
current = SolidObjects::ActorSnapshot.new(reference)
265+
registrations.each do |registration|
266+
render_component(registration, view_context, snapshot: current)
267+
end
268+
end
269+
payload = measure_delivery(count) do
270+
SolidObjects::PayloadBroadcast.new(
271+
snapshot: SolidObjects::ActorSnapshot.new(reference),
272+
name: "playmat_state",
273+
authorization_context: BenchmarkConnection.new("alice")
274+
).call
275+
end
276+
277+
puts "three components changing in one mutation, #{count} iterations"
278+
puts format(
279+
" individual refreshes: 3 requests, %.3fms per mutation",
280+
individual
281+
)
282+
puts format(
283+
" batched refresh: 1 request, %.3fms per mutation",
284+
batched
285+
)
286+
puts format(
287+
" state payload: 0 requests, %.3fms per mutation",
288+
payload
289+
)
290+
end
291+
192292
# @rbs () -> void
193293
def query_count
194294
CounterActor.ref("queries").async(:increment)
@@ -209,6 +309,37 @@ def query_count
209309

210310
private
211311

312+
# @rbs (ComponentRegistration, untyped, ?snapshot: ActorSnapshot?) -> untyped
313+
def render_component(registration, view_context, snapshot: nil)
314+
SolidObjects::ComponentRenderer.new(
315+
snapshot: snapshot || SolidObjects::ActorSnapshot.new(registration.reference),
316+
registration:,
317+
view_context:,
318+
authorization_context: nil
319+
).call
320+
end
321+
322+
# @rbs (Integer) { () -> untyped } -> Float
323+
def measure_delivery(iterations)
324+
yield
325+
elapsed = Benchmark.realtime { iterations.times { yield } }
326+
(elapsed / iterations) * 1_000
327+
end
328+
329+
# @rbs () -> untyped
330+
def benchmark_view_context
331+
resolver = ActionView::FixtureResolver.new(
332+
"actors/solid_objects_benchmark/playmat_actor/_player.html.erb" => "<p><%= actor.player %></p>",
333+
"actors/solid_objects_benchmark/playmat_actor/_player_controls.html.erb" => "<ul><% actor.player_controls.each do |c| %><li><%= c %></li><% end %></ul>",
334+
"actors/solid_objects_benchmark/playmat_actor/_library_search.html.erb" => "<ul><% actor.library_search.each do |c| %><li><%= c %></li><% end %></ul>"
335+
)
336+
ActionView::Base.with_empty_template_cache.new(
337+
ActionView::LookupContext.new([ resolver ]),
338+
{},
339+
nil
340+
)
341+
end
342+
212343
# @rbs () -> void
213344
def establish_connection
214345
database_url = ENV["SOLID_OBJECTS_DATABASE_URL"]

docs/benchmarks.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,28 @@ result is why Solid Objects does not publish one latency promise. Network
5454
topology, adapter behavior, host schema, logging, callbacks, and contention all
5555
matter.
5656

57+
## Reactive delivery paths
58+
59+
Measured 2026-08-09 on an Apple M5 with 200 iterations, for one actor mutation
60+
that changes three components.
61+
62+
| Delivery path | Browser requests | Server render time |
63+
| --- | ---: | ---: |
64+
| Individual component refreshes | 3 | 0.535 ms |
65+
| Batched refresh | 1 | 0.249 ms |
66+
| State payload broadcast | 0 | 0.100 ms |
67+
68+
The request column is the headline. Server render time is small in every path,
69+
so the win is not faster rendering, it is fewer round trips: each individual
70+
refresh costs a full HTTP request through the Rails middleware stack, and a
71+
batch replaces three of those with one. A state payload removes the HTTP leg
72+
entirely by travelling on the Action Cable connection the page already holds.
73+
74+
These are server-side numbers. They do not include network latency, Action
75+
Cable delivery, or browser rendering, which dominate wall-clock time in a real
76+
deployment and make the request-count difference matter more than it appears
77+
here. End-to-end latency against a deployed application has not been measured.
78+
5779
## Durable row growth
5880

5981
The storage cost is deterministic even when latency is not:

lib/solid_objects/configuration.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class Configuration
1717
# @rbs @max_result_bytes: Integer
1818
# @rbs @max_attempts: Integer
1919
# @rbs @retry_delay: Proc
20+
# @rbs @lock_retry_attempts: Integer
2021
# @rbs @process_heartbeat_interval: Float
2122
# @rbs @process_alive_threshold: Float
2223
# @rbs @shutdown_timeout: Float
@@ -57,6 +58,7 @@ class Configuration
5758
:max_result_bytes,
5859
:max_attempts,
5960
:retry_delay,
61+
:lock_retry_attempts,
6062
:process_heartbeat_interval,
6163
:process_alive_threshold,
6264
:shutdown_timeout,
@@ -99,6 +101,7 @@ def initialize
99101
@max_result_bytes = 1.megabyte
100102
@max_attempts = 5
101103
@retry_delay = ->(attempt) { [ 2**(attempt - 1), 60 ].min.to_f }
104+
@lock_retry_attempts = 10
102105
@process_heartbeat_interval = 15.0
103106
@process_alive_threshold = 60.0
104107
@shutdown_timeout = 15.0

lib/solid_objects/database_adapters/sqlite.rb

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ module SolidObjects
44
module DatabaseAdapters
55
class Sqlite < DatabaseAdapter
66
LOCK_RETRY_INTERVAL = 0.001
7+
MAXIMUM_BUSY_RETRY_INTERVAL = 0.25
78
LOCK_RETRY_MUTEX = Thread::Mutex.new
89
LOCK_RETRY_CONDITION = Thread::ConditionVariable.new
910

@@ -14,9 +15,28 @@ def current_time_expression
1415

1516
# @rbs () { () -> untyped } -> untyped
1617
def transaction(&block)
17-
return super unless SyncDeadline.active?
18+
return with_lock_retry { super } if SyncDeadline.active?
1819

19-
with_lock_retry { super }
20+
with_busy_retry { super }
21+
end
22+
23+
# A write outside a synchronous deadline has no Ruby-level budget, so it
24+
# depends entirely on SQLite's busy handler. Concurrent writers can
25+
# exhaust that, which surfaces as a lock error the caller cannot retry.
26+
# @rbs () { () -> untyped } -> untyped
27+
def with_busy_retry
28+
attempts = 0
29+
begin
30+
yield
31+
rescue => error
32+
raise unless busy_error?(error)
33+
34+
attempts += 1
35+
raise if attempts > SolidObjects.configuration.lock_retry_attempts
36+
37+
wait_before_busy_retry(attempts)
38+
retry
39+
end
2040
end
2141

2242
# @rbs () { () -> untyped } -> untyped
@@ -116,6 +136,11 @@ def configured_busy_handler_timeout(connection)
116136
def deadline_error?(error)
117137
return false unless SyncDeadline.active?
118138

139+
busy_error?(error)
140+
end
141+
142+
# @rbs (Exception) -> bool
143+
def busy_error?(error)
119144
cause = error
120145
while cause
121146
return true if cause.class.name.match?(/BusyException|BusyError/)
@@ -125,6 +150,16 @@ def deadline_error?(error)
125150
false
126151
end
127152

153+
# @rbs (Integer) -> void
154+
def wait_before_busy_retry(attempts)
155+
LOCK_RETRY_MUTEX.synchronize do
156+
LOCK_RETRY_CONDITION.wait(
157+
LOCK_RETRY_MUTEX,
158+
[ LOCK_RETRY_INTERVAL * (2**(attempts - 1)), MAXIMUM_BUSY_RETRY_INTERVAL ].min
159+
)
160+
end
161+
end
162+
128163
# @rbs () -> void
129164
def wait_before_retry
130165
LOCK_RETRY_MUTEX.synchronize do

lib/solid_objects/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# rbs_inline: enabled
22

33
module SolidObjects
4-
VERSION = "0.7.0"
4+
VERSION = "0.7.1"
55
end

0 commit comments

Comments
 (0)