Day 5 Checkpoint: Backpressure and the Leak

The Course
•
September 19, 2026
•
10 min read

A semaphore stops you doing too much at once. It doesn’t stop you producing faster than anything downstream can consume — and when the producer is a fast network and the consumer is a slow database, that difference is measured in gigabytes.

A bounded queue is not enough on its own

asyncio.Queue(maxsize=n) makes put wait once there are n items waiting, and that wait is backpressure. But it only bounds what the queue has accepted. It does nothing about work that has been produced and is waiting to be put — and that is where the memory actually goes.

The trap is spawning one task per input:

    async with asyncio.TaskGroup() as group:
        for name in names:
            group.create_task(produce(name))     # one task per input — the leak

Every task reads its source (bounded three at a time by the semaphore), then lines up at queue.put. A task blocked on put has already finished reading and is holding its result. So with ten thousand inputs you get a bounded queue and ten thousand results sitting in blocked tasks. Counting only what the queue accepted hides it completely.

The fix is a fixed pool of workers that pull inputs lazily from a shared iterator. A worker reads the next input only after it has handed off the current one, so it can’t run ahead of the consumer — and the total in flight is bounded by workers + queue_size + 1, whatever the input count. (The + 1 is the value the consumer is holding.) Because the workers advance the iterator only as they become free, a generator or other lazy stream is never drained up front.

Per-input tasks versus a worker pool Python
import asyncio


async def naive(queue_size, items, consume_delay=0.02):
    queue = asyncio.Queue(maxsize=queue_size)
    outstanding = peak = 0

    async def produce(index):
        nonlocal outstanding, peak
        await asyncio.sleep(0.001)              # a fast read
        outstanding += 1                        # the value exists now, before the hand-off
        peak = max(peak, outstanding)
        await queue.put(index)                  # ...and the task keeps holding it here

    async def consume():
        nonlocal outstanding
        while True:
            await queue.get()
            await asyncio.sleep(consume_delay)
            outstanding -= 1

    consumer = asyncio.create_task(consume())
    async with asyncio.TaskGroup() as group:
        for index in range(items):
            group.create_task(produce(index))
    consumer.cancel()
    return peak


async def pooled(workers, queue_size, items, consume_delay=0.02):
    sources = iter(range(items))                # pulled lazily, one at a time
    queue = asyncio.Queue(maxsize=queue_size)
    outstanding = peak = 0

    async def produce():
        nonlocal outstanding, peak
        while True:
            try:
                index = next(sources)           # no await between asking and owning it
            except StopIteration:
                return
            await asyncio.sleep(0.001)
            outstanding += 1
            peak = max(peak, outstanding)
            await queue.put(index)              # blocks until taken: no reading ahead

    async def consume():
        nonlocal outstanding
        taken = 0
        while taken < items:
            await queue.get()
            await asyncio.sleep(consume_delay)
            outstanding -= 1
            taken += 1

    async with asyncio.TaskGroup() as group:
        for _ in range(workers):
            group.create_task(produce())
        group.create_task(consume())
    return peak


async def main():
    for items in (10, 40):
        n = await naive(queue_size=2, items=items)
        p = await pooled(workers=3, queue_size=2, items=items)
        print(f"{items:3} inputs:  one task each -> peak {n:3}    worker pool -> peak {p}")


asyncio.run(main())

outstanding is counted the moment a value exists — before the hand-off — so it measures the real backlog, not just what the queue holds. The per-task version’s peak tracks the input count: 10, then 40. The worker pool stays flat at workers + queue_size + 1 — six here — whatever you throw at it. And because next(sources) is synchronous, no two workers ever grab the same input, and a generator is advanced only as far as the workers have reached.

Backpressure is a system property

The queue only pushes back if the producer awaits the put. put_nowait raises QueueFull instead of waiting, which turns backpressure into an error you have to handle — sometimes the right choice (dropping frames in a video pipeline), usually not. And a producer that reads from another unbounded source just moves the pile-up one step upstream. The bound has to reach all the way back to where the work enters your system — which is exactly what “a worker reads the next input only after handing off” enforces.

Shutting down, and supervising the consumer

run_pipeline runs the workers and the consumer inside one TaskGroup, so no task can outlive the call and a failure anywhere cancels the rest. The consumer loops forever, so a coordinator tells it when to stop:

    async def coordinate() -> None:
        async with asyncio.TaskGroup() as producers:
            for _ in range(workers):
                producers.create_task(produce())
        await results.put(_STOP)            # producers done: release the consumer

    async with asyncio.TaskGroup() as group:
        group.create_task(coordinate())
        group.create_task(consume())        # a failure here cancels the producers

Putting the consumer in the group is what supervises it. If the consumer raises — a bad row, a closed socket — the group cancels the producers instead of leaving them blocked on a full queue forever. The earlier design, with the consumer created outside the group and a queue.join() waiting on it, would simply hang if the consumer died. A hang is the worst failure mode there is: no error, no exit, nothing to see.

The checkpoint

Checkpoint

Day 5 checkpoint: read everything, a few at a time

pipeline.py has read_one, the Meter and run_pipeline written. The two functions in the middle are yours: read_all, where a failing source must not lose the others, and read_all_strict, where a failure ends everything. Seventeen tests, including the cancellation ones from the last lesson and the bounded-backlog, laziness and dying-consumer tests for run_pipeline.

