Coverage for Lib/asyncio/staggered.py: 95%
79 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-09 01:31 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-09 01:31 +0000
1"""Support for running coroutines in parallel with staggered start times."""
3__all__ = 'staggered_race',
5import contextlib
7from . import events
8from . import exceptions as exceptions_mod
9from . import locks
10from . import tasks
11from . import futures
14async def staggered_race(coro_fns, delay, *, loop=None):
15 """Run coroutines with staggered start times and take the first to finish.
17 This method takes an iterable of coroutine functions. The first one is
18 started immediately. From then on, whenever the immediately preceding one
19 fails (raises an exception), or when *delay* seconds has passed, the next
20 coroutine is started. This continues until one of the coroutines complete
21 successfully, in which case all others are cancelled, or until all
22 coroutines fail.
24 The coroutines provided should be well-behaved in the following way:
26 * They should only ``return`` if completed successfully.
28 * They should always raise an exception if they did not complete
29 successfully. In particular, if they handle cancellation, they should
30 probably reraise, like this::
32 try:
33 # do work
34 except asyncio.CancelledError:
35 # undo partially completed work
36 raise
38 Args:
39 coro_fns: an iterable of coroutine functions, i.e. callables that
40 return a coroutine object when called. Use ``functools.partial`` or
41 lambdas to pass arguments.
43 delay: amount of time, in seconds, between starting coroutines. If
44 ``None``, the coroutines will run sequentially.
46 loop: the event loop to use.
48 Returns:
49 tuple *(winner_result, winner_index, exceptions)* where
51 - *winner_result*: the result of the winning coroutine, or ``None``
52 if no coroutines won.
54 - *winner_index*: the index of the winning coroutine in
55 ``coro_fns``, or ``None`` if no coroutines won. If the winning
56 coroutine may return None on success, *winner_index* can be used
57 to definitively determine whether any coroutine won.
59 - *exceptions*: list of exceptions returned by the coroutines.
60 ``len(exceptions)`` is equal to the number of coroutines actually
61 started, and the order is the same as in ``coro_fns``. The winning
62 coroutine's entry is ``None``.
64 """
65 loop = loop or events.get_running_loop()
66 parent_task = tasks.current_task(loop)
67 enum_coro_fns = enumerate(coro_fns)
68 winner_result = None
69 winner_index = None
70 unhandled_exceptions = []
71 exceptions = []
72 running_tasks = set()
73 on_completed_fut = None
75 def task_done(task):
76 running_tasks.discard(task)
77 futures.future_discard_from_awaited_by(task, parent_task)
78 if (
79 on_completed_fut is not None
80 and not on_completed_fut.done()
81 and not running_tasks
82 ):
83 on_completed_fut.set_result(None)
85 if task.cancelled():
86 return
88 exc = task.exception()
89 if exc is None: 89 ↛ 91line 89 didn't jump to line 91 because the condition on line 89 was always true
90 return
91 unhandled_exceptions.append(exc)
93 async def run_one_coro(previous_failed) -> None:
94 # Wait for the previous task to finish, or for delay seconds
95 if previous_failed is not None:
96 with contextlib.suppress(exceptions_mod.TimeoutError):
97 # Use asyncio.wait_for() instead of asyncio.wait() here, so
98 # that if we get cancelled at this point, Event.wait() is also
99 # cancelled, otherwise there will be a "Task destroyed but it is
100 # pending" later.
101 await tasks.wait_for(previous_failed.wait(), delay)
102 # Get the next coroutine to run
103 try:
104 this_index, coro_fn = next(enum_coro_fns)
105 except StopIteration:
106 return
107 # Start task that will run the next coroutine
108 this_failed = locks.Event()
109 next_task = loop.create_task(
110 run_one_coro(this_failed),
111 eager_start=False,
112 )
113 futures.future_add_to_awaited_by(next_task, parent_task)
114 running_tasks.add(next_task)
115 next_task.add_done_callback(task_done)
116 # Prepare place to put this coroutine's exceptions if not won
117 exceptions.append(None)
118 assert len(exceptions) == this_index + 1
120 try:
121 result = await coro_fn()
122 except (SystemExit, KeyboardInterrupt):
123 raise
124 except BaseException as e:
125 exceptions[this_index] = e
126 this_failed.set() # Kickstart the next coroutine
127 else:
128 # Store winner's results
129 nonlocal winner_index, winner_result
130 assert winner_index is None
131 winner_index = this_index
132 winner_result = result
133 # Cancel all other tasks. We take care to not cancel the current
134 # task as well. If we do so, then since there is no `await` after
135 # here and CancelledError are usually thrown at one, we will
136 # encounter a curious corner case where the current task will end
137 # up as done() == True, cancelled() == False, exception() ==
138 # asyncio.CancelledError. This behavior is specified in
139 # https://bugs.python.org/issue30048
140 current_task = tasks.current_task(loop)
141 for t in running_tasks:
142 if t is not current_task:
143 t.cancel()
145 propagate_cancellation_error = None
146 try:
147 first_task = loop.create_task(run_one_coro(None), eager_start=False)
148 futures.future_add_to_awaited_by(first_task, parent_task)
149 running_tasks.add(first_task)
150 first_task.add_done_callback(task_done)
151 # first_task has been appended to running_tasks before the event loop starts running it.
152 propagate_cancellation_error = None
153 # Make sure no tasks are left running if we leave this function
154 while running_tasks:
155 on_completed_fut = loop.create_future()
156 try:
157 await on_completed_fut
158 except exceptions_mod.CancelledError as ex:
159 propagate_cancellation_error = ex
160 for task in running_tasks:
161 task.cancel(*ex.args)
162 on_completed_fut = None
163 if __debug__ and unhandled_exceptions: 163 ↛ 166line 163 didn't jump to line 166 because the condition on line 163 was never true
164 # If run_one_coro raises an unhandled exception, it's probably a
165 # programming error, and I want to see it.
166 raise ExceptionGroup("staggered race failed", unhandled_exceptions)
167 if propagate_cancellation_error is not None:
168 raise propagate_cancellation_error
169 return winner_result, winner_index, exceptions
170 finally:
171 del exceptions, propagate_cancellation_error, unhandled_exceptions, parent_task