Skip to content

Commit 15f0fdb

Browse files
committed
fix: back off failed retention passes
The previous fix removed the timestamp that suppressed retries but left the failure path waiting the configured interval, so a transient lock or connection error still deferred retention for up to an hour. Greptile caught that the reply overstated what the change did. A failed pass now retries at monitor cadence and doubles the pause per consecutive failure, capped by the retention interval, so recovery is prompt without polling a database that stays down once a second forever. The retry test now configures a 600 second interval, which the previous behaviour would have waited out. Also merge the duplicate Unreleased changelog heading left by the rebase.
1 parent cc2cbb9 commit 15f0fdb

4 files changed

Lines changed: 70 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
unscheduled. Every actor call writes a durable message row, so a policy that
77
nothing invokes let history grow without bound until an application scheduled
88
its own job. `retention_interval` defaults to one hour, and zero disables it.
9-
10-
## Unreleased
11-
9+
Retention runs on its own thread, so a slow pass cannot delay replacing a
10+
crashed role, and a failed pass retries at monitor cadence with a doubling
11+
backoff rather than deferring for the whole interval.
1212
- Add Ruby 4.0 to the compatibility matrix, which now covers Ruby 3.3, 3.4, and
1313
4.0 against Rails 8.0 and 8.1.
1414

lib/solid_objects/supervisor.rb

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

33
module SolidObjects
44
class Supervisor
5+
MAXIMUM_RETENTION_BACKOFF_DOUBLINGS = 16
6+
57
# @rbs @components: Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
68
# @rbs @threads: Array[Thread]
79
# @rbs @monitor: Thread?
@@ -139,29 +141,31 @@ def thread_error(thread)
139141

140142
# Retention gets its own thread rather than sharing the monitor's. A large
141143
# 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+
# wait behind housekeeping.
144145
# @rbs () -> void
145146
def retention_loop
147+
failures = 0
146148
while @started
147149
begin
148150
prune_expired_records
151+
failures = 0
149152
rescue => error
153+
failures += 1
150154
SolidObjects.instrument(
151155
:"supervisor.retention_failed",
152156
error_class: error.class.name,
153157
error_message: error.message
154158
)
155159
end
156-
wait_for_next_retention
160+
wait_for_next_retention(failures)
157161
end
158162
end
159163

160164
# Sleeping the whole interval would make shutdown wait out an hour-long
161165
# 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
166+
# @rbs (Integer) -> void
167+
def wait_for_next_retention(failures)
168+
deadline = monotonic_now + retention_pause(failures)
165169
step = SolidObjects.configuration.supervisor_monitor_interval
166170
while @started && monotonic_now < deadline
167171
sleep [ step, deadline - monotonic_now ].min
@@ -180,12 +184,19 @@ def prune_expired_records
180184
ProcessPruner.new.prune
181185
end
182186

183-
# @rbs () -> Float
184-
def retention_pause
187+
# A transient lock or connection error must not defer retention for the
188+
# whole interval, so a failed pass retries at monitor cadence. The pause
189+
# then doubles per consecutive failure, capped by the interval, so a
190+
# database that stays down is not polled once a second forever.
191+
# @rbs (Integer) -> Float
192+
def retention_pause(failures)
185193
interval = SolidObjects.configuration.retention_interval
186-
return interval if interval.positive?
194+
interval = SolidObjects.configuration.supervisor_monitor_interval unless interval.positive?
195+
return interval if failures.zero?
187196

188-
SolidObjects.configuration.supervisor_monitor_interval
197+
backoff = SolidObjects.configuration.supervisor_monitor_interval *
198+
(2**[ failures - 1, MAXIMUM_RETENTION_BACKOFF_DOUBLINGS ].min)
199+
[ backoff, interval ].min
189200
end
190201

191202
# @rbs () -> void

sig/generated/lib/solid_objects/supervisor.rbs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
module SolidObjects
44
class Supervisor
5+
MAXIMUM_RETENTION_BACKOFF_DOUBLINGS: ::Integer
6+
57
@lifecycle: Thread::Mutex
68

79
@retention: Thread?
@@ -56,15 +58,14 @@ module SolidObjects
5658

5759
# Retention gets its own thread rather than sharing the monitor's. A large
5860
# 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+
# wait behind housekeeping.
6162
# @rbs () -> void
6263
def retention_loop: () -> void
6364

6465
# Sleeping the whole interval would make shutdown wait out an hour-long
6566
# nap, so the pause is taken in short steps that notice a stop request.
66-
# @rbs () -> void
67-
def wait_for_next_retention: () -> void
67+
# @rbs (Integer) -> void
68+
def wait_for_next_retention: (Integer) -> void
6869

