From dbe66ccf55fbfa87bb063ad7f3c405281c24b301 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 16 Aug 2026 14:12:19 -0700 Subject: [PATCH 1/2] feat: expose process administration Provide an authorization-guarded process query through the runtime database adapter so operators can inspect live and stale rows without opening a competing SQLite connection. Keep the polling warning evidence-based and document rolling-deployment overlap. --- CHANGELOG.md | 6 +++ Gemfile.lock | 4 +- docs/operations.md | 8 ++++ lib/solid_objects.rb | 7 +++ lib/solid_objects/administration.rb | 44 +++++++++++++++++++ lib/solid_objects/version.rb | 2 +- sig/generated/lib/solid_objects.rbs | 3 ++ .../lib/solid_objects/administration.rbs | 13 ++++++ test/integration/administration_test.rb | 32 ++++++++++++++ test/integration/polling_test.rb | 18 ++++++++ 10 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 lib/solid_objects/administration.rb create mode 100644 sig/generated/lib/solid_objects/administration.rbs create mode 100644 test/integration/administration_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 544d057..16fff6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.13.2 - 2026-08-16 + +- Add an authorized `SolidObjects.administration.processes` query for + inspecting live and stale process rows through the runtime database adapter. +- Document rolling-deployment overlap as a reason for the polling-only warning. + ## 0.13.1 - 2026-08-16 - Back idle actor, effect, reminder, and broadcast polling off exponentially diff --git a/Gemfile.lock b/Gemfile.lock index caec902..e4f2665 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - solid_objects (0.13.1) + solid_objects (0.13.2) actioncable (>= 8.0) actionpack (>= 8.0) actionview (>= 8.0) @@ -384,7 +384,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.13.1) + solid_objects (0.13.2) 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/docs/operations.md b/docs/operations.md index 032874e..30d7e90 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -98,6 +98,14 @@ adapter is configured, the runtime logs need prompt delivery. Without one, newly committed work can wait up to the current idle polling interval. +The warning excludes process rows with the current hostname and PID. It can +therefore appear during a rolling deployment or restart overlap when an older +and newer process briefly share the database. A process that stopped without +graceful cleanup remains live until its heartbeat exceeds +`process_alive_threshold`; inspect `SolidObjects.administration.processes` to +distinguish a live overlap from a stale row without opening a second SQLite +connection. + Each role exposes `current_polling_interval`. `solid_objects.polling.interval_changed` reports the role, reason, previous interval, and current interval. The polling-only warning is also emitted as diff --git a/lib/solid_objects.rb b/lib/solid_objects.rb index 4729969..f491b2f 100644 --- a/lib/solid_objects.rb +++ b/lib/solid_objects.rb @@ -31,6 +31,7 @@ require "solid_objects/message_pruner" require "solid_objects/instance_pruner" require "solid_objects/process_pruner" +require "solid_objects/administration" require "solid_objects/stream_name" require "solid_objects/dom_identity" require "solid_objects/stream_token" @@ -137,6 +138,11 @@ def dead_letters @dead_letters ||= DeadLetterManager.new end + # @rbs () -> Administration + def administration + @administration ||= Administration.new + end + # @rbs (String | Symbol) -> String def table_name(name) "#{configuration.table_name_prefix}#{name}" @@ -164,6 +170,7 @@ def reset! @effect_registry = EffectRegistry.new @commit_action_registry = CommitActionRegistry.new @dead_letters = nil + @administration = nil end # @rbs () -> DatabaseAdapter diff --git a/lib/solid_objects/administration.rb b/lib/solid_objects/administration.rb new file mode 100644 index 0000000..fcb026d --- /dev/null +++ b/lib/solid_objects/administration.rb @@ -0,0 +1,44 @@ +# rbs_inline: enabled + +module SolidObjects + class Administration + # @rbs (?authorization_context: untyped) -> Array[Hash[Symbol, untyped]] + def processes(authorization_context: nil) + authorize!(authorization_context:) + now = SolidObjects.database_adapter.database_now + stale_at = now - SolidObjects.configuration.process_alive_threshold + + Process.order(:kind, :started_at).map do |process_record| + { + id: process_record.id, + kind: process_record.kind, + hostname: process_record.hostname, + pid: process_record.pid, + metadata: process_record.metadata, + shutdown_state: process_record.shutdown_state, + shutdown_requested_at: process_record.shutdown_requested_at, + started_at: process_record.started_at, + last_heartbeat_at: process_record.last_heartbeat_at, + stopped_at: process_record.stopped_at, + stale: process_record.shutdown_state != "stopped" && + process_record.last_heartbeat_at <= stale_at + }.freeze + end.freeze + end + + private + + # @rbs (?authorization_context: untyped) -> void + def authorize!(authorization_context: nil) + authorized = SolidObjects.configuration.authorize_administration.call( + action: :inspect, + resource: "processes", + resource_id: nil, + authorization_context: + ) + return if authorized + + raise Unauthorized, "actor administration is not authorized" + end + end +end diff --git a/lib/solid_objects/version.rb b/lib/solid_objects/version.rb index 457e47f..700f48f 100644 --- a/lib/solid_objects/version.rb +++ b/lib/solid_objects/version.rb @@ -1,5 +1,5 @@ # rbs_inline: enabled module SolidObjects - VERSION = "0.13.1" + VERSION = "0.13.2" end diff --git a/sig/generated/lib/solid_objects.rbs b/sig/generated/lib/solid_objects.rbs index 6694db0..d5e40a4 100644 --- a/sig/generated/lib/solid_objects.rbs +++ b/sig/generated/lib/solid_objects.rbs @@ -36,6 +36,9 @@ module SolidObjects # @rbs () -> DeadLetterManager def self.dead_letters: () -> DeadLetterManager + # @rbs () -> Administration + def self.administration: () -> Administration + # @rbs (String | Symbol) -> String def self.table_name: (String | Symbol) -> String diff --git a/sig/generated/lib/solid_objects/administration.rbs b/sig/generated/lib/solid_objects/administration.rbs new file mode 100644 index 0000000..8fa16ae --- /dev/null +++ b/sig/generated/lib/solid_objects/administration.rbs @@ -0,0 +1,13 @@ +# Generated from lib/solid_objects/administration.rb with RBS::Inline + +module SolidObjects + class Administration + # @rbs (?authorization_context: untyped) -> Array[Hash[Symbol, untyped]] + def processes: (?authorization_context: untyped) -> Array[Hash[Symbol, untyped]] + + private + + # @rbs (?authorization_context: untyped) -> void + def authorize!: (?authorization_context: untyped) -> void + end +end diff --git a/test/integration/administration_test.rb b/test/integration/administration_test.rb new file mode 100644 index 0000000..101173f --- /dev/null +++ b/test/integration/administration_test.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "database_test_helper" + +class AdministrationTest < ActiveSupport::TestCase + test "requires administration authorization for process inspection" do + assert_raises(SolidObjects::Unauthorized) do + SolidObjects.administration.processes + end + end + + test "returns frozen process snapshots with current liveness" do + SolidObjects.configuration.authorize_administration = ->(**) { true } + process = SolidObjects::Process.create!( + id: SecureRandom.uuid, + kind: "worker", + hostname: "test-host", + pid: ::Process.pid, + started_at: Time.current, + last_heartbeat_at: Time.current, + metadata: { "solid_objects_version" => SolidObjects::VERSION } + ) + + processes = SolidObjects.administration.processes + + snapshot = processes.find { |record| record[:id] == process.id } + assert_equal "worker", snapshot[:kind] + assert_equal false, snapshot[:stale] + assert_predicate processes, :frozen? + assert_predicate snapshot, :frozen? + end +end diff --git a/test/integration/polling_test.rb b/test/integration/polling_test.rb index 6ec021d..9d2eaa0 100644 --- a/test/integration/polling_test.rb +++ b/test/integration/polling_test.rb @@ -251,6 +251,24 @@ def signal workers&.each(&:stop) end + test "does not warn when all observed processes share the current Ruby process" do + logger = RecordingLogger.new + SolidObjects.configuration.logger = logger + SolidObjects::Process.create!( + id: SecureRandom.uuid, + kind: "worker", + hostname: Socket.gethostname, + pid: ::Process.pid, + started_at: Time.current, + last_heartbeat_at: Time.current, + metadata: {} + ) + + SolidObjects::ProcessRegistry.warn_if_polling_is_only_cross_process_wake_up + + assert_empty logger.warnings + end + test "does not warn when a cross-process wake-up adapter is configured" do logger = RecordingLogger.new SolidObjects.configuration.logger = logger From 4682da5197c10e07381ccd50d774e39a915b3858 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sun, 16 Aug 2026 14:18:26 -0700 Subject: [PATCH 2/2] fix: freeze process metadata Deep-freeze metadata returned by process administration so nested values cannot mutate a published snapshot. Add a regression test for nested mutation. --- lib/solid_objects/administration.rb | 2 +- test/integration/administration_test.rb | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/solid_objects/administration.rb b/lib/solid_objects/administration.rb index fcb026d..200b368 100644 --- a/lib/solid_objects/administration.rb +++ b/lib/solid_objects/administration.rb @@ -14,7 +14,7 @@ def processes(authorization_context: nil) kind: process_record.kind, hostname: process_record.hostname, pid: process_record.pid, - metadata: process_record.metadata, + metadata: Serialization.readonly_copy(process_record.metadata), shutdown_state: process_record.shutdown_state, shutdown_requested_at: process_record.shutdown_requested_at, started_at: process_record.started_at, diff --git a/test/integration/administration_test.rb b/test/integration/administration_test.rb index 101173f..31c7d24 100644 --- a/test/integration/administration_test.rb +++ b/test/integration/administration_test.rb @@ -18,7 +18,10 @@ class AdministrationTest < ActiveSupport::TestCase pid: ::Process.pid, started_at: Time.current, last_heartbeat_at: Time.current, - metadata: { "solid_objects_version" => SolidObjects::VERSION } + metadata: { + "solid_objects_version" => SolidObjects::VERSION, + "nested" => { "value" => "original" } + } ) processes = SolidObjects.administration.processes @@ -28,5 +31,8 @@ class AdministrationTest < ActiveSupport::TestCase assert_equal false, snapshot[:stale] assert_predicate processes, :frozen? assert_predicate snapshot, :frozen? + assert_raises(FrozenError) do + snapshot[:metadata]["nested"]["value"] = "changed" + end end end