@@ -608,17 +608,28 @@ class TaskCancelledError(Exception):
608608 """Exception type for cancelled orchestration tasks."""
609609
610610
611+ class _TaskCompletionError (Exception ):
612+ """Collect callback errors without requiring Python 3.11 ExceptionGroup."""
613+
614+ def __init__ (self , errors : list [Exception ]) -> None :
615+ self .errors = tuple (errors )
616+ super ().__init__ (
617+ "Multiple task completion callbacks failed: "
618+ + "; " .join (f"{ type (error ).__name__ } : { error } " for error in errors ))
619+
620+
611621class Task (ABC , Generic [T ]):
612622 """Abstract base class for asynchronous tasks in a durable orchestration."""
613623 _result : T
614624 _exception : TaskFailedError | None
615- _parent : CompositeTask [Any ] | None
625+ _parents : dict [ int , CompositeTask [Any ]]
616626
617627 def __init__ (self ) -> None :
618628 super ().__init__ ()
619629 self ._is_complete = False
620630 self ._exception = None
621- self ._parent = None
631+ # Dict insertion order preserves subscription order for shared races.
632+ self ._parents = {}
622633
623634 @property
624635 def is_complete (self ) -> bool :
@@ -650,8 +661,19 @@ def get_exception(self) -> TaskFailedError:
650661 return self ._exception
651662
652663 def _notify_parent (self ) -> None :
653- if self ._parent is not None :
654- self ._parent .on_child_completed (self )
664+ parents = tuple (self ._parents .values ())
665+ self ._parents .clear ()
666+ errors : list [Exception ] = []
667+ for parent in parents :
668+ try :
669+ parent .on_child_completed (self )
670+ except Exception as error :
671+ # A canceled result can throw without blocking other observers.
672+ errors .append (error )
673+ if len (errors ) == 1 :
674+ raise errors [0 ]
675+ if errors :
676+ raise _TaskCompletionError (errors ) from errors [0 ]
655677
656678
657679class CompositeTask (Task [T ]):
@@ -665,10 +687,24 @@ def __init__(self, tasks: list[Task[Any]]):
665687 self ._tasks = tasks
666688 self ._completed_tasks = 0
667689 self ._failed_tasks = 0
690+ # Track references by identity, including unhashable Task subclasses.
691+ self ._pending_children : dict [int , tuple [Task [Any ], int ]] = {}
668692 for task in tasks :
669- task ._parent = self
693+ _ , count = self ._pending_children .get (id (task ), (task , 0 ))
694+ self ._pending_children [id (task )] = (task , count + 1 )
695+ for task , _ in tuple (self ._pending_children .values ()):
696+ if self .is_complete :
697+ break
670698 if task .is_complete :
671699 self .on_child_completed (task )
700+ else :
701+ task ._parents [id (self )] = self
702+
703+ def _notify_parent (self ) -> None :
704+ for task , _ in self ._pending_children .values ():
705+ task ._parents .pop (id (self ), None )
706+ self ._pending_children .clear ()
707+ super ()._notify_parent ()
672708
673709 def get_tasks (self ) -> list [Task [Any ]]:
674710 return self ._tasks
@@ -698,10 +734,14 @@ def pending_tasks(self) -> int:
698734
699735 def on_child_completed (self , task : Task [Any ]) -> None :
700736 if self .is_complete :
701- raise ValueError ('The task has already completed.' )
702- self ._completed_tasks += 1
737+ return
738+ pending = self ._pending_children .pop (id (task ), None )
739+ if pending is None :
740+ return
741+ _ , occurrences = pending
742+ self ._completed_tasks += occurrences
703743 if task .is_failed :
704- self ._failed_tasks += 1
744+ self ._failed_tasks += occurrences
705745 if self ._pending_exception is None :
706746 # Stage the first failure but do NOT expose it via `_exception`
707747 # yet. Exposing it now would make `is_failed` return True while
@@ -871,12 +911,20 @@ def on_child_completed(self, task: Task[Any]) -> None:
871911
872912
873913def when_all (tasks : list [Task [T ]]) -> WhenAllTask [T ]:
874- """Returns a task that completes when all of the provided tasks complete or when one of the tasks fail."""
914+ """Wait for every provided task to complete or fail.
915+
916+ Results preserve input order and duplicate task references. Tasks may be
917+ shared between groups without scheduling additional work. If any task fails,
918+ the first observed failure is raised after every task has finished.
919+ """
875920 return WhenAllTask (tasks )
876921
877922
878923def when_any (tasks : Sequence [Task [T ]]) -> WhenAnyTask [T ]:
879- """Returns a task that completes when any of the provided tasks complete or fail."""
924+ """Return the first direct child to complete or fail.
925+
926+ Tasks may be shared between groups. Losing tasks are not canceled.
927+ """
880928 return WhenAnyTask (list (tasks ))
881929
882930
0 commit comments