Skip to content

Commit 94e679b

Browse files
committed
fix(receiver): harden callback draining during shutdown
- keep callback tasks owned until cleanup completes - prevent AnyIO level-cancellation busy loops - preserve interruptible unbounded graceful waits - release execution capacity exactly once - clarify wait-tasks-timeout semantics - add deterministic lifecycle and cancellation tests
1 parent 8a1b04d commit 94e679b

6 files changed

Lines changed: 607 additions & 26 deletions

File tree

docs/guide/cli.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,10 @@ kill -HUP <main pid>
143143

144144
### Graceful and force shutdowns
145145

146-
If you send `SIGINT` or `SIGKILL` to the main process by pressing <kbd>Ctrl</kbd>+<kbd>C</kbd> or using the `kill` command, it will initiate the shutdown process.
146+
If you send `SIGINT` or `SIGTERM` to the main process by pressing <kbd>Ctrl</kbd>+<kbd>C</kbd> or using the `kill` command, it will initiate the shutdown process.
147147
By default, it will stop fetching new messages immediately after receiving the signal but will wait for the completion of all currently executing tasks.
148148

149-
If you don't want to wait too long for tasks to complete each time you shut down the worker, you can either send termination signals three times to the main process to perform a hard kill or configure the `--wait-tasks-timeout` to set a hard time limit for shutting down.
149+
If you don't want to wait indefinitely for tasks to complete, configure `--wait-tasks-timeout` to set their graceful completion period. Once that period expires, the worker requests cancellation of asynchronous task callbacks that are still running and waits for their cleanup before continuing shutdown. Synchronous functions already running in a thread or process executor cannot be forcibly stopped by asyncio cancellation and may continue until the executor shuts down. This is not an absolute process deadline; repeat the termination signal until the configured hard-kill threshold is reached if the process must stop immediately.
150150

