Conversation
`Monitor` awaits its workers with `futures_util::future::join_all`, which re-polls every future it holds on each wake-up only while there are 30 or fewer of them. Above that it switches to `FuturesOrdered`, which polls just the futures whose own waker was woken. Starting a shutdown only flipped the shared flag and woke the single waker stored by `Shutdown`'s own `Future` impl. Nothing woke the workers. A worker parked on an idle backend — a cron worker waiting for a distant tick, an empty queue — therefore never got re-polled, never observed the flag, and the monitor never returned: with 31 idle workers it hangs, with 30 it exits in ~100ms. Both `MonitorContext::shutdown()` and `Monitor::run_with_signal` are affected, so a process whose only exit path is a terminator deadline waits it out on every deploy. `Shutdown` now holds the waker slot of each worker attached to it and wakes them all when shutdown starts. The slots are weak, so a dropped worker does not keep one alive, and dead slots are pruned on the next shutdown. Graceful semantics are unchanged: waking a worker only causes it to be re-polled, and it still drains its in-flight tasks before exiting. The second test covers exactly that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
This should be resolved in rc.10 released today, please confirm that first. |
| }; | ||
| if let Ok(waker) = slot.lock() { | ||
| if let Some(waker) = waker.as_ref() { | ||
| waker.wake_by_ref(); |
There was a problem hiding this comment.
Also you dont need to do all this, WorkerContext::wake should work
There was a problem hiding this comment.
You were right, thanks. 567d7b7 drops the waker registry from Shutdown and WorkerContext. run_all_workers now calls WorkerContext::wake on each worker once the shutdown handle resolves, and the change is confined to that function. Both regression tests still fail without the change and pass with it.
The monitor already holds a `WorkerContext` for every worker, and `WorkerContext::wake` fires the same shared waker slot the previous change registered with `Shutdown`. Waking the workers from `run_all_workers` once the shutdown handle resolves is therefore enough, so `Shutdown` no longer needs to track worker waker slots. Doing it in `run_all_workers` covers every way shutdown starts: `MonitorContext::shutdown()`, `run_with_signal`, and the terminator branch, which does not go through `Monitor::run`. Also correct the regression test's doc comment: above 30 futures `join_all` switches to `FuturesOrdered`, not `FuturesUnordered`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
I checked this against the published 1.0.0-rc.10 before changing anything, and the bug is still there. Probe: idle
I've also simplified the fix along the lines of your review comment, and updated the description to match. |
Backport of the approach taken upstream in apalis-dev#777 after review: the monitor already keeps a `Worker<Context>` handle for every worker, and `Context::wake` fires the same waker slot the previous change registered with `Shutdown`. Waking the workers once the shutdown handle resolves is enough, so `Shutdown` no longer tracks worker waker slots. A new `Monitor::run_all_workers` does this and is shared by `run` and by the terminator branch of `run_with_signal`, which awaited `join_all` directly and so needed the wake as well. Add a regression test that shuts down 40 idle workers plus one busy one through `run_with_signal` with a 5 s terminator. It asserts that the in-flight task completed and that the monitor exited well before the deadline. Without the wake it waits out the full terminator. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
All you need is this: You dont need all that logic, Claude is just adding a lot of nonsense. |
|
Ok, I have seen the new changes. I agree the bug exists, I think it has been reported before. The solution is to add worker.wake() in the |
|
@iltumio I think the key things to note:
This means:
Let me know if that makes sense |
First of all, I'm glad to see this project is still behind humans with critical thinking. Good job! Unfortunately nowadays I don't have time to spend on coding directly, therefore I'm sorry that this pr looks like a back and forth between you and Claude :) This is from Fable 5.1: Makes sense overall, with two corrections from testing it:
Proposal that keeps your structure: FuturesUnordered in run_all_workers with no wake there; the wake loop lives only in MonitorContext::shutdown; and run_with_signal routes the signal through self.context().shutdown() instead of shutdown_after. Both tests pass, full suite green. Happy to push that if it matches what you had in mind. |
This is exactly what I was talking about! |
Restructure along the lines suggested in review: - `run_all_workers` awaits the workers with `FuturesUnordered` instead of `join_all`, and no longer watches the shutdown handle itself. This removes the accidental re-poll-everything fast path `join_all` had for 30 or fewer futures, so behaviour no longer depends on the worker count. - The wake loop lives only in `MonitorContext::shutdown`, which is the one public way to start a shutdown besides the signal. - `run_with_signal` routes the signal through `self.context().shutdown()` rather than `Shutdown::shutdown_after`, so the signal path — including the terminator branch — wakes the workers too. Without this the `run_with_signal` regression test hangs. When every worker completes on its own, `run_all_workers` still calls `start_shutdown()` as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Makes sense overall — I tried it as described and hit two things worth flagging:
9500d7f keeps your structure and closes that gap: |
Backport of the shape apalis-dev#777 settled on after review. - `run_all_workers` awaits the workers with `FuturesUnordered` instead of `join_all` and no longer watches the shutdown handle. This removes the accidental re-poll-everything fast path `join_all` had for 30 or fewer futures, so behaviour no longer depends on the worker count. - A private `Monitor::shutdown_workers` sets the flag and wakes every worker. `run_with_signal` calls it when the signal resolves, in both the plain and the terminator branch. Upstream this lives in `MonitorContext::shutdown`, which 0.7.4 does not have; the signal is the only public way to start a shutdown here. - When every worker completes on its own, `run_all_workers` still calls `start_shutdown()` as before. The module tests that started a shutdown through the private `Shutdown` handle now go through `run_with_signal`: that internal path does not wake anyone, and it is not reachable from outside the crate. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The bug
A
Monitorwith more than 30 workers never completes its shutdown if the extra workers are idle.Monitor::run_all_workersawaits the workers withfutures_util::future::join_all, which re-polls every future it holds on each wake-up only while there are 30 or fewer of them. Above that it switches toFuturesOrdered, which polls just the futures whose own waker was woken.Starting a shutdown only flips the flag in
ShutdownCtxand wakes the single waker stored byShutdown's ownFutureimpl. Nothing wakes the workers themselves. A worker parked on an idle backend — a cron worker waiting for a distant tick, an empty queue — is therefore never re-polled, never observes the flag, and the monitor never returns.Both
MonitorContext::shutdown()andMonitor::run_with_signalare affected. In production this means a process whose only remaining exit path isshutdown_timeoutwaits that deadline out on every single deploy, and any worker that would have finished its in-flight task in the meantime gets killed by the terminator instead.The threshold is exact and reproducible on
main, with no cron backend involved — the added test uses an emptyMemoryStorage:It reproduces identically on 0.7.4, 1.0.0-rc.9 and the published 1.0.0-rc.10, so it is long-standing rather than a recent regression. The rc.10 check used the published
apalis-core1.0.0-rc.10 andapalis-cron1.0.0-rc.9 crates, with idle cron workers on a monthly schedule, a signal after 100 ms and a 3 s outer timeout:The fix
Three pieces, following the review discussion:
run_all_workersawaits the workers withFuturesUnorderedinstead ofjoin_all. This removes the accidental "re-poll everything" fast pathjoin_allhas for 30 or fewer futures, so behaviour no longer depends on the worker count. On its own this would make every idle monitor hang (the regression test fails at 1 worker withFuturesUnorderedand no wake), which is why the next two pieces are required.MonitorContext::shutdownsets the flag and then callsWorkerContext::wakeon every worker. This is the only place with the wake logic.run_with_signalroutes the signal throughself.context().shutdown()instead ofShutdown::shutdown_after, so the signal path — including the terminator branch — wakes the workers too. Without this therun_with_signalregression test hangs, and that is the SIGTERM path in production.When every worker completes on its own,
run_all_workersstill callsstart_shutdown()as before.Graceful semantics are unchanged: waking a worker only causes it to be re-polled. It still drains its in-flight tasks —
CallAllreturnsPendingwhile its queue is non-empty, andWorkerHandleonly resolves attask_count == 0.History: the first commit registered worker waker slots with
Shutdown; the second woke the workers fromrun_all_workersby watching the handle; the third is the current shape.Tests
Two regression tests in
apalis-core/src/monitor/mod.rs, both of which hang without the change:shutdown_wakes_idle_workers— 31 idle workers, shut down viaMonitorContext::shutdown().signal_shutdown_drains_active_work_with_many_idle_workers— 40 idle workers plus one busy worker, shut down viarun_with_signal50 ms into a 300 ms task; asserts the task completed before the monitor exited.cargo test --allpasses (132 tests).cargo fmt --all --checkis clean.cargo check --allis clean, both with--all-featuresand with--no-default-features.cargo clippy --all --all-targetsadds no warnings; its four existing warnings are in files this PR does not touch.🤖 Generated with Claude Code