From 7b034ead34050df15007148d79deb6a111e2caf3 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Mon, 10 Aug 2026 12:39:27 -0700 Subject: [PATCH 1/2] 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. --- CHANGELOG.md | 13 +++++++++-- Gemfile.lock | 4 ++-- benchmark/support.rb | 42 +++++++++++++++++++++++++++++++----- docs/benchmarks.md | 16 ++++++++++++-- docs/roadmap.md | 38 +++++++++++++++++++------------- exe/solid_objects | 13 ++++++++++- lib/solid_objects/version.rb | 2 +- test/integration/cli_test.rb | 23 ++++++++++++++++++++ 8 files changed, 123 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0f4600..c0824f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,16 @@ # 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 and had drifted: a message turn costs 26 queries rather than + the documented 29, and a synchronous call 53 rather than 49. Both are + deterministic and now reported by the same script. - 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 @@ -27,7 +37,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 diff --git a/Gemfile.lock b/Gemfile.lock index fb38a21..9768291 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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 diff --git a/benchmark/support.rb b/benchmark/support.rb index 7776870..2ff2b63 100644 --- a/benchmark/support.rb +++ b/benchmark/support.rb @@ -291,8 +291,43 @@ def component_delivery # @rbs () -> void def query_count + message_turn_query_count + synchronous_query_count + end + + private + + # @rbs () -> void + def message_turn_query_count CounterActor.ref("queries").async(:increment) worker = SolidObjects::Worker.new + processed = nil + queries = count_queries { processed = worker.run_once } + puts "database queries: #{queries} for #{processed} message" + ensure + worker&.stop + end + + # A synchronous call also registers or heartbeats the caller process, + # claims the activation, and observes the result, so it costs more than the + # worker turn it contains. The first call is discarded because it pays for + # activation and caller registration that a steady-state call does not. + # @rbs () -> void + def synchronous_query_count + reference = CounterActor.ref("sync-queries") + worker = SolidObjects::Worker.new + runner = Thread.new { worker.run } + reference.sync(:increment) + queries = count_queries { reference.sync(:increment) } + puts "database queries: #{queries} for 1 synchronous call" + ensure + worker&.request_shutdown + runner&.join(5) + worker&.stop + end + + # @rbs () { () -> untyped } -> Integer + def count_queries queries = 0 subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event| next if %w[SCHEMA TRANSACTION].include?(event.payload[:name]) @@ -300,15 +335,12 @@ def query_count 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( diff --git a/docs/benchmarks.md b/docs/benchmarks.md index ebea3d8..67cdd7c 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -40,8 +40,20 @@ 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 reports both and is +deterministic across runs: + +| Scenario | Queries | +| --- | ---: | +| One message turn | 26 | +| One synchronous call | 53 | + +Both numbers moved since they were first recorded, in opposite directions: a +worker turn fell from 29 and a synchronous call rose from 49. The synchronous +count had no script behind it until now, which is why it drifted unnoticed. 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 diff --git a/docs/roadmap.md b/docs/roadmap.md index e602567..66ab92b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 @@ -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 @@ -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 diff --git a/exe/solid_objects b/exe/solid_objects index 8356f3d..47e0ce1 100755 --- a/exe/solid_objects +++ b/exe/solid_objects @@ -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 diff --git a/lib/solid_objects/version.rb b/lib/solid_objects/version.rb index a7afad6..ebda327 100644 --- a/lib/solid_objects/version.rb +++ b/lib/solid_objects/version.rb @@ -1,5 +1,5 @@ # rbs_inline: enabled module SolidObjects - VERSION = "0.9.0" + VERSION = "0.10.0" end diff --git a/test/integration/cli_test.rb b/test/integration/cli_test.rb index ce0184e..d1a10a2 100644 --- a/test/integration/cli_test.rb +++ b/test/integration/cli_test.rb @@ -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") From 8bb44918073f39b42f0c66ec798ed8ccb6eb5aaa Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Mon, 10 Aug 2026 12:45:56 -0700 Subject: [PATCH 2/2] fix: scope query counting to the measuring thread The synchronous benchmark subscribed to sql.active_record process-wide while a worker ran on its own thread, so whatever the worker polled during the window landed in the count. Stability across runs made that look deterministic when it was only consistent. Counting is now scoped to the thread being measured, and the caller and the worker turn are measured separately rather than by watching both at once, because a poll belongs to no particular call. This corrects a claim made in the previous commit. The caller of a synchronous call costs 49 queries, exactly what was documented before; the 53 reported earlier was four stray polls. The message turn drift is real and stands at 26 against a documented 29, since that measurement always ran on the measuring thread with nothing else alive. The script also reports the total a synchronous call costs the database, 75, which was never recorded. --- CHANGELOG.md | 9 ++++++--- benchmark/support.rb | 36 +++++++++++++++++++++++------------- docs/benchmarks.md | 17 +++++++++++------ 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0824f0..9633a2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,12 @@ 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 and had drifted: a message turn costs 26 queries rather than - the documented 29, and a synchronous call 53 rather than 49. Both are - deterministic and now reported by the same script. + 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 diff --git a/benchmark/support.rb b/benchmark/support.rb index 2ff2b63..1b77f4b 100644 --- a/benchmark/support.rb +++ b/benchmark/support.rb @@ -291,45 +291,55 @@ def component_delivery # @rbs () -> void def query_count - message_turn_query_count - synchronous_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 - # @rbs () -> void + # 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 - processed = nil - queries = count_queries { processed = worker.run_once } - puts "database queries: #{queries} for #{processed} message" + 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 it costs more than the - # worker turn it contains. The first call is discarded because it pays for - # activation and caller registration that a steady-state call does not. - # @rbs () -> void - def synchronous_query_count + # 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 } reference.sync(:increment) - queries = count_queries { reference.sync(:increment) } - puts "database queries: #{queries} for 1 synchronous call" + 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] diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 67cdd7c..b1a1398 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -43,17 +43,22 @@ scenario used four worker threads. 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 reports both and is -deterministic across runs: +`bundle exec ruby benchmark/query_count.rb`, which is deterministic across runs: | Scenario | Queries | | --- | ---: | | One message turn | 26 | -| One synchronous call | 53 | +| The caller of one synchronous call | 49 | +| One synchronous call, caller plus the turn it waits on | 75 | -Both numbers moved since they were first recorded, in opposite directions: a -worker turn fell from 29 and a synchronous call rose from 49. The synchronous -count had no script behind it until now, which is why it drifted unnoticed. +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