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
16 changes: 14 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
# Changelog

## Unreleased
## 0.10.0 - 2026-08-10

- Report a denied CLI command as a policy decision rather than a crash.
Administration denies by default, so an unconfigured host met a thirty-line
Ruby backtrace on its first `solid_objects` command. The executable now
prints the refusal and the setting that grants access, and exits 1.
- Measure the query count for a synchronous call. `benchmark/query_count.rb`
only measured a worker turn, so the documented synchronous number had no
script behind it. It reports three now: a message turn costs 26 queries
rather than the documented 29, the caller of a synchronous call costs 49, and
a synchronous call in total costs 75, being a caller plus the turn it waits
on. Counting is scoped to the measuring thread, since a worker loop polls
whether or not a call is in flight and a process-wide count folds those polls
into the result.

- Run payload broadcast blocks against the actor instance, like every other
block in the actor DSL. `self` was the actor class, so an actor instance
Expand All @@ -27,7 +40,6 @@
revision with a failed payload does not advance the delivery watermark, so a
transient failure is retried on the next broadcast instead of being recorded
as delivered and deduplicated away.

- Run retention on the supervisor rather than leaving it configured but
unscheduled. Every actor call writes a durable message row, so a policy that
nothing invokes let history grow without bound until an application scheduled
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.9.0)
solid_objects (0.10.0)
actioncable (>= 8.0)
actionpack (>= 8.0)
actionview (>= 8.0)
Expand Down Expand Up @@ -380,7 +380,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.9.0)
solid_objects (0.10.0)
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
52 changes: 47 additions & 5 deletions benchmark/support.rb
Original file line number Diff line number Diff line change
Expand Up @@ -291,24 +291,66 @@ def component_delivery

# @rbs () -> void
def query_count
turn = message_turn_query_count
caller_queries = synchronous_caller_query_count
puts "database queries: #{turn} for 1 message turn"
puts "database queries: #{caller_queries} for the caller of 1 synchronous call"
puts "database queries: #{caller_queries + turn} for 1 synchronous call, caller plus the turn it waits on"
end

private

# Runs the turn on the measuring thread, so nothing else can contribute.
# @rbs () -> Integer
def message_turn_query_count
CounterActor.ref("queries").async(:increment)
worker = SolidObjects::Worker.new
count_queries { worker.run_once }
ensure
worker&.stop
end

# A synchronous call also registers or heartbeats the caller process,
# claims the activation, and observes the result, so the caller costs more
# than the turn it waits on. Only the caller thread is counted; the worker
# runs on its own thread and its turn is measured separately, because a
# worker loop also polls and those polls belong to no particular call. The
# first call is discarded because it pays for activation and caller
# registration that a steady-state call does not.
# @rbs () -> Integer
def synchronous_caller_query_count
reference = CounterActor.ref("sync-queries")
worker = SolidObjects::Worker.new
runner = Thread.new { worker.run }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Background SQL contaminates query count

The process-wide sql.active_record subscriber measures the synchronous call while worker.run concurrently performs heartbeat, activation-maintenance, claim, and drain queries. Those background queries are included in the reported total, so the result does not isolate one synchronous invocation and its documented determinism depends on thread timing.

Prompt To Fix With AI
This is a comment left during a code review.
Path: benchmark/support.rb
Line: 319

Comment:
**Background SQL contaminates query count**

The process-wide `sql.active_record` subscriber measures the synchronous call while `worker.run` concurrently performs heartbeat, activation-maintenance, claim, and drain queries. Those background queries are included in the reported total, so the result does not isolate one synchronous invocation and its documented determinism depends on thread timing.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and it changed the answer. Fixed in 8bb4491.

count_queries now scopes to the measuring thread. Notifications run on the thread that issued the query, so Thread.current.equal?(measuring) excludes the worker entirely. The caller and the worker turn are measured separately rather than by watching both threads at once, because a poll belongs to no particular call.

This also corrects a claim I made in the PR body. With the worker excluded, the caller of a synchronous call costs 49 queries, which is exactly what the docs already said. The 53 I reported was four stray polls landing in the window. Five identical runs made it look deterministic when it was only consistent on this machine, which is the trap you caught.

The message-turn drift is real and stands: 26 against a documented 29. That measurement calls worker.run_once on the measuring thread with no other thread alive, so it was never contaminated.

The script now reports three numbers, all stable across five runs:

database queries: 26 for 1 message turn
database queries: 49 for the caller of 1 synchronous call
database queries: 75 for 1 synchronous call, caller plus the turn it waits on

The 75 is new. A synchronous call is a caller plus a worker turn, and only the sum says what the database actually serves for one call. docs/benchmarks.md and the changelog now carry all three plus the reason counting is thread-scoped.

bundle exec rake: 366 runs, 1253 assertions, 0 failures, 0 errors, 13 skips.

@greptileai review

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix in 8bb4491 is correct and directly addresses the contamination concern.

count_queries now captures Thread.current as measuring before subscribing, and the sql.active_record block exits early unless Thread.current.equal?(measuring). Because ActiveSupport notifications fire on the thread that issued the query, the worker's polls are structurally excluded — no timing window to fall into.

The separation is also the right design: message_turn_query_count runs worker.run_once on the measuring thread with no other thread alive (clean by construction), while synchronous_caller_query_count runs the worker on a separate thread and counts only the caller. Combining them as caller + turn for the 75-query total is a sound way to express what the database actually serves for one synchronous call.

The queries counter is only ever incremented from the measuring thread (guarded by the equal? check), so no mutex is needed — that's correct too.

The original comment stands resolved.

reference.sync(:increment)
count_queries { reference.sync(:increment) }
ensure
worker&.request_shutdown
runner&.join(5)
worker&.stop
end