6970
# Every actor call writes a durable message row, so retention that is only
7071
# configured and never run leaves those rows to grow without bound. The
@@ -73,8 +74,12 @@ module SolidObjects
7374
# @rbs () -> void
7475
def prune_expired_records: () -> void
7576

76-
# @rbs () -> Float
77-
def retention_pause: () -> Float
77+
# A transient lock or connection error must not defer retention for the
78+
# whole interval, so a failed pass retries at monitor cadence. The pause
79+
# then doubles per consecutive failure, capped by the interval, so a
80+
# database that stays down is not polled once a second forever.
81+
# @rbs (Integer) -> Float
82+
def retention_pause: (Integer) -> Float
7883

7984
# @rbs () -> void
8085
def stop_retention: () -> void

test/integration/scheduled_retention_test.rb

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ def runs = self.class.shared_runs
3232
def run
3333
self.class.shared_runs += 1
3434
if self.class.shared_runs == 1
35+
# The crash is the point of the test, so its backtrace is not news.
36+
Thread.current.report_on_exception = false
3537
sleep 0.1
3638
raise "role crashed"
3739
end
@@ -46,12 +48,17 @@ def run
4648
SolidObjects.configuration.dead_process_cleanup_interval = 0
4749
SolidObjects.configuration.retention_interval = 0.05
4850
SolidObjects.configuration.message_retention = 0
51+
# A test that stalls a retention pass on purpose should not pay the
52+
# production join before the thread is killed.
53+
SolidObjects.configuration.shutdown_timeout = 0.2
4954
end
5055

5156
teardown do
5257
@supervisor&.stop
5358
SolidObjects.configuration.retention_interval = 3600.0
5459
SolidObjects.configuration.dead_process_cleanup_interval = 60.0
60+
SolidObjects.configuration.supervisor_monitor_interval = 1.0
61+
SolidObjects.configuration.shutdown_timeout = 15.0
5562
end
5663

5764
test "the supervisor prunes expired messages without being asked" do
@@ -108,7 +115,11 @@ def run
108115
ActiveSupport::Notifications.unsubscribe(subscription) if subscription
109116
end
110117

118+
# The interval here is far longer than the assertion window, so a retry that
119+
# waited it out would fail. A transient lock or connection error must not
120+
# defer retention for the whole hour.
111121
test "a failing retention pass retries rather than deferring for the interval" do
122+
SolidObjects.configuration.retention_interval = 600
112123
failures = []
113124
subscription = ActiveSupport::Notifications.subscribe("solid_objects.supervisor.retention_failed") do
114125
failures << true
@@ -118,16 +129,37 @@ def run
118129
@supervisor.define_singleton_method(:prune_expired_records) { raise "boom" }
119130

120131
@supervisor.start
121-
Timeout.timeout(10) { sleep 0.02 until failures.length >= 2 }
132+
Timeout.timeout(5) { sleep 0.02 until failures.length >= 3 }
122133

123134
assert @supervisor.instance_variable_get(:@monitor).alive?,
124135
"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"
136+
assert_operator failures.length, :>=, 3
127137
ensure
128138
ActiveSupport::Notifications.unsubscribe(subscription) if subscription
129139
end
130140

141+
# Retrying at monitor cadence forever would hammer a database that stays
142+
# down, so the pause grows and is capped by the configured interval.
143+
test "repeated retention failures back off" do
144+
SolidObjects.configuration.retention_interval = 600
145+
SolidObjects.configuration.supervisor_monitor_interval = 0.05
146+
@supervisor = supervisor
147+
@supervisor.define_singleton_method(:prune_expired_records) { raise "boom" }
148+
149+
pauses = (1..5).map { |failures| @supervisor.send(:retention_pause, failures) }
150+
151+
assert_equal pauses.sort, pauses, "each failure should wait at least as long"
152+
assert_operator pauses.last, :>, pauses.first
153+
assert_operator pauses.max, :<=, 600
154+
end
155+
156+
test "a recovered retention pass returns to its interval" do
157+
SolidObjects.configuration.retention_interval = 600
158+
@supervisor = supervisor
159+
160+
assert_equal 600, @supervisor.send(:retention_pause, 0)
161+
end
162+
131163
# Role replacement must not wait behind housekeeping.
132164
test "a slow retention pass does not block role replacement" do
133165
SolidObjects.configuration.retention_interval = 0.02

0 commit comments

Comments
 (0)