pipeline.py Python
"""Fetch from many sources at once, without fetching from all of them at once.""" import asyncio from collections.abc import Iterable from typing import Any from sources import Source, SourceError async def read_one(name: str, limit: asyncio.Semaphore, *, delay: float = 0.01, fail: bool = False) -> str: """Read one source, holding a slot for as short a time as possible.""" async with limit: # wait for a free slot async with Source(name, delay=delay, fail=fail) as source: return await source.read() async def read_all(names: Iterable[str], *, limit: int = 3, delay: float = 0.01, failing: frozenset[str] = frozenset()) -> dict[str, str]: """Read every source, at most `limit` at a time. One failure doesn't lose the rest: a source that raises `SourceError` is simply absent from the result, and this never raises for that. When a failure *should* be fatal, use `read_all_strict`. """ # TODO: read every name, at most `limit` at a time, and return # {name: value} for the ones that worked. Catch SourceError inside the # worker so the others survive. Use asyncio.Semaphore and a TaskGroup. raise NotImplementedError("read_all") async def read_all_strict(names: Iterable[str], *, limit: int = 3, delay: float = 0.01, failing: frozenset[str] = frozenset()) -> dict[str, str]: """Read every source, and raise if any of them fails. A TaskGroup cancels its siblings as soon as one task raises, and reports everything that went wrong as an ExceptionGroup. """ # TODO: as above, but a failure is fatal: do NOT catch SourceError. A # TaskGroup cancels the siblings and raises an ExceptionGroup on its own. raise NotImplementedError("read_all_strict") class Meter: """How much work is sitting in memory, produced but not yet consumed. `outstanding` is incremented the moment a value exists — before it is handed to the queue — so it counts the true backlog, including values a blocked worker is still holding, not just what the queue has accepted. `consumed` keeps every item so the tests can check what came through. That is diagnostic history, *not* part of the bounded memory: a real streaming consumer would process each item and drop it. Don't read this list as evidence of the pipeline's footprint — `peak_outstanding` is that. """ def __init__(self) -> None: self.outstanding = 0 self.peak_outstanding = 0 self.consumed: list[str] = [] def produced(self) -> None: self.outstanding += 1 self.peak_outstanding = max(self.peak_outstanding, self.outstanding) def took(self, item: str) -> None: self.outstanding -= 1 self.consumed.append(item) _STOP = object() # sentinel: producers are done, the consumer may stop async def run_pipeline(names: Iterable[str], *, consume_delay: float = 0.05, workers: int = 3, queue_size: int = 2, meter: "Meter | None" = None) -> "Meter": """Read many sources with a fixed pool of workers and one slow consumer. The backlog in memory is bounded by `workers + queue_size + 1`, not by the number of inputs: a worker reads the next input only after handing its current result off, so it can't run ahead of the consumer. The `+ 1` is the value the consumer is holding between taking it off the queue and finishing with it. Inputs are pulled **lazily** from a shared iterator — one `next()` per worker, with no `await` between asking for an input and owning it, so two workers can't grab the same one. A generator is advanced only as workers become free, so an unbounded or expensive input stream is never materialised up front. Everything runs inside one TaskGroup, so no task outlives this call, and if the consumer fails the producers are cancelled, not left blocking. """ if workers < 1 or queue_size < 1: raise ValueError("workers and queue_size must be at least 1") meter = meter or Meter() sources = iter(names) # pulled lazily, one input at a time results: asyncio.Queue[Any] = asyncio.Queue(maxsize=queue_size) slots = asyncio.Semaphore(workers) async def produce() -> None: while True: try: name = next(sources) # sync: no await between asking and owning it except StopIteration: return value = await read_one(name, slots) meter.produced() # counted before the hand-off await results.put(value) # a full queue blocks here, so we stop reading async def coordinate() -> None: async with asyncio.TaskGroup() as producers: for _ in range(workers): producers.create_task(produce()) await results.put(_STOP) # producers done: release the consumer async def consume() -> None: while True: item = await results.get() if item is _STOP: return await asyncio.sleep(consume_delay) # a slow consumer meter.took(item) async with asyncio.TaskGroup() as group: group.create_task(coordinate()) group.create_task(consume()) # a failure here cancels the producers return meter """Simulated I/O: sources that take time, sometimes fail, and must be closed. Nothing here touches the network. `asyncio.sleep` stands in for waiting on a socket, which is what makes these examples run identically everywhere. """ import asyncio class SourceError(Exception): """A source that failed to answer.""" class Source: """One remote source. Tracks how many are open, so tests can check cleanup. `open_sources` is a class attribute on purpose: it is a counter shared by every instance, which is what makes it useful for the tests. Deliberate shared state, as on Day 1. """ open_sources = 0 peak_in_flight = 0 def __init__(self, name: str, *, delay: float = 0.01, fail: bool = False) -> None: self.name = name self.delay = delay self.fail = fail self.closed = False async def __aenter__(self) -> "Source": Source.open_sources += 1 Source.peak_in_flight = max(Source.peak_in_flight, Source.open_sources) return self async def __aexit__(self, *exception: object) -> None: await self.close() async def close(self) -> None: if not self.closed: self.closed = True Source.open_sources -= 1 async def read(self) -> str: await asyncio.sleep(self.delay) if self.fail: raise SourceError(f"{self.name} is unavailable") return f"{self.name}: 42" @classmethod def reset(cls) -> None: cls.open_sources = 0 cls.peak_in_flight = 0 """Tests for the async pipeline. Every test drives asyncio.run from a plain TestCase, rather than using IsolatedAsyncioTestCase, so the suite runs in the course's browser runtime too. """ import asyncio import contextlib import unittest import pipeline from pipeline import Meter, read_all, read_all_strict, run_pipeline from sources import Source, SourceError class Bounded(unittest.TestCase): def setUp(self) -> None: Source.reset() def test_every_source_is_read(self) -> None: names = [f"s{index}" for index in range(6)] results = asyncio.run(read_all(names, limit=2)) self.assertEqual(sorted(results), sorted(names)) def test_never_more_than_the_limit_at_once(self) -> None: asyncio.run(read_all([f"s{index}" for index in range(8)], limit=3)) self.assertLessEqual(Source.peak_in_flight, 3) def test_the_limit_is_actually_reached(self) -> None: """A limit nothing reaches would make the test above pass for free.""" asyncio.run(read_all([f"s{index}" for index in range(8)], limit=3)) self.assertEqual(Source.peak_in_flight, 3) def test_every_source_is_closed_afterwards(self) -> None: asyncio.run(read_all([f"s{index}" for index in range(5)], limit=2)) self.assertEqual(Source.open_sources, 0) def test_one_failure_does_not_lose_the_others(self) -> None: results = asyncio.run(read_all(["a", "b", "c"], failing=frozenset({"b"}))) self.assertEqual(sorted(results), ["a", "c"]) self.assertEqual(Source.open_sources, 0) class Strict(unittest.TestCase): def setUp(self) -> None: Source.reset() def test_a_failure_arrives_as_an_exception_group(self) -> None: with self.assertRaises(ExceptionGroup) as caught: asyncio.run(read_all_strict(["a", "b"], failing=frozenset({"b"}))) self.assertEqual([type(error) for error in caught.exception.exceptions], [SourceError]) def test_two_failures_are_both_reported(self) -> None: with self.assertRaises(ExceptionGroup) as caught: asyncio.run(read_all_strict(["a", "b", "c"], limit=3, failing=frozenset({"b", "c"}))) self.assertEqual(len(caught.exception.exceptions), 2) def test_sources_are_closed_even_when_siblings_are_cancelled(self) -> None: with contextlib.suppress(ExceptionGroup): asyncio.run(read_all_strict([f"s{index}" for index in range(6)], limit=2, failing=frozenset({"s0"}))) self.assertEqual(Source.open_sources, 0) class Cancellation(unittest.TestCase): def setUp(self) -> None: Source.reset() def test_a_timeout_closes_every_source(self) -> None: async def scenario() -> None: with contextlib.suppress(TimeoutError): async with asyncio.timeout(0.02): await read_all([f"s{index}" for index in range(4)], limit=4, delay=5) asyncio.run(scenario()) self.assertEqual(Source.open_sources, 0, "a cancelled read left a source open") def test_cancelling_a_task_still_cleans_up(self) -> None: async def scenario() -> None: task = asyncio.create_task( read_all([f"s{index}" for index in range(3)], limit=3, delay=5) ) await asyncio.sleep(0.01) # let the reads start task.cancel() with contextlib.suppress(asyncio.CancelledError): await task asyncio.run(scenario()) self.assertEqual(Source.open_sources, 0) class Backpressure(unittest.TestCase): def setUp(self) -> None: Source.reset() def test_everything_is_consumed(self) -> None: meter = asyncio.run(run_pipeline([f"s{index}" for index in range(8)], consume_delay=0.01, workers=3, queue_size=2)) self.assertEqual(len(meter.consumed), 8) def test_the_backlog_is_bounded_however_many_inputs(self) -> None: """The real test: scale the input while the consumer stays slow.""" few = asyncio.run(run_pipeline([f"s{index}" for index in range(8)], consume_delay=0.02, workers=3, queue_size=2)) Source.reset() many = asyncio.run(run_pipeline([f"s{index}" for index in range(60)], consume_delay=0.02, workers=3, queue_size=2)) # 8x the inputs, same bound. It is exactly workers + queue_size + 1: # a full queue, one value per blocked worker, and one the consumer holds. self.assertLessEqual(many.peak_outstanding, 3 + 2 + 1) self.assertLessEqual(few.peak_outstanding, 3 + 2 + 1) def test_inputs_are_pulled_lazily(self) -> None: """A generator is advanced only as workers are free — not drained up front. Measures how far input requests run ahead of consumption.""" state = {"requested": 0, "peak_ahead": 0} class Tracking(Meter): def took(self, item: str) -> None: super().took(item) state["peak_ahead"] = max(state["peak_ahead"], state["requested"] - len(self.consumed)) def names(): for index in range(60): state["requested"] += 1 yield f"s{index}" meter = asyncio.run(run_pipeline(names(), workers=3, queue_size=2, consume_delay=0.02, meter=Tracking())) self.assertEqual(len(meter.consumed), 60) # Requests stay within the backlog bound of consumption — they do not # jump to 60 while the consumer is still on the first few. self.assertLessEqual(state["peak_ahead"], 3 + 2 + 1) def test_a_bigger_queue_allows_a_bigger_backlog(self) -> None: """The comparison that shows the bound is doing something.""" small = asyncio.run(run_pipeline([f"s{index}" for index in range(30)], consume_delay=0.02, workers=3, queue_size=2)) Source.reset() big = asyncio.run(run_pipeline([f"s{index}" for index in range(30)], consume_delay=0.02, workers=3, queue_size=20)) self.assertGreater(big.peak_outstanding, small.peak_outstanding) def test_nonpositive_settings_are_refused(self) -> None: for bad in ({"workers": 0}, {"queue_size": 0}, {"workers": -1}): with self.assertRaises(ValueError): asyncio.run(run_pipeline(["a"], **bad)) def test_no_task_outlives_the_pipeline(self) -> None: async def scenario() -> int: before = asyncio.all_tasks() await run_pipeline(["a", "b", "c"], consume_delay=0.001, workers=2, queue_size=2) leaked = {t for t in asyncio.all_tasks() if t not in before and not t.done()} return len(leaked) # compare to the starting set, not an absolute count self.assertEqual(asyncio.run(scenario()), 0) def test_a_dying_consumer_does_not_hang(self) -> None: """If the consumer raises, producers are cancelled, not left blocking.""" class Exploding(Meter): def took(self, item: str) -> None: raise RuntimeError("consumer blew up") async def scenario() -> None: # wait_for turns a hang into a failure instead of freezing the suite await asyncio.wait_for( run_pipeline([f"s{i}" for i in range(20)], consume_delay=0.01, workers=2, queue_size=2, meter=Exploding()), timeout=5, ) with self.assertRaises(ExceptionGroup): asyncio.run(scenario()) self.assertEqual(Source.open_sources, 0) import sys import unittest import test_pipeline suite = unittest.defaultTestLoader.loadTestsFromModule(test_pipeline) unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)
Hint 1: the idea

