Skip to content

Commit 8fb82c9

Browse files
committed
feat: replace supervised roles that die
A role that raised left its thread dead while the process kept running and quietly did less work, with no signal beyond reduced throughput. The supervisor now watches its threads and restarts any that stopped before shutdown was requested, and prunes dead process records on the same monitor. Both intervals are configurable, a failing maintenance pass cannot stop the monitor, and no restart happens once shutdown begins.
1 parent f632c4a commit 8fb82c9

7 files changed

Lines changed: 269 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
## Unreleased
44

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`.
11+
12+
## Unreleased
13+
514
- Add `SolidObjects::WakeUpAdapters::Postgresql`, an optional cross-process
615
wake-up using PostgreSQL notifications. In-process signalling cannot reach a
716
worker process, so reactive delivery waited out `polling_interval`. With the

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
@@ -42,8 +44,6 @@
4244

4345
## Partially implemented
4446

45-
- Supervisor: starts and drains thread roles, but does not replace a crashed
46-
role or run periodic maintenance automatically.
4747
- Wake-up strategy: in-process signaling, durable polling, injection, and an
4848
opt-in PostgreSQL notification adapter are implemented; a Redis adapter is
4949
not. In-process signaling cannot cross process boundaries, so without the
@@ -73,19 +73,18 @@
7373

7474
## Next milestones
7575

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

lib/solid_objects/configuration.rb

Lines changed: 6 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

lib/solid_objects/supervisor.rb

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ 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
810

911
# @rbs (?worker_count: Integer, ?effect_worker_count: Integer, ?broadcast_worker_count: Integer, ?reminder_scheduler_count: Integer) -> void
1012
def initialize(
@@ -20,7 +22,9 @@ def initialize(
2022
reminder_scheduler_count:
2123
)
2224
@threads = []
25+
@monitor = nil
2326
@started = false
27+
@cleaned_up_at = nil
2428
end
2529

2630
# @rbs () -> void
@@ -36,7 +40,8 @@ def start
3640
return if @started
3741

3842
@started = true
39-
@threads = components.map { |component| Thread.new { component.run } }
43+
@threads = components.map { |component| supervise(component) }
44+
@monitor = Thread.new { monitor_loop }
4045
SolidObjects.instrument(:"supervisor.started", component_count: components.length)
4146
end
4247

@@ -45,14 +50,16 @@ def stop
4550
return unless @started
4651

4752
begin
53+
@started = false
54+
@monitor&.join(SolidObjects.configuration.supervisor_monitor_interval * 2)
4855
components.each(&:request_shutdown)
4956
join_until_timeout
5057
components.reject(&:stopped?).each(&:stop)
5158
ensure
5259
# Connections held outside the pool must be released even when a
5360
# component fails to stop, or they accumulate across restarts.
5461
release_wake_up
55-
@started = false
62+
@monitor = nil
5663
SolidObjects.instrument(:"supervisor.stopped", component_count: components.length)
5764
end
5865
end
@@ -61,6 +68,59 @@ def stop
6168

6269
attr_reader :components, :threads
6370

71+
# A role that raises leaves its thread dead. Without replacement the
72+
# process keeps running while quietly doing less work, so the supervisor
73+
# watches its threads and restarts any that stopped before shutdown.
74+
# @rbs () -> void
75+
def monitor_loop
76+
while @started
77+
replace_dead_roles
78+
cleanup_dead_processes
79+
sleep SolidObjects.configuration.supervisor_monitor_interval
80+
end
81+
rescue
82+
retry if @started
83+
end
84+
85+
# @rbs () -> void
86+
def replace_dead_roles
87+
components.each_with_index do |component, index|
88+
thread = threads[index]
89+
next if thread&.alive?
90+
next if component.stopped?
91+
92+
threads[index] = supervise(component)
93+
SolidObjects.instrument(
94+
:"supervisor.role_replaced",
95+
role: component.class.name,
96+
error_class: thread_error(thread)
97+
)
98+
end
99+
end
100+
101+
# @rbs (Thread?) -> String?
102+
def thread_error(thread)
103+
thread&.join
104+
nil
105+
rescue => error
106+
error.class.name
107+
end
108+
109+
# @rbs () -> void
110+
def cleanup_dead_processes
111+
interval = SolidObjects.configuration.dead_process_cleanup_interval
112+
return unless interval.positive?
113+
return if @cleaned_up_at && monotonic_now - @cleaned_up_at < interval
114+
115+
@cleaned_up_at = monotonic_now
116+
ProcessRegistry.cleanup_dead
117+
end
118+
119+
# @rbs (untyped) -> Thread
120+
def supervise(component)
121+
Thread.new { component.run }
122+
end
123+
64124
# A wake-up adapter may hold connections outside the pool, which would
65125
# otherwise accumulate across restarts in one process.
66126
# @rbs () -> void

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: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22

33
module SolidObjects
44
class Supervisor
5-
@components: Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
5+
@cleaned_up_at: Float
6+
7+
@started: bool
8+
9+
@monitor: Thread?
610

711
@threads: Array[Thread]
812

9-
@started: bool
13+
@components: Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
1014

1115
# @rbs (?worker_count: Integer, ?effect_worker_count: Integer, ?broadcast_worker_count: Integer, ?reminder_scheduler_count: Integer) -> void
1216
def initialize: (?worker_count: Integer, ?effect_worker_count: Integer, ?broadcast_worker_count: Integer, ?reminder_scheduler_count: Integer) -> void
@@ -26,6 +30,24 @@ module SolidObjects
2630

2731
attr_reader threads: untyped
2832

33+
# A role that raises leaves its thread dead. Without replacement the
34+
# process keeps running while quietly doing less work, so the supervisor
35+
# watches its threads and restarts any that stopped before shutdown.
36+
# @rbs () -> void
37+
def monitor_loop: () -> void
38+
39+
# @rbs () -> void
40+
def replace_dead_roles: () -> void
41+
42+
# @rbs (Thread?) -> String?
43+
def thread_error: (Thread?) -> String?
44+
45+
# @rbs () -> void
46+
def cleanup_dead_processes: () -> void
47+
48+
# @rbs (untyped) -> Thread
49+
def supervise: (untyped) -> Thread
50+
2951
# A wake-up adapter may hold connections outside the pool, which would
3052
# otherwise accumulate across restarts in one process.
3153
# @rbs () -> void

0 commit comments

Comments
 (0)