Skip to content

Commit 6d8cd60

Browse files
authored
Merge pull request #16 from cardmagic/agent/supervisor-role-replacement
feat: replace supervised roles that die
2 parents 8dafd92 + bf39d20 commit 6d8cd60

9 files changed

Lines changed: 398 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
# Changelog
22

3-
## Unreleased
4-
3+
## 0.8.0 - 2026-08-10
4+
5+
- Replace a supervised role whose thread died. A role that raised left its
6+
thread dead while the process kept running and quietly did less work; the
7+
supervisor now restarts it until shutdown is requested. Prune dead process
8+
records on an interval as part of the same monitor. Both intervals are
9+
configurable through `supervisor_monitor_interval` and
10+
`dead_process_cleanup_interval`.
511
- Run compatibility CI across the span the gemspec advertises: Ruby 3.3 and 3.4
612
against Rails 8.0 and 8.1. The suite previously ran on one combination, so
713
`>= 8.0` was a claim rather than a tested guarantee. Set `RAILS_VERSION` to

Gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
solid_objects (0.7.3)
4+
solid_objects (0.8.0)
55
actioncable (>= 8.0)
66
actionpack (>= 8.0)
77
actionview (>= 8.0)
@@ -373,7 +373,7 @@ CHECKSUMS
373373
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
374374
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
375375
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
376-
solid_objects (0.7.3)
376+
solid_objects (0.8.0)
377377
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
378378
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
379379
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b

docs/roadmap.md

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
- Bounded message/process pruning, actor-type opt-in instance expiration,
3232
graceful caller shutdown, committed state snapshots, and an opt-in Minitest
3333
helper
34+
- Supervisor role replacement: a role whose thread dies is restarted until
35+
shutdown is requested, and dead process records are pruned on an interval
3436
- SQLite, PostgreSQL, and MySQL integration suites
3537
- Opt-in cross-process wake-up on PostgreSQL through `WakeUpAdapters.for`, with
3638
a listening connection per waiting thread and release on supervisor shutdown
@@ -45,8 +47,6 @@
4547

4648
## Partially implemented
4749

48-
- Supervisor: starts and drains thread roles, but does not replace a crashed
49-
role or run periodic maintenance automatically.
5050
- Wake-up strategy: in-process signaling, durable polling, injection, and an
5151
opt-in PostgreSQL notification adapter are implemented; a Redis adapter is
5252
not. In-process signaling cannot cross process boundaries, so without the
@@ -76,19 +76,18 @@
7676

7777
## Next milestones
7878

79-
1. Add automatic supervisor role replacement and periodic dead-process cleanup.
80-
2. Add an optional Redis wake-up adapter, which is the remaining cross-process
79+
1. Add an optional Redis wake-up adapter, which is the remaining cross-process
8180
option for MySQL. The PostgreSQL notification adapter, its latency
8281
benchmark, and its concurrency tests are implemented.
83-
3. Add result lookup by request ID and broader deadlock retry classification.
84-
4. Add scheduled retention and stale-process maintenance.
85-
5. Add database/server-version checks and MySQL InnoDB verification at boot.
86-
6. Add Turbo append intents and expand reconnect coverage in a full browser.
87-
7. Add distributed rate limits, global admission hooks, and cache-capacity
82+
2. Add result lookup by request ID and broader deadlock retry classification.
83+
3. Add scheduled retention and stale-process maintenance.
84+
4. Add database/server-version checks and MySQL InnoDB verification at boot.
85+
5. Add Turbo append intents and expand reconnect coverage in a full browser.
86+
6. Add distributed rate limits, global admission hooks, and cache-capacity
8887
eviction.
89-
8. Expand security scanning and run compatibility CI across supported Rails and
88+
7. Expand security scanning and run compatibility CI across supported Rails and
9089
Ruby versions.
91-
9. Benchmark all workloads under documented hardware/database settings and
90+
8. Benchmark all workloads under documented hardware/database settings and
9291
publish adapter-specific adoption measurements. Throughput, synchronous
9392
latency, query counts, and the three reactive delivery paths are measured on
9493
SQLite; adapter-specific and end-to-end browser measurements are not.

lib/solid_objects/configuration.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ class Configuration
2121
# @rbs @process_heartbeat_interval: Float
2222
# @rbs @process_alive_threshold: Float
2323
# @rbs @shutdown_timeout: Float
24+
# @rbs @supervisor_monitor_interval: Float
25+
# @rbs @dead_process_cleanup_interval: Float
2426
# @rbs @message_retention: Numeric
2527
# @rbs @message_retention_by_actor_type: Hash[String, Numeric]
2628
# @rbs @instance_retention_by_actor_type: Hash[String, Numeric]
@@ -62,6 +64,8 @@ class Configuration
6264
:process_heartbeat_interval,
6365
:process_alive_threshold,
6466
:shutdown_timeout,
67+
:supervisor_monitor_interval,
68+
:dead_process_cleanup_interval,
6569
:message_retention,
6670
:message_retention_by_actor_type,
6771
:instance_retention_by_actor_type,
@@ -102,6 +106,8 @@ def initialize
102106
@max_attempts = 5
103107
@retry_delay = ->(attempt) { [ 2**(attempt - 1), 60 ].min.to_f }
104108
@lock_retry_attempts = 10
109+
@supervisor_monitor_interval = 1.0
110+
@dead_process_cleanup_interval = 60.0
105111
@process_heartbeat_interval = 15.0
106112
@process_alive_threshold = 60.0
107113
@shutdown_timeout = 15.0
@@ -138,6 +144,10 @@ def validate!
138144
raise ArgumentError, "table_name_prefix must contain lowercase letters, digits, and underscores"
139145
end
140146