Two versions of this morning’s gather_results, with Day 5’s limit added. The only difference between them is where a SourceError may go: caught inside each worker, so the others survive — or left to reach the TaskGroup, which cancels the rest and raises an ExceptionGroup on its own.

Hint 2: a first step

Write read_all first. Make one asyncio.Semaphore(limit) and pass it to every read_one — it acquires a slot itself. Inside an inner worker, store each value, with the try/except SourceError around the call. read_all_strict is then the same code without the try.

Show one solution
async def read_all(names, *, limit=3, delay=0.01, failing=frozenset()):
    results: dict[str, str] = {}

    async def worker(name: str) -> None:
        try:
            results[name] = await read_one(name, limit_slots, delay=delay,
                                           fail=name in failing)
        except SourceError:
            pass                            # a missing source is not fatal here

    limit_slots = asyncio.Semaphore(limit)
    async with asyncio.TaskGroup() as group:
        for name in names:
            group.create_task(worker(name))
    return results


async def read_all_strict(names, *, limit=3, delay=0.01, failing=frozenset()):
    results: dict[str, str] = {}
    limit_slots = asyncio.Semaphore(limit)

    async def worker(name: str) -> None:
        results[name] = await read_one(name, limit_slots, delay=delay,
                                       fail=name in failing)

    async with asyncio.TaskGroup() as group:
        for name in names:
            group.create_task(worker(name))
    return results

