Skip to content

Commit cc2cbb9

Browse files
committed
fix: run retention on its own thread
Greptile flagged two defects in the scheduled retention pass. A failed pass suppressed retries: the timestamp was recorded before pruning ran, so a transient lock or connection error deferred retention for the full interval, up to an hour by default. Retention now retries on the next tick, and the failure is instrumented as supervisor.retention_failed. Retention also shared the monitor thread with role replacement. Pruning is unbounded work against a table that only grows, so a slow pass held back the supervision that keeps a crashed role alive. It now runs on a dedicated thread. The pause between passes is taken in monitor-interval steps so shutdown does not wait out an hour-long sleep.
1 parent e67ad34 commit cc2cbb9

3 files changed

Lines changed: 118 additions & 12 deletions

File tree

lib/solid_objects/supervisor.rb

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ class Supervisor
77
# @rbs @monitor: Thread?
88
# @rbs @started: bool
99
# @rbs @cleaned_up_at: Float
10-
# @rbs @pruned_at: Float
10+
# @rbs @retention: Thread?
1111
# @rbs @lifecycle: Thread::Mutex
1212

1313
# @rbs (?worker_count: Integer, ?effect_worker_count: Integer, ?broadcast_worker_count: Integer, ?reminder_scheduler_count: Integer) -> void
@@ -27,7 +27,7 @@ def initialize(
2727
@monitor = nil
2828
@started = false
2929
@cleaned_up_at = nil
30-
@pruned_at = nil
30+
@retention = nil
3131
@lifecycle = Thread::Mutex.new
3232
end
3333

@@ -46,6 +46,7 @@ def start
4646
@started = true
4747
@threads = components.map { |component| supervise(component) }
4848
@monitor = Thread.new { monitor_loop }
49+
@retention = Thread.new { retention_loop }
4950
SolidObjects.instrument(:"supervisor.started", component_count: components.length)
5051
end
5152

@@ -59,6 +60,7 @@ def stop
5960
# list, or never starts.
6061
@lifecycle.synchronize { @started = false }
6162
stop_monitor
63+
stop_retention
6264
components.each(&:request_shutdown)
6365
join_until_timeout
6466
components.reject(&:stopped?).each(&:stop)
@@ -86,7 +88,6 @@ def monitor_loop
8688
begin
8789
replace_dead_roles
8890
cleanup_dead_processes
89-
prune_expired_records
9091
rescue => error
9192
SolidObjects.instrument(
9293
:"supervisor.monitor_failed",
@@ -136,21 +137,67 @@ def thread_error(thread)
136137
error.class.name
137138
end
138139

140+
# Retention gets its own thread rather than sharing the monitor's. A large
141+
# backlog or a lock wait can make a pass slow, and role replacement must not
142+
# wait behind housekeeping. A failed pass retries on the next tick instead of
143+
# deferring for the whole interval.
144+
# @rbs () -> void
145+
def retention_loop
146+
while @started
147+
begin
148+
prune_expired_records
149+
rescue => error
150+
SolidObjects.instrument(
151+
:"supervisor.retention_failed",
152+
error_class: error.class.name,
153+
error_message: error.message
154+
)
155+
end
156+
wait_for_next_retention
157+
end
158+
end
159+
160+
# Sleeping the whole interval would make shutdown wait out an hour-long
161+
# nap, so the pause is taken in short steps that notice a stop request.
162+
# @rbs () -> void
163+
def wait_for_next_retention
164+
deadline = monotonic_now + retention_pause
165+
step = SolidObjects.configuration.supervisor_monitor_interval
166+
while @started && monotonic_now < deadline
167+
sleep [ step, deadline - monotonic_now ].min
168+
end
169+
end
170+
139171
# Every actor call writes a durable message row, so retention that is only
140172
# configured and never run leaves those rows to grow without bound. The
141173
# supervisor runs it rather than requiring every application to schedule
142174
# its own job.
143175
# @rbs () -> void
144176
def prune_expired_records
145-
interval = SolidObjects.configuration.retention_interval
146-
return unless interval.positive?
147-
return if @pruned_at && monotonic_now - @pruned_at < interval
177+
return unless SolidObjects.configuration.retention_interval.positive?
148178

149-
@pruned_at = monotonic_now
150179
MessagePruner.new.prune
151180
ProcessPruner.new.prune
152181
end
153182

183+
# @rbs () -> Float
184+
def retention_pause
185+
interval = SolidObjects.configuration.retention_interval
186+
return interval if interval.positive?
187+
188+
SolidObjects.configuration.supervisor_monitor_interval
189+
end
190+
191+
# @rbs () -> void
192+
def stop_retention
193+
retention = @retention
194+
@retention = nil
195+
return unless retention
196+
197+
retention.join(SolidObjects.configuration.shutdown_timeout)
198+
retention.kill if retention.alive?
199+
end
200+
154201
# @rbs () -> void
155202
def cleanup_dead_processes
156203
interval = SolidObjects.configuration.dead_process_cleanup_interval

sig/generated/lib/solid_objects/supervisor.rbs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ module SolidObjects
44
class Supervisor
55
@lifecycle: Thread::Mutex
66

7-
@pruned_at: Float
7+
@retention: Thread?
88

99
@cleaned_up_at: Float
1010

@@ -54,13 +54,31 @@ module SolidObjects
5454
# @rbs (Thread?) -> String?
5555
def thread_error: (Thread?) -> String?
5656

57+
# Retention gets its own thread rather than sharing the monitor's. A large
58+
# backlog or a lock wait can make a pass slow, and role replacement must not
59+
# wait behind housekeeping. A failed pass retries on the next tick instead of
60+
# deferring for the whole interval.
61+
# @rbs () -> void
62+
def retention_loop: () -> void
63+
64+
# Sleeping the whole interval would make shutdown wait out an hour-long
65+
# nap, so the pause is taken in short steps that notice a stop request.
66+
# @rbs () -> void
67+
def wait_for_next_retention: () -> void
68+
5769
# Every actor call writes a durable message row, so retention that is only
5870
# configured and never run leaves those rows to grow without bound. The
5971
# supervisor runs it rather than requiring every application to schedule
6072
# its own job.
6173
# @rbs () -> void
6274
def prune_expired_records: () -> void
6375

76+
# @rbs () -> Float
77+
def retention_pause: () -> Float
78+
79+
# @rbs () -> void
80+
def stop_retention: () -> void
81+
6482
# @rbs () -> void
6583
def cleanup_dead_processes: () -> void
6684

test/integration/scheduled_retention_test.rb

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,32 @@ def stop = @stopped = true
1616
def stopped? = @stopped
1717
end
1818

19+
class CrashOnceRole < IdleRole
20+
class << self
21+
attr_accessor :shared_runs
22+
end
23+
24+
def initialize
25+
self.class.shared_runs ||= 0
26+
end
27+
28+
def runs = self.class.shared_runs
29+
30+
# Crashes only after the first monitor pass has begun, so replacement is
31+
# needed while a slow retention pass is already running.
32+
def run
33+
self.class.shared_runs += 1
34+
if self.class.shared_runs == 1
35+
sleep 0.1
36+
raise "role crashed"
37+
end
38+
39+
super
40+
end
41+
end
42+
1943
setup do
44+
CrashOnceRole.shared_runs = nil
2045
SolidObjects.configuration.supervisor_monitor_interval = 0.02
2146
SolidObjects.configuration.dead_process_cleanup_interval = 0
2247
SolidObjects.configuration.retention_interval = 0.05
@@ -83,25 +108,41 @@ def stopped? = @stopped
83108
ActiveSupport::Notifications.unsubscribe(subscription) if subscription
84109
end
85110

86-
test "a failing retention pass does not stop the supervisor" do
111+
test "a failing retention pass retries rather than deferring for the interval" do
87112
failures = []
88-
subscription = ActiveSupport::Notifications.subscribe("solid_objects.supervisor.monitor_failed") do
113+
subscription = ActiveSupport::Notifications.subscribe("solid_objects.supervisor.retention_failed") do
89114
failures << true
90115
end
91116
role = IdleRole.new
92117
@supervisor = supervisor(role)
93118
@supervisor.define_singleton_method(:prune_expired_records) { raise "boom" }
94119

95120
@supervisor.start
96-
Timeout.timeout(10) { sleep 0.02 until failures.any? }
97-
sleep 0.1
121+
Timeout.timeout(10) { sleep 0.02 until failures.length >= 2 }
98122

99123
assert @supervisor.instance_variable_get(:@monitor).alive?,
100124
"the monitor should survive a failing retention pass"
125+
assert_operator failures.length, :>=, 2,
126+
"a failed pass should retry on the next tick, not wait out the interval"
101127
ensure
102128
ActiveSupport::Notifications.unsubscribe(subscription) if subscription
103129
end
104130

131+
# Role replacement must not wait behind housekeeping.
132+
test "a slow retention pass does not block role replacement" do
133+
SolidObjects.configuration.retention_interval = 0.02
134+
role = CrashOnceRole.new
135+
@supervisor = supervisor(role)
136+
@supervisor.define_singleton_method(:prune_expired_records) { sleep 10 }
137+
138+
@supervisor.start
139+
140+
# Shorter than the pruning pass, so sharing a thread with it fails here.
141+
Timeout.timeout(3) { sleep 0.02 until role.runs >= 2 }
142+
assert_operator role.runs, :>=, 2,
143+
"a crashed role should be replaced while retention is still running"
144+
end
145+
105146
test "rejects a negative retention interval" do
106147
SolidObjects.configuration.retention_interval = -1
107148

0 commit comments

Comments
 (0)