@@ -38,10 +38,10 @@ class _QueueSignal(Enum):
3838
3939@dataclass (frozen = True , slots = True )
4040class _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
5664def _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 :
0 commit comments