solid_objects 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/benchmark/component_delivery.rb +5 -0
- data/benchmark/support.rb +131 -0
- data/docs/benchmarks.md +22 -0
- data/lib/solid_objects/configuration.rb +3 -0
- data/lib/solid_objects/database_adapters/sqlite.rb +37 -2
- data/lib/solid_objects/version.rb +1 -1
- data/sig/generated/lib/solid_objects/configuration.rbs +7 -3
- data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +14 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 75595f12197f05985076afb8974ec528b85914db25da49c29b342b9fc531ad97
|
|
4
|
+
data.tar.gz: d240311fe0bfbbd12386d582c4a8dcb560a2698385d65c1f0781340880cbd11c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e303049a41809dca76e99ed3b035cde028817e2f0a6a7fadc1fd90f41bde9e108b83bc06247d596722c135ffad4b9b2fda9e5fb75277a89e837acc0c3ade9b25
|
|
7
|
+
data.tar.gz: 8f800ed5e80df86176ff1f7967da7f117363607c2993872670bfe0d85a0ece83db1eef8f1f4f72a74992182cae77f36bd80ea30ebc81b9c7a5bd03ba16a14785
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
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
|
+
|
|
3
13
|
## 0.7.0 - 2026-08-09
|
|
4
14
|
|
|
5
15
|
- Add `batch:` to reactive components. Components sharing a batch in one actor
|
data/benchmark/support.rb
CHANGED
|
@@ -22,6 +22,43 @@ module SolidObjectsBenchmark
|
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
24
|
|
|
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
|
+
|
|
25
62
|
class << self
|
|
26
63
|
# @rbs () -> Integer
|
|
27
64
|
def count
|
|
@@ -189,6 +226,69 @@ module SolidObjectsBenchmark
|
|
|
189
226
|
worker&.stop
|
|
190
227
|
end
|
|
191
228
|
|
|
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
|
+
|
|
192
292
|
# @rbs () -> void
|
|
193
293
|
def query_count
|
|
194
294
|
CounterActor.ref("queries").async(:increment)
|
|
@@ -209,6 +309,37 @@ module SolidObjectsBenchmark
|
|
|
209
309
|
|
|
210
310
|
private
|
|
211
311
|
|
|
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
|
+
|
|
212
343
|
# @rbs () -> void
|
|
213
344
|
def establish_connection
|
|
214
345
|
database_url = ENV["SOLID_OBJECTS_DATABASE_URL"]
|
data/docs/benchmarks.md
CHANGED
|
@@ -54,6 +54,28 @@ result is why Solid Objects does not publish one latency promise. Network
|
|
|
54
54
|
topology, adapter behavior, host schema, logging, callbacks, and contention all
|
|
55
55
|
matter.
|
|
56
56
|
|
|
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
|
+
|
|
57
79
|
## Durable row growth
|
|
58
80
|
|
|
59
81
|
The storage cost is deterministic even when latency is not:
|
|
@@ -17,6 +17,7 @@ module SolidObjects
|
|
|
17
17
|
# @rbs @max_result_bytes: Integer
|
|
18
18
|
# @rbs @max_attempts: Integer
|
|
19
19
|
# @rbs @retry_delay: Proc
|
|
20
|
+
# @rbs @lock_retry_attempts: Integer
|
|
20
21
|
# @rbs @process_heartbeat_interval: Float
|
|
21
22
|
# @rbs @process_alive_threshold: Float
|
|
22
23
|
# @rbs @shutdown_timeout: Float
|
|
@@ -57,6 +58,7 @@ module SolidObjects
|
|
|
57
58
|
:max_result_bytes,
|
|
58
59
|
:max_attempts,
|
|
59
60
|
:retry_delay,
|
|
61
|
+
:lock_retry_attempts,
|
|
60
62
|
:process_heartbeat_interval,
|
|
61
63
|
:process_alive_threshold,
|
|
62
64
|
:shutdown_timeout,
|
|
@@ -99,6 +101,7 @@ module SolidObjects
|
|
|
99
101
|
@max_result_bytes = 1.megabyte
|
|
100
102
|
@max_attempts = 5
|
|
101
103
|
@retry_delay = ->(attempt) { [ 2**(attempt - 1), 60 ].min.to_f }
|
|
104
|
+
@lock_retry_attempts = 10
|
|
102
105
|
@process_heartbeat_interval = 15.0
|
|
103
106
|
@process_alive_threshold = 60.0
|
|
104
107
|
@shutdown_timeout = 15.0
|
|
@@ -4,6 +4,7 @@ module SolidObjects
|
|
|
4
4
|
module DatabaseAdapters
|
|
5
5
|
class Sqlite < DatabaseAdapter
|
|
6
6
|
LOCK_RETRY_INTERVAL = 0.001
|
|
7
|
+
MAXIMUM_BUSY_RETRY_INTERVAL = 0.25
|
|
7
8
|
LOCK_RETRY_MUTEX = Thread::Mutex.new
|
|
8
9
|
LOCK_RETRY_CONDITION = Thread::ConditionVariable.new
|
|
9
10
|
|
|
@@ -14,9 +15,28 @@ module SolidObjects
|
|
|
14
15
|
|
|
15
16
|
# @rbs () { () -> untyped } -> untyped
|
|
16
17
|
def transaction(&block)
|
|
17
|
-
return super
|
|
18
|
+
return with_lock_retry { super } if SyncDeadline.active?
|
|
18
19
|
|
|
19
|
-
|
|
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
|
|
20
40
|
end
|
|
21
41
|
|
|
22
42
|
# @rbs () { () -> untyped } -> untyped
|
|
@@ -116,6 +136,11 @@ module SolidObjects
|
|
|
116
136
|
def deadline_error?(error)
|
|
117
137
|
return false unless SyncDeadline.active?
|
|
118
138
|
|
|
139
|
+
busy_error?(error)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @rbs (Exception) -> bool
|
|
143
|
+
def busy_error?(error)
|
|
119
144
|
cause = error
|
|
120
145
|
while cause
|
|
121
146
|
return true if cause.class.name.match?(/BusyException|BusyError/)
|
|
@@ -125,6 +150,16 @@ module SolidObjects
|
|
|
125
150
|
false
|
|
126
151
|
end
|
|
127
152
|
|
|
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
|
+
|
|
128
163
|
# @rbs () -> void
|
|
129
164
|
def wait_before_retry
|
|
130
165
|
LOCK_RETRY_MUTEX.synchronize do
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
class Configuration
|
|
5
|
-
@
|
|
5
|
+
@process_alive_threshold: Float
|
|
6
6
|
|
|
7
7
|
@shutdown_timeout: Float
|
|
8
8
|
|
|
@@ -48,6 +48,8 @@ module SolidObjects
|
|
|
48
48
|
|
|
49
49
|
@authorize_administration: Proc
|
|
50
50
|
|
|
51
|
+
@table_name_prefix: String
|
|
52
|
+
|
|
51
53
|
@polling_interval: Float
|
|
52
54
|
|
|
53
55
|
@sync_polling_interval: Float
|
|
@@ -76,9 +78,9 @@ module SolidObjects
|
|
|
76
78
|
|
|
77
79
|
@retry_delay: Proc
|
|
78
80
|
|
|
79
|
-
@
|
|
81
|
+
@lock_retry_attempts: Integer
|
|
80
82
|
|
|
81
|
-
@
|
|
83
|
+
@process_heartbeat_interval: Float
|
|
82
84
|
|
|
83
85
|
attr_accessor table_name_prefix: untyped
|
|
84
86
|
|
|
@@ -110,6 +112,8 @@ module SolidObjects
|
|
|
110
112
|
|
|
111
113
|
attr_accessor retry_delay: untyped
|
|
112
114
|
|
|
115
|
+
attr_accessor lock_retry_attempts: untyped
|
|
116
|
+
|
|
113
117
|
attr_accessor process_heartbeat_interval: untyped
|
|
114
118
|
|
|
115
119
|
attr_accessor process_alive_threshold: untyped
|
|
@@ -5,6 +5,8 @@ module SolidObjects
|
|
|
5
5
|
class Sqlite < DatabaseAdapter
|
|
6
6
|
LOCK_RETRY_INTERVAL: ::Float
|
|
7
7
|
|
|
8
|
+
MAXIMUM_BUSY_RETRY_INTERVAL: ::Float
|
|
9
|
+
|
|
8
10
|
LOCK_RETRY_MUTEX: untyped
|
|
9
11
|
|
|
10
12
|
LOCK_RETRY_CONDITION: untyped
|
|
@@ -15,6 +17,12 @@ module SolidObjects
|
|
|
15
17
|
# @rbs () { () -> untyped } -> untyped
|
|
16
18
|
def transaction: () { () -> untyped } -> untyped
|
|
17
19
|
|
|
20
|
+
# A write outside a synchronous deadline has no Ruby-level budget, so it
|
|
21
|
+
# depends entirely on SQLite's busy handler. Concurrent writers can
|
|
22
|
+
# exhaust that, which surfaces as a lock error the caller cannot retry.
|
|
23
|
+
# @rbs () { () -> untyped } -> untyped
|
|
24
|
+
def with_busy_retry: () { () -> untyped } -> untyped
|
|
25
|
+
|
|
18
26
|
# @rbs () { () -> untyped } -> untyped
|
|
19
27
|
def with_lock_retry: () { () -> untyped } -> untyped
|
|
20
28
|
|
|
@@ -38,6 +46,12 @@ module SolidObjects
|
|
|
38
46
|
# @rbs (Exception) -> bool
|
|
39
47
|
def deadline_error?: (Exception) -> bool
|
|
40
48
|
|
|
49
|
+
# @rbs (Exception) -> bool
|
|
50
|
+
def busy_error?: (Exception) -> bool
|
|
51
|
+
|
|
52
|
+
# @rbs (Integer) -> void
|
|
53
|
+
def wait_before_busy_retry: (Integer) -> void
|
|
54
|
+
|
|
41
55
|
# @rbs () -> void
|
|
42
56
|
def wait_before_retry: () -> void
|
|
43
57
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: solid_objects
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.7.
|
|
4
|
+
version: 0.7.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lucas Carlson
|
|
@@ -287,6 +287,7 @@ files:
|
|
|
287
287
|
- benchmark/adoption_latency.rb
|
|
288
288
|
- benchmark/claim.rb
|
|
289
289
|
- benchmark/cold_actors.rb
|
|
290
|
+
- benchmark/component_delivery.rb
|
|
290
291
|
- benchmark/concurrent_actors.rb
|
|
291
292
|
- benchmark/enqueue.rb
|
|
292
293
|
- benchmark/hot_actor.rb
|