Skip to content

Commit 7b034ea

Browse files
committed
chore: prepare 0.10.0
Two features and a behaviour change since 0.9.0, so a minor bump: the supervisor now schedules retention, payload broadcasts gained payload_authorization_context, and payload blocks run against the actor instance rather than the class. The manual QA pass turned up three things, fixed here. A denied CLI command printed a thirty-line Ruby backtrace. Administration denies by default, so that is what an unconfigured host sees from its first solid_objects command. A policy decision is not a crash, and the backtrace buried the one line saying how to grant access. Two documented query counts were wrong. A message turn costs 26 queries, not the documented 29, and a synchronous call 53, not 49. Both are deterministic. The synchronous number had drifted because no script measured it; query_count.rb now reports both. The roadmap claimed the Redis wake-up adapter was not implemented, which has been false since 0.9.0, and listed outboxes as partially implemented when the only outstanding item is a possible future optimisation.
1 parent 13e83d3 commit 7b034ea

8 files changed

Lines changed: 123 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
# Changelog
22

3-
## Unreleased
3+
## 0.10.0 - 2026-08-10
4+
5+
- Report a denied CLI command as a policy decision rather than a crash.
6+
Administration denies by default, so an unconfigured host met a thirty-line
7+
Ruby backtrace on its first `solid_objects` command. The executable now
8+
prints the refusal and the setting that grants access, and exits 1.
9+
- Measure the query count for a synchronous call. `benchmark/query_count.rb`
10+
only measured a worker turn, so the documented synchronous number had no
11+
script behind it and had drifted: a message turn costs 26 queries rather than
12+
the documented 29, and a synchronous call 53 rather than 49. Both are
13+
deterministic and now reported by the same script.
414

515
- Run payload broadcast blocks against the actor instance, like every other
616
block in the actor DSL. `self` was the actor class, so an actor instance
@@ -27,7 +37,6 @@
2737
revision with a failed payload does not advance the delivery watermark, so a
2838
transient failure is retried on the next broadcast instead of being recorded
2939
as delivered and deduplicated away.
30-
3140
- Run retention on the supervisor rather than leaving it configured but
3241
unscheduled. Every actor call writes a durable message row, so a policy that
3342
nothing invokes let history grow without bound until an application scheduled

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.9.0)
4+
solid_objects (0.10.0)
55
actioncable (>= 8.0)
66
actionpack (>= 8.0)
77
actionview (>= 8.0)
@@ -380,7 +380,7 @@ CHECKSUMS
380380
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
381381
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
382382
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
383-
solid_objects (0.9.0)
383+
solid_objects (0.10.0)
384384
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
385385
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
386386
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b

benchmark/support.rb

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -291,24 +291,56 @@ def component_delivery
291291

292292
# @rbs () -> void
293293
def query_count
294+
message_turn_query_count
295+
synchronous_query_count
296+
end
297+
298+
private
299+
300+
# @rbs () -> void
301+
def message_turn_query_count
294302
CounterActor.ref("queries").async(:increment)
295303
worker = SolidObjects::Worker.new
304+
processed = nil
305+
queries = count_queries { processed = worker.run_once }
306+
puts "database queries: #{queries} for #{processed} message"
307+
ensure
308+
worker&.stop
309+
end
310+
311+
# A synchronous call also registers or heartbeats the caller process,
312+
# claims the activation, and observes the result, so it costs more than the
313+
# worker turn it contains. The first call is discarded because it pays for
314+
# activation and caller registration that a steady-state call does not.
315+
# @rbs () -> void
316+
def synchronous_query_count
317+
reference = CounterActor.ref("sync-queries")
318+
worker = SolidObjects::Worker.new
319+
runner = Thread.new { worker.run }
320+
reference.sync(:increment)
321+
queries = count_queries { reference.sync(:increment) }
322+
puts "database queries: #{queries} for 1 synchronous call"
323+
ensure
324+
worker&.request_shutdown
325+
runner&.join(5)
326+
worker&.stop
327+
end
328+
329+
# @rbs () { () -> untyped } -> Integer
330+
def count_queries
296331
queries = 0
297332
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
298333
next if %w[SCHEMA TRANSACTION].include?(event.payload[:name])
299334
next if event.payload[:cached]
300335