# Subscriptions are process-wide and notifications run on the thread that
# issued the query, so counting is scoped to the measuring thread. Without
# that, a worker polling in the background inflates the count by however
# many times it happened to poll during the window.
# @rbs () { () -> untyped } -> Integer
def count_queries
measuring = Thread.current
queries = 0
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
next unless Thread.current.equal?(measuring)
next if %w[SCHEMA TRANSACTION].include?(event.payload[:name])
next if event.payload[:cached]

queries += 1
end
processed = worker.run_once
puts "database queries: #{queries} for #{processed} message"
yield
queries
ensure
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
worker&.stop
end

private

# @rbs (ComponentRegistration, untyped, ?snapshot: ActorSnapshot?) -> untyped
def render_component(registration, view_context, snapshot: nil)
SolidObjects::ComponentRenderer.new(
Expand Down
21 changes: 19 additions & 2 deletions docs/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,25 @@ scenario used four worker threads.
| Process, four workers | 556.5 messages/s |
| Synchronous latency | p50 1.8 ms, p95 25.6 ms, p99 156.2 ms |
| Activation reuse | 98.0%, four activations for 200 messages |
| Queries for one message turn | 29 |
| Queries for one synchronous call | 49 |

Query counts are a property of the code rather than the host, so they are
tracked separately. Re-measured 2026-08-10 against 0.10.0 on SQLite with
`bundle exec ruby benchmark/query_count.rb`, which is deterministic across runs:

| Scenario | Queries |
| --- | ---: |
| One message turn | 26 |
| The caller of one synchronous call | 49 |
| One synchronous call, caller plus the turn it waits on | 75 |

A worker turn fell from the 29 recorded earlier. The caller count is unchanged
at 49, and the combined figure is new: a synchronous call is a caller and a
worker turn, and only the sum says what the database actually serves.

Counting is scoped to the measuring thread. A worker loop polls whether or not
a call is in flight, so a process-wide count folds however many polls happened
to land inside the window into the result. That is also why the caller and the
turn are measured separately rather than by watching both threads at once.

A synchronous call costs far more queries than a worker turn because the caller
also registers or heartbeats its caller process, claims the activation, and
Expand Down
38 changes: 23 additions & 15 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,21 @@
- At-least-once retries, terminal domain rejection, strict poison ordering,
dead letters, and tail retry
- Transactional effects with success/failure actor messages
- Actor-to-actor asynchronous outbox delivery
- Actor-to-actor asynchronous outbox delivery. Effects and broadcasts use
portable status rows with polling indexes and database check constraints on
status, which works on all three adapters; a future version may add narrow
ready/claimed membership tables for very large outboxes, as messages already
have
- One-shot and recurring reminders with `:latest` or `:all` catch-up
- Durable observable invalidations, scalar Turbo replacement, keyed ERB
components, signed component locals, and authorized replace or morph refresh
- Batched component refreshes: components sharing a signed `batch:` collapse to
one browser request per revision, served as HTML frames in a JSON envelope
- Personalized state payload broadcasts computed per subscriber under that
subscriber's authorization context, fenced by actor revision
subscriber's authorization context, fenced by actor revision, resolved through
`payload_authorization_context` so the block and `authorize_query` see the
same subject a controller render passes, and confined so one failing payload
cannot reject the subscription or stop its siblings
- Reconciliation read APIs
- Installation doctor, authorization reference, fit guide, and legacy-state
migration cookbook
Expand Down Expand Up @@ -59,17 +66,20 @@

## Partially implemented

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

## Next milestones

Expand Down
13 changes: 12 additions & 1 deletion exe/solid_objects
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,15 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
require "solid_objects"
require "solid_objects/cli"

SolidObjects::CLI.start(ARGV)
begin
SolidObjects::CLI.start(ARGV)
rescue SolidObjects::Unauthorized => error
# Authorization denies by default, so this is what an unconfigured host sees
# from its first command. A policy decision is not a crash, and printing a
# backtrace for one buries the single line that says how to grant access.
warn "solid_objects: #{error.message}"
warn "Set configuration.authorize_administration in your Solid Objects " \
"initializer to allow this command. See the commented example in " \
"config/initializers/solid_objects.rb."
exit 1
end
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.9.0"
VERSION = "0.10.0"
end
23 changes: 23 additions & 0 deletions test/integration/cli_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,29 @@ class CLITest < ActiveSupport::TestCase
assert_raises(SolidObjects::Unauthorized) { command.prune_messages }
end

# Deny-by-default means an unconfigured host hits this on its first CLI
# command, so it is the most likely thing a new adopter ever sees.
test "a denied command reports the policy rather than a backtrace" do
Dir.mktmpdir("solid-objects-cli") do |directory|
dummy_root = File.expand_path("../dummy", __dir__)
_output, error_output, status = Open3.capture3(
{
"BUNDLE_GEMFILE" => File.expand_path("../../Gemfile", __dir__),
"RAILS_ENV" => "test",
"SOLID_OBJECTS_DUMMY_DATABASE" => File.join(directory, "dummy.sqlite3")
},
"bundle", "exec", "solid_objects", "status",
chdir: dummy_root
)

refute status.success?, "a denied command must exit non-zero"
assert_match(/authorize_administration/, error_output,
"the message should name the setting that grants access")
refute_match(/lib\/solid_objects\/cli\.rb:\d+/, error_output,
"a policy decision is not a crash and should not print a backtrace")
end
end

test "start loads and processes application actors when eager loading is disabled" do
Dir.mktmpdir("solid-objects-cli") do |directory|
database = File.join(directory, "dummy.sqlite3")
Expand Down
Loading