Async Generators¶
AsyncPlan[T] describes one eventual Result[T, ErrInfo]. FuncPipe indexing
does not produce only one value: a document source can produce many documents,
each document can produce many chunks, and failures can appear between valid
items.
Module 08 therefore needs a second description:
An AsyncGen is a factory for a fresh asynchronous result stream.
Start from the application pressure¶
The synchronous Module 07 route already streams:
Replacing that route with async def build_index() -> list[Chunk] would
materialize every result and lose demand-driven consumption. Module 08 instead
preserves the stream shape while allowing selected stages to wait.
flowchart LR
docs["AsyncGen[RawDoc]"]
expand["async_gen_and_then\none doc → many chunks"]
chunks["AsyncGen[ChunkWithoutEmbedding]"]
schedule["bounded async embedding"]
embedded["AsyncGen[Chunk]"]
docs --> expand --> chunks --> schedule --> embedded
The stream is asynchronous because pulling an item may wait. The individual
items still use Result, so expected failures remain visible data.
Distinguish a stream factory from an iterator¶
This is an async generator function:
The function numbers can create a fresh iterator each time. The value
numbers() is one iterator with mutable traversal state.
FuncPipe stores the factory:
This has the same replayability pattern as AsyncPlan:
| Description | One execution |
|---|---|
AsyncPlan[T] |
Awaitable[Result[T, ErrInfo]] |
AsyncGen[T] |
AsyncIterator[Result[T, ErrInfo]] |
Do not cache and reuse the iterator returned by source(). Ask the description
for a fresh one.
Run a demand-driven example¶
The following example mirrors the tracked Module 08 proof:
import asyncio
from collections.abc import AsyncIterator
from funcpipe_rag.domain.effects.async_ import AsyncGen, async_gen_map
from funcpipe_rag.result.types import ErrInfo, Ok, Result
pulled: list[int] = []
async def source_items() -> AsyncIterator[Result[int, ErrInfo]]:
for value in (1, 2, 3):
pulled.append(value)
yield Ok(value)
source: AsyncGen[int] = source_items
doubled = async_gen_map(source, lambda value: value * 2)
print(pulled)
async def consume_prefix():
iterator = doubled()
first = await anext(iterator)
second = await anext(iterator)
await iterator.aclose()
return first, second
print(asyncio.run(consume_prefix()))
print(pulled)
Expected output:
Mapping the stream did not pull an item. Consuming two results did not pull the third. This is observable laziness, not merely a claim that async generators “are efficient.”
Trace one pull¶
For await anext(doubled()), the control flow is:
consumer requests one result
└── mapped stream requests one source result
└── source resumes until its next yield
└── Ok(1) returns to async_gen_map
└── map applies value * 2
└── consumer receives Ok(2)
No background producer exists. Demand moves upstream from the consumer.
This distinction becomes important in the next core: bounded mapping
intentionally introduces tasks, while ordinary async_gen_map does not.
Preserve failures during transformation¶
async_gen_map delegates to Result.map:
Therefore:
The transform is not called for failed items. A learner should prove that with a call log instead of relying on intuition.
async_gen_and_then supports one-to-many expansion:
For each Ok(value), the returned inner stream is consumed. An outer Err
passes through without calling expand.
Read the shipped FuncPipe composition¶
async_rag_chunks uses async_gen_and_then to prepare chunks:
def prepare_document(doc: RawDoc) -> AsyncGen[ChunkWithoutEmbedding]:
async def generate():
try:
if keep is not None and not keep(doc):
return
cleaned = cleaner(doc)
for chunk in gen_chunk_doc(cleaned, env):
yield Ok(chunk)
except (TypeError, ValueError) as exc:
yield Err(ErrInfo.from_exception(exc))
return generate
prepared = async_gen_and_then(docs, prepare_document)
This code makes four judgments explicit:
- each successful document may yield zero or more chunks;
- filtering, cleaning, and chunking remain synchronous domain work;
- document validation failures become one stream item; and
- a failed source item never enters document preparation.
The inner async generator contains no await. That is acceptable: it adapts a
demand-driven synchronous expansion into the AsyncGen shape needed by the
surrounding application. It does not pretend that cleaning is asynchronous.
Partial consumption is an application behavior¶
Suppose the consumer stops after the first embedded chunk. A correct design should not silently continue embedding the whole corpus.
For ordinary async generators, call aclose() when manually consuming a
prefix:
An async for loop normally handles iterator shutdown when its own generator
frame exits, but adapters that acquire resources must still place cleanup in
finally or use async_gen_using.
The Module 08 proof
test_service_stream_releases_resource_after_partial_consumption records:
That trace is stronger than asserting only the returned value. It proves the resource boundary closes after a partial pull.
Counterexamples¶
Eager materialization¶
This can be correct at a deliberate boundary, but it is not streaming. It couples memory use and first-result latency to total input size.
Hidden task creation¶
async def mapped(source, transform):
async for item in source():
yield await asyncio.create_task(transform(item))
Creating one task and immediately awaiting it adds scheduling machinery without concurrency. Creating all tasks up front is worse: it discards backpressure.
Reusing an iterator¶
The first traversal consumes state needed by later traversals. Keep the factory, not the iterator.
Swallowing stream failures¶
Dropping failures may be a boundary policy, but it must not be hidden inside a general transform. The caller otherwise cannot distinguish “no item” from “item failed.”
Compare the proof to the claim¶
Run:
Then focus on:
test_async_stream_pulls_only_what_the_consumer_requests
test_service_stream_releases_resource_after_partial_consumption
test_async_stream_map_preserves_function_composition
test_async_rag_chunks_keeps_failures_in_the_application_stream
Together these tests establish:
- demand-driven pulls;
- cleanup after partial consumption;
- map composition over replayable streams; and
- application-level continuation across typed failures.
They do not establish a concurrency bound. Ordinary AsyncGen composition
creates no scheduling policy. Continue to Backpressure for
the point where tasks and bounded windows enter the design.
Move-forward criteria¶
You are ready for the next core when you can:
- expand the
AsyncGenalias without guessing; - distinguish a stream factory from one iterator;
- explain why mapping is lazy;
- show how
Errbypasses a successful transform; - close a partially consumed iterator;
- identify where
async_rag_chunksexpands documents into chunks; and - state why none of those behaviors alone bounds concurrent embedding.