The two differ by one try, and that’s the lesson: where you catch decides whether a failure is local or fatal. Inside the worker, the group never learns anything went wrong. Outside, the group cancels the siblings and raises an ExceptionGroup.

Both create the semaphore inside the async function, so it binds to the loop that’s actually running — a module-level one would break the second time a test calls asyncio.run.

Neither needs a lock around results[name] = ...: the assignment contains no await, so no other task can run in the middle of it.

Bug hunt: the cleanup that skips cancellations

Someone rewrote read_one without the async with, “to make the close explicit”.

Reproduce Python
"""Fetch from many sources at once, without fetching from all of them at once.""" import asyncio from collections.abc import Iterable from typing import Any from sources import Source, SourceError async def read_one(name: str, limit: asyncio.Semaphore, *, delay: float = 0.01, fail: bool = False) -> str: """Read one source, holding a slot for as short a time as possible.""" async with limit: # wait for a free slot source = await Source(name, delay=delay, fail=fail).__aenter__() try: value = await source.read() except Exception: # CancelledError is not an Exception await source.close() raise await source.close() return value async def read_all(names: Iterable[str], *, limit: int = 3, delay: float = 0.01, failing: frozenset[str] = frozenset()) -> dict[str, str]: """Read every source, at most `limit` at a time. One failure doesn't lose the rest: a source that raises `SourceError` is simply absent from the result, and this never raises for that. When a failure *should* be fatal, use `read_all_strict`. """ results: dict[str, str] = {} async def worker(name: str) -> None: try: results[name] = await read_one(name, limit_slots, delay=delay, fail=name in failing) except SourceError: pass # a missing source is not fatal here limit_slots = asyncio.Semaphore(limit) async with asyncio.TaskGroup() as group: for name in names: group.create_task(worker(name)) return results async def read_all_strict(names: Iterable[str], *, limit: int = 3, delay: float = 0.01, failing: frozenset[str] = frozenset()) -> dict[str, str]: """Read every source, and raise if any of them fails. A TaskGroup cancels its siblings as soon as one task raises, and reports everything that went wrong as an ExceptionGroup. """ results: dict[str, str] = {} limit_slots = asyncio.Semaphore(limit) async def worker(name: str) -> None: results[name] = await read_one(name, limit_slots, delay=delay, fail=name in failing) async with asyncio.TaskGroup() as group: for name in names: group.create_task(worker(name)) return results class Meter: """How much work is sitting in memory, produced but not yet consumed. `outstanding` is incremented the moment a value exists — before it is handed to the queue — so it counts the true backlog, including values a blocked worker is still holding, not just what the queue has accepted. `consumed` keeps every item so the tests can check what came through. That is diagnostic history, *not* part of the bounded memory: a real streaming consumer would process each item and drop it. Don't read this list as evidence of the pipeline's footprint — `peak_outstanding` is that. """ def __init__(self) -> None: self.outstanding = 0 self.peak_outstanding = 0 self.consumed: list[str] = [] def produced(self) -> None: self.outstanding += 1 self.peak_outstanding = max(self.peak_outstanding, self.outstanding) def took(self, item: str) -> None: self.outstanding -= 1 self.consumed.append(item) _STOP = object() # sentinel: producers are done, the consumer may stop async def run_pipeline(names: Iterable[str], *, consume_delay: float = 0.05, workers: int = 3, queue_size: int = 2, meter: "Meter | None" = None) -> "Meter": """Read many sources with a fixed pool of workers and one slow consumer. The backlog in memory is bounded by `workers + queue_size + 1`, not by the number of inputs: a worker reads the next input only after handing its current result off, so it can't run ahead of the consumer. The `+ 1` is the value the consumer is holding between taking it off the queue and finishing with it. Inputs are pulled **lazily** from a shared iterator — one `next()` per worker, with no `await` between asking for an input and owning it, so two workers can't grab the same one. A generator is advanced only as workers become free, so an unbounded or expensive input stream is never materialised up front. Everything runs inside one TaskGroup, so no task outlives this call, and if the consumer fails the producers are cancelled, not left blocking. """ if workers < 1 or queue_size < 1: raise ValueError("workers and queue_size must be at least 1") meter = meter or Meter() sources = iter(names) # pulled lazily, one input at a time results: asyncio.Queue[Any] = asyncio.Queue(maxsize=queue_size) slots = asyncio.Semaphore(workers) async def produce() -> None: while True: try: name = next(sources) # sync: no await between asking and owning it except StopIteration: return value = await read_one(name, slots) meter.produced() # counted before the hand-off await results.put(value) # a full queue blocks here, so we stop reading async def coordinate() -> None: async with asyncio.TaskGroup() as producers: for _ in range(workers): producers.create_task(produce()) await results.put(_STOP) # producers done: release the consumer async def consume() -> None: while True: item = await results.get() if item is _STOP: return await asyncio.sleep(consume_delay) # a slow consumer meter.took(item) async with asyncio.TaskGroup() as group: group.create_task(coordinate()) group.create_task(consume()) # a failure here cancels the producers return meter """Simulated I/O: sources that take time, sometimes fail, and must be closed. Nothing here touches the network. `asyncio.sleep` stands in for waiting on a socket, which is what makes these examples run identically everywhere. """ import asyncio class SourceError(Exception): """A source that failed to answer.""" class Source: """One remote source. Tracks how many are open, so tests can check cleanup. `open_sources` is a class attribute on purpose: it is a counter shared by every instance, which is what makes it useful for the tests. Deliberate shared state, as on Day 1. """ open_sources = 0 peak_in_flight = 0 def __init__(self, name: str, *, delay: float = 0.01, fail: bool = False) -> None: self.name = name self.delay = delay self.fail = fail self.closed = False async def __aenter__(self) -> "Source": Source.open_sources += 1 Source.peak_in_flight = max(Source.peak_in_flight, Source.open_sources) return self async def __aexit__(self, *exception: object) -> None: await self.close() async def close(self) -> None: if not self.closed: self.closed = True Source.open_sources -= 1 async def read(self) -> str: await asyncio.sleep(self.delay) if self.fail: raise SourceError(f"{self.name} is unavailable") return f"{self.name}: 42" @classmethod def reset(cls) -> None: cls.open_sources = 0 cls.peak_in_flight = 0 """Tests for the async pipeline. Every test drives asyncio.run from a plain TestCase, rather than using IsolatedAsyncioTestCase, so the suite runs in the course's browser runtime too. """ import asyncio import contextlib import unittest import pipeline from pipeline import Meter, read_all, read_all_strict, run_pipeline from sources import Source, SourceError class Bounded(unittest.TestCase): def setUp(self) -> None: Source.reset() def test_every_source_is_read(self) -> None: names = [f"s{index}" for index in range(6)] results = asyncio.run(read_all(names, limit=2)) self.assertEqual(sorted(results), sorted(names)) def test_never_more_than_the_limit_at_once(self) -> None: asyncio.run(read_all([f"s{index}" for index in range(8)], limit=3)) self.assertLessEqual(Source.peak_in_flight, 3) def test_the_limit_is_actually_reached(self) -> None: """A limit nothing reaches would make the test above pass for free.""" asyncio.run(read_all([f"s{index}" for index in range(8)], limit=3)) self.assertEqual(Source.peak_in_flight, 3) def test_every_source_is_closed_afterwards(self) -> None: asyncio.run(read_all([f"s{index}" for index in range(5)], limit=2)) self.assertEqual(Source.open_sources, 0) def test_one_failure_does_not_lose_the_others(self) -> None: results = asyncio.run(read_all(["a", "b", "c"], failing=frozenset({"b"}))) self.assertEqual(sorted(results), ["a", "c"]) self.assertEqual(Source.open_sources, 0) class Strict(unittest.TestCase): def setUp(self) -> None: Source.reset() def test_a_failure_arrives_as_an_exception_group(self) -> None: with self.assertRaises(ExceptionGroup) as caught: asyncio.run(read_all_strict(["a", "b"], failing=frozenset({"b"}))) self.assertEqual([type(error) for error in caught.exception.exceptions], [SourceError]) def test_two_failures_are_both_reported(self) -> None: with self.assertRaises(ExceptionGroup) as caught: asyncio.run(read_all_strict(["a", "b", "c"], limit=3, failing=frozenset({"b", "c"}))) self.assertEqual(len(caught.exception.exceptions), 2) def test_sources_are_closed_even_when_siblings_are_cancelled(self) -> None: with contextlib.suppress(ExceptionGroup): asyncio.run(read_all_strict([f"s{index}" for index in range(6)], limit=2, failing=frozenset({"s0"}))) self.assertEqual(Source.open_sources, 0) class Cancellation(unittest.TestCase): def setUp(self) -> None: Source.reset() def test_a_timeout_closes_every_source(self) -> None: async def scenario() -> None: with contextlib.suppress(TimeoutError): async with asyncio.timeout(0.02): await read_all([f"s{index}" for index in range(4)], limit=4, delay=5) asyncio.run(scenario()) self.assertEqual(Source.open_sources, 0, "a cancelled read left a source open") def test_cancelling_a_task_still_cleans_up(self) -> None: async def scenario() -> None: task = asyncio.create_task( read_all([f"s{index}" for index in range(3)], limit=3, delay=5) ) await asyncio.sleep(0.01) # let the reads start task.cancel() with contextlib.suppress(asyncio.CancelledError): await task asyncio.run(scenario()) self.assertEqual(Source.open_sources, 0) class Backpressure(unittest.TestCase): def setUp(self) -> None: Source.reset() def test_everything_is_consumed(self) -> None: meter = asyncio.run(run_pipeline([f"s{index}" for index in range(8)], consume_delay=0.01, workers=3, queue_size=2)) self.assertEqual(len(meter.consumed), 8) def test_the_backlog_is_bounded_however_many_inputs(self) -> None: """The real test: scale the input while the consumer stays slow.""" few = asyncio.run(run_pipeline([f"s{index}" for index in range(8)], consume_delay=0.02, workers=3, queue_size=2)) Source.reset() many = asyncio.run(run_pipeline([f"s{index}" for index in range(60)], consume_delay=0.02, workers=3, queue_size=2)) # 8x the inputs, same bound. It is exactly workers + queue_size + 1: # a full queue, one value per blocked worker, and one the consumer holds. self.assertLessEqual(many.peak_outstanding, 3 + 2 + 1) self.assertLessEqual(few.peak_outstanding, 3 + 2 + 1) def test_inputs_are_pulled_lazily(self) -> None: """A generator is advanced only as workers are free — not drained up front. Measures how far input requests run ahead of consumption.""" state = {"requested": 0, "peak_ahead": 0} class Tracking(Meter): def took(self, item: str) -> None: super().took(item) state["peak_ahead"] = max(state["peak_ahead"], state["requested"] - len(self.consumed)) def names(): for index in range(60): state["requested"] += 1 yield f"s{index}" meter = asyncio.run(run_pipeline(names(), workers=3, queue_size=2, consume_delay=0.02, meter=Tracking())) self.assertEqual(len(meter.consumed), 60) # Requests stay within the backlog bound of consumption — they do not # jump to 60 while the consumer is still on the first few. self.assertLessEqual(state["peak_ahead"], 3 + 2 + 1) def test_a_bigger_queue_allows_a_bigger_backlog(self) -> None: """The comparison that shows the bound is doing something.""" small = asyncio.run(run_pipeline([f"s{index}" for index in range(30)], consume_delay=0.02, workers=3, queue_size=2)) Source.reset() big = asyncio.run(run_pipeline([f"s{index}" for index in range(30)], consume_delay=0.02, workers=3, queue_size=20)) self.assertGreater(big.peak_outstanding, small.peak_outstanding) def test_nonpositive_settings_are_refused(self) -> None: for bad in ({"workers": 0}, {"queue_size": 0}, {"workers": -1}): with self.assertRaises(ValueError): asyncio.run(run_pipeline(["a"], **bad)) def test_no_task_outlives_the_pipeline(self) -> None: async def scenario() -> int: before = asyncio.all_tasks() await run_pipeline(["a", "b", "c"], consume_delay=0.001, workers=2, queue_size=2) leaked = {t for t in asyncio.all_tasks() if t not in before and not t.done()} return len(leaked) # compare to the starting set, not an absolute count self.assertEqual(asyncio.run(scenario()), 0) def test_a_dying_consumer_does_not_hang(self) -> None: """If the consumer raises, producers are cancelled, not left blocking.""" class Exploding(Meter): def took(self, item: str) -> None: raise RuntimeError("consumer blew up") async def scenario() -> None: # wait_for turns a hang into a failure instead of freezing the suite await asyncio.wait_for( run_pipeline([f"s{i}" for i in range(20)], consume_delay=0.01, workers=2, queue_size=2, meter=Exploding()), timeout=5, ) with self.assertRaises(ExceptionGroup): asyncio.run(scenario()) self.assertEqual(Source.open_sources, 0) import sys import unittest import test_pipeline suite = unittest.defaultTestLoader.loadTestsFromModule(test_pipeline) unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)

