Skip to content

Commit 4ffc46f

Browse files
committed
fix(receiver): preserve task finalization during shutdown
- Defer shutdown cancellation during dependency teardown and executor calls. - Preserve cancellation across nested execution without interrupting cleanup. - Cover thread and process executors, retries, and reentrant finalization. - Clarify graceful shutdown limits and per-worker hard-kill handling.
1 parent 94e679b commit 4ffc46f

5 files changed

Lines changed: 836 additions & 44 deletions

File tree

‎docs/guide/cli.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,10 @@ kill -HUP <main pid>
146146
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 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.
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 awaits their completion before continuing shutdown. Synchronous functions already running in a thread or process executor cannot be forcibly stopped by asyncio cancellation. Shutdown cancellation is deferred while awaiting such a call so that task dependencies remain usable until the function returns. This is not an absolute process deadline. Enforce a hard deadline through your process supervisor, with forced termination configured for the worker processes and their executor subprocesses; forced termination does not guarantee task cleanup.
150150

151151
::: tip Cool tip
152-
The number of signals before a hard kill can be configured with the `--hardkill-count` CLI argument.
152+
The `--hardkill-count` CLI argument counts termination signals received by each worker process. Additional signals sent only to the main process are not forwarded after shutdown begins.
153153
:::
154154

155155

@@ -172,7 +172,7 @@ The number of signals before a hard kill can be configured with the `--hardkill-
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).
174174
* `--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.
175-
* `--hardkill-count` - Number of termination signals to the main process before performing a hardkill.
175+
* `--hardkill-count` - per-worker termination-signal threshold for the worker's hard-kill handling.
176176

177177
## Scheduler
178178

