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 poolPython
import asyncioasync 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 peakasync 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 peakasync 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 oneTaskGroup, 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.pyPython"""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 resultsasync 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”.
ReproducePython"""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 5Python"""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)
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.
Your progress is saved in this browser.
Sign in
to keep it on every device.