Skip to content

fix(core): wake idle workers when shutdown starts - #777

Open
iltumio wants to merge 3 commits into
apalis-dev:mainfrom
iltumio:fix/monitor-shutdown-wakes-idle-workers
Open

iltumio wants to merge 3 commits into
apalis-dev:mainfrom
iltumio:fix/monitor-shutdown-wakes-idle-workers

Conversation

@iltumio

@iltumio iltumio commented Sep 15, 2026

Copy link
Copy Markdown

The bug

A Monitor with more than 30 workers never completes its shutdown if the extra workers are idle.

Monitor::run_all_workers awaits the 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 flips the flag in ShutdownCtx and wakes the single waker stored by Shutdown's own Future impl. 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() and Monitor::run_with_signal are affected. In production this means a process whose only remaining exit path is shutdown_timeout waits 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 empty MemoryStorage:

Idle workers Result
30 shuts down in ~100 ms
31 hangs

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-core 1.0.0-rc.10 and apalis-cron 1.0.0-rc.9 crates, with idle cron workers on a monthly schedule, a signal after 100 ms and a 3 s outer timeout:

Idle cron workers rc.10 as published rc.10 + this PR
30 ~102 ms ~102 ms
31 timed out at 3 s ~106 ms
51 timed out at 3 s ~103 ms
200 ~106 ms

The fix

Three pieces, following the review discussion:

  • run_all_workers awaits the workers with FuturesUnordered instead of join_all. This removes the accidental "re-poll everything" fast path join_all has 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 with FuturesUnordered and no wake), which is why the next two pieces are required.
  • MonitorContext::shutdown sets the flag and then calls WorkerContext::wake on every worker. This is the only place with the wake logic.
  • run_with_signal routes the signal through self.context().shutdown() instead of Shutdown::shutdown_after, so the signal path — including the terminator branch — wakes the workers too. Without this the run_with_signal regression test hangs, and that is the SIGTERM path in production.

When every worker completes on its own, run_all_workers still calls start_shutdown() as before.

Graceful semantics are unchanged: waking a worker only causes it to be re-polled. It still drains its in-flight tasks — CallAll returns Pending while its queue is non-empty, and WorkerHandle only resolves at task_count == 0.

History: the first commit registered worker waker slots with Shutdown; the second woke the workers from run_all_workers by 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 via MonitorContext::shutdown().
  • signal_shutdown_drains_active_work_with_many_idle_workers — 40 idle workers plus one busy worker, shut down via run_with_signal 50 ms into a 300 ms task; asserts the task completed before the monitor exited.

cargo test --all passes (132 tests). cargo fmt --all --check is clean. cargo check --all is clean, both with --all-features and with --no-default-features. cargo clippy --all --all-targets adds no warnings; its four existing warnings are in files this PR does not touch.

🤖 Generated with Claude Code

`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>
@geofmureithi

Copy link
Copy Markdown
Member

This should be resolved in rc.10 released today, please confirm that first.

Comment thread apalis-core/src/monitor/shutdown.rs Outdated
};
if let Ok(waker) = slot.lock() {
if let Some(waker) = waker.as_ref() {
waker.wake_by_ref();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also you dont need to do all this, WorkerContext::wake should work

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@iltumio

iltumio commented Sep 16, 2026

Copy link
Copy Markdown
Author

I checked this against the published 1.0.0-rc.10 before changing anything, and the bug is still there. apalis-core 1.0.0-rc.10's src/ is identical to main, and start_shutdown() still only sets the flag and wakes the Shutdown future's own waker.

Probe: idle CronScheduler workers on a monthly schedule (apalis-core =1.0.0-rc.10, apalis-cron =1.0.0-rc.9), shut down with run_with_signal after 100 ms, inside a 3 s outer timeout:

Idle workers rc.10 as published rc.10 + this PR
30 ~102 ms ~102 ms
31 timed out at 3 s ~106 ms
51 timed out at 3 s ~103 ms

I've also simplified the fix along the lines of your review comment, and updated the description to match.

iltumio added a commit to iltumio/apalis that referenced this pull request Sep 16, 2026
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>
@geofmureithi

Copy link
Copy Markdown
Member

All you need is this:

 pub fn shutdown(&self) -> Result<(), MonitorError> {
        self.shutdown.start_shutdown();
        let _: Vec<_> = self.workers().iter().map(|s| s.wake()).collect();
        Ok(())
}

You dont need all that logic, Claude is just adding a lot of nonsense.

@geofmureithi

Copy link
Copy Markdown
Member

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 monitor_ctx.shutdown() method. Let me know if that works

@geofmureithi

Copy link
Copy Markdown
Member

@iltumio I think the key things to note:

  1. join_all is one part of the problem. I was not aware of the 30 Futures limit, we can resolve that with FuturesUnordered
  2. The extra logic to wake workers on shutdown should be added to MonitorContext::shutdown
  3. If 1&2 are applied, then we dont need to call wake in run_workers. This was the original intention, since a worker can internally stop.

This means:

  • If all workers complete without a shutdown being called, start_shutdown is called
  • If MonitorContext::shutdown is called, the we call wake on each worker, which should start the shutdown process

Let me know if that makes sense

@iltumio

iltumio commented Sep 17, 2026

Copy link
Copy Markdown
Author

@iltumio I think the key things to note:

1. `join_all` is one part of the problem. I was not aware of the 30 Futures limit, we can resolve that with `FuturesUnordered`

2. The extra logic to wake workers on shutdown should be added to `MonitorContext::shutdown`

3. If 1&2 are applied, then we dont need to call `wake` in `run_workers`. This was the original intention, since a worker can internally stop.

This means:

* If all workers complete without a shutdown being called, `start_shutdown` is called

* If `MonitorContext::shutdown` is called, the we call wake on each worker, which should start the shutdown process

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:

  1. FuturesUnordered doesn't lift the 30 limit — it removes the fast path. Below 30, join_all re-polls every future on each wake-up, which is the only reason small monitors work today; FuturesUnordered polls only futures whose waker fired, same as FuturesOrdered above 30. With FuturesUnordered and no explicit wake, the idle-worker test hangs at 1 worker, not 31. So I agree on using it (it makes the behaviour deterministic), but only together with a guaranteed wake.
  2. Waking in MonitorContext::shutdown alone misses run_with_signal: shutdown_after calls Shutdown::start_shutdown() directly, never going through the context. With your proposal applied verbatim, the run_with_signal regression test still hangs — and that's the SIGTERM path in production.

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.

@geofmureithi

Copy link
Copy Markdown
Member

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>
@iltumio

iltumio commented Sep 17, 2026

Copy link
Copy Markdown
Author

Makes sense overall — I tried it as described and hit two things worth flagging:

  1. FuturesUnordered doesn't lift the 30 limit, it removes the fast path. Below 30, join_all re-polls every future on each wake-up, which is the only reason small monitors work today; FuturesUnordered polls only futures whose waker fired, same as FuturesOrdered above 30. With FuturesUnordered and no explicit wake, the idle-worker test hangs at 1 worker, not 31. So agreed on using it (behaviour becomes deterministic), but only together with a guaranteed wake.
  2. Waking in MonitorContext::shutdown alone misses run_with_signal: shutdown_after calls Shutdown::start_shutdown() directly and never goes through the context. With the proposal applied verbatim, the run_with_signal regression test still hangs — and that's the SIGTERM path in production.

9500d7f keeps your structure and closes that gap: 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. When all workers complete on their own, start_shutdown() is still called as before. Both regression tests pass, full suite green. Description updated to match.

iltumio added a commit to iltumio/apalis that referenced this pull request Sep 17, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants