diff --git a/CHANGELOG.md b/CHANGELOG.md index f0f4600..9633a2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 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..1b77f4b 100644 --- a/benchmark/support.rb +++ b/benchmark/support.rb @@ -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 } + 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( diff --git a/docs/benchmarks.md b/docs/benchmarks.md index ebea3d8..b1a1398 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -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 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")