Reproduce. Three failures, and the clearest message is:

AssertionError: 4 != 0 : a cancelled read left a source open

All the ordinary tests pass. Reads work, failures work, the limit holds. Only the cancellation tests fail.

Inspect. The new code:

        source = await Source(name, delay=delay, fail=fail).__aenter__()
        try:
            value = await source.read()
        except Exception:
            await source.close()
            raise
        await source.close()
        return value

Narrow. There are three ways out of await source.read(): a value, a SourceError, or a CancelledError from the timeout. The first two close the source. The third doesn’t, because CancelledError inherits from BaseException — so except Exception doesn’t see it, and the close() after the try is skipped by the exception on its way up.

Each cancelled read leaks one source. In a real library that’s a connection, a file handle, or a lock somebody else is waiting for.

Pin. test_a_timeout_closes_every_source and test_cancelling_a_task_still_cleans_up are the regression tests, and between them they cover both ways a cancellation arrives: a deadline and an explicit task.cancel().

Fix. Let the context manager do it:

    async with limit:
        async with Source(name, delay=delay, fail=fail) as source:
            return await source.read()

async with compiles to a try/finally, so every exit closes the source — including the one nobody tests for.

The tests that only fail under cancellation

This bug is invisible to fourteen of the seventeen tests. Any library doing I/O needs cancellation tests by name: cancel it mid-flight, time it out, and then assert that the resource counter is back to zero. Without them, this code ships.