147+
unless supervisor_monitor_interval.positive?
148+
raise ArgumentError, "supervisor_monitor_interval must be positive"
149+
end
150+
141151
unless lease_duration > lease_renewal_interval
142152
raise ArgumentError, "lease_duration must be greater than lease_renewal_interval"
143153
end

lib/solid_objects/supervisor.rb

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ module SolidObjects
44
class Supervisor
55
# @rbs @components: Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
66
# @rbs @threads: Array[Thread]
7+
# @rbs @monitor: Thread?
78
# @rbs @started: bool
9+
# @rbs @cleaned_up_at: Float
10+
# @rbs @lifecycle: Thread::Mutex
811

912
# @rbs (?worker_count: Integer, ?effect_worker_count: Integer, ?broadcast_worker_count: Integer, ?reminder_scheduler_count: Integer) -> void
1013
def initialize(
@@ -20,7 +23,10 @@ def initialize(
2023
reminder_scheduler_count:
2124
)
2225
@threads = []
26+
@monitor = nil
2327
@started = false
28+
@cleaned_up_at = nil
29+
@lifecycle = Thread::Mutex.new
2430
end
2531

2632
# @rbs () -> void
@@ -36,7 +42,8 @@ def start
3642
return if @started
3743

3844
@started = true
39-
@threads = components.map { |component| Thread.new { component.run } }
45+
@threads = components.map { |component| supervise(component) }
46+
@monitor = Thread.new { monitor_loop }
4047
SolidObjects.instrument(:"supervisor.started", component_count: components.length)
4148
end
4249

@@ -45,14 +52,19 @@ def stop
4552
return unless @started
4653

4754
begin
55+
# Flipping the flag under the same lock replacement takes means a
56+
# replacement either completes before shutdown reads the component
57+
# list, or never starts.
58+
@lifecycle.synchronize { @started = false }
59+
stop_monitor
4860
components.each(&:request_shutdown)
4961
join_until_timeout
5062
components.reject(&:stopped?).each(&:stop)
5163
ensure
5264
# Connections held outside the pool must be released even when a
5365
# component fails to stop, or they accumulate across restarts.
5466
release_wake_up
55-
@started = false
67+
@monitor = nil
5668
SolidObjects.instrument(:"supervisor.stopped", component_count: components.length)
5769
end
5870
end
@@ -61,6 +73,95 @@ def stop
6173

6274
attr_reader :components, :threads
6375

76+
# A role that raises leaves its thread dead. Without replacement the
77+
# process keeps running while quietly doing less work, so the supervisor
78+
# watches its threads and restarts any that stopped before shutdown.
79+
# A failing pass must not stop supervision, and must not retry without
80+
# pacing either: a persistently failing database would otherwise spin.
81+
# @rbs () -> void
82+
def monitor_loop
83+
while @started
84+
begin
85+
replace_dead_roles
86+
cleanup_dead_processes
87+
rescue => error
88+
SolidObjects.instrument(
89+
:"supervisor.monitor_failed",
90+
error_class: error.class.name,
91+
error_message: error.message
92+
)
93+
end
94+
sleep SolidObjects.configuration.supervisor_monitor_interval
95+
end
96+
end
97+
98+
# A role that raises runs its own shutdown cleanup on the way out, so a
99+
# crashed component reports itself stopped exactly like one that was asked
100+
# to stop. While the supervisor is still running, a dead thread can only
101+
# mean a crash, so replacement keys on the supervisor rather than on the
102+
# component. The crashed instance has already released its process record,
103+
# so a fresh one takes its place.
104+
# @rbs () -> void
105+
def replace_dead_roles
106+
components.each_with_index do |component, index|
107+
thread = threads[index]
108+
next if thread&.alive?
109+
110+
replaced = @lifecycle.synchronize do
111+
next false unless @started
112+
113+
replacement = component.class.new
114+
components[index] = replacement
115+
threads[index] = supervise(replacement)
116+
replacement
117+
end
118+
break unless replaced
119+
120+
SolidObjects.instrument(
121+
:"supervisor.role_replaced",
122+
role: replaced.class.name,
123+
error_class: thread_error(thread)
124+
)
125+
end
126+
end
127+
128+
# @rbs (Thread?) -> String?
129+
def thread_error(thread)
130+
thread&.join
131+
nil
132+
rescue => error
133+
error.class.name
134+
end
135+
136+
# @rbs () -> void
137+
def cleanup_dead_processes
138+
interval = SolidObjects.configuration.dead_process_cleanup_interval
139+
return unless interval.positive?
140+
return if @cleaned_up_at && monotonic_now - @cleaned_up_at < interval
141+
142+
@cleaned_up_at = monotonic_now
143+
ProcessRegistry.cleanup_dead
144+
end
145+
146+
# @rbs (untyped) -> Thread
147+
def supervise(component)
148+
Thread.new { component.run }
149+
end
150+
151+
# The monitor only performs maintenance, so shutdown must never return while
152+
# it is still alive: a pass blocked on the database would otherwise outlive
153+
# the supervisor that owns it.
154+
# @rbs () -> void
155+
def stop_monitor
156+
monitor = @monitor
157+
@monitor = nil
158+
return unless monitor
159+
160+
monitor.join(SolidObjects.configuration.shutdown_timeout)
161+
monitor.kill if monitor.alive?
162+
monitor.join(SolidObjects.configuration.supervisor_monitor_interval)
163+
end
164+
64165
# A wake-up adapter may hold connections outside the pool, which would
65166
# otherwise accumulate across restarts in one process.
66167
# @rbs () -> void

lib/solid_objects/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# rbs_inline: enabled
22

33
module SolidObjects
4-
VERSION = "0.7.3"
4+
VERSION = "0.8.0"
55
end

sig/generated/lib/solid_objects/configuration.rbs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
module SolidObjects
44
class Configuration
5-
@process_alive_threshold: Float
6-
75
@shutdown_timeout: Float
86

7+
@supervisor_monitor_interval: Float
8+
9+
@dead_process_cleanup_interval: Float
10+
911
@message_retention: Numeric
1012

1113
@message_retention_by_actor_type: Hash[String, Numeric]
@@ -82,6 +84,8 @@ module SolidObjects
8284

8385
@process_heartbeat_interval: Float
8486

87+
@process_alive_threshold: Float
88+
8589
attr_accessor table_name_prefix: untyped
8690

8791
attr_accessor polling_interval: untyped
@@ -120,6 +124,10 @@ module SolidObjects
120124

121125
attr_accessor shutdown_timeout: untyped
122126

127+
attr_accessor supervisor_monitor_interval: untyped
128+
129+
attr_accessor dead_process_cleanup_interval: untyped
130+
123131
attr_accessor message_retention: untyped
124132

125133
attr_accessor message_retention_by_actor_type: untyped

sig/generated/lib/solid_objects/supervisor.rbs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,18 @@
22

33
module SolidObjects
44
class Supervisor
5-
@components: Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
5+
@lifecycle: Thread::Mutex
66

7-
@threads: Array[Thread]
7+
@cleaned_up_at: Float
88

99
@started: bool
1010

11+
@monitor: Thread?
12+
13+
@threads: Array[Thread]
14+
15+
@components: Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
16+
1117
# @rbs (?worker_count: Integer, ?effect_worker_count: Integer, ?broadcast_worker_count: Integer, ?reminder_scheduler_count: Integer) -> void
1218
def initialize: (?worker_count: Integer, ?effect_worker_count: Integer, ?broadcast_worker_count: Integer, ?reminder_scheduler_count: Integer) -> void
1319

@@ -26,6 +32,38 @@ module SolidObjects
2632

2733
attr_reader threads: untyped
2834

35+
# A role that raises leaves its thread dead. Without replacement the
36+
# process keeps running while quietly doing less work, so the supervisor
37+
# watches its threads and restarts any that stopped before shutdown.
38+
# A failing pass must not stop supervision, and must not retry without
39+
# pacing either: a persistently failing database would otherwise spin.
40+
# @rbs () -> void
41+
def monitor_loop: () -> void
42+
43+
# A role that raises runs its own shutdown cleanup on the way out, so a
44+
# crashed component reports itself stopped exactly like one that was asked
45+
# to stop. While the supervisor is still running, a dead thread can only
46+
# mean a crash, so replacement keys on the supervisor rather than on the
47+
# component. The crashed instance has already released its process record,
48+
# so a fresh one takes its place.
49+
# @rbs () -> void
50+
def replace_dead_roles: () -> void
51+
52+
# @rbs (Thread?) -> String?
53+
def thread_error: (Thread?) -> String?
54+
55+
# @rbs () -> void
56+
def cleanup_dead_processes: () -> void
57+
58+
# @rbs (untyped) -> Thread
59+
def supervise: (untyped) -> Thread
60+
61+
# The monitor only performs maintenance, so shutdown must never return while
62+
# it is still alive: a pass blocked on the database would otherwise outlive
63+
# the supervisor that owns it.
64+
# @rbs () -> void
65+
def stop_monitor: () -> void
66+
2967
# A wake-up adapter may hold connections outside the pool, which would
3068
# otherwise accumulate across restarts in one process.
3169
# @rbs () -> void

0 commit comments

Comments
 (0)