‎taskiq/cli/worker/args.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,8 +268,7 @@ def from_cli(
268268
"--hardkill-count",
269269
type=int,
270270
default=3,
271-
help="Number of termination signals to the main "
272-
"process before performing a hardkill.",
271+
help="Per-worker termination-signal threshold for hard-kill handling.",
273272
)
274273
parser.add_argument(
275274
"--use-process-pool",

‎taskiq/receiver/receiver.py‎

Lines changed: 119 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import inspect
66
import random
77
import sys
8+
from collections import Counter
89
from collections.abc import AsyncGenerator, Callable
910
from concurrent.futures import Executor, ProcessPoolExecutor
1011
from dataclasses import dataclass
@@ -111,6 +112,11 @@ def __init__(
111112
self.known_tasks: set[str] = set()
112113
self.max_tasks_to_execute = max_tasks_to_execute
113114
self.wait_tasks_timeout = wait_tasks_timeout
115+
self._executing_tasks: Counter[asyncio.Task[Any]] = Counter()
116+
self._finishing_tasks: Counter[asyncio.Task[Any]] = Counter()
117+
self._sync_tasks: set[asyncio.Task[Any]] = set()
118+
# Execution depth receiving cancellation, or None for a deferred request.
119+
self._shutdown_cancellations: dict[asyncio.Task[Any], int | None] = {}
114120
self._listen_error: BaseException | None = None
115121
if max_prefetch < 0:
116122
raise ValueError("max_prefetch cannot be negative.")
@@ -312,6 +318,9 @@ async def run_task( # noqa: C901, PLR0912, PLR0915
312318
if PY_VERSION <= (3, 13)
313319
else inspect.iscoroutinefunction
314320
)
321+
execution_task = asyncio.current_task()
322+
if execution_task is not None:
323+
self._executing_tasks[execution_task] += 1
315324
try:
316325
# We put kwargs resolving here,
317326
# to be able to catch any exception (for example ),
@@ -348,6 +357,7 @@ async def run_task( # noqa: C901, PLR0912, PLR0915
348357
ctx.run,
349358
func,
350359
)
360+
target_future = self._await_sync_result(target_future)
351361
timeout = message.labels.get("timeout")
352362
if timeout is not None:
353363
if not is_coroutine:
@@ -374,41 +384,99 @@ async def run_task( # noqa: C901, PLR0912, PLR0915
374384
except BaseException as exc:
375385
found_exception = exc
376386
logger.exception("Exception found while executing function.")
387+
finally:
388+
if execution_task is not None:
389+
if self._shutdown_cancellations.get(execution_task) == (
390+
self._executing_tasks[execution_task]
391+
):
392+
self._shutdown_cancellations[execution_task] = None
393+
self._executing_tasks[execution_task] -= 1
394+
if not self._executing_tasks[execution_task]:
395+
del self._executing_tasks[execution_task]
377396
# Stop the timer.
378397
execution_time = time() - start_time
379-
if dep_ctx:
380-
args = (None, None, None)
381-
if found_exception and self.propagate_exceptions:
382-
args = ( # type: ignore
383-
type(found_exception),
384-
found_exception,
385-
found_exception.__traceback__,
386-
)
387-
await dep_ctx.close(*args)
388-
389-
# Assemble result.
390-
result: TaskiqResult[Any] = TaskiqResult(
391-
is_err=found_exception is not None,
392-
log=None,
393-
return_value=returned,
394-
execution_time=round(execution_time, 2),
395-
error=found_exception,
396-
labels=message.labels,
397-
)
398-
# If exception is found we execute middlewares.
399-
if found_exception is not None:
400-
for middleware in reversed(self.broker.middlewares):
401-
if middleware.__class__.on_error != TaskiqMiddleware.on_error:
402-
await maybe_awaitable(
403-
middleware.on_error(
404-
message,
405-
result,
406-
found_exception,
407-
),
398+
if execution_task is not None:
399+
self._finishing_tasks[execution_task] += 1
400+
try:
401+
if dep_ctx:
402+
args = (None, None, None)
403+
if found_exception and self.propagate_exceptions:
404+
args = ( # type: ignore
405+
type(found_exception),
406+
found_exception,
407+
found_exception.__traceback__,
408408
)
409-
409+
await dep_ctx.close(*args)
410+
411+
# Assemble result.
412+
result: TaskiqResult[Any] = TaskiqResult(
413+
is_err=found_exception is not None,
414+
log=None,
415+
return_value=returned,
416+
execution_time=round(execution_time, 2),
417+
error=found_exception,
418+
labels=message.labels,
419+
)
420+
# If exception is found we execute middlewares.
421+
if found_exception is not None:
422+
for middleware in reversed(self.broker.middlewares):
423+
if middleware.__class__.on_error != TaskiqMiddleware.on_error:
424+
await maybe_awaitable(
425+
middleware.on_error(
426+
message,
427+
result,
428+
found_exception,
429+
),
430+
)
431+
finally:
432+
if execution_task is not None:
433+
self._finishing_tasks[execution_task] -= 1
434+
if not self._finishing_tasks[execution_task]:
435+
del self._finishing_tasks[execution_task]
436+
if execution_task in self._shutdown_cancellations:
437+
asyncio.get_running_loop().call_soon(
438+
self._deliver_deferred_cancellation,
439+
execution_task,
440+
)
441+
442+
if execution_task is not None and self._deliver_deferred_cancellation(
443+
execution_task,
444+
):
445+
await asyncio.sleep(0)
410446
return result
411447

448+
def _deliver_deferred_cancellation(self, task: asyncio.Task[Any]) -> bool:
449+
"""Deliver pending shutdown only when the task resumes execution."""
450+
if (
451+
task not in self._shutdown_cancellations
452+
or task in self._finishing_tasks
453+
or task in self._sync_tasks
454+
):
455+
return False
456+
if task not in self._executing_tasks or task.done():
457+
del self._shutdown_cancellations[task]
458+
return False
459+
if self._shutdown_cancellations[task] is None:
460+
self._shutdown_cancellations[task] = self._executing_tasks[task]
461+
return task.cancel()
462+
return False
463+
464+
async def _await_sync_result(self, future: asyncio.Future[Any]) -> Any:
465+
"""Defer receiver shutdown while the executor still uses dependencies."""
466+
task = asyncio.current_task()
467+
if task is not None:
468+
self._sync_tasks.add(task)
469+
try:
470+
return await future
471+
finally:
472+
if task is not None:
473+
self._sync_tasks.discard(task)
474+
if task in self._shutdown_cancellations:
475+
asyncio.get_running_loop().call_soon(
476+
self._deliver_deferred_cancellation,
477+
task,
478+
)
479+
412480
async def listen(self, finish_event: asyncio.Event) -> None: # pragma: no cover
413481
"""
414482
This function iterates over tasks asynchronously.
@@ -722,23 +790,36 @@ async def _drain_active_tasks(
722790
pending = {task for task in tasks if not task.done()}
723791

724792
if pending:
725-
logger.warning("Cancelling %d running callback tasks.", len(pending))
726793
with anyio.CancelScope(shield=True):
727794
await self._cancel_callback_tasks(pending)
728795
logger.info("No more tasks to wait for. Shutting down.")
729796

730-
@staticmethod
731797
async def _cancel_callback_tasks(
798+
self,
732799
tasks: set[asyncio.Task[Any]],
733800
) -> None:
734-
"""Cancel callbacks once and await cleanup despite outer cancellation."""
735-
for task in tasks:
801+
"""Cancel execution without interrupting task finalization."""
802+
cancellable_tasks = tasks.difference(self._finishing_tasks, self._sync_tasks)
803+
self._shutdown_cancellations.update(
804+
(task, self._executing_tasks[task] if task in cancellable_tasks else None)
805+
for task in tasks
806+
)
807+
if cancellable_tasks:
808+
logger.warning(
809+
"Cancelling %d running callback tasks.",
810+
len(cancellable_tasks),
811+
)
812+
for task in cancellable_tasks:
736813
task.cancel()
737814
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()
815+
try:
816+
while not waiter.done():
817+
with contextlib.suppress(asyncio.CancelledError):
818+
await asyncio.shield(waiter)
819+
waiter.result()
820+
finally:
821+
for task in tasks:
822+
self._shutdown_cancellations.pop(task, None)
742823

743824
def _start_callback(
744825
self,

‎tests/receiver/receiver_lifecycle_support.py‎

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,129 @@
11
import asyncio
2-
from collections.abc import Awaitable
2+
import contextvars
3+
import os
4+
import threading
5+
from collections.abc import AsyncGenerator, Awaitable
6+
from dataclasses import dataclass
7+
from io import StringIO
8+
from pathlib import Path
39
from typing import Any
410

11+
import anyio
12+
from taskiq_dependencies import Depends
13+
514
from taskiq.abc.broker import AckableMessage
615
from taskiq.brokers.inmemory_broker import InMemoryBroker
716
from taskiq.receiver.receiver import Receiver, _PrefetchedMessage, _QueueSignal
17+
from taskiq.state import TaskiqState
818
from tests.receiver.receiver_listener_support import (
919
ReceiverLifecycleError,
1020
assert_semaphore_capacity,
1121
)
22+
from tests.utils import AsyncQueueBroker
23+
24+
process_resource_broker = AsyncQueueBroker()
25+
26+
27+
@dataclass
28+
class ProcessResourceProbe:
29+
"""Share a resource path and explicit checkpoints with a spawned worker."""
30+
31+
resource: Path
32+
started: threading.Event
33+
release: threading.Event
34+
35+
36+
async def process_resource_dependency(
37+
state: TaskiqState = Depends(),
38+
) -> AsyncGenerator[ProcessResourceProbe, None]:
39+
"""Keep the resource available until the receiver closes the dependency."""
40+
probe: ProcessResourceProbe = state.process_resource
41+
try:
42+
yield probe
43+
finally:
44+
probe.resource.unlink()
45+
46+
47+
@process_resource_broker.task
48+
def read_process_resource(
49+
probe: ProcessResourceProbe = Depends(process_resource_dependency),
50+
) -> tuple[int, str]:
51+
"""Read the resource in an importable, spawn-compatible task function."""
52+
probe.started.set()
53+
if not probe.release.wait(timeout=15):
54+
raise TimeoutError("Process resource probe was not released")
55+
return os.getpid(), probe.resource.read_text()
56+
57+
58+
class SyncResourceProbe:
59+
"""Keep a real executor function alive while its dependency is inspected."""
60+
61+
def __init__(self) -> None:
62+
self.loop = asyncio.get_running_loop()
63+
self.started = asyncio.Event()
64+
self.finished = asyncio.Event()
65+
self.release = threading.Event()
66+
self.resource = StringIO("completed")
67+
self.continuation_started = asyncio.Event()
68+
self.continuation_finished = asyncio.Event()
69+
self.release_continuation = asyncio.Event()
70+
71+
async def dependency(self) -> AsyncGenerator[StringIO, None]:
72+
try:
73+
yield self.resource
74+
finally:
75+
self.resource.close()
76+
77+
def run(self, resource: StringIO) -> str:
78+
self.loop.call_soon_threadsafe(self.started.set)
79+
try:
80+
assert self.release.wait(timeout=10)
81+
return resource.getvalue()
82+
finally:
83+
self.loop.call_soon_threadsafe(self.finished.set)
84+
85+
async def continuation(self) -> str:
86+
self.continuation_started.set()
87+
try:
88+
await self.release_continuation.wait()
89+
return self.resource.getvalue()
90+
finally:
91+
assert not self.resource.closed
92+
self.continuation_finished.set()
93+
94+
95+
class DependencyCleanupProbe:
96+
"""Expose teardown checkpoints while enforcing the original task/context."""
97+
98+
def __init__(self) -> None:
99+
self.started = asyncio.Event()
100+
self.release = asyncio.Event()
101+
self.finished = asyncio.Event()
102+
self.context = contextvars.ContextVar("dependency-cleanup", default=False)
103+
104+
async def dependency(self) -> AsyncGenerator[None, None]:
105+
owner = asyncio.current_task()
106+
token = self.context.set(True)
107+
try:
108+
yield
109+
finally:
110+
self.started.set()
111+
await self.release.wait()
112+
for _ in range(20):
113+
await asyncio.sleep(0)
114+
assert asyncio.current_task() is owner
115+
self.context.reset(token)
116+
self.finished.set()
117+
118+
119+
async def listen_in_scope(
120+
receiver: Receiver,
121+
finish_event: asyncio.Event,
122+
scope: anyio.CancelScope,
123+
) -> None:
124+
"""Expose listener cancellation through a real AnyIO scope."""
125+
with scope:
126+
await receiver.listen(finish_event)
12127

13128

14129
class ShieldCallCounter:

0 commit comments

Comments
 (0)