Skip to content

Commit e15d278

Browse files
committed
fix(receiver): harden prefetch and listener lifecycle
Track delivery and prefetch capacity ownership across shutdown paths. Close pending listener reads, preserve late deliveries, and restore fail-fast transport error propagation. Keep middleware accounting balanced, report discarded deliveries, and retain deferred lifecycle diagnostics when task group failures overlap.
1 parent 8cf9bb4 commit e15d278

6 files changed

Lines changed: 312 additions & 64 deletions

File tree

docs/guide/cli.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ The number of signals before a hard kill can be configured with the `--hardkill-
162162
* `--log-format` is used to set a log format (default `%(asctime)s][%(name)s][%(levelname)-7s][%(processName)s] %(message)s`).
163163
* `--max-async-tasks` - maximum number of simultaneously running async tasks.
164164
* `--max-async-tasks-jitter` – Randomly varies the max async task limit between --max-async-tasks and a jittered value, helping prevent simultaneous worker restarts.
165-
* `--max-prefetch` - maximum number of deliveries buffered in addition to the listener's single in-flight delivery. The default `0` disables additional buffering while still allowing one delivery to move from the broker listener toward execution. The value must be non-negative. This option is useful for systems with high message rates, but brokers should support acknowledgements.
165+
* `--max-prefetch` - maximum number of deliveries allowed to wait for execution beyond the available async execution capacity. With a finite `--max-async-tasks` limit, a worker admits at most its effective async execution limit plus `max_prefetch` running or waiting deliveries. The default `0` disables additional buffering, and the value must be non-negative. This option is useful for systems with high message rates, but brokers should support acknowledgements.
166166
* `--max-threadpool-threads` - number of threads for sync function execution.
167167
* `--no-propagate-errors` - if this parameter is enabled, exceptions won't be thrown in generator dependencies.
168168
* `--receiver` - python path to custom receiver class.

taskiq/api/receiver.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ async def run_receiver_task(
4545
:param validate_params: whether to validate params or not.
4646
:param max_async_tasks: maximum number of simultaneous async tasks.
4747
:param max_async_tasks_jitter: random jitter to add to max_async_tasks.
48-
:param max_prefetch: maximum number of deliveries buffered in addition to
49-
the listener's single in-flight delivery. Must be non-negative.
48+
:param max_prefetch: maximum number of deliveries allowed to wait beyond
49+
the available async execution capacity. Must be non-negative.
5050
:param propagate_exceptions: whether to propagate exceptions in generators or not.
5151
:param run_startup: whether to run startup function or not.
5252
:param ack_time: acknowledge type to use.

taskiq/cli/worker/args.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ def from_cli(
225225
dest="max_prefetch",
226226
default=0,
227227
help=(
228-
"Maximum deliveries buffered beyond the in-flight delivery. "
228+
"Maximum deliveries waiting beyond async execution capacity. "
229229
"Must be non-negative. "
230230
),
231231
)

taskiq/receiver/receiver.py

Lines changed: 80 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,10 @@ class _QueueSignal(Enum):
3838

3939
@dataclass(frozen=True, slots=True)
4040
class _PrefetchedMessage:
41-
"""A delivery and its prefetch-capacity ownership."""
41+
"""A delivery and its admission-capacity ownership."""
4242

4343
data: bytes | AckableMessage
44-
owns_prefetch_slot: bool
44+
owns_delivery_slot: bool
4545

4646

4747
@dataclass(slots=True)
@@ -50,7 +50,15 @@ class _PrefetchState:
5050

5151
iterator: AsyncGenerator[bytes | AckableMessage, None]
5252
current_message: asyncio.Task[bytes | AckableMessage] | None = None
53-
owns_prefetch_slot: bool = False
53+
owns_delivery_slot: bool = False
54+
55+
56+
@dataclass(frozen=True, slots=True)
57+
class _StartedCallback:
58+
"""A callback task and the delivery capacity transferred to it."""
59+
60+
task: asyncio.Task[Any]
61+
owns_delivery_slot: bool
5462

5563

5664
def _execute_sync_task_in_executor(
@@ -103,9 +111,13 @@ def __init__(
103111
self.max_tasks_to_execute = max_tasks_to_execute
104112
self.wait_tasks_timeout = wait_tasks_timeout
105113
self._listen_error: BaseException | None = None
114+
if max_prefetch < 0:
115+
raise ValueError("max_prefetch cannot be negative.")
106116
for task in self.broker.get_all_tasks().values():
107117
self._prepare_task(task.task_name, task.original_func)
118+
108119
self.sem: asyncio.Semaphore | None = None
120+
delivery_capacity = max_prefetch + 1
109121
if max_async_tasks is not None and max_async_tasks > 0:
110122
# Apply jitter to prevent all workers from hitting the limit simultaneously
111123
actual_limit = max_async_tasks
@@ -116,16 +128,13 @@ def __init__(
116128
max_async_tasks_jitter,
117129
)
118130
self.sem = asyncio.Semaphore(actual_limit)
131+
delivery_capacity = actual_limit + max_prefetch
119132
else:
120133
logger.warning(
121134
"Setting unlimited number of async tasks "
122135
"can result in undefined behavior",
123136
)
124-
if max_prefetch < 0:
125-
raise ValueError("max_prefetch cannot be negative.")
126-
# One slot tracks the listener's in-flight delivery. The configured
127-
# value bounds the additional deliveries buffered ahead of execution.
128-
self.sem_prefetch = asyncio.Semaphore(max_prefetch + 1)
137+
self.sem_prefetch = asyncio.Semaphore(delivery_capacity)
129138
self.is_process_pool = isinstance(executor, ProcessPoolExecutor)
130139

131140
async def callback( # noqa: C901, PLR0912
@@ -481,10 +490,10 @@ async def prefetcher(
481490
queue.put_nowait(
482491
_PrefetchedMessage(
483492
data=message,
484-
owns_prefetch_slot=True,
493+
owns_delivery_slot=True,
485494
),
486495
)
487-
state.owns_prefetch_slot = False
496+
state.owns_delivery_slot = False
488497
try:
489498
await self._notify_prefetch_hook("on_prefetch_queue_add")
490499
except asyncio.CancelledError:
@@ -522,7 +531,7 @@ async def _get_prefetched_message(
522531
finish_waiter: asyncio.Task[bool],
523532
) -> bytes | AckableMessage | None:
524533
"""Acquire capacity and wait for one delivery or the finish signal."""
525-
if not await self._acquire_prefetch_slot(
534+
if not await self._acquire_delivery_slot(
526535
state,
527536
finish_event,
528537
finish_waiter,
@@ -540,50 +549,49 @@ async def _get_prefetched_message(
540549
state.current_message = None
541550
return current_message.result()
542551

543-
self._release_prefetch_slot(state)
544552
return None
545553
except BaseException:
546-
self._release_prefetch_slot(state)
554+
self._release_delivery_slot(state)
547555
raise
548556

549-
async def _acquire_prefetch_slot(
557+
async def _acquire_delivery_slot(
550558
self,
551559
state: _PrefetchState,
552560
finish_event: asyncio.Event,
553561
finish_waiter: asyncio.Task[bool],
554562
) -> bool:
555-
"""Acquire one prefetch slot unless shutdown wins the race."""
563+
"""Acquire one delivery-admission slot unless shutdown wins the race."""
556564
acquire_task = asyncio.create_task(self.sem_prefetch.acquire())
557565
try:
558566
done, _ = await asyncio.wait(
559567
{acquire_task, finish_waiter},
560568
return_when=asyncio.FIRST_COMPLETED,
561569
)
562570
except BaseException:
563-
await self._settle_prefetch_acquire(acquire_task)
571+
await self._settle_delivery_acquire(acquire_task)
564572
raise
565573

566574
if finish_waiter in done or finish_event.is_set():
567-
await self._settle_prefetch_acquire(acquire_task)
575+
await self._settle_delivery_acquire(acquire_task)
568576
return False
569577

570578
acquire_task.result()
571-
state.owns_prefetch_slot = True
579+
state.owns_delivery_slot = True
572580
return True
573581

574-
async def _settle_prefetch_acquire(self, acquire_task: asyncio.Task[bool]) -> None:
582+
async def _settle_delivery_acquire(self, acquire_task: asyncio.Task[bool]) -> None:
575583
"""Cancel a capacity waiter and return a concurrently acquired slot."""
576584
acquire_task.cancel()
577585
with anyio.CancelScope(shield=True):
578586
result = await asyncio.gather(acquire_task, return_exceptions=True)
579587
if result and result[0] is True:
580588
self.sem_prefetch.release()
581589

582-
def _release_prefetch_slot(self, state: _PrefetchState) -> None:
590+
def _release_delivery_slot(self, state: _PrefetchState) -> None:
583591
"""Release the slot currently owned by the prefetch state."""
584-
if not state.owns_prefetch_slot:
592+
if not state.owns_delivery_slot:
585593
return
586-
state.owns_prefetch_slot = False
594+
state.owns_delivery_slot = False
587595
self.sem_prefetch.release()
588596

589597
async def _enqueue_late_prefetched_message(
@@ -592,16 +600,11 @@ async def _enqueue_late_prefetched_message(
592600
state: _PrefetchState,
593601
) -> None:
594602
"""Close listener state and retain a delivery completed during stop."""
595-
late_message = await self._close_prefetch_state(state)
596-
if late_message is None:
603+
late_delivery = await self._close_prefetch_state(state)
604+
if late_delivery is None:
597605
return
598606

599-
queue.put_nowait(
600-
_PrefetchedMessage(
601-
data=late_message,
602-
owns_prefetch_slot=False,
603-
),
604-
)
607+
queue.put_nowait(late_delivery)
605608
try:
606609
await self._notify_prefetch_hook("on_prefetch_queue_add")
607610
except BaseException as exc:
@@ -610,9 +613,8 @@ async def _enqueue_late_prefetched_message(
610613
async def _close_prefetch_state(
611614
self,
612615
state: _PrefetchState,
613-
) -> bytes | AckableMessage | None:
616+
) -> _PrefetchedMessage | None:
614617
"""Close the pending read and iterator, retaining their first error."""
615-
self._release_prefetch_slot(state)
616618
late_message: bytes | AckableMessage | None = None
617619
current_message = state.current_message
618620
state.current_message = None
@@ -631,7 +633,17 @@ async def _close_prefetch_state(
631633
pass
632634
except BaseException as exc:
633635
self._record_listen_error(exc)
634-
return late_message
636+
637+
if late_message is None:
638+
self._release_delivery_slot(state)
639+
return None
640+
641+
late_delivery = _PrefetchedMessage(
642+
data=late_message,
643+
owns_delivery_slot=state.owns_delivery_slot,
644+
)
645+
state.owns_delivery_slot = False
646+
return late_delivery
635647

636648
async def _notify_prefetch_hook(self, hook_name: str) -> None:
637649
"""Run all prefetch hooks and preserve the first failure."""
@@ -667,7 +679,7 @@ async def _discard_queued_messages(
667679
break
668680
if isinstance(queued_message, _PrefetchedMessage):
669681
discarded_messages += 1
670-
if queued_message.owns_prefetch_slot:
682+
if queued_message.owns_delivery_slot:
671683
self.sem_prefetch.release()
672684

673685
if discarded_messages:
@@ -699,20 +711,6 @@ async def runner(
699711
"""
700712
tasks: set[asyncio.Task[Any]] = set()
701713

702-
def task_cb(task: "asyncio.Task[Any]") -> None:
703-
"""
704-
Callback for tasks.
705-
706-
This function used to remove task
707-
from the list of active tasks and release
708-
the semaphore, so other tasks can use it.
709-
710-
:param task: finished task
711-
"""
712-
tasks.discard(task)
713-
if self.sem is not None:
714-
self.sem.release()
715-
716714
while True:
717715
try:
718716
queued_message = await queue.get()
@@ -725,16 +723,22 @@ def task_cb(task: "asyncio.Task[Any]") -> None:
725723
await asyncio.wait(tasks, timeout=self.wait_tasks_timeout)
726724
logger.info("No more tasks to wait for. Shutting down.")
727725
break
728-
task = await self._start_callback(queued_message)
729-
tasks.add(task)
726+
started_callback = await self._start_callback(queued_message)
727+
tasks.add(started_callback.task)
730728

731729
# We want the task to remove itself from the set when it's done.
732730
#
733731
# Because if we won't save it anywhere,
734732
# python's GC can silently cancel task
735733
# and this behaviour considered to be a Hisenbug.
736734
# https://textual.textualize.io/blog/2023/02/11/the-heisenbug-lurking-in-your-async-code/
737-
task.add_done_callback(task_cb)
735+
started_callback.task.add_done_callback(
736+
functools.partial(
737+
self._on_callback_done,
738+
active_tasks=tasks,
739+
owns_delivery_slot=started_callback.owns_delivery_slot,
740+
),
741+
)
738742

739743
except asyncio.CancelledError:
740744
break
@@ -743,29 +747,46 @@ def task_cb(task: "asyncio.Task[Any]") -> None:
743747
async def _start_callback(
744748
self,
745749
message: _PrefetchedMessage,
746-
) -> asyncio.Task[Any]:
747-
"""Transfer prefetch and execution capacity to a callback task."""
748-
owns_prefetch_slot = message.owns_prefetch_slot
750+
) -> _StartedCallback:
751+
"""Transfer execution and delivery capacity to a callback task."""
752+
owns_delivery_slot = message.owns_delivery_slot
749753
owns_execution_slot = False
750754
try:
751755
await self._notify_prefetch_hook("on_prefetch_queue_remove")
752756
if self.sem is not None:
753757
await self.sem.acquire()
754758
owns_execution_slot = True
755759

756-
if owns_prefetch_slot:
760+
if self.sem is None and owns_delivery_slot:
757761
self.sem_prefetch.release()
758-
owns_prefetch_slot = False
759-
return asyncio.create_task(
760-
self.callback(message=message.data, raise_err=False),
762+
owns_delivery_slot = False
763+
return _StartedCallback(
764+
task=asyncio.create_task(
765+
self.callback(message=message.data, raise_err=False),
766+
),
767+
owns_delivery_slot=owns_delivery_slot,
761768
)
762769
except BaseException:
763-
if owns_prefetch_slot:
770+
if owns_delivery_slot:
764771
self.sem_prefetch.release()
765772
if owns_execution_slot and self.sem is not None:
766773
self.sem.release()
767774
raise
768775

776+
def _on_callback_done(
777+
self,
778+
task: asyncio.Task[Any],
779+
*,
780+
active_tasks: set[asyncio.Task[Any]],
781+
owns_delivery_slot: bool,
782+
) -> None:
783+
"""Release capacity transferred to a completed callback task."""
784+
active_tasks.discard(task)
785+
if self.sem is not None:
786+
self.sem.release()
787+
if owns_delivery_slot:
788+
self.sem_prefetch.release()
789+
769790
def _record_listen_error(self, error: BaseException) -> None:
770791
"""Preserve the first listener error and report cleanup failures."""
771792
if self._listen_error is None:

tests/receiver/receiver_listener_support.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,13 @@ class ObservedSemaphore(asyncio.Semaphore):
3333
def __init__(self, value: int) -> None:
3434
super().__init__(value)
3535
self.acquire_started = asyncio.Event()
36+
self.acquire_attempts: asyncio.Queue[int] = asyncio.Queue()
37+
self._acquire_count = 0
3638

3739
async def acquire(self) -> Literal[True]:
3840
"""Record the wait before delegating to asyncio.Semaphore."""
41+
self._acquire_count += 1
42+
self.acquire_attempts.put_nowait(self._acquire_count)
3943
self.acquire_started.set()
4044
return await super().acquire()
4145

0 commit comments

Comments
 (0)