Hey @TkTech !
When a worker receives SIGTERM or SIGINT, Worker.on_signal() immediately calls manager.cancel_all():
async def on_signal(self, signum: int):
if signum in (signal.SIGTERM, signal.SIGINT):
if self.shutdown_event.is_set():
...
self.shutdown_event.set()
await self.manager.cancel_all()
This cancels every task the worker owns, including the per-queue _maintain_queue tasks. The cancellation propagates into the executor's async with block, so Executor.stop() runs and force-cancels all in-flight jobs (task.cancel() on AsyncExecutor, pool.shutdown(cancel_futures=True) on the pool-based ones).
Meanwhile Worker.stop() has a proper drain: it clears _queues, then waits up to shutdown_timeout seconds for the executors to empty before tearing anything down. But the signal handler never goes through stop(), so on the most common shutdown path in production (Kubernetes, systemd, Docker — all deliver SIGTERM), shutdown_timeout is effectively dead code and every running job gets killed and left for recovery to retry.
Proposed fix
I have a working branch and would like to know if you're open to a PR. The strategy:
on_signal() delegates to stop() so the existing drain/timeout mechanism is the single shutdown path. A second signal still forces an immediate shutdown.
stop() orchestrates the shutdown explicitly.
- A small internal
Executor._drain(timeout) waits for in-flight jobs without cancelling them.
- A few things fall out of making the drain actually hold up:
- executor teardown must not block the event loop on sync jobs it can't interrupt (thread/sub-interpreter pools shut down with wait=False; the process executor reuses the SIGUSR1 mechanism from cancel() first);
- job state updates produced during shutdown (completions from the drain, final state of force-cancelled stragglers, pool futures cancelled before they started) are flushed to the database before the worker exits, instead of leaving stale RUNNING rows;
stop() becomes idempotent, since the signal handler and the async context manager's __aexit__ can now both call it concurrently.
No public API change. The branch also adds a tests/test_shutdown.py covering the signal path, the drain on all four executors, timeout expiry, and the flush-on-shutdown behavior; the full suite passes locally.
Branch is here if you want to look at the actual diff before I open anything: PaulM5406#1
Would you be open to a PR for this? Happy to adjust the approach if you'd prefer something else.
Hey @TkTech !
When a worker receives SIGTERM or SIGINT,
Worker.on_signal()immediately callsmanager.cancel_all():This cancels every task the worker owns, including the per-queue
_maintain_queuetasks. The cancellation propagates into the executor's async with block, soExecutor.stop()runs and force-cancels all in-flight jobs (task.cancel()onAsyncExecutor,pool.shutdown(cancel_futures=True)on the pool-based ones).Meanwhile
Worker.stop()has a proper drain: itclears _queues, then waits up toshutdown_timeoutseconds for the executors to empty before tearing anything down. But the signal handler never goes throughstop(), so on the most common shutdown path in production (Kubernetes, systemd, Docker — all deliver SIGTERM),shutdown_timeoutis effectively dead code and every running job gets killed and left for recovery to retry.Proposed fix
I have a working branch and would like to know if you're open to a PR. The strategy:
on_signal()delegates tostop()so the existing drain/timeout mechanism is the single shutdown path. A second signal still forces an immediate shutdown.stop()orchestrates the shutdown explicitly.Executor._drain(timeout)waits for in-flight jobs without cancelling them.stop()becomes idempotent, since the signal handler and the async context manager's__aexit__can now both call it concurrently.No public API change. The branch also adds a
tests/test_shutdown.pycovering the signal path, the drain on all four executors, timeout expiry, and the flush-on-shutdown behavior; the full suite passes locally.Branch is here if you want to look at the actual diff before I open anything: PaulM5406#1
Would you be open to a PR for this? Happy to adjust the approach if you'd prefer something else.