Skip to content
Draft
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
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.13.1)
solid_objects (0.13.2)
actioncable (>= 8.0)
actionpack (>= 8.0)
actionview (>= 8.0)
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions lib/solid_objects.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -164,6 +170,7 @@ def reset!
@effect_registry = EffectRegistry.new
@commit_action_registry = CommitActionRegistry.new
@dead_letters = nil
@administration = nil
end

# @rbs () -> DatabaseAdapter
Expand Down
44 changes: 44 additions & 0 deletions lib/solid_objects/administration.rb
Original file line number Diff line number Diff line change
@@ -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: 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,
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
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.13.1"
VERSION = "0.13.2"
end
3 changes: 3 additions & 0 deletions sig/generated/lib/solid_objects.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions sig/generated/lib/solid_objects/administration.rbs
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions test/integration/administration_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 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,
"nested" => { "value" => "original" }
}
)

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?
assert_raises(FrozenError) do
snapshot[:metadata]["nested"]["value"] = "changed"
end
end
end
18 changes: 18 additions & 0 deletions test/integration/polling_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading