Bulk and streaming producers¶
process(item) is convenient when concurrent callers already exist, such as HTTP handlers.
For a list, generator, or async source, map() manages a bounded group of requests:
import asyncio
from contextlib import aclosing
from async_batcher import AsyncBatcher
class DoubleBatcher(AsyncBatcher[int, int]):
async def process_batch(self, batch: list[int]) -> list[int]:
return [item * 2 for item in batch]
async def main():
batcher = DoubleBatcher(max_batch_size=32, max_queue_size=64)
try:
async with aclosing(batcher.map(range(1000), max_pending=128)) as results:
async for result in results:
print(result)
if result >= 20:
break
finally:
await batcher.stop()
asyncio.run(main())
Consumption and result order¶
batcher.map(source, *, max_pending=128, ordered=True) returns an async iterator.
The source may be an Iterable or AsyncIterable; it is consumed only after iteration starts.
max_pending must be a positive integer. It bounds items consumed from this source whose
results have not yet been yielded, including items waiting for queue space and completed
results waiting behind an earlier result. A slow first result or a paused consumer therefore
cannot cause unbounded prefetching. Other users of the batcher have their own independent work.
The default yields in input order. ordered=False yields in completion order; simultaneous
completions have no guaranteed relative order. A slow async source does not delay results
that are already available. Synchronous iterators run on the event-loop thread, so their
__next__ methods must not perform blocking I/O.
Map uses waiting queue admission, so a bounded queue pauses its submissions instead of raising
QueueFullException. max_pending bounds source consumption; max_queue_size bounds the
batcher's shared queue; concurrency bounds active batches. They control different resources.
Choose a pending window large enough to fill the intended batch. A smaller window still works,
but partial batches may wait for the collection delay before dispatching.
Failures, cancellation, and ownership¶
Iteration is fail-fast: a source error or any request error is raised on the next iteration
step as soon as it is observed, even if an earlier ordered result is still pending. This can
omit successful results already buffered. A per-request CancelledError cancels iteration.
Use map_outcomes() when ordinary item failures should be returned alongside successes.
When iteration finishes, fails, is cancelled during an iteration step, or is explicitly closed,
map cancels and joins its own feeder/request tasks and closes the source iterator if it exposes
close() or aclose(). It does not stop the shared batcher or cancel unrelated callers.
The source iterator belongs to that map operation; do not consume or close it concurrently.
Source cleanup must cooperate with cancellation; a source that refuses to finish cleanup can
delay closing the map.
A plain break does not guarantee immediate async-generator cleanup. Use
contextlib.aclosing(batcher.map(...)), as above, or call await results.aclose() in a finally
block, including when consumer code raises outside an iteration step. Accepted work may still
execute after request cancellation, so early closure is not a rollback of external writes.
Stop the batcher separately when all of its users are finished.
Waiting for queue capacity¶
The existing await batcher.process(item) behavior is unchanged: a full queue raises
QueueFullException immediately. To wait for capacity instead:
result = await batcher.process(item, wait_for_space=True, enqueue_timeout=0.5)
enqueue_timeout is optional, finite, and nonnegative, and requires wait_for_space=True.
It limits only time waiting for queue admission; it is not a result timeout or a batching
collection deadline. Zero accepts immediately available capacity and otherwise raises
TimeoutError. Without a timeout, admission waits until space is available or shutdown starts.
A cancelled or timed-out admission attempt never enters the queue. Shutdown wakes blocked
producers with RuntimeError("Batcher is stopping"); those attempts cannot slip into a later
restart. Once accepted, existing result/cancellation semantics apply. Waiting producers are
not included in the shared queue limit. Avoid creating an unbounded number of them: use map()
for a bounded producer. Waiting admission does not promise priority over other submissions.
Collect correlated successes and failures¶
batcher.map_outcomes(source, *, max_pending=128, ordered=True) has the same bounded consumption,
waiting admission, ordering, and source ownership as map(). It yields frozen MapOutcome[T, S]
envelopes instead of aborting on an ordinary per-item exception:
from contextlib import aclosing
async with aclosing(batcher.map_outcomes(source, max_pending=64, ordered=False)) as outcomes:
async for outcome in outcomes:
if outcome.succeeded:
print(outcome.index, outcome.item, outcome.result)
else:
print(outcome.index, outcome.item, outcome.error)
index is the zero-based source position, so duplicate inputs and out-of-order results remain
correlated. item is the original input reference; it is not copied, and callers remain responsible
for mutations. result contains the successful value and error contains the original ordinary
Exception, including an admission error. succeeded distinguishes successful None results
from failures. Exceptions may retain their normal traceback references while an outcome is held.
An ordinary item failure does not abort the stream. Source iteration/cleanup errors still raise,
and a per-request CancelledError or cancellation of the consumer still terminates iteration.
These terminal conditions can prevent delivery of buffered outcomes. Closing the stream cancels
only its own request tasks, closes its source, and leaves the shared batcher usable; accepted
backend work can still run. Use aclosing for early breaks, just as with map().
Flush an acceptance boundary¶
Use await batcher.flush(timeout=5) to dispatch a partial batch and wait for work
already accepted while keeping intake open. Admission waiters and later submissions
are not included. It waits for batch cleanup even if individual callers were cancelled;
it does not turn item failures into success. See the flush contract
for cancellation, forced shutdown, and checkpoint ownership.
GroupedBatcher.flush() captures the same boundary separately for each owned child,
including children already closing. Manager reservations awaiting group eviction or child
admission are excluded. See grouped flush
for ownership and shutdown behavior.