mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-24 08:32:24 +02:00
Merge streaming-transformer drain-on-consume redesign
# Conflicts: # libs/langgraph/langgraph/stream/run_stream.py # libs/langgraph/langgraph/stream/streaming_handler.py # libs/langgraph/langgraph/stream/transformers.py # libs/langgraph/tests/test_streaming_handler.py
This commit is contained in:
@@ -4,7 +4,7 @@ Provides a `StreamingHandler` that wraps a compiled graph and exposes
|
||||
ergonomic streaming projections through a transformer pipeline.
|
||||
"""
|
||||
|
||||
from langgraph.stream._event_log import BufferOverflowError, EventLog
|
||||
from langgraph.stream._event_log import EventLog
|
||||
from langgraph.stream._types import ProtocolEvent, StreamTransformer
|
||||
from langgraph.stream.run_stream import AsyncGraphRunStream, GraphRunStream
|
||||
from langgraph.stream.stream_channel import StreamChannel
|
||||
@@ -12,7 +12,6 @@ from langgraph.stream.streaming_handler import StreamingHandler
|
||||
|
||||
__all__ = [
|
||||
"AsyncGraphRunStream",
|
||||
"BufferOverflowError",
|
||||
"EventLog",
|
||||
"GraphRunStream",
|
||||
"ProtocolEvent",
|
||||
|
||||
@@ -2,60 +2,50 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import deque
|
||||
from collections.abc import AsyncIterator, Callable, Iterator
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class BufferOverflowError(RuntimeError):
|
||||
"""Raised when an EventLog cursor falls off the back of a bounded buffer.
|
||||
|
||||
Mirrors the `restored: false` signal from the protocol's reconnection
|
||||
story (§ 06): consumers that fall behind the retention window get an
|
||||
explicit error and can decide to rebuild from a snapshot rather than
|
||||
silently losing events.
|
||||
"""
|
||||
|
||||
|
||||
class EventLog(Generic[T]):
|
||||
"""Append-only buffer that supports multiple independent consumers.
|
||||
"""Single-consumer drainable queue for streaming events.
|
||||
|
||||
Items are popped off the front as the consumer advances — there is
|
||||
no retention beyond what's currently queued. A log accepts exactly
|
||||
one subscriber; a second `__iter__` / `__aiter__` call raises. Use
|
||||
`tee(n)` / `atee(n)` for fan-out.
|
||||
|
||||
Starts unbound — neither `__iter__` nor `__aiter__` is available
|
||||
until the StreamMux calls `_bind(is_async)`. After binding, only
|
||||
the matching iteration protocol works; the other raises `TypeError`.
|
||||
|
||||
All access is single-threaded: sync mode is caller-driven (no
|
||||
background thread), async mode runs entirely on the event loop.
|
||||
Pump wiring (set by the run stream, not by `_bind`):
|
||||
- `_request_more`: sync pump callable, returns True if a new
|
||||
event was produced.
|
||||
- `_arequest_more`: async pump coroutine factory, same contract.
|
||||
|
||||
Producer API:
|
||||
- `push(item)`: append an item, notify all waiting cursors.
|
||||
- `close()`: mark the log as done.
|
||||
- `fail(err)`: mark the log as errored.
|
||||
Memory is bounded by caller pace: both sync and async use caller-
|
||||
driven pumps, so each cursor advance produces at most one event.
|
||||
The only shape where a log can accumulate meaningfully is
|
||||
concurrent async consumers at unequal rates — a slow consumer's
|
||||
log grows while fast consumers drive the shared pump. That's the
|
||||
documented tradeoff for concurrent consumption; consume at similar
|
||||
rates or use a single consumer if memory matters.
|
||||
|
||||
Sync iteration is pull-based: when a cursor catches up it calls
|
||||
`_request_more` to drive the graph forward. Async iteration uses a
|
||||
shared `asyncio.Event` — cursors await the event when they catch
|
||||
up, and the producer sets it on each push.
|
||||
|
||||
Pass `maxlen=N` to cap memory. When the buffer is full, `push`
|
||||
drops the oldest item to make room. Cursors track an absolute
|
||||
sequence number; a cursor that falls off the back of the retention
|
||||
window raises `BufferOverflowError` on its next read.
|
||||
|
||||
New cursors start at the current head of the buffer, not at seq 0
|
||||
— they see whatever is still retained. This matches the protocol's
|
||||
reconnection semantics (§ 06: "missed events can be replayed from
|
||||
a bounded buffer").
|
||||
Lazy-subscribe: `push` is a no-op when no subscriber has registered.
|
||||
Transformers still execute `process()` (so scalar state like
|
||||
`ValuesTransformer._latest` stays current); only the log append is
|
||||
skipped.
|
||||
"""
|
||||
|
||||
def __init__(self, maxlen: int | None = None) -> None:
|
||||
"""Initialize an empty, unbound log.
|
||||
|
||||
Args:
|
||||
maxlen: Optional cap on retained items. When reached, the
|
||||
oldest item is dropped on each new push. `None` (the
|
||||
default) leaves the log unbounded.
|
||||
maxlen: Accepted for forward compatibility; currently unused.
|
||||
The caller-driven pump bounds memory naturally for
|
||||
single-consumer use.
|
||||
|
||||
Raises:
|
||||
ValueError: If `maxlen` is not a positive integer or `None`.
|
||||
@@ -64,18 +54,19 @@ class EventLog(Generic[T]):
|
||||
raise ValueError("EventLog maxlen must be a positive int or None")
|
||||
self._items: deque[T] = deque()
|
||||
self._maxlen: int | None = maxlen
|
||||
self._first_seq = 0 # absolute seq of _items[0]
|
||||
self._closed = False
|
||||
self._error: BaseException | None = None
|
||||
|
||||
# Binding state — None means unbound.
|
||||
self._is_async: bool | None = None
|
||||
|
||||
# Sync pull callback (set by the run stream, not by bind).
|
||||
self._request_more: Callable[[], bool] | None = None
|
||||
# Flipped on first __iter__ / __aiter__. Pre-subscription
|
||||
# pushes are silent no-ops.
|
||||
self._subscribed = False
|
||||
|
||||
# Async notification (allocated on bind).
|
||||
self._event: asyncio.Event | None = None
|
||||
# Pump wiring set by the run stream after bind.
|
||||
self._request_more: Callable[[], bool] | None = None
|
||||
self._arequest_more: Callable[[], Awaitable[bool]] | None = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Binding
|
||||
@@ -96,75 +87,50 @@ class EventLog(Generic[T]):
|
||||
if self._is_async is not None:
|
||||
raise RuntimeError("EventLog is already bound")
|
||||
self._is_async = is_async
|
||||
if is_async:
|
||||
self._event = asyncio.Event()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Producer API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def push(self, item: T) -> None:
|
||||
"""Append an item and wake all waiting cursors.
|
||||
"""Append an item. No-op when no subscriber is registered.
|
||||
|
||||
In bounded mode, evicts the oldest item first if the buffer is
|
||||
full, advancing `_first_seq` so cursors can detect that they've
|
||||
fallen off the back of the retention window.
|
||||
|
||||
Args:
|
||||
item: The item to append.
|
||||
Non-blocking in both sync and async — matches v1's
|
||||
`put_nowait` producer shape. Memory is bounded by caller pace
|
||||
via the caller-driven pump.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the log is closed.
|
||||
RuntimeError: If the log is closed (and subscribed).
|
||||
"""
|
||||
if not self._subscribed:
|
||||
return
|
||||
if self._closed:
|
||||
raise RuntimeError("Cannot push to a closed EventLog")
|
||||
if self._maxlen is not None and len(self._items) >= self._maxlen:
|
||||
self._items.popleft()
|
||||
self._first_seq += 1
|
||||
self._items.append(item)
|
||||
self._notify()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Mark the log as complete.
|
||||
|
||||
Open cursors will finish cleanly once they drain the buffer.
|
||||
"""
|
||||
"""Mark the log as complete."""
|
||||
self._closed = True
|
||||
self._notify()
|
||||
|
||||
def fail(self, err: BaseException) -> None:
|
||||
"""Mark the log as errored.
|
||||
|
||||
Open cursors will raise `err` once they drain the buffer.
|
||||
|
||||
Args:
|
||||
err: The exception to surface to consumers.
|
||||
err: The exception to surface to the subscriber.
|
||||
"""
|
||||
self._error = err
|
||||
self._closed = True
|
||||
self._notify()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Notification
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _notify(self) -> None:
|
||||
"""Wake async cursors waiting for data.
|
||||
|
||||
No-op when sync-bound (no event exists).
|
||||
"""
|
||||
if self._event is not None:
|
||||
self._event.set()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sync iteration (pull-based)
|
||||
# Sync iteration (caller-driven pump)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def __iter__(self) -> Iterator[T]:
|
||||
"""Return a new independent sync cursor over the log.
|
||||
"""Subscribe and return a sync cursor. Can be called only once.
|
||||
|
||||
Raises:
|
||||
TypeError: If the log is unbound or bound to async mode.
|
||||
RuntimeError: If the log already has a subscriber.
|
||||
"""
|
||||
if self._is_async is None:
|
||||
raise TypeError(
|
||||
@@ -175,49 +141,38 @@ class EventLog(Generic[T]):
|
||||
raise TypeError(
|
||||
"This EventLog is bound to async mode — use 'async for' instead."
|
||||
)
|
||||
if self._subscribed:
|
||||
raise RuntimeError(
|
||||
"EventLog already has a subscriber; use .tee(n) for fan-out."
|
||||
)
|
||||
self._subscribed = True
|
||||
return self._sync_cursor()
|
||||
|
||||
def _sync_cursor(self) -> Iterator[T]:
|
||||
# Start at the current head — if maxlen is None this is 0 (seen everything),
|
||||
# if bounded this is wherever retention currently begins.
|
||||
seq = self._first_seq
|
||||
while True:
|
||||
if seq < self._first_seq:
|
||||
raise BufferOverflowError(
|
||||
f"Cursor fell {self._first_seq - seq} items behind the "
|
||||
f"bounded EventLog's retention window (maxlen={self._maxlen})"
|
||||
)
|
||||
idx = seq - self._first_seq
|
||||
if idx < len(self._items):
|
||||
item = self._items[idx]
|
||||
seq += 1
|
||||
yield item
|
||||
if self._items:
|
||||
yield self._items.popleft()
|
||||
elif self._closed:
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return
|
||||
elif self._request_more is not None:
|
||||
# Pull from the producer until this log gets a new item
|
||||
# or the graph is exhausted (which closes the log). A push
|
||||
# may evict the item this cursor was about to read; in that
|
||||
# case the inner loop breaks and the outer `seq < _first_seq`
|
||||
# check catches the overflow on the next iteration.
|
||||
while (seq - self._first_seq) >= len(self._items) and not self._closed:
|
||||
if not self._request_more():
|
||||
break
|
||||
if not self._request_more():
|
||||
if not self._items and not self._closed:
|
||||
return
|
||||
else:
|
||||
# No producer callback and not closed — buffer is complete.
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Async iteration
|
||||
# Async iteration (caller-driven pump)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def __aiter__(self) -> AsyncIterator[T]:
|
||||
"""Return a new independent async cursor over the log.
|
||||
"""Subscribe and return an async cursor. Can be called only once.
|
||||
|
||||
Raises:
|
||||
TypeError: If the log is unbound or bound to sync mode.
|
||||
RuntimeError: If the log already has a subscriber.
|
||||
"""
|
||||
if self._is_async is None:
|
||||
raise TypeError(
|
||||
@@ -226,26 +181,126 @@ class EventLog(Generic[T]):
|
||||
)
|
||||
if not self._is_async:
|
||||
raise TypeError("This EventLog is bound to sync mode — use 'for' instead.")
|
||||
if self._subscribed:
|
||||
raise RuntimeError(
|
||||
"EventLog already has a subscriber; use .atee(n) for fan-out."
|
||||
)
|
||||
self._subscribed = True
|
||||
return self._async_cursor()
|
||||
|
||||
async def _async_cursor(self) -> AsyncIterator[T]:
|
||||
assert self._event is not None
|
||||
seq = self._first_seq
|
||||
while True:
|
||||
if seq < self._first_seq:
|
||||
raise BufferOverflowError(
|
||||
f"Cursor fell {self._first_seq - seq} items behind the "
|
||||
f"bounded EventLog's retention window (maxlen={self._maxlen})"
|
||||
)
|
||||
idx = seq - self._first_seq
|
||||
if idx < len(self._items):
|
||||
yield self._items[idx]
|
||||
seq += 1
|
||||
if self._items:
|
||||
yield self._items.popleft()
|
||||
elif self._closed:
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return
|
||||
elif self._arequest_more is not None:
|
||||
if not await self._arequest_more():
|
||||
if not self._items and not self._closed:
|
||||
return
|
||||
else:
|
||||
self._event.clear()
|
||||
if (seq - self._first_seq) >= len(self._items) and not self._closed:
|
||||
await self._event.wait()
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fan-out via tee
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def tee(self, n: int = 2) -> tuple[Iterator[T], ...]:
|
||||
"""Subscribe and return `n` independent sync iterators.
|
||||
|
||||
Each branch has its own buffer; items pulled from the
|
||||
underlying cursor are copied into every branch. Branches are
|
||||
naturally bounded by caller pace since the sync pump is
|
||||
caller-driven.
|
||||
|
||||
Args:
|
||||
n: Number of branches to create. Must be >= 1.
|
||||
|
||||
Returns:
|
||||
A tuple of `n` iterators over the same underlying stream.
|
||||
|
||||
Raises:
|
||||
TypeError: If the log is unbound or bound to async mode.
|
||||
RuntimeError: If the log already has a subscriber.
|
||||
ValueError: If `n` < 1.
|
||||
"""
|
||||
if n < 1:
|
||||
raise ValueError("tee() requires n >= 1")
|
||||
source = self.__iter__()
|
||||
buffers: list[deque[T]] = [deque() for _ in range(n)]
|
||||
exhausted = [False]
|
||||
|
||||
def branch(i: int) -> Iterator[T]:
|
||||
buf = buffers[i]
|
||||
while True:
|
||||
if buf:
|
||||
yield buf.popleft()
|
||||
elif exhausted[0]:
|
||||
return
|
||||
else:
|
||||
try:
|
||||
item = next(source)
|
||||
except StopIteration:
|
||||
exhausted[0] = True
|
||||
return
|
||||
for b in buffers:
|
||||
b.append(item)
|
||||
|
||||
return tuple(branch(i) for i in range(n))
|
||||
|
||||
def atee(self, n: int = 2) -> tuple[AsyncIterator[T], ...]:
|
||||
"""Subscribe and return `n` independent async iterators.
|
||||
|
||||
Caller-driven fan-out: each branch's `__anext__` either pops
|
||||
from its own buffer or, under a shared `asyncio.Lock`, pulls
|
||||
one item from the underlying cursor and distributes it to
|
||||
every branch's buffer.
|
||||
|
||||
Args:
|
||||
n: Number of branches to create. Must be >= 1.
|
||||
|
||||
Returns:
|
||||
A tuple of `n` async iterators over the same underlying
|
||||
stream.
|
||||
|
||||
Raises:
|
||||
TypeError: If the log is unbound or bound to sync mode.
|
||||
RuntimeError: If the log already has a subscriber.
|
||||
ValueError: If `n` < 1.
|
||||
"""
|
||||
if n < 1:
|
||||
raise ValueError("atee() requires n >= 1")
|
||||
source = self.__aiter__()
|
||||
buffers: list[deque[T]] = [deque() for _ in range(n)]
|
||||
exhausted = [False]
|
||||
error: list[BaseException | None] = [None]
|
||||
lock = asyncio.Lock()
|
||||
|
||||
async def branch(i: int) -> AsyncIterator[T]:
|
||||
buf = buffers[i]
|
||||
while True:
|
||||
if buf:
|
||||
yield buf.popleft()
|
||||
continue
|
||||
if exhausted[0]:
|
||||
if error[0] is not None:
|
||||
raise error[0]
|
||||
return
|
||||
async with lock:
|
||||
if buf or exhausted[0]:
|
||||
continue
|
||||
try:
|
||||
item = await source.__anext__()
|
||||
except StopAsyncIteration:
|
||||
exhausted[0] = True
|
||||
continue
|
||||
except Exception as e:
|
||||
error[0] = e
|
||||
exhausted[0] = True
|
||||
continue
|
||||
for b in buffers:
|
||||
b.append(item)
|
||||
|
||||
return tuple(branch(i) for i in range(n))
|
||||
|
||||
@@ -40,7 +40,6 @@ class StreamMux:
|
||||
transformers: list[StreamTransformer] | None = None,
|
||||
*,
|
||||
is_async: bool = False,
|
||||
max_events: int | None = None,
|
||||
) -> None:
|
||||
"""Initialize the mux and register transformers in order.
|
||||
|
||||
@@ -55,11 +54,6 @@ class StreamMux:
|
||||
`None` or empty gives a mux with no projections.
|
||||
is_async: True for async dispatch (`apush` / `aclose` /
|
||||
`afail`), False for the sync path.
|
||||
max_events: Default capacity for every EventLog and
|
||||
StreamChannel the mux binds, including the main event
|
||||
log. Logs constructed with an explicit `maxlen` keep
|
||||
their own setting — the mux only fills in unset
|
||||
defaults. `None` leaves the logs unbounded.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If any transformer requires an async run but
|
||||
@@ -68,8 +62,7 @@ class StreamMux:
|
||||
ValueError: If transformers' projection keys collide.
|
||||
"""
|
||||
self._is_async = is_async
|
||||
self._default_maxlen = max_events
|
||||
self._events: EventLog[ProtocolEvent] = EventLog(maxlen=max_events)
|
||||
self._events: EventLog[ProtocolEvent] = EventLog()
|
||||
self._events._bind(is_async=is_async)
|
||||
self._transformers: list[StreamTransformer] = []
|
||||
self._channels: list[StreamChannel[Any]] = []
|
||||
@@ -218,6 +211,10 @@ class StreamMux:
|
||||
async work. For decoupled work, use `schedule()` from inside
|
||||
`process` / `aprocess` instead.
|
||||
|
||||
The main log append is a non-blocking `push` — matching v1's
|
||||
`put_nowait` shape. Memory is bounded by caller pace via the
|
||||
caller-driven pump; see `EventLog` for the full tradeoff story.
|
||||
|
||||
Args:
|
||||
event: The protocol event to dispatch.
|
||||
"""
|
||||
@@ -324,7 +321,6 @@ class StreamMux:
|
||||
"""Bind and wire EventLog / StreamChannel instances in a projection."""
|
||||
for value in projection.values():
|
||||
if isinstance(value, StreamChannel):
|
||||
self._apply_default_maxlen(value._log)
|
||||
value._bind(is_async=self._is_async)
|
||||
self._channels.append(value)
|
||||
channel_name = value.name
|
||||
@@ -337,15 +333,9 @@ class StreamMux:
|
||||
|
||||
value._wire(_make_forward(channel_name))
|
||||
elif isinstance(value, EventLog):
|
||||
self._apply_default_maxlen(value)
|
||||
value._bind(is_async=self._is_async)
|
||||
self._logs.append(value)
|
||||
|
||||
def _apply_default_maxlen(self, log: EventLog[Any]) -> None:
|
||||
"""Fill in the mux's default maxlen when the log hasn't set its own."""
|
||||
if log._maxlen is None and self._default_maxlen is not None:
|
||||
log._maxlen = self._default_maxlen
|
||||
|
||||
def _forward(self, channel_name: str, item: Any) -> None:
|
||||
"""Inject a ProtocolEvent for a StreamChannel push.
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Iterator, Mapping
|
||||
from types import MappingProxyType
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping
|
||||
from types import MappingProxyType, TracebackType
|
||||
from typing import Any
|
||||
|
||||
from langgraph.stream._convert import convert_to_protocol_event
|
||||
@@ -13,20 +13,31 @@ from langgraph.stream.stream_channel import StreamChannel
|
||||
from langgraph.stream.transformers import ValuesTransformer
|
||||
|
||||
|
||||
def _drive_until_done(pump: Callable[[], bool]) -> None:
|
||||
"""Call the sync pump until it returns False."""
|
||||
while pump():
|
||||
pass
|
||||
|
||||
|
||||
async def _adrive_until_done(pump: Callable[[], Awaitable[bool]]) -> None:
|
||||
"""Call the async pump until it returns False."""
|
||||
while await pump():
|
||||
pass
|
||||
|
||||
|
||||
class GraphRunStream:
|
||||
"""Sync run stream with caller-driven pumping.
|
||||
|
||||
The caller's iteration on any projection (`values`, `messages`,
|
||||
raw events, or `output`) drives the graph forward. No background
|
||||
thread is used — this matches v1's model where the caller's `for`
|
||||
loop is the pump.
|
||||
thread is used — the caller's `for` loop is the pump.
|
||||
|
||||
Projections are single-consumer — iterating `run.values` twice
|
||||
raises. Use `projection.tee(n)` if you genuinely need fan-out.
|
||||
|
||||
All transformer projections live in `extensions`. Native transformer
|
||||
projections (those with `_native = True`) are also set as direct
|
||||
attributes on this instance (e.g. `run.values`, `run.messages`).
|
||||
|
||||
Iterating the run stream directly yields raw `ProtocolEvent` objects
|
||||
from the mux's main event log.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -48,23 +59,18 @@ class GraphRunStream:
|
||||
self.extensions: Mapping[str, Any] = MappingProxyType(mux.extensions)
|
||||
self._values_transformer = values_transformer
|
||||
self._exhausted = False
|
||||
# Native-transformer projections also show up as direct attributes.
|
||||
for key in mux.native_keys:
|
||||
setattr(self, key, mux.extensions[key])
|
||||
# Wire pull-based iteration: every sync EventLog calls _pump_next
|
||||
# when its cursor catches up to the buffer.
|
||||
self._wire_request_more(mux)
|
||||
|
||||
def _wire_request_more(self, mux: StreamMux) -> None:
|
||||
"""Install `_request_more` on every sync EventLog.
|
||||
|
||||
Sync iteration is caller-driven, so a cursor that catches up to
|
||||
the buffer's tail needs a way to ask the graph for more events.
|
||||
"""Install `_request_more` on every sync EventLog so cursors can
|
||||
drive the pump when their buffer catches up.
|
||||
|
||||
Also calls `_bind_pump` on any transformer that exposes it, so
|
||||
that transformers producing ChatModelStream objects (e.g.
|
||||
MessagesTransformer) can wire the pull callback on each stream as
|
||||
it's created.
|
||||
transformers producing ChatModelStream objects (e.g.
|
||||
MessagesTransformer) can wire the pull callback on each stream
|
||||
as it's created.
|
||||
"""
|
||||
mux._events._request_more = self._pump_next
|
||||
for value in mux.extensions.values():
|
||||
@@ -91,22 +97,43 @@ class GraphRunStream:
|
||||
self._mux.close()
|
||||
self._exhausted = True
|
||||
return False
|
||||
except BaseException as e:
|
||||
except Exception as e:
|
||||
self._mux.fail(e)
|
||||
self._exhausted = True
|
||||
return False
|
||||
self._mux.push(convert_to_protocol_event(part))
|
||||
return True
|
||||
|
||||
def _pump_all(self) -> None:
|
||||
"""Drain the graph completely."""
|
||||
while self._pump_next():
|
||||
def abort(self) -> None:
|
||||
"""Stop the run early.
|
||||
|
||||
Closes the mux and marks the stream exhausted. The graph
|
||||
iterator is dropped; any in-flight nodes see the closure on
|
||||
their next yield point. Idempotent.
|
||||
"""
|
||||
if self._exhausted:
|
||||
return
|
||||
self._exhausted = True
|
||||
try:
|
||||
self._mux.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __enter__(self) -> GraphRunStream:
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> None:
|
||||
self.abort()
|
||||
|
||||
@property
|
||||
def output(self) -> dict[str, Any] | None:
|
||||
"""Block until the run completes and return the final state."""
|
||||
self._pump_all()
|
||||
"""Drive the run to completion and return the final state."""
|
||||
_drive_until_done(self._pump_next)
|
||||
err = self._values_transformer.error
|
||||
if err is not None:
|
||||
raise err
|
||||
@@ -114,12 +141,13 @@ class GraphRunStream:
|
||||
|
||||
@property
|
||||
def interrupted(self) -> bool:
|
||||
"""Block until the run completes, then return whether it was interrupted.
|
||||
"""Drive the run to completion, then return whether it was
|
||||
interrupted.
|
||||
|
||||
Raises:
|
||||
BaseException: If the run ended with an error.
|
||||
"""
|
||||
self._pump_all()
|
||||
_drive_until_done(self._pump_next)
|
||||
err = self._values_transformer.error
|
||||
if err is not None:
|
||||
raise err
|
||||
@@ -127,70 +155,193 @@ class GraphRunStream:
|
||||
|
||||
@property
|
||||
def interrupts(self) -> list[Any]:
|
||||
"""Block until the run completes, then return interrupt payloads.
|
||||
"""Drive the run to completion, then return interrupt payloads.
|
||||
|
||||
Raises:
|
||||
BaseException: If the run ended with an error.
|
||||
"""
|
||||
self._pump_all()
|
||||
_drive_until_done(self._pump_next)
|
||||
err = self._values_transformer.error
|
||||
if err is not None:
|
||||
raise err
|
||||
return self._values_transformer._interrupts
|
||||
|
||||
def __iter__(self) -> Iterator[ProtocolEvent]:
|
||||
"""Iterate all protocol events from the mux's main event log."""
|
||||
"""Subscribe to the main event log and iterate protocol events."""
|
||||
return iter(self._mux._events)
|
||||
|
||||
def interleave(self, *names: str) -> Iterator[tuple[str, Any]]:
|
||||
"""Iterate multiple projections round-robin, yielding ``(name, item)``.
|
||||
|
||||
Each turn advances one projection's cursor; when a cursor's buffer
|
||||
is empty, pulling from it drives the pump once, which fans out to
|
||||
every subscribed projection log. Projections whose items aren't
|
||||
consumed on this turn sit in their own buffers only until the next
|
||||
turn reaches them, bounding memory by the skew between projection
|
||||
rates rather than letting any single log grow to the full run
|
||||
length.
|
||||
|
||||
Projections are exhausted independently; a projection that finishes
|
||||
early drops out of the rotation while others continue. The overall
|
||||
iterator ends once all named projections are done.
|
||||
|
||||
Args:
|
||||
*names: Projection keys to interleave. Must match keys in
|
||||
``extensions``.
|
||||
|
||||
Yields:
|
||||
``(name, item)`` tuples in round-robin order across the named
|
||||
projections.
|
||||
|
||||
Raises:
|
||||
KeyError: If a name doesn't match a registered projection.
|
||||
|
||||
Example:
|
||||
```python
|
||||
for name, item in run.interleave("messages", "values"):
|
||||
if name == "messages":
|
||||
print("msg:", item)
|
||||
else:
|
||||
print("val:", item)
|
||||
```
|
||||
"""
|
||||
cursors: dict[str, Iterator[Any]] = {
|
||||
name: iter(self.extensions[name]) for name in names
|
||||
}
|
||||
done: set[str] = set()
|
||||
while len(done) < len(cursors):
|
||||
for name, cursor in cursors.items():
|
||||
if name in done:
|
||||
continue
|
||||
try:
|
||||
item = next(cursor)
|
||||
except StopIteration:
|
||||
done.add(name)
|
||||
continue
|
||||
yield (name, item)
|
||||
|
||||
|
||||
class AsyncGraphRunStream:
|
||||
"""Async run stream with transformer-driven projections.
|
||||
"""Async run stream with caller-driven pumping.
|
||||
|
||||
A background asyncio task pumps events from the graph into the
|
||||
transformer pipeline. This is the standard async pattern — the task
|
||||
runs on the same event loop and async consumers can iterate
|
||||
multiple projections concurrently.
|
||||
Async iteration on any projection drives the graph forward — there
|
||||
is no background task. Concurrent consumers share a single-flight
|
||||
pump via an `asyncio.Lock`, so each awaiting cursor contributes one
|
||||
event per acquisition. Backpressure comes from the logs: when a
|
||||
subscribed log's buffer reaches `maxlen`, `apush` awaits the
|
||||
subscriber to drain, which holds back the pump and paces the graph.
|
||||
|
||||
All transformer projections live in `extensions`. Native transformer
|
||||
projections (those with `_native = True`) are also set as direct
|
||||
attributes on this instance (e.g. `run.values`, `run.messages`).
|
||||
Projections are single-consumer — a second `aiter(run.values)`
|
||||
raises. Use `projection.tee(n)` for fan-out.
|
||||
|
||||
Async-iterating the run stream yields raw `ProtocolEvent` objects
|
||||
from the mux's main event log.
|
||||
Use as an async context manager to guarantee clean shutdown on
|
||||
early exit:
|
||||
|
||||
```python
|
||||
async with await handler.astream(input) as run:
|
||||
async for msg in run.messages:
|
||||
...
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
graph_aiter: AsyncIterator[Any],
|
||||
mux: StreamMux,
|
||||
values_transformer: ValuesTransformer,
|
||||
pump_task: asyncio.Task[None],
|
||||
) -> None:
|
||||
"""Initialize the async run stream.
|
||||
|
||||
Args:
|
||||
graph_aiter: Async iterator over the graph's stream.
|
||||
mux: The StreamMux owning projections and the main log.
|
||||
values_transformer: The built-in values transformer
|
||||
providing `output` / `interrupted` / `interrupts`.
|
||||
pump_task: Background task pumping graph events into the mux.
|
||||
"""
|
||||
self._graph_aiter = graph_aiter
|
||||
self._mux = mux
|
||||
self.extensions: Mapping[str, Any] = MappingProxyType(mux.extensions)
|
||||
self._values_transformer = values_transformer
|
||||
self._pump_task = pump_task
|
||||
# Native-transformer projections also show up as direct attributes.
|
||||
self._exhausted = False
|
||||
self._pump_lock = asyncio.Lock()
|
||||
for key in mux.native_keys:
|
||||
setattr(self, key, mux.extensions[key])
|
||||
self._wire_arequest_more(mux)
|
||||
|
||||
def _wire_arequest_more(self, mux: StreamMux) -> None:
|
||||
"""Install `_arequest_more` on every async EventLog so cursors
|
||||
can drive the pump when their buffer catches up."""
|
||||
mux._events._arequest_more = self._apump_next
|
||||
for value in mux.extensions.values():
|
||||
if isinstance(value, EventLog):
|
||||
value._arequest_more = self._apump_next
|
||||
elif isinstance(value, StreamChannel):
|
||||
value._log._arequest_more = self._apump_next
|
||||
|
||||
async def _apump_next(self) -> bool:
|
||||
"""Pull one event from the graph and push it through the mux.
|
||||
|
||||
Serialized via `self._pump_lock` so concurrent cursors each
|
||||
produce one event per acquisition rather than racing on the
|
||||
graph iterator.
|
||||
|
||||
`except Exception` is intentional — `CancelledError` and other
|
||||
`BaseException` subclasses propagate, matching asyncio's
|
||||
cancellation contract.
|
||||
|
||||
Returns:
|
||||
True if an event was pulled, False if the graph is
|
||||
exhausted or has raised.
|
||||
"""
|
||||
async with self._pump_lock:
|
||||
if self._exhausted:
|
||||
return False
|
||||
try:
|
||||
part = await self._graph_aiter.__anext__()
|
||||
except StopAsyncIteration:
|
||||
self._exhausted = True
|
||||
await self._mux.aclose()
|
||||
return False
|
||||
except Exception as e:
|
||||
self._exhausted = True
|
||||
await self._mux.afail(e)
|
||||
return False
|
||||
await self._mux.apush(convert_to_protocol_event(part))
|
||||
return True
|
||||
|
||||
async def abort(self) -> None:
|
||||
"""Stop the run early.
|
||||
|
||||
Closes the mux and marks the stream exhausted. Any awaiting
|
||||
cursors wake up and see the closed state; any `apush` blocked
|
||||
on backpressure wakes and returns without appending. Idempotent.
|
||||
"""
|
||||
async with self._pump_lock:
|
||||
if self._exhausted:
|
||||
return
|
||||
self._exhausted = True
|
||||
try:
|
||||
await self._mux.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> AsyncGraphRunStream:
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> None:
|
||||
await self.abort()
|
||||
|
||||
async def output(self) -> dict[str, Any] | None:
|
||||
"""Wait for the run to complete and return the final state.
|
||||
"""Drive the run to completion and return the final state.
|
||||
|
||||
Methods (not properties) on the async lane so `run.output` without
|
||||
`await` raises at type-check time instead of silently yielding a
|
||||
coroutine object that's truthy, lenless, and never awaited.
|
||||
|
||||
The pump routes any Exception into `mux.afail`, which surfaces on
|
||||
`ValuesTransformer.error`. CancelledError / KeyboardInterrupt
|
||||
propagate so cancellation isn't silently dropped.
|
||||
Methods (not properties) on the async lane so `run.output`
|
||||
without `await` raises at type-check time instead of silently
|
||||
yielding a coroutine object.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@@ -200,52 +351,34 @@ class AsyncGraphRunStream:
|
||||
Raises:
|
||||
BaseException: If the run ended with an error.
|
||||
"""
|
||||
try:
|
||||
await self._pump_task
|
||||
except Exception:
|
||||
pass
|
||||
await _adrive_until_done(self._apump_next)
|
||||
if (err := self._values_transformer.error) is not None:
|
||||
raise err
|
||||
return self._values_transformer._latest
|
||||
|
||||
async def interrupted(self) -> bool:
|
||||
"""Wait for the run to complete and return whether it was interrupted.
|
||||
|
||||
Example:
|
||||
```python
|
||||
interrupted = await run.interrupted()
|
||||
```
|
||||
"""Drive the run to completion and return whether it was
|
||||
interrupted.
|
||||
|
||||
Raises:
|
||||
BaseException: If the run ended with an error.
|
||||
"""
|
||||
try:
|
||||
await self._pump_task
|
||||
except Exception:
|
||||
pass
|
||||
await _adrive_until_done(self._apump_next)
|
||||
if (err := self._values_transformer.error) is not None:
|
||||
raise err
|
||||
return self._values_transformer._interrupted
|
||||
|
||||
async def interrupts(self) -> list[Any]:
|
||||
"""Wait for the run to complete and return interrupt payloads.
|
||||
|
||||
Example:
|
||||
```python
|
||||
interrupts = await run.interrupts()
|
||||
```
|
||||
"""Drive the run to completion and return interrupt payloads.
|
||||
|
||||
Raises:
|
||||
BaseException: If the run ended with an error.
|
||||
"""
|
||||
try:
|
||||
await self._pump_task
|
||||
except Exception:
|
||||
pass
|
||||
await _adrive_until_done(self._apump_next)
|
||||
if (err := self._values_transformer.error) is not None:
|
||||
raise err
|
||||
return self._values_transformer._interrupts
|
||||
|
||||
def __aiter__(self) -> AsyncIterator[ProtocolEvent]:
|
||||
"""Iterate all protocol events from the mux's main event log."""
|
||||
"""Subscribe to the main event log and iterate protocol events."""
|
||||
return self._mux._events.__aiter__()
|
||||
|
||||
@@ -93,3 +93,17 @@ class StreamChannel(Generic[T]):
|
||||
|
||||
def __aiter__(self) -> AsyncIterator[T]:
|
||||
return self._log.__aiter__()
|
||||
|
||||
def tee(self, n: int = 2) -> tuple[Iterator[T], ...]:
|
||||
"""Fan out the channel into `n` independent sync iterators.
|
||||
|
||||
Delegates to the underlying EventLog's `tee()`.
|
||||
"""
|
||||
return self._log.tee(n)
|
||||
|
||||
def atee(self, n: int = 2) -> tuple[AsyncIterator[T], ...]:
|
||||
"""Fan out the channel into `n` independent async iterators.
|
||||
|
||||
Delegates to the underlying EventLog's `atee()`.
|
||||
"""
|
||||
return self._log.atee(n)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
@@ -8,7 +7,6 @@ from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from langgraph._internal._constants import CONF, CONFIG_KEY_STREAM_MESSAGES_V2
|
||||
from langgraph.pregel import Pregel
|
||||
from langgraph.stream._convert import convert_to_protocol_event
|
||||
from langgraph.stream._mux import StreamMux
|
||||
from langgraph.stream._types import StreamTransformer
|
||||
from langgraph.stream.run_stream import AsyncGraphRunStream, GraphRunStream
|
||||
@@ -83,7 +81,6 @@ class StreamingHandler:
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
transformers: list[StreamTransformer] | None = None,
|
||||
max_events: int | None = None,
|
||||
) -> GraphRunStream:
|
||||
"""Start a sync streaming run.
|
||||
|
||||
@@ -99,12 +96,6 @@ class StreamingHandler:
|
||||
interrupt_after: Nodes to interrupt after, if any.
|
||||
transformers: User transformers appended after the built-in
|
||||
`ValuesTransformer` and `MessagesTransformer`.
|
||||
max_events: Caps the retention of every EventLog and
|
||||
StreamChannel the mux binds (main event log plus each
|
||||
transformer's projection logs), dropping the oldest
|
||||
when full. Transformers that constructed their own
|
||||
logs with an explicit `maxlen` keep their setting.
|
||||
Unbounded when `None`.
|
||||
|
||||
Returns:
|
||||
A GraphRunStream the caller can iterate to drive the run.
|
||||
@@ -113,7 +104,6 @@ class StreamingHandler:
|
||||
mux = StreamMux(
|
||||
[values_t, MessagesTransformer(), *(transformers or ())],
|
||||
is_async=False,
|
||||
max_events=max_events,
|
||||
)
|
||||
|
||||
graph_iter = iter(
|
||||
@@ -138,12 +128,13 @@ class StreamingHandler:
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
transformers: list[StreamTransformer] | None = None,
|
||||
max_events: int | None = None,
|
||||
) -> AsyncGraphRunStream:
|
||||
"""Start an async streaming run.
|
||||
|
||||
Returns an AsyncGraphRunStream immediately. A background asyncio
|
||||
task pumps events from the graph into the transformer pipeline.
|
||||
Returns an AsyncGraphRunStream immediately. The caller's
|
||||
iteration on any projection drives the graph forward — there
|
||||
is no background task. Concurrent consumers share a
|
||||
single-flight pump via an internal `asyncio.Lock`.
|
||||
|
||||
Args:
|
||||
input: Graph input.
|
||||
@@ -152,37 +143,26 @@ class StreamingHandler:
|
||||
interrupt_after: Nodes to interrupt after, if any.
|
||||
transformers: User transformers appended after the built-in
|
||||
`ValuesTransformer` and `MessagesTransformer`.
|
||||
max_events: Caps retention of every EventLog and
|
||||
StreamChannel the mux binds — see `stream()` for the
|
||||
full semantics.
|
||||
|
||||
Returns:
|
||||
An AsyncGraphRunStream whose projections can be awaited
|
||||
concurrently while the background pump runs.
|
||||
concurrently; each subscribed cursor drives the pump when
|
||||
its buffer is empty.
|
||||
"""
|
||||
values_t = ValuesTransformer()
|
||||
mux = StreamMux(
|
||||
[values_t, MessagesTransformer(), *(transformers or ())],
|
||||
is_async=True,
|
||||
max_events=max_events,
|
||||
)
|
||||
|
||||
async def pump() -> None:
|
||||
try:
|
||||
async for part in self._graph.astream(
|
||||
input,
|
||||
_merge_v2_messages_flag(config),
|
||||
stream_mode=STREAM_V2_MODES,
|
||||
subgraphs=True,
|
||||
version="v2",
|
||||
interrupt_before=interrupt_before,
|
||||
interrupt_after=interrupt_after,
|
||||
):
|
||||
await mux.apush(convert_to_protocol_event(part))
|
||||
await mux.aclose()
|
||||
except BaseException as e:
|
||||
await mux.afail(e)
|
||||
graph_aiter = self._graph.astream(
|
||||
input,
|
||||
_merge_v2_messages_flag(config),
|
||||
stream_mode=STREAM_V2_MODES,
|
||||
subgraphs=True,
|
||||
version="v2",
|
||||
interrupt_before=interrupt_before,
|
||||
interrupt_after=interrupt_after,
|
||||
).__aiter__()
|
||||
|
||||
task = asyncio.create_task(pump())
|
||||
|
||||
return AsyncGraphRunStream(mux, values_t, task)
|
||||
return AsyncGraphRunStream(graph_aiter, mux, values_t)
|
||||
|
||||
@@ -18,7 +18,12 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class ValuesTransformer(StreamTransformer):
|
||||
"""Capture values events as an iterable of state snapshots.
|
||||
"""Capture values events as a drainable stream of state snapshots.
|
||||
|
||||
Keeps `_latest` / `_interrupted` / `_interrupts` as scalar state
|
||||
regardless of whether the log has a subscriber — so `run.output()`
|
||||
and `run.interrupted` work without forcing the caller to iterate
|
||||
`run.values`. Log pushes are silent no-ops when unsubscribed.
|
||||
|
||||
Native transformer — projection keys are exposed as direct
|
||||
attributes on the run stream (e.g. `run.values`).
|
||||
@@ -53,11 +58,11 @@ class ValuesTransformer(StreamTransformer):
|
||||
if params["namespace"]:
|
||||
return True
|
||||
self._latest = params["data"]
|
||||
self._log.push(params["data"])
|
||||
interrupts = params.get("interrupts", ())
|
||||
if interrupts:
|
||||
self._interrupted = True
|
||||
self._interrupts.extend(interrupts)
|
||||
self._log.push(params["data"])
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ from typing_extensions import TypedDict
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.stream import (
|
||||
BufferOverflowError,
|
||||
EventLog,
|
||||
StreamChannel,
|
||||
StreamingHandler,
|
||||
@@ -142,78 +141,98 @@ class TestEventLog:
|
||||
def test_sync_iteration(self) -> None:
|
||||
log: EventLog[int] = EventLog()
|
||||
log._bind(is_async=False)
|
||||
it = iter(log) # subscribe before pushing
|
||||
log.push(1)
|
||||
log.push(2)
|
||||
log.push(3)
|
||||
log.close()
|
||||
assert list(log) == [1, 2, 3]
|
||||
assert list(it) == [1, 2, 3]
|
||||
|
||||
def test_multi_cursor(self) -> None:
|
||||
def test_drain_on_consume(self) -> None:
|
||||
"""Items are popped as consumed — no retention across iterations."""
|
||||
log: EventLog[str] = EventLog()
|
||||
log._bind(is_async=False)
|
||||
it = iter(log)
|
||||
log.push("a")
|
||||
log.push("b")
|
||||
log.close()
|
||||
# Two independent cursors see all items.
|
||||
assert list(log) == ["a", "b"]
|
||||
assert list(log) == ["a", "b"]
|
||||
assert list(it) == ["a", "b"]
|
||||
# Buffer is drained.
|
||||
assert list(log._items) == []
|
||||
|
||||
def test_second_subscribe_raises(self) -> None:
|
||||
"""Only one subscriber allowed; tee() is the fan-out escape hatch."""
|
||||
log: EventLog[str] = EventLog()
|
||||
log._bind(is_async=False)
|
||||
log.close()
|
||||
_ = iter(log)
|
||||
with pytest.raises(RuntimeError, match="already has a subscriber"):
|
||||
iter(log)
|
||||
|
||||
def test_pre_subscription_push_is_noop(self) -> None:
|
||||
"""Lazy-subscribe: pushes before subscription are dropped silently."""
|
||||
log: EventLog[int] = EventLog()
|
||||
log._bind(is_async=False)
|
||||
log.push(1)
|
||||
log.push(2)
|
||||
it = iter(log)
|
||||
log.push(3)
|
||||
log.close()
|
||||
# Only the post-subscribe push survives.
|
||||
assert list(it) == [3]
|
||||
|
||||
def test_fail_propagation(self) -> None:
|
||||
log: EventLog[int] = EventLog()
|
||||
log._bind(is_async=False)
|
||||
it = iter(log)
|
||||
log.push(1)
|
||||
log.fail(ValueError("test error"))
|
||||
with pytest.raises(ValueError, match="test error"):
|
||||
list(log)
|
||||
list(it)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_iteration(self) -> None:
|
||||
log: EventLog[int] = EventLog()
|
||||
log._bind(is_async=True)
|
||||
|
||||
async def producer():
|
||||
for i in range(3):
|
||||
log.push(i)
|
||||
log.close()
|
||||
|
||||
producer_task = asyncio.create_task(producer())
|
||||
items = [item async for item in log]
|
||||
await producer_task
|
||||
cursor = aiter(log)
|
||||
for i in range(3):
|
||||
log.push(i)
|
||||
log.close()
|
||||
items = [item async for item in cursor]
|
||||
assert items == [0, 1, 2]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_multi_cursor(self) -> None:
|
||||
async def test_async_second_subscribe_raises(self) -> None:
|
||||
log: EventLog[str] = EventLog()
|
||||
log._bind(is_async=True)
|
||||
log.push("x")
|
||||
log.push("y")
|
||||
log.close()
|
||||
items1 = [item async for item in log]
|
||||
items2 = [item async for item in log]
|
||||
assert items1 == ["x", "y"]
|
||||
assert items2 == ["x", "y"]
|
||||
_ = log.__aiter__()
|
||||
with pytest.raises(RuntimeError, match="already has a subscriber"):
|
||||
log.__aiter__()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_fail(self) -> None:
|
||||
log: EventLog[int] = EventLog()
|
||||
log._bind(is_async=True)
|
||||
cursor = aiter(log)
|
||||
log.push(1)
|
||||
log.fail(RuntimeError("async error"))
|
||||
with pytest.raises(RuntimeError, match="async error"):
|
||||
async for _ in log:
|
||||
async for _ in cursor:
|
||||
pass
|
||||
|
||||
def test_sync_cursor_yields_items_before_error(self) -> None:
|
||||
"""Sync cursor should yield all buffered items before raising."""
|
||||
log: EventLog[int] = EventLog()
|
||||
log._bind(is_async=False)
|
||||
it = iter(log)
|
||||
log.push(1)
|
||||
log.push(2)
|
||||
log.push(3)
|
||||
log.fail(ValueError("late error"))
|
||||
items: list[int] = []
|
||||
with pytest.raises(ValueError, match="late error"):
|
||||
for item in log:
|
||||
for item in it:
|
||||
items.append(item)
|
||||
assert items == [1, 2, 3]
|
||||
|
||||
@@ -222,30 +241,38 @@ class TestEventLog:
|
||||
"""Async cursor should yield all buffered items before raising."""
|
||||
log: EventLog[int] = EventLog()
|
||||
log._bind(is_async=True)
|
||||
cursor = aiter(log)
|
||||
log.push(1)
|
||||
log.push(2)
|
||||
log.push(3)
|
||||
log.fail(ValueError("late error"))
|
||||
items: list[int] = []
|
||||
with pytest.raises(ValueError, match="late error"):
|
||||
async for item in log:
|
||||
async for item in cursor:
|
||||
items.append(item)
|
||||
assert items == [1, 2, 3]
|
||||
|
||||
def test_push_after_close_raises(self) -> None:
|
||||
"""Push after close should raise RuntimeError."""
|
||||
"""Push after close should raise RuntimeError (when subscribed)."""
|
||||
log: EventLog[int] = EventLog()
|
||||
log._bind(is_async=False)
|
||||
it = iter(log)
|
||||
log.push(1)
|
||||
log.close()
|
||||
with pytest.raises(RuntimeError, match="Cannot push to a closed EventLog"):
|
||||
log.push(2)
|
||||
_ = list(it)
|
||||
|
||||
def test_push_after_fail_raises(self) -> None:
|
||||
"""Fail closes the log, so push after fail should also raise."""
|
||||
"""Fail closes the log, so push after fail should also raise (when subscribed)."""
|
||||
log: EventLog[int] = EventLog()
|
||||
log._bind(is_async=False)
|
||||
it = iter(log)
|
||||
log.fail(ValueError("err"))
|
||||
with pytest.raises(RuntimeError, match="Cannot push to a closed EventLog"):
|
||||
log.push(1)
|
||||
with pytest.raises(ValueError, match="err"):
|
||||
list(it)
|
||||
|
||||
def test_empty_log_sync(self) -> None:
|
||||
"""Iterating a closed empty log should yield nothing."""
|
||||
@@ -321,31 +348,35 @@ class TestStreamChannel:
|
||||
def test_push_and_iterate(self) -> None:
|
||||
ch: StreamChannel[str] = StreamChannel("test")
|
||||
ch._bind(is_async=False)
|
||||
it = iter(ch)
|
||||
ch.push("a")
|
||||
ch.push("b")
|
||||
ch._close()
|
||||
assert list(ch) == ["a", "b"]
|
||||
assert list(it) == ["a", "b"]
|
||||
|
||||
def test_wire_callback(self) -> None:
|
||||
forwarded: list[str] = []
|
||||
ch: StreamChannel[str] = StreamChannel("test")
|
||||
ch._bind(is_async=False)
|
||||
ch._wire(lambda item: forwarded.append(item))
|
||||
it = iter(ch)
|
||||
ch.push("x")
|
||||
ch.push("y")
|
||||
ch._close()
|
||||
# Wire callback fires on every push, regardless of subscription.
|
||||
assert forwarded == ["x", "y"]
|
||||
assert list(ch) == ["x", "y"]
|
||||
assert list(it) == ["x", "y"]
|
||||
|
||||
def test_fail_propagation(self) -> None:
|
||||
"""_fail() should propagate the error through the underlying log."""
|
||||
ch: StreamChannel[str] = StreamChannel("test")
|
||||
ch._bind(is_async=False)
|
||||
it = iter(ch)
|
||||
ch.push("a")
|
||||
ch._fail(ValueError("channel error"))
|
||||
items: list[str] = []
|
||||
with pytest.raises(ValueError, match="channel error"):
|
||||
for item in ch:
|
||||
for item in it:
|
||||
items.append(item)
|
||||
assert items == ["a"]
|
||||
|
||||
@@ -354,10 +385,11 @@ class TestStreamChannel:
|
||||
"""Async iteration should delegate to the inner event log."""
|
||||
ch: StreamChannel[str] = StreamChannel("test")
|
||||
ch._bind(is_async=True)
|
||||
ch.push("x")
|
||||
ch.push("y")
|
||||
cursor = ch.__aiter__()
|
||||
ch._log.push("x")
|
||||
ch._log.push("y")
|
||||
ch._close()
|
||||
items = [item async for item in ch]
|
||||
items = [item async for item in cursor]
|
||||
assert items == ["x", "y"]
|
||||
|
||||
def test_push_without_wire(self) -> None:
|
||||
@@ -365,9 +397,10 @@ class TestStreamChannel:
|
||||
ch: StreamChannel[int] = StreamChannel("test")
|
||||
ch._bind(is_async=False)
|
||||
assert ch._wire_fn is None
|
||||
it = iter(ch)
|
||||
ch.push(42)
|
||||
ch._close()
|
||||
assert list(ch) == [42]
|
||||
assert list(it) == [42]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -440,6 +473,49 @@ class TestStreamingHandlerSync:
|
||||
assert custom_events[0]["params"]["data"] == {"step": "start"}
|
||||
assert custom_events[1]["params"]["data"] == {"step": "end"}
|
||||
|
||||
def test_interleave_values_and_messages(self) -> None:
|
||||
graph = _build_simple_graph()
|
||||
handler = StreamingHandler(graph)
|
||||
run = handler.stream({"value": "x", "items": []})
|
||||
|
||||
tagged = list(run.interleave("values", "messages"))
|
||||
names = [name for name, _ in tagged]
|
||||
assert set(names).issubset({"values", "messages"})
|
||||
# Values projection must have fired at least once.
|
||||
assert names.count("values") >= 1
|
||||
# Values have been drained by the interleave cursor — re-subscribing raises.
|
||||
with pytest.raises(RuntimeError, match="already has a subscriber"):
|
||||
list(run.values)
|
||||
|
||||
def test_abort_marks_exhausted_and_closes_mux(self) -> None:
|
||||
graph = _build_simple_graph()
|
||||
handler = StreamingHandler(graph)
|
||||
run = handler.stream({"value": "x", "items": []})
|
||||
values_iter = iter(run.values)
|
||||
# Consume one item so the pump advances.
|
||||
_ = next(values_iter)
|
||||
run.abort()
|
||||
# Remaining iteration yields whatever was buffered, then stops.
|
||||
list(values_iter)
|
||||
assert run._exhausted is True
|
||||
# Second abort is idempotent.
|
||||
run.abort()
|
||||
|
||||
def test_context_manager_calls_abort_on_exit(self) -> None:
|
||||
graph = _build_simple_graph()
|
||||
handler = StreamingHandler(graph)
|
||||
with handler.stream({"value": "x", "items": []}) as run:
|
||||
values_iter = iter(run.values)
|
||||
_ = next(values_iter)
|
||||
assert run._exhausted is True
|
||||
|
||||
def test_interleave_unknown_projection(self) -> None:
|
||||
graph = _build_simple_graph()
|
||||
handler = StreamingHandler(graph)
|
||||
run = handler.stream({"value": "x", "items": []})
|
||||
with pytest.raises(KeyError):
|
||||
list(run.interleave("values", "does_not_exist"))
|
||||
|
||||
|
||||
class TestStreamingHandlerSyncErrors:
|
||||
def test_error_propagation_output(self) -> None:
|
||||
@@ -533,6 +609,32 @@ class TestStreamingHandlerAsync:
|
||||
for event in events:
|
||||
assert event["type"] == "event"
|
||||
|
||||
@pytest.mark.anyio
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_abort_marks_exhausted_and_closes_mux(self) -> None:
|
||||
graph = _build_simple_graph()
|
||||
handler = StreamingHandler(graph)
|
||||
run = await handler.astream({"value": "x", "items": []})
|
||||
values_iter = aiter(run.values)
|
||||
_ = await anext(values_iter)
|
||||
await run.abort()
|
||||
# Drain the rest; should terminate promptly now that mux is closed.
|
||||
async for _item in values_iter:
|
||||
pass
|
||||
assert run._exhausted is True
|
||||
await run.abort() # idempotent
|
||||
|
||||
@pytest.mark.anyio
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_async_context_manager_calls_abort_on_exit(self) -> None:
|
||||
graph = _build_simple_graph()
|
||||
handler = StreamingHandler(graph)
|
||||
run = await handler.astream({"value": "x", "items": []})
|
||||
async with run:
|
||||
values_iter = aiter(run.values)
|
||||
_ = await anext(values_iter)
|
||||
assert run._exhausted is True
|
||||
|
||||
@pytest.mark.anyio
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_extensions_has_native_keys(self) -> None:
|
||||
@@ -699,13 +801,14 @@ class TestStreamMux:
|
||||
return event["method"] != "updates"
|
||||
|
||||
mux = StreamMux([FilterTransformer()])
|
||||
it = iter(mux._events)
|
||||
|
||||
mux.push(_event("values", {"a": 1}))
|
||||
mux.push(_event("updates", {"b": 2}))
|
||||
mux.push(_event("custom", {"c": 3}))
|
||||
mux.close()
|
||||
|
||||
events = list(mux._events)
|
||||
events = list(it)
|
||||
methods = [e["method"] for e in events]
|
||||
assert "updates" not in methods
|
||||
assert methods == ["values", "custom"]
|
||||
@@ -744,9 +847,10 @@ class TestStreamMux:
|
||||
def test_empty_mux(self) -> None:
|
||||
"""Push/close/fail on a mux with no transformers should work."""
|
||||
mux = StreamMux()
|
||||
it = iter(mux._events)
|
||||
mux.push(_event("values", {"x": 1}))
|
||||
mux.close()
|
||||
events = list(mux._events)
|
||||
events = list(it)
|
||||
assert len(events) == 1
|
||||
assert events[0]["method"] == "values"
|
||||
|
||||
@@ -769,12 +873,13 @@ class TestValuesTransformer:
|
||||
t = ValuesTransformer()
|
||||
t.init()
|
||||
t._log._bind(is_async=False)
|
||||
it = iter(t._log)
|
||||
|
||||
t.process(_event("values", {"val": "root"}))
|
||||
t.process(_event("values", {"val": "sub"}, namespace=["sub"]))
|
||||
|
||||
t._log.close()
|
||||
items = list(t._log)
|
||||
items = list(it)
|
||||
assert len(items) == 1
|
||||
assert items[0]["val"] == "root"
|
||||
|
||||
@@ -783,11 +888,12 @@ class TestValuesTransformer:
|
||||
t = ValuesTransformer()
|
||||
t.init()
|
||||
t._log._bind(is_async=False)
|
||||
it = iter(t._log)
|
||||
|
||||
result = t.process(_event("updates", {"x": 1}))
|
||||
assert result is True # passed through
|
||||
t._log.close()
|
||||
assert list(t._log) == [] # but not captured
|
||||
assert list(it) == [] # but not captured
|
||||
|
||||
def test_tracks_interrupts(self) -> None:
|
||||
"""Interrupts should be accumulated across events."""
|
||||
@@ -812,6 +918,7 @@ class TestMessagesTransformer:
|
||||
t.init()
|
||||
t._log._bind(is_async=False)
|
||||
t._bind_pump(lambda: False)
|
||||
it = iter(t._log)
|
||||
|
||||
meta = {"langgraph_node": "llm", "run_id": "run-1"}
|
||||
for evt in (
|
||||
@@ -820,7 +927,7 @@ class TestMessagesTransformer:
|
||||
):
|
||||
t.process(_event("messages", (evt, meta)))
|
||||
t._log.close()
|
||||
items = list(t._log)
|
||||
items = list(it)
|
||||
assert len(items) == 1
|
||||
# Items in the messages log are ChatModelStream objects, not raw
|
||||
# tuples — the content-block-centric projection.
|
||||
@@ -832,6 +939,7 @@ class TestMessagesTransformer:
|
||||
t.init()
|
||||
t._log._bind(is_async=False)
|
||||
t._bind_pump(lambda: False)
|
||||
it = iter(t._log)
|
||||
|
||||
meta = {"langgraph_node": "llm", "run_id": "run-1"}
|
||||
t.process(
|
||||
@@ -842,25 +950,27 @@ class TestMessagesTransformer:
|
||||
)
|
||||
)
|
||||
t._log.close()
|
||||
assert list(t._log) == []
|
||||
assert list(it) == []
|
||||
|
||||
def test_ignores_non_messages_methods(self) -> None:
|
||||
t = MessagesTransformer()
|
||||
t.init()
|
||||
t._log._bind(is_async=False)
|
||||
it = iter(t._log)
|
||||
|
||||
result = t.process(_event("values", {"v": 1}))
|
||||
assert result is True
|
||||
t._log.close()
|
||||
assert list(t._log) == []
|
||||
assert list(it) == []
|
||||
|
||||
def test_fail_propagates(self) -> None:
|
||||
t = MessagesTransformer()
|
||||
t.init()
|
||||
t._log._bind(is_async=False)
|
||||
it = iter(t._log)
|
||||
t._log.fail(ValueError("msg error"))
|
||||
with pytest.raises(ValueError, match="msg error"):
|
||||
list(t._log)
|
||||
list(it)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -993,10 +1103,11 @@ class TestCustomTransformer:
|
||||
handler = StreamingHandler(graph)
|
||||
counter_t = CounterTransformer()
|
||||
run = handler.stream({"value": "x", "items": []}, transformers=[counter_t])
|
||||
_ = run.output
|
||||
assert "counter" in run.extensions
|
||||
# Counter channel should have been pushed to.
|
||||
counts = list(run.extensions["counter"])
|
||||
# Subscribe before driving the run so channel pushes are retained.
|
||||
counter_iter = iter(run.extensions["counter"])
|
||||
_ = run.output
|
||||
counts = list(counter_iter)
|
||||
assert len(counts) > 0
|
||||
# Non-native transformer should not set direct attributes.
|
||||
assert not hasattr(run, "counter")
|
||||
@@ -1023,12 +1134,14 @@ class TestCustomTransformer:
|
||||
handler = StreamingHandler(graph)
|
||||
foo_t = FooTransformer()
|
||||
run = handler.stream({"value": "x", "items": []}, transformers=[foo_t])
|
||||
# Subscribe before driving the run.
|
||||
foo_iter = iter(run.foo)
|
||||
_ = run.output
|
||||
# foo should be both in extensions and as a direct attribute.
|
||||
assert "foo" in run.extensions
|
||||
assert hasattr(run, "foo")
|
||||
assert run.foo is run.extensions["foo"]
|
||||
items = list(run.foo)
|
||||
items = list(foo_iter)
|
||||
assert "saw_values" in items
|
||||
|
||||
def test_stream_channel_auto_forward(self) -> None:
|
||||
@@ -1080,12 +1193,13 @@ class TestCustomTransformer:
|
||||
return True
|
||||
|
||||
mux = StreamMux([ChannelPusher()])
|
||||
it = iter(mux._events)
|
||||
|
||||
mux.push(_event("values"))
|
||||
mux.push(_event("updates"))
|
||||
mux.close()
|
||||
|
||||
events = list(mux._events)
|
||||
events = list(it)
|
||||
seqs = [e["seq"] for e in events]
|
||||
# Seq numbers must be strictly increasing.
|
||||
for i in range(1, len(seqs)):
|
||||
@@ -1134,12 +1248,13 @@ class TestEventLogAutoLifecycle:
|
||||
return True
|
||||
|
||||
mux = StreamMux([SimpleTransformer()])
|
||||
it = iter(mux._events)
|
||||
|
||||
mux.push(_event("values"))
|
||||
mux.close()
|
||||
|
||||
# The log should have been auto-closed — iteration should work.
|
||||
items = list(mux._events)
|
||||
items = list(it)
|
||||
assert len(items) == 1
|
||||
|
||||
def test_mux_auto_fails_event_logs(self) -> None:
|
||||
@@ -1159,13 +1274,14 @@ class TestEventLogAutoLifecycle:
|
||||
|
||||
t = SimpleTransformer()
|
||||
mux = StreamMux([t])
|
||||
it = iter(t._log)
|
||||
|
||||
mux.push(_event("values"))
|
||||
mux.fail(ValueError("boom"))
|
||||
|
||||
# The transformer's log should have been auto-failed.
|
||||
with pytest.raises(ValueError, match="boom"):
|
||||
list(t._log)
|
||||
list(it)
|
||||
|
||||
def test_no_double_close_if_transformer_closes_own_log(self) -> None:
|
||||
"""If a transformer closes its log in finalize(), mux should not error."""
|
||||
@@ -1209,8 +1325,9 @@ class TestEventLogAutoLifecycle:
|
||||
handler = StreamingHandler(graph)
|
||||
t = MinimalTransformer()
|
||||
run = handler.stream({"value": "x", "items": []}, transformers=[t])
|
||||
minimal_iter = iter(run.extensions["minimal"])
|
||||
_ = run.output
|
||||
items = list(run.extensions["minimal"])
|
||||
items = list(minimal_iter)
|
||||
assert len(items) > 0
|
||||
|
||||
|
||||
@@ -1469,13 +1586,14 @@ class TestAsyncTransformerLane:
|
||||
|
||||
async_t = AsyncOne()
|
||||
mux = StreamMux([SyncOne(), async_t], is_async=True)
|
||||
seen_cursor = aiter(async_t._log)
|
||||
|
||||
await mux.apush(_event("values", {}))
|
||||
await mux.apush(_event("updates", {}))
|
||||
await mux.aclose()
|
||||
|
||||
assert seen_sync == ["values", "updates"]
|
||||
items = [x async for x in async_t._log]
|
||||
items = [x async for x in seen_cursor]
|
||||
assert items == ["values", "updates"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -1510,148 +1628,146 @@ class TestAsyncTransformerLane:
|
||||
{"value": "x", "items": []},
|
||||
transformers=[Scorer()],
|
||||
)
|
||||
# Subscribe before driving the run so scheduled pushes are retained.
|
||||
scores_cursor = aiter(run.extensions["scores"])
|
||||
_ = await run.output()
|
||||
scores = [x async for x in run.extensions["scores"]]
|
||||
scores = [x async for x in scores_cursor]
|
||||
assert scores and all(s == 42 for s in scores)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bounded EventLog / StreamChannel — memory caps and overflow semantics
|
||||
# Drain-on-consume semantics — bounded backpressure, single subscriber
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBoundedEventLog:
|
||||
class TestMemoryBounds:
|
||||
"""Drain-on-consume guarantees memory stays bounded for the common
|
||||
access patterns. These tests lock in the property — if a change
|
||||
re-introduces retention, they should fail."""
|
||||
|
||||
def test_sync_subscribed_buffer_stays_at_most_one_between_yields(self) -> None:
|
||||
"""With a single sync consumer, the pump produces exactly one event
|
||||
per cursor advance, so the buffer never holds more than one."""
|
||||
graph = _build_simple_graph()
|
||||
handler = StreamingHandler(graph)
|
||||
run = handler.stream({"value": "x", "items": []})
|
||||
events_iter = iter(run)
|
||||
max_buffered = 0
|
||||
count = 0
|
||||
for _ in events_iter:
|
||||
max_buffered = max(max_buffered, len(run._mux._events._items))
|
||||
count += 1
|
||||
assert count > 0
|
||||
assert max_buffered == 0, (
|
||||
f"Subscribed buffer should hold 0 items after each yield "
|
||||
f"(drain-on-consume), observed max {max_buffered}"
|
||||
)
|
||||
|
||||
def test_unsubscribed_projections_never_accumulate(self) -> None:
|
||||
"""Projections without a subscriber drop pushes silently —
|
||||
their buffers stay empty regardless of run length."""
|
||||
graph = _build_simple_graph()
|
||||
handler = StreamingHandler(graph)
|
||||
run = handler.stream({"value": "x", "items": []})
|
||||
# Subscribe to main events only; leave values and messages unsubscribed.
|
||||
list(run)
|
||||
values_log = run.extensions["values"]
|
||||
messages_log = run.extensions["messages"]
|
||||
assert len(values_log._items) == 0
|
||||
assert len(messages_log._items) == 0
|
||||
assert values_log._subscribed is False
|
||||
assert messages_log._subscribed is False
|
||||
|
||||
def test_output_path_does_not_retain_values(self) -> None:
|
||||
"""`run.output` is a scalar accessor — it updates `_latest` from
|
||||
process() without populating the log, so the values log buffer
|
||||
stays empty even across a full run."""
|
||||
graph = _build_simple_graph()
|
||||
handler = StreamingHandler(graph)
|
||||
run = handler.stream({"value": "x", "items": []})
|
||||
_ = run.output
|
||||
values_log = run.extensions["values"]
|
||||
assert len(values_log._items) == 0
|
||||
assert values_log._subscribed is False
|
||||
|
||||
def test_drained_subscriber_buffer_returns_to_empty(self) -> None:
|
||||
"""After fully draining a subscribed log, the internal deque is empty."""
|
||||
graph = _build_simple_graph()
|
||||
handler = StreamingHandler(graph)
|
||||
run = handler.stream({"value": "x", "items": []})
|
||||
values_log = run.extensions["values"]
|
||||
list(run.values)
|
||||
assert len(values_log._items) == 0
|
||||
|
||||
@pytest.mark.anyio
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_async_single_consumer_buffer_stays_at_most_one(self) -> None:
|
||||
"""Same drain-on-consume guarantee for the async lane."""
|
||||
graph = _build_simple_graph()
|
||||
handler = StreamingHandler(graph)
|
||||
run = await handler.astream({"value": "x", "items": []})
|
||||
max_buffered = 0
|
||||
count = 0
|
||||
async for _ in run:
|
||||
max_buffered = max(max_buffered, len(run._mux._events._items))
|
||||
count += 1
|
||||
assert count > 0
|
||||
assert max_buffered == 0
|
||||
|
||||
@pytest.mark.anyio
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_async_unsubscribed_projections_never_accumulate(self) -> None:
|
||||
"""Projections with no async subscriber stay empty under astream."""
|
||||
graph = _build_simple_graph()
|
||||
handler = StreamingHandler(graph)
|
||||
run = await handler.astream({"value": "x", "items": []})
|
||||
_ = await run.output()
|
||||
values_log = run.extensions["values"]
|
||||
messages_log = run.extensions["messages"]
|
||||
assert len(values_log._items) == 0
|
||||
assert len(messages_log._items) == 0
|
||||
assert values_log._subscribed is False
|
||||
assert messages_log._subscribed is False
|
||||
|
||||
|
||||
class TestDrainOnConsume:
|
||||
def test_invalid_maxlen_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="positive int or None"):
|
||||
EventLog(maxlen=0)
|
||||
with pytest.raises(ValueError, match="positive int or None"):
|
||||
EventLog(maxlen=-3)
|
||||
|
||||
def test_unbounded_default_preserves_replay(self) -> None:
|
||||
"""Default EventLog (maxlen=None) still replays from seq 0."""
|
||||
def test_push_unbounded_by_design(self) -> None:
|
||||
"""Push is non-blocking and doesn't enforce capacity — the
|
||||
caller-driven pump bounds memory via iteration pace."""
|
||||
log: EventLog[int] = EventLog()
|
||||
log._bind(is_async=False)
|
||||
it = iter(log)
|
||||
for i in range(100):
|
||||
log.push(i)
|
||||
log.close()
|
||||
assert list(log) == list(range(100))
|
||||
|
||||
def test_bounded_drops_oldest_on_overflow(self) -> None:
|
||||
"""When bounded, pushing past maxlen evicts the oldest item."""
|
||||
log: EventLog[int] = EventLog(maxlen=3)
|
||||
log._bind(is_async=False)
|
||||
for i in range(5):
|
||||
log.push(i)
|
||||
log.close()
|
||||
# Only the last 3 survive; new cursors start at the current head.
|
||||
assert list(log) == [2, 3, 4]
|
||||
|
||||
def test_new_cursor_starts_at_head_not_zero(self) -> None:
|
||||
"""New cursors see the retained window, not the evicted prefix."""
|
||||
log: EventLog[int] = EventLog(maxlen=2)
|
||||
log._bind(is_async=False)
|
||||
log.push(1)
|
||||
log.push(2)
|
||||
log.push(3) # evicts 1
|
||||
log.close()
|
||||
assert list(log) == [2, 3]
|
||||
assert list(it) == list(range(100))
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_cursor_overflow_raises(self) -> None:
|
||||
"""An async cursor that falls behind the retention window raises."""
|
||||
log: EventLog[int] = EventLog(maxlen=2)
|
||||
async def test_atee_fans_out(self) -> None:
|
||||
"""atee provides the documented fan-out for concurrent consumers."""
|
||||
log: EventLog[int] = EventLog()
|
||||
log._bind(is_async=True)
|
||||
|
||||
log.push(1)
|
||||
cursor = aiter(log)
|
||||
# Advance cursor to seq 1, reading item 1.
|
||||
first = await anext(cursor)
|
||||
assert first == 1
|
||||
# Now push enough to roll the cursor off the back.
|
||||
log.push(2) # buffer: [1, 2] _first_seq=0, cursor at seq=1
|
||||
log.push(3) # buffer: [2, 3] _first_seq=1, cursor at seq=1 still OK
|
||||
log.push(4) # buffer: [3, 4] _first_seq=2, cursor at seq=1 — overflow
|
||||
with pytest.raises(BufferOverflowError, match="fell"):
|
||||
await anext(cursor)
|
||||
|
||||
def test_sync_cursor_sees_all_while_bounded_but_under_cap(self) -> None:
|
||||
"""Bounded mode with pushes under cap behaves identically to unbounded."""
|
||||
log: EventLog[int] = EventLog(maxlen=100)
|
||||
log._bind(is_async=False)
|
||||
log.push(1)
|
||||
log.push(2)
|
||||
a, b = log.atee(2)
|
||||
for i in range(3):
|
||||
log.push(i)
|
||||
log.close()
|
||||
assert list(log) == [1, 2]
|
||||
items_a = [x async for x in a]
|
||||
items_b = [x async for x in b]
|
||||
assert items_a == [0, 1, 2]
|
||||
assert items_b == [0, 1, 2]
|
||||
|
||||
|
||||
class TestStreamChannelMaxlen:
|
||||
def test_maxlen_passes_through_to_inner_log(self) -> None:
|
||||
ch: StreamChannel[int] = StreamChannel("ch", maxlen=2)
|
||||
ch._bind(is_async=False)
|
||||
ch.push(1)
|
||||
ch.push(2)
|
||||
ch.push(3)
|
||||
ch._close()
|
||||
assert list(ch) == [2, 3]
|
||||
|
||||
|
||||
class TestMuxMaxEventsDefault:
|
||||
def test_mux_fills_in_default_when_log_has_none(self) -> None:
|
||||
class Simple(StreamTransformer):
|
||||
def __init__(self) -> None:
|
||||
self.log: EventLog[int] = EventLog()
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"out": self.log}
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
return True
|
||||
|
||||
t = Simple()
|
||||
StreamMux([t], max_events=10)
|
||||
assert t.log._maxlen == 10
|
||||
|
||||
def test_explicit_log_maxlen_wins_over_mux_default(self) -> None:
|
||||
class Explicit(StreamTransformer):
|
||||
def __init__(self) -> None:
|
||||
self.log: EventLog[int] = EventLog(maxlen=3)
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"out": self.log}
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
return True
|
||||
|
||||
t = Explicit()
|
||||
StreamMux([t], max_events=1000)
|
||||
assert t.log._maxlen == 3 # transformer author's setting stands
|
||||
|
||||
def test_main_event_log_respects_max_events(self) -> None:
|
||||
mux = StreamMux([], max_events=5)
|
||||
assert mux._events._maxlen == 5
|
||||
|
||||
def test_max_events_default_cascades_to_channels(self) -> None:
|
||||
class WithChannel(StreamTransformer):
|
||||
def __init__(self) -> None:
|
||||
self.ch: StreamChannel[int] = StreamChannel("out")
|
||||
|
||||
def init(self) -> dict[str, Any]:
|
||||
return {"out": self.ch}
|
||||
|
||||
def process(self, event: ProtocolEvent) -> bool:
|
||||
return True
|
||||
|
||||
t = WithChannel()
|
||||
StreamMux([t], max_events=7)
|
||||
assert t.ch._log._maxlen == 7
|
||||
|
||||
def test_handler_propagates_max_events(self) -> None:
|
||||
graph = _build_simple_graph()
|
||||
handler = StreamingHandler(graph)
|
||||
run = handler.stream({"value": "x", "items": []}, max_events=50)
|
||||
# Main log inherits the default.
|
||||
assert run._mux._events._maxlen == 50
|
||||
# Native projections (values log, messages log) inherit too.
|
||||
for log in run._mux._logs:
|
||||
assert log._maxlen == 50
|
||||
_ = run.output
|
||||
def test_tee_fans_out_sync(self) -> None:
|
||||
log: EventLog[int] = EventLog()
|
||||
log._bind(is_async=False)
|
||||
a, b = log.tee(2)
|
||||
for i in range(3):
|
||||
log.push(i)
|
||||
log.close()
|
||||
assert list(a) == [0, 1, 2]
|
||||
assert list(b) == [0, 1, 2]
|
||||
|
||||
Reference in New Issue
Block a user