301336
queries += 1
302337
end
303-
processed = worker.run_once
304-
puts "database queries: #{queries} for #{processed} message"
338+
yield
339+
queries
305340
ensure
306341
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
307-
worker&.stop
308342
end
309343

310-
private
311-
312344
# @rbs (ComponentRegistration, untyped, ?snapshot: ActorSnapshot?) -> untyped
313345
def render_component(registration, view_context, snapshot: nil)
314346
SolidObjects::ComponentRenderer.new(

docs/benchmarks.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,20 @@ scenario used four worker threads.
4040
| Process, four workers | 556.5 messages/s |
4141
| Synchronous latency | p50 1.8 ms, p95 25.6 ms, p99 156.2 ms |
4242
| Activation reuse | 98.0%, four activations for 200 messages |
43-
| Queries for one message turn | 29 |
44-
| Queries for one synchronous call | 49 |
43+
44+
Query counts are a property of the code rather than the host, so they are
45+
tracked separately. Re-measured 2026-08-10 against 0.10.0 on SQLite with
46+
`bundle exec ruby benchmark/query_count.rb`, which reports both and is
47+
deterministic across runs:
48+
49+
| Scenario | Queries |
50+
| --- | ---: |
51+
| One message turn | 26 |
52+
| One synchronous call | 53 |
53+
54+
Both numbers moved since they were first recorded, in opposite directions: a
55+
worker turn fell from 29 and a synchronous call rose from 49. The synchronous
56+
count had no script behind it until now, which is why it drifted unnoticed.
4557

4658
A synchronous call costs far more queries than a worker turn because the caller
4759
also registers or heartbeats its caller process, claims the activation, and

docs/roadmap.md

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,21 @@
1313
- At-least-once retries, terminal domain rejection, strict poison ordering,
1414
dead letters, and tail retry
1515
- Transactional effects with success/failure actor messages
16-
- Actor-to-actor asynchronous outbox delivery
16+
- Actor-to-actor asynchronous outbox delivery. Effects and broadcasts use
17+
portable status rows with polling indexes and database check constraints on
18+
status, which works on all three adapters; a future version may add narrow
19+
ready/claimed membership tables for very large outboxes, as messages already
20+
have
1721
- One-shot and recurring reminders with `:latest` or `:all` catch-up
1822
- Durable observable invalidations, scalar Turbo replacement, keyed ERB
1923
components, signed component locals, and authorized replace or morph refresh
2024
- Batched component refreshes: components sharing a signed `batch:` collapse to
2125
one browser request per revision, served as HTML frames in a JSON envelope
2226
- Personalized state payload broadcasts computed per subscriber under that
23-
subscriber's authorization context, fenced by actor revision
27+
subscriber's authorization context, fenced by actor revision, resolved through
28+
`payload_authorization_context` so the block and `authorize_query` see the
29+
same subject a controller render passes, and confined so one failing payload
30+
cannot reject the subscription or stop its siblings
2431
- Reconciliation read APIs
2532
- Installation doctor, authorization reference, fit guide, and legacy-state
2633
migration cookbook
@@ -59,17 +66,20 @@
5966

6067
## Partially implemented
6168

62-
- Wake-up strategy: in-process signaling, durable polling, injection, and an
63-
opt-in PostgreSQL notification adapter are implemented; a Redis adapter is
64-
not. In-process signaling cannot cross process boundaries, so without the
65-
adapter a commit in a web process does not wake a broadcast executor in a
66-
worker process and that delivery waits up to `polling_interval`, 100 ms by
67-
default. `WakeUpAdapters.for` removes that delay on PostgreSQL, measured at
68-
103.7 ms to 2.9 ms at p50. It is opt-in rather than automatic: it opens a
69-
connection per waiting thread outside the pool, and `LISTEN` does not survive
70-
a transaction-pooling proxy such as PgBouncer. MySQL has no notification
71-
primitive, so MySQL applications keep polling unless they configure the Redis
72-
adapter.
69+
- Wake-up strategy: in-process signaling, durable polling, injection, and
70+
cross-process adapters for PostgreSQL and Redis are implemented and tested.
71+
What is not done is making any of them automatic. In-process signaling cannot
72+
cross process boundaries, so by default a commit in a web process does not
73+
wake a broadcast executor in a worker process and that delivery waits up to
74+
`polling_interval`, 100 ms. An adapter removes that floor, measured at 103.7 ms
75+
to 2.9 ms at p50 on PostgreSQL and 103.8 ms to 5.7 ms on Redis, but each stays
76+
opt-in for a reason: the PostgreSQL adapter opens a connection per waiting
77+
thread outside the pool and `LISTEN` does not survive a transaction-pooling
78+
proxy such as PgBouncer, and Redis is not a dependency of this gem.
79+
`WakeUpAdapters.for` selects notifications on PostgreSQL and the in-process
80+
default elsewhere; it never selects Redis. An application that configures
81+
nothing keeps polling, and MySQL applications keep polling unless they
82+
configure Redis explicitly.
7383
- Realtime: scalar and dependency-driven keyed ERB component replacement or
7484
morphing, personalized refresh authorization, revision fencing, coalescing,
7585
reconnect convergence, batched refreshes, and personalized state payloads are
@@ -88,8 +98,6 @@
8898
distributed per-actor rate limits and global admission control do not.
8999
- Administration: actor and dead-letter views plus policy hooks exist; richer
90100
filtering, audit records, and bulk-safe tools do not.
91-
- Outboxes use portable status rows with polling indexes; future versions may
92-
introduce narrow ready/claimed membership tables for very large outboxes.
93101

94102
## Next milestones
95103

exe/solid_objects

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,15 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
66
require "solid_objects"
77
require "solid_objects/cli"
88

9-
SolidObjects::CLI.start(ARGV)
9+
begin
10+
SolidObjects::CLI.start(ARGV)
11+
rescue SolidObjects::Unauthorized => error
12+
# Authorization denies by default, so this is what an unconfigured host sees
13+
# from its first command. A policy decision is not a crash, and printing a
14+
# backtrace for one buries the single line that says how to grant access.
15+
warn "solid_objects: #{error.message}"
16+
warn "Set configuration.authorize_administration in your Solid Objects " \
17+
"initializer to allow this command. See the commented example in " \
18+
"config/initializers/solid_objects.rb."
19+
exit 1
20+
end

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.9.0"
4+
VERSION = "0.10.0"
55
end

test/integration/cli_test.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,29 @@ class CLITest < ActiveSupport::TestCase
4444
assert_raises(SolidObjects::Unauthorized) { command.prune_messages }
4545
end
4646

47+
# Deny-by-default means an unconfigured host hits this on its first CLI
48+
# command, so it is the most likely thing a new adopter ever sees.
49+
test "a denied command reports the policy rather than a backtrace" do
50+
Dir.mktmpdir("solid-objects-cli") do |directory|
51+
dummy_root = File.expand_path("../dummy", __dir__)
52+
_output, error_output, status = Open3.capture3(
53+
{
54+
"BUNDLE_GEMFILE" => File.expand_path("../../Gemfile", __dir__),
55+
"RAILS_ENV" => "test",
56+
"SOLID_OBJECTS_DUMMY_DATABASE" => File.join(directory, "dummy.sqlite3")
57+
},
58+
"bundle", "exec", "solid_objects", "status",
59+
chdir: dummy_root
60+
)
61+
62+
refute status.success?, "a denied command must exit non-zero"
63+
assert_match(/authorize_administration/, error_output,
64+
"the message should name the setting that grants access")
65+
refute_match(/lib\/solid_objects\/cli\.rb:\d+/, error_output,
66+
"a policy decision is not a crash and should not print a backtrace")
67+
end
68+
end
69+
4770
test "start loads and processes application actors when eager loading is disabled" do
4871
Dir.mktmpdir("solid-objects-cli") do |directory|
4972
database = File.join(directory, "dummy.sqlite3")

0 commit comments

Comments
 (0)