Where Day 5 leaves you

The finished Day 5 Python
"""Fetch from many sources at once, without fetching from all of them at once.""" import asyncio from collections.abc import Iterable from typing import Any from sources import Source, SourceError async def read_one(name: str, limit: asyncio.Semaphore, *, delay: float = 0.01, fail: bool = False) -> str: """Read one source, holding a slot for as short a time as possible.""" async with limit: # wait for a free slot async with Source(name, delay=delay, fail=fail) as source: return await source.read() async def read_all(names: Iterable[str], *, limit: int = 3, delay: float = 0.01, failing: frozenset[str] = frozenset()) -> dict[str, str]: """Read every source, at most `limit` at a time. One failure doesn't lose the rest: a source that raises `SourceError` is simply absent from the result, and this never raises for that. When a failure *should* be fatal, use `read_all_strict`. """ results: dict[str, str] = {} async def worker(name: str) -> None: try: results[name] = await read_one(name, limit_slots, delay=delay, fail=name in failing) except SourceError: pass # a missing source is not fatal here limit_slots = asyncio.Semaphore(limit) async with asyncio.TaskGroup() as group: for name in names: group.create_task(worker(name)) return results async def read_all_strict(names: Iterable[str], *, limit: int = 3, delay: float = 0.01, failing: frozenset[str] = frozenset()) -> dict[str, str]: """Read every source, and raise if any of them fails. A TaskGroup cancels its siblings as soon as one task raises, and reports everything that went wrong as an ExceptionGroup. """ results: dict[str, str] = {} limit_slots = asyncio.Semaphore(limit) async def worker(name: str) -> None: results[name] = await read_one(name, limit_slots, delay=delay, fail=name in failing) async with asyncio.TaskGroup() as group: for name in names: group.create_task(worker(name)) return results class Meter: """How much work is sitting in memory, produced but not yet consumed. `outstanding` is incremented the moment a value exists — before it is handed to the queue — so it counts the true backlog, including values a blocked worker is still holding, not just what the queue has accepted. `consumed` keeps every item so the tests can check what came through. That is diagnostic history, *not* part of the bounded memory: a real streaming consumer would process each item and drop it. Don't read this list as evidence of the pipeline's footprint — `peak_outstanding` is that. """ def __init__(self) -> None: self.outstanding = 0 self.peak_outstanding = 0 self.consumed: list[str] = [] def produced(self) -> None: self.outstanding += 1 self.peak_outstanding = max(self.peak_outstanding, self.outstanding) def took(self, item: str) -> None: self.outstanding -= 1 self.consumed.append(item) _STOP = object() # sentinel: producers are done, the consumer may stop async def run_pipeline(names: Iterable[str], *, consume_delay: float = 0.05, workers: int = 3, queue_size: int = 2, meter: "Meter | None" = None) -> "Meter": """Read many sources with a fixed pool of workers and one slow consumer. The backlog in memory is bounded by `workers + queue_size + 1`, not by the number of inputs: a worker reads the next input only after handing its current result off, so it can't run ahead of the consumer. The `+ 1` is the value the consumer is holding between taking it off the queue and finishing with it. Inputs are pulled **lazily** from a shared iterator — one `next()` per worker, with no `await` between asking for an input and owning it, so two workers can't grab the same one. A generator is advanced only as workers become free, so an unbounded or expensive input stream is never materialised up front. Everything runs inside one TaskGroup, so no task outlives this call, and if the consumer fails the producers are cancelled, not left blocking. """ if workers < 1 or queue_size < 1: raise ValueError("workers and queue_size must be at least 1") meter = meter or Meter() sources = iter(names) # pulled lazily, one input at a time results: asyncio.Queue[Any] = asyncio.Queue(maxsize=queue_size) slots = asyncio.Semaphore(workers) async def produce() -> None: while True: try: name = next(sources) # sync: no await between asking and owning it except StopIteration: return value = await read_one(name, slots) meter.produced() # counted before the hand-off await results.put(value) # a full queue blocks here, so we stop reading async def coordinate() -> None: async with asyncio.TaskGroup() as producers: for _ in range(workers): producers.create_task(produce()) await results.put(_STOP) # producers done: release the consumer async def consume() -> None: while True: item = await results.get() if item is _STOP: return await asyncio.sleep(consume_delay) # a slow consumer meter.took(item) async with asyncio.TaskGroup() as group: group.create_task(coordinate()) group.create_task(consume()) # a failure here cancels the producers return meter """Simulated I/O: sources that take time, sometimes fail, and must be closed. Nothing here touches the network. `asyncio.sleep` stands in for waiting on a socket, which is what makes these examples run identically everywhere. """ import asyncio class SourceError(Exception): """A source that failed to answer.""" class Source: """One remote source. Tracks how many are open, so tests can check cleanup. `open_sources` is a class attribute on purpose: it is a counter shared by every instance, which is what makes it useful for the tests. Deliberate shared state, as on Day 1. """ open_sources = 0 peak_in_flight = 0 def __init__(self, name: str, *, delay: float = 0.01, fail: bool = False) -> None: self.name = name self.delay = delay self.fail = fail self.closed = False async def __aenter__(self) -> "Source": Source.open_sources += 1 Source.peak_in_flight = max(Source.peak_in_flight, Source.open_sources) return self async def __aexit__(self, *exception: object) -> None: await self.close() async def close(self) -> None: if not self.closed: self.closed = True Source.open_sources -= 1 async def read(self) -> str: await asyncio.sleep(self.delay) if self.fail: raise SourceError(f"{self.name} is unavailable") return f"{self.name}: 42" @classmethod def reset(cls) -> None: cls.open_sources = 0 cls.peak_in_flight = 0 """Tests for the async pipeline. Every test drives asyncio.run from a plain TestCase, rather than using IsolatedAsyncioTestCase, so the suite runs in the course's browser runtime too. """ import asyncio import contextlib import unittest import pipeline from pipeline import Meter, read_all, read_all_strict, run_pipeline from sources import Source, SourceError class Bounded(unittest.TestCase): def setUp(self) -> None: Source.reset() def test_every_source_is_read(self) -> None: names = [f"s{index}" for index in range(6)] results = asyncio.run(read_all(names, limit=2)) self.assertEqual(sorted(results), sorted(names)) def test_never_more_than_the_limit_at_once(self) -> None: asyncio.run(read_all([f"s{index}" for index in range(8)], limit=3)) self.assertLessEqual(Source.peak_in_flight, 3) def test_the_limit_is_actually_reached(self) -> None: """A limit nothing reaches would make the test above pass for free.""" asyncio.run(read_all([f"s{index}" for index in range(8)], limit=3)) self.assertEqual(Source.peak_in_flight, 3) def test_every_source_is_closed_afterwards(self) -> None: asyncio.run(read_all([f"s{index}" for index in range(5)], limit=2)) self.assertEqual(Source.open_sources, 0) def test_one_failure_does_not_lose_the_others(self) -> None: results = asyncio.run(read_all(["a", "b", "c"], failing=frozenset({"b"}))) self.assertEqual(sorted(results), ["a", "c"]) self.assertEqual(Source.open_sources, 0) class Strict(unittest.TestCase): def setUp(self) -> None: Source.reset() def test_a_failure_arrives_as_an_exception_group(self) -> None: with self.assertRaises(ExceptionGroup) as caught: asyncio.run(read_all_strict(["a", "b"], failing=frozenset({"b"}))) self.assertEqual([type(error) for error in caught.exception.exceptions], [SourceError]) def test_two_failures_are_both_reported(self) -> None: with self.assertRaises(ExceptionGroup) as caught: asyncio.run(read_all_strict(["a", "b", "c"], limit=3, failing=frozenset({"b", "c"}))) self.assertEqual(len(caught.exception.exceptions), 2) def test_sources_are_closed_even_when_siblings_are_cancelled(self) -> None: with contextlib.suppress(ExceptionGroup): asyncio.run(read_all_strict([f"s{index}" for index in range(6)], limit=2, failing=frozenset({"s0"}))) self.assertEqual(Source.open_sources, 0) class Cancellation(unittest.TestCase): def setUp(self) -> None: Source.reset() def test_a_timeout_closes_every_source(self) -> None: async def scenario() -> None: with contextlib.suppress(TimeoutError): async with asyncio.timeout(0.02): await read_all([f"s{index}" for index in range(4)], limit=4, delay=5) asyncio.run(scenario()) self.assertEqual(Source.open_sources, 0, "a cancelled read left a source open") def test_cancelling_a_task_still_cleans_up(self) -> None: async def scenario() -> None: task = asyncio.create_task( read_all([f"s{index}" for index in range(3)], limit=3, delay=5) ) await asyncio.sleep(0.01) # let the reads start task.cancel() with contextlib.suppress(asyncio.CancelledError): await task asyncio.run(scenario()) self.assertEqual(Source.open_sources, 0) class Backpressure(unittest.TestCase): def setUp(self) -> None: Source.reset() def test_everything_is_consumed(self) -> None: meter = asyncio.run(run_pipeline([f"s{index}" for index in range(8)], consume_delay=0.01, workers=3, queue_size=2)) self.assertEqual(len(meter.consumed), 8) def test_the_backlog_is_bounded_however_many_inputs(self) -> None: """The real test: scale the input while the consumer stays slow.""" few = asyncio.run(run_pipeline([f"s{index}" for index in range(8)], consume_delay=0.02, workers=3, queue_size=2)) Source.reset() many = asyncio.run(run_pipeline([f"s{index}" for index in range(60)], consume_delay=0.02, workers=3, queue_size=2)) # 8x the inputs, same bound. It is exactly workers + queue_size + 1: # a full queue, one value per blocked worker, and one the consumer holds. self.assertLessEqual(many.peak_outstanding, 3 + 2 + 1) self.assertLessEqual(few.peak_outstanding, 3 + 2 + 1) def test_inputs_are_pulled_lazily(self) -> None: """A generator is advanced only as workers are free — not drained up front. Measures how far input requests run ahead of consumption.""" state = {"requested": 0, "peak_ahead": 0} class Tracking(Meter): def took(self, item: str) -> None: super().took(item) state["peak_ahead"] = max(state["peak_ahead"], state["requested"] - len(self.consumed)) def names(): for index in range(60): state["requested"] += 1 yield f"s{index}" meter = asyncio.run(run_pipeline(names(), workers=3, queue_size=2, consume_delay=0.02, meter=Tracking())) self.assertEqual(len(meter.consumed), 60) # Requests stay within the backlog bound of consumption — they do not # jump to 60 while the consumer is still on the first few. self.assertLessEqual(state["peak_ahead"], 3 + 2 + 1) def test_a_bigger_queue_allows_a_bigger_backlog(self) -> None: """The comparison that shows the bound is doing something.""" small = asyncio.run(run_pipeline([f"s{index}" for index in range(30)], consume_delay=0.02, workers=3, queue_size=2)) Source.reset() big = asyncio.run(run_pipeline([f"s{index}" for index in range(30)], consume_delay=0.02, workers=3, queue_size=20)) self.assertGreater(big.peak_outstanding, small.peak_outstanding) def test_nonpositive_settings_are_refused(self) -> None: for bad in ({"workers": 0}, {"queue_size": 0}, {"workers": -1}): with self.assertRaises(ValueError): asyncio.run(run_pipeline(["a"], **bad)) def test_no_task_outlives_the_pipeline(self) -> None: async def scenario() -> int: before = asyncio.all_tasks() await run_pipeline(["a", "b", "c"], consume_delay=0.001, workers=2, queue_size=2) leaked = {t for t in asyncio.all_tasks() if t not in before and not t.done()} return len(leaked) # compare to the starting set, not an absolute count self.assertEqual(asyncio.run(scenario()), 0) def test_a_dying_consumer_does_not_hang(self) -> None: """If the consumer raises, producers are cancelled, not left blocking.""" class Exploding(Meter): def took(self, item: str) -> None: raise RuntimeError("consumer blew up") async def scenario() -> None: # wait_for turns a hang into a failure instead of freezing the suite await asyncio.wait_for( run_pipeline([f"s{i}" for i in range(20)], consume_delay=0.01, workers=2, queue_size=2, meter=Exploding()), timeout=5, ) with self.assertRaises(ExceptionGroup): asyncio.run(scenario()) self.assertEqual(Source.open_sources, 0) import sys import unittest import test_pipeline suite = unittest.defaultTestLoader.loadTestsFromModule(test_pipeline) unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)