151151
::: tip Cool tip
152152
The number of signals before a hard kill can be configured with the `--hardkill-count` CLI argument.
@@ -171,7 +171,7 @@ The number of signals before a hard kill can be configured with the `--hardkill-
171171
* `--max-tasks-per-child` - maximum number of tasks to be executed by a single worker process before restart.
172172
* `--max-fails` - Maximum number of child process exits.
173173
* `--shutdown-timeout` - maximum amount of time for graceful broker's shutdown in seconds (default 5).
174-
* `--wait-tasks-timeout` - if cannot read new messages from the broker or maximum number of tasks is reached, worker will wait for all current tasks to finish. This parameter sets the maximum amount of time to wait until shutdown.
174+
* `--wait-tasks-timeout` - graceful completion period for current tasks during shutdown. Cancellation is requested for callbacks still running after the period, and their cleanup is awaited. The default `None` waits without a timeout.
175175
* `--hardkill-count` - Number of termination signals to the main process before performing a hardkill.
176176

177177
## Scheduler

taskiq/cli/worker/args.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,10 @@ def from_cli(
259259
"--wait-tasks-timeout",
260260
type=float,
261261
default=None,
262-
help="Maximum time to wait for all current tasks to finish before exiting.",
262+
help=(
263+
"Grace period for current task callbacks before cancellation is "
264+
"requested during shutdown."
265+
),
263266
)
264267
parser.add_argument(
265268
"--hardkill-count",

taskiq/receiver/receiver.py

Lines changed: 71 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -656,19 +656,12 @@ async def runner(
656656
:param queue: queue with prefetched data.
657657
"""
658658
tasks: set[asyncio.Task[Any]] = set()
659-
660-
while True:
661-
try:
659+
graceful_shutdown = False
660+
try:
661+
while True:
662662
queued_message = await queue.get()
663663
if queued_message is _QueueSignal.DONE:
664-
# asyncio.wait will throw an error if there is nothing to wait for
665-
if tasks:
666-
logger.info(
667-
"Waiting for %d running tasks to complete...",
668-
len(tasks),
669-
)
670-
await asyncio.wait(tasks, timeout=self.wait_tasks_timeout)
671-
logger.info("No more tasks to wait for. Shutting down.")
664+
graceful_shutdown = True
672665
break
673666
execution_semaphore = self.sem
674667
owns_execution_slot = execution_semaphore is not None
@@ -684,23 +677,68 @@ async def runner(
684677
)
685678
tasks.add(started_callback.task)
686679

687-
# We want the task to remove itself from the set when it's done.
688-
#
689-
# Because if we won't save it anywhere,
690-
# python's GC can silently cancel task
691-
# and this behaviour considered to be a Hisenbug.
692-
# https://textual.textualize.io/blog/2023/02/11/the-heisenbug-lurking-in-your-async-code/
693680
started_callback.task.add_done_callback(
694681
functools.partial(
695682
self._on_callback_done,
696683
active_tasks=tasks,
697684
owns_delivery_slot=started_callback.owns_delivery_slot,
698685
),
699686
)
687+
except asyncio.CancelledError:
688+
pass
689+
finally:
690+
await self._drain_active_tasks(
691+
tasks,
692+
cancel_immediately=not graceful_shutdown,
693+
)
694+
logger.info("The runner is stopped.")
700695

696+
async def _run_owned_callback(self, message: bytes | AckableMessage) -> None:
697+
"""Run one callback outside repeated listener-scope cancellation."""
698+
with anyio.CancelScope(shield=True):
699+
await self.callback(message=message, raise_err=False)
700+
701+
async def _drain_active_tasks(
702+
self,
703+
tasks: set[asyncio.Task[Any]],
704+
*,
705+
cancel_immediately: bool,
706+
) -> None:
707+
"""Wait for callbacks and cancel work beyond the graceful boundary."""
708+
tasks = set(tasks)
709+
if not tasks:
710+
return
711+
712+
logger.info("Waiting for %d running tasks to complete...", len(tasks))
713+
if cancel_immediately:
714+
pending = {task for task in tasks if not task.done()}
715+
else:
716+
try:
717+
_, pending = await asyncio.wait(
718+
tasks,
719+
timeout=self.wait_tasks_timeout,
720+
)
701721
except asyncio.CancelledError:
702-
break
703-
logger.info("The runner is stopped.")
722+
pending = {task for task in tasks if not task.done()}
723+
724+
if pending:
725+
logger.warning("Cancelling %d running callback tasks.", len(pending))
726+
with anyio.CancelScope(shield=True):
727+
await self._cancel_callback_tasks(pending)
728+
logger.info("No more tasks to wait for. Shutting down.")
729+
730+
@staticmethod
731+
async def _cancel_callback_tasks(
732+
tasks: set[asyncio.Task[Any]],
733+
) -> None:
734+
"""Cancel callbacks once and await cleanup despite outer cancellation."""
735+
for task in tasks:
736+
task.cancel()
737+
waiter = asyncio.gather(*tasks, return_exceptions=True)
738+
while not waiter.done():
739+
with contextlib.suppress(asyncio.CancelledError):
740+
await asyncio.shield(waiter)
741+
waiter.result()
704742

705743
def _start_callback(
706744
self,
@@ -716,7 +754,7 @@ def _start_callback(
716754
owns_delivery_slot = False
717755
return _StartedCallback(
718756
task=asyncio.create_task(
719-
self.callback(message=message.data, raise_err=False),
757+
self._run_owned_callback(message=message.data),
720758
),
721759
owns_delivery_slot=owns_delivery_slot,
722760
)
@@ -737,12 +775,24 @@ def _on_callback_done(
737775
active_tasks: set[asyncio.Task[Any]],
738776
owns_delivery_slot: bool,
739777
) -> None:
740-
"""Release capacity transferred to a completed callback task."""
778+
"""Release callback capacity and retrieve unexpected failures."""
741779
active_tasks.discard(task)
742780
if self.sem is not None:
743781
self.sem.release()
744782
if owns_delivery_slot:
745783
self.sem_prefetch.release()
784+
if task.cancelled():
785+
return
786+
task_exception = task.exception()
787+
if task_exception is not None:
788+
logger.error(
789+
"Receiver callback failed outside task execution handling.",
790+
exc_info=(
791+
type(task_exception),
792+
task_exception,
793+
task_exception.__traceback__,
794+
),
795+
)
746796

747797
def _record_listen_error(self, error: BaseException) -> None:
748798
"""Preserve the first listener error and report cleanup failures."""
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import asyncio
2+
from collections.abc import Awaitable
3+
from typing import Any
4+
5+
from taskiq.abc.broker import AckableMessage
6+
from taskiq.brokers.inmemory_broker import InMemoryBroker
7+
from taskiq.receiver.receiver import Receiver, _PrefetchedMessage, _QueueSignal
8+
from tests.receiver.receiver_listener_support import (
9+
ReceiverLifecycleError,
10+
assert_semaphore_capacity,
11+
)
12+
13+
14+
class ShieldCallCounter:
15+
"""Count shield calls made by one named asyncio task."""
16+
17+
def __init__(self, task_name: str) -> None:
18+
self.task_name = task_name
19+
self.runner_calls = 0
20+
self._shield = asyncio.shield
21+
22+
def __call__(self, awaitable: Awaitable[Any]) -> asyncio.Future[Any]:
23+
current_task = asyncio.current_task()
24+
if current_task is not None and current_task.get_name() == self.task_name:
25+
self.runner_calls += 1
26+
return self._shield(awaitable)
27+
28+
29+
class ReceiverQueue(asyncio.Queue[_PrefetchedMessage | _QueueSignal]):
30+
"""Expose runner reads and graceful-shutdown sentinel delivery."""
31+
32+
def __init__(self) -> None:
33+
super().__init__()
34+
self.get_started = asyncio.Event()
35+
self.shutdown_received = asyncio.Event()
36+
37+
async def get(self) -> _PrefetchedMessage | _QueueSignal:
38+
self.get_started.set()
39+
message = await super().get()
40+
if message is _QueueSignal.DONE:
41+
self.shutdown_received.set()
42+
return message
43+
44+
45+
class ControlledReceiver(Receiver):
46+
"""Receiver with deterministic callback and cleanup checkpoints."""
47+
48+
def __init__(
49+
self,
50+
*,
51+
wait_tasks_timeout: float | None,
52+
max_async_tasks: int | None = 1,
53+
fail: bool = False,
54+
) -> None:
55+
super().__init__(
56+
InMemoryBroker(),
57+
max_async_tasks=max_async_tasks,
58+
run_startup=False,
59+
wait_tasks_timeout=wait_tasks_timeout,
60+
)
61+
self.fail = fail
62+
self.started_callbacks: asyncio.Queue[None] = asyncio.Queue()
63+
self.release_callback = asyncio.Event()
64+
self.cleanup_started_callbacks: asyncio.Queue[None] = asyncio.Queue()
65+
self.release_cleanup = asyncio.Event()
66+
self.finished_callbacks: asyncio.Queue[None] = asyncio.Queue()
67+
self.callback_tasks: list[asyncio.Task[None]] = []
68+
69+
@property
70+
def callback_task(self) -> asyncio.Task[None] | None:
71+
"""Return the most recently started callback task."""
72+
if not self.callback_tasks:
73+
return None
74+
return self.callback_tasks[-1]
75+
76+
async def callback(
77+
self,
78+
message: bytes | AckableMessage,
79+
raise_err: bool = False,
80+
) -> None:
81+
del message, raise_err
82+
callback_task = asyncio.current_task()
83+
assert callback_task is not None
84+
self.callback_tasks.append(callback_task)
85+
self.started_callbacks.put_nowait(None)
86+
try:
87+
await self.release_callback.wait()
88+
if self.fail:
89+
raise ReceiverLifecycleError("callback failed")
90+
finally:
91+
self.cleanup_started_callbacks.put_nowait(None)
92+
await self.release_cleanup.wait()
93+
for _ in range(20):
94+
await asyncio.sleep(0)
95+
self.finished_callbacks.put_nowait(None)
96+
97+
async def settle(self, runner_task: asyncio.Task[None]) -> None:
98+
"""Release all checkpoints and settle test-owned tasks."""
99+
self.release_callback.set()
100+
self.release_cleanup.set()
101+
callback_tasks = set(self.callback_tasks)
102+
for callback_task in callback_tasks:
103+
if not callback_task.done():
104+
callback_task.cancel()
105+
if not runner_task.done():
106+
runner_task.cancel()
107+
await asyncio.gather(
108+
*callback_tasks,
109+
runner_task,
110+
return_exceptions=True,
111+
)
112+
113+
114+
async def start_callback(
115+
receiver: ControlledReceiver,
116+
) -> tuple[ReceiverQueue, asyncio.Task[None]]:
117+
"""Start one controlled callback through the real runner boundary."""
118+
queue = ReceiverQueue()
119+
await queue.put(_PrefetchedMessage(b"payload", owns_delivery_slot=False))
120+
runner_task = asyncio.create_task(receiver.runner(queue))
121+
await wait_for_signals(receiver.started_callbacks)
122+
return queue, runner_task
123+
124+
125+
async def wait_for_signals(
126+
signals: asyncio.Queue[None],
127+
count: int = 1,
128+
) -> None:
129+
"""Wait for an exact number of deterministic lifecycle checkpoints."""
130+
await asyncio.wait_for(
131+
asyncio.gather(*(signals.get() for _ in range(count))),
132+
timeout=1,
133+
)
134+
135+
136+
async def assert_exact_capacity(receiver: Receiver, slots: int = 1) -> None:
137+
"""Assert that exactly the expected execution permits were returned."""
138+
assert receiver.sem is not None
139+
await assert_semaphore_capacity(receiver.sem, slots)

0 commit comments

Comments
 (0)