We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Capstone Projects step 5 of 6
Capstone: Extend the Worker Pool to Keep Going After Failures
This capstone extends the concurrency track's capstone, "Build an Async Worker Pool with Retries and Clean Shutdown". The pool still takes items from an async source through a bounded queue, runs a handler on several at a time, and yields each result as soon as it is ready. Three rules change:
- An item that fails for good no longer stops the pool. The pool keeps taking
items, and raises every failure together in an
ExceptionGroupat the end. - A timeout is always worth a retry, whether or not
TimeoutErroris inretryable. metricsreceives the original input item instead of arepr()string, and the event for a failed item is"failed".
Every other rule is the same. If you solved the earlier capstone, start from
your WorkerPool and make those three changes. The starter here names the
end marker _DONE instead of SENTINEL.
async def numbers() -> AsyncGenerator[int, None]:
for n in range(10):
yield n
async def times_ten(n: int) -> int:
await asyncio.sleep(0.01)
if n == 2:
raise ValueError("bad item")
return n * 10
async def main() -> None:
pool: WorkerPool[int, int] = WorkerPool(
times_ten, workers=2, queue_size=2, item_timeout=1.0
)
results: list[int] = []
try:
async with aclosing(pool.run(numbers())) as output:
async for value in output:
results.append(value)
except ExceptionGroup as group:
print(sorted(results)) # [0, 10, 30, 40, 50, 60, 70, 80, 90]
print(group.exceptions) # (ValueError('bad item'),)
Item 2 fails, and the pool still handles items 3 to 9. The earlier pool would stop taking items after item 2 failed, so the last few would never run.
What to write
Fill in WorkerPool with this interface. Keep the type parameters T for
inputs and R for results, and make the file pass mypy --strict.
class WorkerPool[T, R]:
def __init__(
self,
handle: Callable[[T], Awaitable[R]],
*,
workers: int,
queue_size: int,
item_timeout: float,
retryable: tuple[type[Exception], ...] = (),
max_retries: int = 0,
metrics: Callable[[str, T], None] | None = None,
) -> None: ...
def request_stop(self) -> None: ...
async def run(self, source: AsyncIterator[T]) -> AsyncGenerator[R, None]: ...
__init__accepts only positiveworkersandqueue_size, a finite positiveitem_timeout, and amax_retriesof zero or more. Anything else raisesValueError.- A pool runs once. Iterating
runa second time raisesRuntimeError("pool is single-use").
Take in work through a bounded queue
- Use one
asyncio.Queue(maxsize=queue_size)for input and a fixed set of exactlyworkersconsumer tasks. - One producer task pulls items from
sourceand queues them. At mostqueue_size + workers + 1pulled items wait for their handler to start. The+ 1is the item the producer holds while it waits for room in the queue. - Each item the source yields is separate work, even if two items are equal.
- Wrap every input and result in the supplied
_Itembefore it goes on a queue. Then a value that isNone, or even a_Done, stays distinct from your end marker.
Yield results as they finish
- Consumers put each successful result on a second, unbounded
asyncio.Queue, andrunyields from it. Consumers keep working while the caller pauses between results. - Yield results in the order consumers finish them, not in input order. Two items that finish close together may swap places between runs.
Give each attempt a deadline
- Each call to the handler is an attempt. Run each one in a new task, so it
gets its own copy of the context. Setting a
ContextVarin one attempt then never changes the value another attempt sees. handlemay return anyAwaitable, andasyncio.create_taskneeds a coroutine. Callhandle(item)inside a smallasync defthat awaits it, so the whole call runs in the new task's context.- If the attempt is still running after
item_timeout, it fails withTimeoutError("item timed out"). The attempt fails this way even if the handler returns a value while the pool cancels it. - To stop a late attempt, cancel its task and wait up to
item_timeoutfor it. Cancel and wait at most three times. After that, wait for the task to finish, however long it takes. - Once the task finishes, read its exception with
task.exception(), unlesstask.cancelled()returnsTrue. Otherwise asyncio logs "Task exception was never retrieved". - If the handler cancels itself, its task ends cancelled before the
deadline. The attempt then fails with
RuntimeError("handler cancelled itself"). - If the caller cancels
run, stop the attempt the same way, wait for it, and then re-raise theCancelledError.
Retry timeouts and the failures you choose
- If an attempt fails with
TimeoutError, or with an instance of a class inretryable, run the handler again, up tomax_retriesmore times. Start each retry only after the previous attempt has finished. - Any other
Exceptionfails the item at once, with no retry. - When the retries run out, the item fails for good with the exception from its last attempt. Record that exception object as it is.
Stop and drain
To drain means to finish every item already queued or in progress and yield its result.
request_stop()is a plaindefmethod, and it is safe to call more than once.- If you call
request_stop()before iterating,runends at once and pulls zero items. - After a stop, the producer stops pulling items. Wake it if it is waiting on the source or on a full queue. An item it already pulled may still be handled.
- Then drain the queue, and
runyields the drained results.
Keep going, then report every failure
- An item that fails for good does not stop the pool. Record its exception, and keep taking and handling items.
- If the source raises an
Exception, record it, stop taking items, and drain. Retries apply only to the handler. - If pulling from the source raises
CancelledErrorbefore any stop was requested, recordRuntimeError("source cancelled itself"), stop taking items, and drain. - When
runhas yielded every result, it raises anExceptionGroupthat holds the original recorded exception objects. If the pool recorded no exceptions,runends normally.
Finish every task the pool starts
- Run the producer and the consumers in an
asyncio.TaskGroupinside a separate driver task.runonly reads the result queue and yields. Ayieldinside theasync withblock would let a failing child task cancel the caller's code between results. - On a normal drain, once the producer has finished, put one
_DONEper consumer on the input queue. Each consumer exits when it takes one. - If the driver is cancelled, let the TaskGroup cancel the consumers. Don't
wait in a
finallyto queue_DONE, because no consumer is left to take it. - If the caller cancels the task iterating
run, or callsaclose()on the generator thatrunreturns, cancel the driver. Wait for every pool task to finish, then re-raise. Keep waiting through a second cancellation. - When
runends for any reason, every task the pool started has finished. - Leave the source open. The caller owns the source and decides when to close it.
Report metrics
- If
metricsis set, callmetrics(event, item)with the original input item, exactly once for each of these events, and only for these:"ok"when an item succeeds."retry"before each retry."failed"when an item fails for good.
- Ignore any
Exceptionfrommetrics, so the pool works the same with a broken metrics backend. CatchException, notBaseException, soCancelledErrorandKeyboardInterruptstill get through.
What the driver checks
Keep solve and the helpers below WorkerPool as they are. The driver's
items are distinct integers, and its handler returns item * 2. Per item,
behaviour makes the handler succeed, sleep for 30 seconds ("slow"), raise
Permanent ("fatal"), or raise Transient flaky_failures times and then
succeed ("flaky"). The driver closes both the source and run with
aclosing.
With stop_after unset, the report has these keys:
modeis"full".resultsholds every yielded result, sorted.failureslists the type names in theExceptionGroup, sorted.retriedlists the items whose handler ran more than once.boundedisTruewhen the source never got more thanqueue_size + workers + 1items ahead of the handlers that had started.isolatedisTruewhen every attempt started with the defaultContextVarvalue and kept its own value throughout.tasks_cleanisTruewhen no task the pool started is still running afterrunends.emittedlists the distinctmetricsevents, sorted.
A stop_after of zero or less calls request_stop() before iterating. A
positive value calls it as the source resumes after yielding that many
items. With stop_after set, the report has these keys:
modeis"stop".no_exceptionisTruewhenrunraised noExceptionGroup.exactly_onceisTruewhenrunyielded no result twice.subsetisTruewhen every result came from an input item.tasks_cleanandboundedmean the same as above.
Going further
Test run yourself for result order, early close, a producer stuck on a full
queue, and repeated cancellation. The report sorts results and shows only the
final outcome.
- Only the input queue is bounded, so a slow caller lets results pile up in memory.
- A retry repeats the handler's side effects. Make handlers safe to run twice.
- A new task copies the context, not the objects in it. Two attempts that
read the same mutable object from a
ContextVarstill share it. - A deadline can cancel a handler only at an
await. A blocking call delays every task on the event loop, and a handler that ignores cancellation keeps the pool waiting. - Breaking out of
async forleaves the pool's tasks running until the event loop closes the generator later.contextlib.aclosing(...)closes it at once. Its argument needs anaclose()method in its type, so the return type ofrunisAsyncGenerator.
Your code, saved here
Sign in to access your accepted solutions and saved draft history. You can keep reading the lesson without an account.
Sign in to your accountStuck?
Python reference solution
Sign in to track your attempts toward unlocking the reference solution.