Working on your own machine? Download the Day 5 files.

Two details in there are worth copying into your own work.

Source.open_sources is a counter on the class, incremented on enter and decremented on exit, and the tests assert it’s zero afterwards. Instrumenting a resource like that costs four lines and turns “I think we close everything” into an assertion.

And every test drives asyncio.run from a plain TestCase rather than using IsolatedAsyncioTestCase, because the browser runtime this course uses can’t run the latter. On your own machine either works.

Threads, the GIL and free-threading

Everything today ran on one event loop in one thread. If your work is CPU-bound, none of it helps: asyncio.to_thread moves a blocking call off the loop, and ProcessPoolExecutor gets you real parallelism. The “no lock needed, there’s no await in between” reasoning today holds because a single event loop only switches tasks at await — it is about coroutines sharing one loop, not about the GIL. The moment the same state is touched from more than one thread you need a lock, GIL or not; free-threaded builds (PEP 703, 3.13+) just make that sharing genuinely parallel and the missing lock genuinely visible. That’s optional reading for now; the structured-concurrency habits transfer either way.

What you learned

  • A bounded queue alone doesn’t bound memory: a fixed pool of workers pulling lazily from a shared iterator keeps the backlog to workers + queue_size + 1 and never materialises the input stream, whatever the input count.
  • Measure the backlog before the hand-off, or the number flatters you.
  • Supervise the consumer inside the same TaskGroup, so its failure cancels the producers instead of hanging them.
  • Where you put the try decides whether a failure is local or fatal.
  • Cleanup must survive cancellation, and only a cancellation test proves it.

Tomorrow: back to the ORM, with a profiler. The identity map from Day 1 has been quietly growing all week, and Day 6 measures it.

Last updated on September 19, 2026

Was this article helpful?

Your response is saved on this device.