Repository navigation
Release in-flight jobs when the async supervisor's shutdown times out - #815
Open
hannesfostie wants to merge 1 commit into
Open
hannesfostie wants to merge 1 commit into
hannesfostie wants to merge 1 commit into
Conversation
When a job outlives shutdown_timeout during graceful termination, the async supervisor ends with exit!, which skips the shutdown callbacks: neither the supervisor nor its threads deregister, and their claimed jobs stay claimed until a later supervisor prunes the stale registrations and fails them with ProcessPrunedError. On a TERM, which is what deploys send, the job is failed instead of going back to its queue as it does in fork mode. Worse, the supervisor and each worker wait for that same timeout, the supervisor starting slightly earlier, so it usually runs out first and calls exit! while the worker is still inside its own deregistration transaction, rolling back the release it had started. When the timeout runs out with threads still alive, deregister the supervisor before exit!, as the fork supervisor does after quitting its forks: the threads still running are deregistered with it and their claimed jobs go back to their queues. Deregister from a fresh copy of the registration, since the threads share theirs with the supervisor's in memory and a thread that already deregistered leaves a frozen record behind. QUIT is unchanged: it still exits without cleanup, as the async lifecycle test documents. The async lifecycle test for TERM past the timeout now expects the in-flight job back in its queue and a clean termination, like its fork counterpart, instead of accepting either outcome. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
|
One thing I didn't ask Claude to add here is a timing change. We could make the supervisor's timeout wait for Happy to add that if you think it's worth it |
hannesfostie
marked this pull request as ready for review
October 8, 2026 08:47
Author
|
As best as I can tell, CI failures will be fixed after #813 makes it in. I can rebase when that happens. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What we hit
We run Solid Queue 1.7.0 in async mode (
bin/jobs, so workers are threads inside the supervisor's process) on Kubernetes. Whenever a deploy replaced the pod while a long job was running, the job ended up failed withSolidQueue::Processes::ProcessPrunedErrorabout five minutes later, instead of going back to its queue. We had 14 of these over two days on our staging cluster, and each one lined up with a rollout.The old pod's logs always ended the same way: the worker logged
Release claimed jobfor one of its two claimed jobs, then nothing more, and noShutdown Supervisor(async). The new pod then pruned that worker's registration and failed both jobs, including the one that had just been logged as released.Why
On
TERM,AsyncSupervisor#perform_graceful_terminationstops the threads and waitsshutdown_timeout. Each worker waits the sameshutdown_timeoutfor its pool and then deregisters. Deregistering destroys its process row and releases its claimed executions in one transaction (after_destroy :release_all_claimed_executions).The supervisor starts its wait slightly before the workers start theirs, so with a job that outlives the timeout it usually runs out first. It then calls
exit!while the worker is still inside that transaction, and the release it had started is rolled back.exit!also skips the supervisor's own shutdown callbacks, so nothing else deregisters the threads. Their rows stay, and later the claimed jobs are failed instead of released.The existing test for this case accepted either outcome and described the race in a comment. In fork mode the same situation ends with the jobs back in their queues: the fork supervisor quits its forks and then deregisters itself, which deregisters them too. That's also what #422 describes as the expected outcome of a deploy.
The change
When the graceful wait runs out with threads still alive, the async supervisor now deregisters itself before
exit!. This deregisters the threads still running and releases their claimed jobs, as in fork mode.The deregistration goes through a freshly loaded copy of the supervisor's registration. The supervisor's own copy shares the threads' registrations in memory (
has_many_inversing), and a thread that has already deregistered leaves a destroyed, frozen record behind. My first version used the in-memory copy, and it failed intermittently on PostgreSQL withFrozenError: can't modify frozen attributes.QUITis unchanged. The async lifecycle test documents that it exits without cleanup, and I didn't want to change that here. The README's signal section says jobs in flight are returned to their queues onQUITas well, which only holds in fork mode, so that may be worth a follow-up.Tests
test "term supervisor exceeding timeout while there are jobs in-flight"in the async lifecycle test now expects the job to bereadyand a clean termination, like its fork-mode counterpart, instead of accepting eitherclaimedorready. That's the reproduction:I couldn't run MySQL locally, because
mysql2doesn't build on my machine.🤖 Generated with Claude Code