-
Notifications
You must be signed in to change notification settings - Fork 0
feat: run retention on the supervisor #24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,11 +2,14 @@ | |
|
|
||
| module SolidObjects | ||
| class Supervisor | ||
| MAXIMUM_RETENTION_BACKOFF_DOUBLINGS = 16 | ||
|
|
||
| # @rbs @components: Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor] | ||
| # @rbs @threads: Array[Thread] | ||
| # @rbs @monitor: Thread? | ||
| # @rbs @started: bool | ||
| # @rbs @cleaned_up_at: Float | ||
| # @rbs @retention: Thread? | ||
| # @rbs @lifecycle: Thread::Mutex | ||
|
|
||
| # @rbs (?worker_count: Integer, ?effect_worker_count: Integer, ?broadcast_worker_count: Integer, ?reminder_scheduler_count: Integer) -> void | ||
|
|
@@ -26,6 +29,7 @@ def initialize( | |
| @monitor = nil | ||
| @started = false | ||
| @cleaned_up_at = nil | ||
| @retention = nil | ||
| @lifecycle = Thread::Mutex.new | ||
| end | ||
|
|
||
|
|
@@ -44,6 +48,7 @@ def start | |
| @started = true | ||
| @threads = components.map { |component| supervise(component) } | ||
| @monitor = Thread.new { monitor_loop } | ||
| @retention = Thread.new { retention_loop } | ||
| SolidObjects.instrument(:"supervisor.started", component_count: components.length) | ||
| end | ||
|
|
||
|
|
@@ -57,6 +62,7 @@ def stop | |
| # list, or never starts. | ||
| @lifecycle.synchronize { @started = false } | ||
| stop_monitor | ||
| stop_retention | ||
| components.each(&:request_shutdown) | ||
| join_until_timeout | ||
| components.reject(&:stopped?).each(&:stop) | ||
|
|
@@ -133,6 +139,76 @@ def thread_error(thread) | |
| error.class.name | ||
| end | ||
|
|
||
| # Retention gets its own thread rather than sharing the monitor's. A large | ||
| # backlog or a lock wait can make a pass slow, and role replacement must not | ||
| # wait behind housekeeping. | ||
| # @rbs () -> void | ||
| def retention_loop | ||
| failures = 0 | ||
| while @started | ||
| begin | ||
| prune_expired_records | ||
| failures = 0 | ||
| rescue => error | ||
| failures += 1 | ||
| SolidObjects.instrument( | ||
| :"supervisor.retention_failed", | ||
| error_class: error.class.name, | ||
| error_message: error.message | ||
| ) | ||
| end | ||
| wait_for_next_retention(failures) | ||
| end | ||
| end | ||
|
|
||
| # Sleeping the whole interval would make shutdown wait out an hour-long | ||
| # nap, so the pause is taken in short steps that notice a stop request. | ||
| # @rbs (Integer) -> void | ||
| def wait_for_next_retention(failures) | ||
| deadline = monotonic_now + retention_pause(failures) | ||
| step = SolidObjects.configuration.supervisor_monitor_interval | ||
| while @started && monotonic_now < deadline | ||
| sleep [ step, deadline - monotonic_now ].min | ||
| end | ||
| end | ||
|
|
||
| # Every actor call writes a durable message row, so retention that is only | ||
| # configured and never run leaves those rows to grow without bound. The | ||
| # supervisor runs it rather than requiring every application to schedule | ||
| # its own job. | ||
| # @rbs () -> void | ||
| def prune_expired_records | ||
| return unless SolidObjects.configuration.retention_interval.positive? | ||
|
|
||
| MessagePruner.new.prune | ||
| ProcessPruner.new.prune | ||
|
Comment on lines
+183
to
+184
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If production has a large expired-record backlog or a database lock wait, these unbounded pruning loops run synchronously in the sole monitor thread, preventing dead-role replacement and dead-process cleanup until pruning finishes. Prompt To Fix With AIThis is a comment left during a code review.
Path: lib/solid_objects/supervisor.rb
Line: 150-151
Comment:
**Retention blocks role supervision**
If production has a large expired-record backlog or a database lock wait, these unbounded pruning loops run synchronously in the sole monitor thread, preventing dead-role replacement and dead-process cleanup until pruning finishes.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in cc2cbb9. Retention moved to a dedicated thread started alongside the monitor, so an unbounded pruning pass no longer holds back Covered by "a slow retention pass does not block role replacement": Shutdown was the other half of this: sleeping the full interval made |
||
| end | ||
|
|
||
| # A transient lock or connection error must not defer retention for the | ||
| # whole interval, so a failed pass retries at monitor cadence. The pause | ||
| # then doubles per consecutive failure, capped by the interval, so a | ||
| # database that stays down is not polled once a second forever. | ||
| # @rbs (Integer) -> Float | ||
| def retention_pause(failures) | ||
| interval = SolidObjects.configuration.retention_interval | ||
| interval = SolidObjects.configuration.supervisor_monitor_interval unless interval.positive? | ||
| return interval if failures.zero? | ||
|
|
||
| backoff = SolidObjects.configuration.supervisor_monitor_interval * | ||
| (2**[ failures - 1, MAXIMUM_RETENTION_BACKOFF_DOUBLINGS ].min) | ||
| [ backoff, interval ].min | ||
| end | ||
|
|
||
| # @rbs () -> void | ||
| def stop_retention | ||
| retention = @retention | ||
| @retention = nil | ||
| return unless retention | ||
|
|
||
| retention.join(SolidObjects.configuration.shutdown_timeout) | ||
| retention.kill if retention.alive? | ||
| end | ||
|
|
||
| # @rbs () -> void | ||
| def cleanup_dead_processes | ||
| interval = SolidObjects.configuration.dead_process_cleanup_interval | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When either pruner raises a transient database error, the rescue path unconditionally calls
wait_for_next_retention, which uses the positiveretention_interval; with the default configuration, expired records remain unpruned for another hour instead of retrying on the next supervisor tick.Prompt To Fix With AI