Skip to content

Commit 8448267

Browse files
authored
Merge pull request #27 from cardmagic/agent/prepare-0-10-0
chore: prepare 0.10.0
2 parents 13e83d3 + 8bb4491 commit 8448267

8 files changed

Lines changed: 141 additions & 28 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
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. It reports three now: a message turn costs 26 queries
12+
rather than the documented 29, the caller of a synchronous call costs 49, and
13+
a synchronous call in total costs 75, being a caller plus the turn it waits
14+
on. Counting is scoped to the measuring thread, since a worker loop polls
15+
whether or not a call is in flight and a process-wide count folds those polls
16+
into the result.
417

518
- Run payload broadcast blocks against the actor instance, like every other
619
block in the actor DSL. `self` was the actor class, so an actor instance
@@ -27,7 +40,6 @@
2740
revision with a failed payload does not advance the delivery watermark, so a
2841
transient failure is retried on the next broadcast instead of being recorded
2942
as delivered and deduplicated away.
30-
3143
- Run retention on the supervisor rather than leaving it configured but
3244
unscheduled. Every actor call writes a durable message row, so a policy that
3345
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: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -291,24 +291,66 @@ def component_delivery
291291

292292
# @rbs () -> void
293293
def query_count
294+
turn = message_turn_query_count
295+
caller_queries = synchronous_caller_query_count
296+
puts "database queries: #{turn} for 1 message turn"
297+
puts "database queries: #{caller_queries} for the caller of 1 synchronous call"
298+
puts "database queries: #{caller_queries + turn} for 1 synchronous call, caller plus the turn it waits on"
299+
end
300+
301+
private
302+
303+
# Runs the turn on the measuring thread, so nothing else can contribute.
304+
# @rbs () -> Integer
305+
def message_turn_query_count
294306
CounterActor.ref("queries").async(:increment)
295307
worker = SolidObjects::Worker.new
308+
count_queries { worker.run_once }
309+
ensure
310+
worker&.stop
311+
end
312+
313+
# A synchronous call also registers or heartbeats the caller process,
314+
# claims the activation, and observes the result, so the caller costs more
315+
# than the turn it waits on. Only the caller thread is counted; the worker
316+
# runs on its own thread and its turn is measured separately, because a
317+
# worker loop also polls and those polls belong to no particular call. The
318+
# first call is discarded because it pays for activation and caller
319+
# registration that a steady-state call does not.
320+
# @rbs () -> Integer
321+
def synchronous_caller_query_count
322+
reference = CounterActor.ref("sync-queries")
323+
worker = SolidObjects::Worker.new
324+
runner = Thread.new { worker.run }
325+
reference.sync(:increment)
326+
count_queries { reference.sync(:increment) }
327+
ensure
328+
worker&.request_shutdown
329+
runner&.join(5)
330+
worker&.stop
331+
end
332+
333+
# Subscriptions are process-wide and notifications run on the thread that
334+
# issued the query, so counting is scoped to the measuring thread. Without
335+
# that, a worker polling in the background inflates the count by however
336+
# many times it happened to poll during the window.
337+
# @rbs () { () -> untyped } -> Integer
338+
def count_queries
339+
measuring = Thread.current
296340
queries = 0
297341
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
342+
next unless Thread.current.equal?(measuring)
298343
next if %w[SCHEMA TRANSACTION].include?(event.payload[:name])
299344
next if event.payload[:cached]
300345

301346
queries += 1
302347
end
303-
processed = worker.run_once
304-
puts "database queries: #{queries} for #{processed} message"
348+
yield
349+
queries
305350
ensure
306351
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
307-
worker&.stop
308352
end
309353

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

‎docs/benchmarks.md‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,25 @@ 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 is deterministic across runs:
47+
48+
| Scenario | Queries |
49+
| --- | ---: |
50+
| One message turn | 26 |
51+
| The caller of one synchronous call | 49 |
52+
| One synchronous call, caller plus the turn it waits on | 75 |
53+
54+
A worker turn fell from the 29 recorded earlier. The caller count is unchanged
55+
at 49, and the combined figure is new: a synchronous call is a caller and a
56+
worker turn, and only the sum says what the database actually serves.
57+
58+
Counting is scoped to the measuring thread. A worker loop polls whether or not
59+
a call is in flight, so a process-wide count folds however many polls happened
60+
to land inside the window into the result. That is also why the caller and the
61+
turn are measured separately rather than by watching both threads at once.
4562

4663
A synchronous call costs far more queries than a worker turn because the caller
4764
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)