mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-08 02:37:52 +02:00
Make channels context managers, Add async tests, Add concurrency tests
This commit is contained in:
@@ -35,6 +35,8 @@ Check `examples` for more examples.
|
||||
- [x] do we want api to send input to multiple channels in invoke()
|
||||
- [x] Finish updating tests to new API
|
||||
- [ ] Implement input_schema and output_schema in Pregel
|
||||
- [ ] More tests
|
||||
- [ ] Test different input and output types (str, str sequence, None)
|
||||
- [ ] Implement checkpointing
|
||||
- [ ] Save checkpoints at end of each step
|
||||
- [ ] Load checkpoint at start of invocation
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from langchain.chat_models.openai import ChatOpenAI
|
||||
from langchain.output_parsers.openai_functions import JsonOutputFunctionsParser
|
||||
from langchain.prompts import SystemMessagePromptTemplate
|
||||
@@ -131,4 +129,6 @@ async def main():
|
||||
print("---")
|
||||
|
||||
|
||||
# import asyncio
|
||||
|
||||
# asyncio.run(main())
|
||||
|
||||
+79
-7
@@ -1,8 +1,16 @@
|
||||
import json
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Callable, FrozenSet, Generic, Optional, Sequence, TypeVar
|
||||
from types import TracebackType
|
||||
from typing import (
|
||||
Callable,
|
||||
FrozenSet,
|
||||
Generic,
|
||||
Optional,
|
||||
Sequence,
|
||||
TypeVar,
|
||||
)
|
||||
|
||||
from typing_extensions import Self, get_args
|
||||
from typing_extensions import Self # , get_args
|
||||
|
||||
Value = TypeVar("Value")
|
||||
Update = TypeVar("Update")
|
||||
@@ -33,9 +41,29 @@ class Channel(Generic[Value, Update], ABC):
|
||||
# return type_args[1]
|
||||
|
||||
@abstractmethod
|
||||
def _empty(self, checkpoint: Optional[str] = None) -> Self:
|
||||
def __enter__(self, checkpoint: Optional[str] = None) -> Self:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def __exit__(
|
||||
self,
|
||||
__exc_type: type[BaseException] | None,
|
||||
__exc_value: BaseException | None,
|
||||
__traceback: TracebackType | None,
|
||||
) -> bool | None:
|
||||
...
|
||||
|
||||
async def __aenter__(self, checkpoint: Optional[str] = None) -> Self:
|
||||
return self.__enter__(checkpoint)
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
__exc_type: type[BaseException] | None,
|
||||
__exc_value: BaseException | None,
|
||||
__traceback: TracebackType | None,
|
||||
) -> None:
|
||||
self.__exit__(__exc_type, __exc_value, __traceback)
|
||||
|
||||
@abstractmethod
|
||||
def _update(self, values: Sequence[Update]) -> None:
|
||||
...
|
||||
@@ -63,12 +91,23 @@ class BinaryOperatorAggregate(Generic[Value], Channel[Value, Value]):
|
||||
super().__init__()
|
||||
self.operator = operator
|
||||
|
||||
def _empty(self, checkpoint: Optional[str] = None) -> Self:
|
||||
def __enter__(self, checkpoint: Optional[str] = None) -> Self:
|
||||
empty = self.__class__(self.operator)
|
||||
if checkpoint is not None:
|
||||
empty.value = json.loads(checkpoint)
|
||||
return empty
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
__exc_type: type[BaseException] | None,
|
||||
__exc_value: BaseException | None,
|
||||
__traceback: TracebackType | None,
|
||||
) -> None:
|
||||
try:
|
||||
del self.value
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def _update(self, values: Sequence[Value]) -> None:
|
||||
if not hasattr(self, "value"):
|
||||
self.value = values[0]
|
||||
@@ -90,12 +129,23 @@ class BinaryOperatorAggregate(Generic[Value], Channel[Value, Value]):
|
||||
class LastValue(Generic[Value], Channel[Value, Value]):
|
||||
"""Stores the last value received."""
|
||||
|
||||
def _empty(self, checkpoint: Optional[str] = None) -> Self:
|
||||
def __enter__(self, checkpoint: Optional[str] = None) -> Self:
|
||||
empty = self.__class__()
|
||||
if checkpoint is not None:
|
||||
empty.value = json.loads(checkpoint)
|
||||
return empty
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
__exc_type: type[BaseException] | None,
|
||||
__exc_value: BaseException | None,
|
||||
__traceback: TracebackType | None,
|
||||
) -> None:
|
||||
try:
|
||||
del self.value
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def _update(self, values: Sequence[Value]) -> None:
|
||||
if len(values) != 1:
|
||||
raise InvalidUpdateError()
|
||||
@@ -115,12 +165,23 @@ class LastValue(Generic[Value], Channel[Value, Value]):
|
||||
class Inbox(Generic[Value], Channel[Sequence[Value], Value]):
|
||||
"""Stores all values received, resets in each step."""
|
||||
|
||||
def _empty(self, checkpoint: Optional[str] = None) -> Self:
|
||||
def __enter__(self, checkpoint: Optional[str] = None) -> Self:
|
||||
empty = self.__class__()
|
||||
if checkpoint is not None:
|
||||
empty.queue = tuple(json.loads(checkpoint))
|
||||
return empty
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
__exc_type: type[BaseException] | None,
|
||||
__exc_value: BaseException | None,
|
||||
__traceback: TracebackType | None,
|
||||
) -> None:
|
||||
try:
|
||||
del self.queue
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def _update(self, values: Sequence[Value]) -> None:
|
||||
self.queue = tuple(values)
|
||||
|
||||
@@ -139,12 +200,23 @@ class Set(Generic[Value], Channel[FrozenSet[Value], Value]):
|
||||
|
||||
set: set[Value]
|
||||
|
||||
def _empty(self, checkpoint: Optional[str] = None) -> Self:
|
||||
def __enter__(self, checkpoint: Optional[str] = None) -> Self:
|
||||
empty = self.__class__()
|
||||
if checkpoint is not None:
|
||||
empty.set = set(json.loads(checkpoint))
|
||||
return empty
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
__exc_type: type[BaseException] | None,
|
||||
__exc_value: BaseException | None,
|
||||
__traceback: TracebackType | None,
|
||||
) -> None:
|
||||
try:
|
||||
del self.set
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def _update(self, values: Sequence[Value]) -> None:
|
||||
if not hasattr(self, "set"):
|
||||
self.set = set()
|
||||
|
||||
+168
-118
@@ -4,17 +4,18 @@ import asyncio
|
||||
import concurrent.futures
|
||||
import logging
|
||||
from collections import defaultdict, deque
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from typing import (
|
||||
Any,
|
||||
AsyncGenerator,
|
||||
AsyncIterator,
|
||||
Callable,
|
||||
Generic,
|
||||
Generator,
|
||||
Iterator,
|
||||
Mapping,
|
||||
Optional,
|
||||
Sequence,
|
||||
cast,
|
||||
overload,
|
||||
)
|
||||
|
||||
from langchain.callbacks.manager import (
|
||||
@@ -40,9 +41,9 @@ from langchain.schema.runnable.config import (
|
||||
get_executor_for_config,
|
||||
patch_config,
|
||||
)
|
||||
from langchain.schema.runnable.utils import ConfigurableFieldSpec, Input, Output
|
||||
from langchain.schema.runnable.utils import ConfigurableFieldSpec
|
||||
|
||||
from permchain.channels import Channel, EmptyChannelError, Inbox
|
||||
from permchain.channels import Channel, EmptyChannelError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -101,6 +102,23 @@ class PregelInvoke(RunnableBinding):
|
||||
|
||||
kwargs: Mapping[str, Any] = Field(default_factory=dict)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
channels: Mapping[None, str] | Mapping[str, str],
|
||||
*,
|
||||
bound: Optional[Runnable[Any, Any]] = None,
|
||||
kwargs: Optional[Mapping[str, Any]] = None,
|
||||
config: Optional[RunnableConfig] = None,
|
||||
**other_kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
channels=channels,
|
||||
bound=bound or RunnablePassthrough(),
|
||||
kwargs=kwargs or {},
|
||||
config=config,
|
||||
**other_kwargs,
|
||||
)
|
||||
|
||||
def join(self, channels: Sequence[str]) -> PregelInvoke:
|
||||
joiner = RunnablePassthrough.assign(
|
||||
**{chan: PregelRead(chan) for chan in channels}
|
||||
@@ -115,7 +133,7 @@ class PregelInvoke(RunnableBinding):
|
||||
other: Runnable[Any, Other]
|
||||
| Callable[[Any], Other]
|
||||
| Mapping[str, Runnable[Any, Other] | Callable[[Any], Other]],
|
||||
) -> Runnable:
|
||||
) -> PregelInvoke:
|
||||
if isinstance(self.bound, RunnablePassthrough):
|
||||
return PregelInvoke(channels=self.channels, bound=coerce_to_runnable(other))
|
||||
else:
|
||||
@@ -140,7 +158,7 @@ class PregelBatch(RunnableEach):
|
||||
other: Runnable[Any, Other]
|
||||
| Callable[[Any], Other]
|
||||
| Mapping[str, Runnable[Any, Other] | Callable[[Any], Other]],
|
||||
) -> Runnable:
|
||||
) -> PregelBatch:
|
||||
if isinstance(self.bound, RunnablePassthrough):
|
||||
return PregelBatch(channel=self.channel, bound=coerce_to_runnable(other))
|
||||
else:
|
||||
@@ -222,7 +240,31 @@ class PregelSink(RunnableLambda):
|
||||
return input
|
||||
|
||||
|
||||
class Pregel(Generic[Output], RunnableSerializable[dict[str, Any] | Any, Output]):
|
||||
@contextmanager
|
||||
def ChannelsManager(
|
||||
channels: Mapping[str, Channel]
|
||||
) -> Generator[Mapping[str, Channel], None, None]:
|
||||
empty = {k: v.__enter__() for k, v in channels.items()}
|
||||
try:
|
||||
yield empty
|
||||
finally:
|
||||
for v in empty.values():
|
||||
v.__exit__(None, None, None)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def AsyncChannelsManager(
|
||||
channels: Mapping[str, Channel]
|
||||
) -> AsyncGenerator[Mapping[str, Channel], None]:
|
||||
empty = {k: await v.__aenter__() for k, v in channels.items()}
|
||||
try:
|
||||
yield empty
|
||||
finally:
|
||||
for v in empty.values():
|
||||
await v.__aexit__(None, None, None)
|
||||
|
||||
|
||||
class Pregel(RunnableSerializable[dict[str, Any] | Any, dict[str, Any] | Any]):
|
||||
channels: Mapping[str, Channel]
|
||||
|
||||
chains: Sequence[PregelInvoke | PregelBatch]
|
||||
@@ -249,7 +291,7 @@ class Pregel(Generic[Output], RunnableSerializable[dict[str, Any] | Any, Output]
|
||||
if isinstance(chain, (list, tuple)):
|
||||
chains_flat.extend(chain)
|
||||
else:
|
||||
chains_flat.append(chain)
|
||||
chains_flat.append(cast(PregelInvoke | PregelBatch, chain))
|
||||
|
||||
validate_chains_channels(chains_flat, channels, input, output)
|
||||
|
||||
@@ -272,7 +314,7 @@ class Pregel(Generic[Output], RunnableSerializable[dict[str, Any] | Any, Output]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def subscribe_to_each(cls, inbox: Inbox) -> PregelBatch:
|
||||
def subscribe_to_each(cls, inbox: str) -> PregelBatch:
|
||||
"""Runs process.batch() with the content of inbox each time it is updated."""
|
||||
return PregelBatch(channel=inbox)
|
||||
|
||||
@@ -297,25 +339,26 @@ class Pregel(Generic[Output], RunnableSerializable[dict[str, Any] | Any, Output]
|
||||
input: Iterator[dict[str, Any] | Any],
|
||||
run_manager: CallbackManagerForChainRun,
|
||||
config: RunnableConfig,
|
||||
) -> Iterator[Output]:
|
||||
) -> Iterator[dict[str, Any] | Any]:
|
||||
processes = tuple(self.chains)
|
||||
# TODO this is where we'd restore from checkpoint
|
||||
channels = {k: v._empty() for k, v in self.channels.items()}
|
||||
next_tasks = _apply_writes_and_prepare_next_tasks(
|
||||
processes,
|
||||
channels,
|
||||
deque((self.input, chunk) for chunk in input)
|
||||
if self.input is not None
|
||||
else deque((k, v) for chunk in input for k, v in chunk.items()),
|
||||
)
|
||||
with ChannelsManager(self.channels) as channels, get_executor_for_config(
|
||||
config
|
||||
) as executor:
|
||||
next_tasks = _apply_writes_and_prepare_next_tasks(
|
||||
processes,
|
||||
channels,
|
||||
deque((self.input, chunk) for chunk in input)
|
||||
if self.input is not None
|
||||
else deque((k, v) for chunk in input for k, v in chunk.items()),
|
||||
)
|
||||
|
||||
def read(chan: Channel) -> Any:
|
||||
try:
|
||||
return channels[chan]._get()
|
||||
except EmptyChannelError:
|
||||
return None
|
||||
def read(chan: str) -> Any:
|
||||
try:
|
||||
return channels[chan]._get()
|
||||
except EmptyChannelError:
|
||||
return None
|
||||
|
||||
with get_executor_for_config(config) as executor:
|
||||
# Similarly to Bulk Synchronous Parallel / Pregel model
|
||||
# computation proceeds in steps, while there are channel updates
|
||||
# channel updates from step N are only visible in step N+1
|
||||
@@ -323,7 +366,7 @@ class Pregel(Generic[Output], RunnableSerializable[dict[str, Any] | Any, Output]
|
||||
# with channel updates applied only at the transition between steps
|
||||
for step in range(config["recursion_limit"]):
|
||||
# collect all writes to channels, without applying them yet
|
||||
pending_writes = deque[tuple[Channel, Any]]()
|
||||
pending_writes = deque[tuple[str, Any]]()
|
||||
|
||||
# execute tasks, and wait for one to fail or all to finish
|
||||
# each task is independent from all other concurrent tasks
|
||||
@@ -373,8 +416,12 @@ class Pregel(Generic[Output], RunnableSerializable[dict[str, Any] | Any, Output]
|
||||
)
|
||||
|
||||
# if any write to output channel in this step, yield current value
|
||||
if any(chan is self.output for chan, _ in pending_writes):
|
||||
yield channels[self.output]._get()
|
||||
if isinstance(self.output, str):
|
||||
if any(chan is self.output for chan, _ in pending_writes):
|
||||
yield channels[self.output]._get()
|
||||
else:
|
||||
if updated := {c for c, _ in pending_writes if c in self.output}:
|
||||
yield {chan: channels[chan]._get() for chan in updated}
|
||||
|
||||
# TODO this is where we'd save checkpoint
|
||||
|
||||
@@ -387,99 +434,102 @@ class Pregel(Generic[Output], RunnableSerializable[dict[str, Any] | Any, Output]
|
||||
input: AsyncIterator[dict[str, Any] | Any],
|
||||
run_manager: AsyncCallbackManagerForChainRun,
|
||||
config: RunnableConfig,
|
||||
) -> AsyncIterator[Output]:
|
||||
) -> AsyncIterator[dict[str, Any] | Any]:
|
||||
processes = tuple(self.chains)
|
||||
channels = {k: v._empty() for k, v in self.channels.items()}
|
||||
next_tasks = _apply_writes_and_prepare_next_tasks(
|
||||
processes,
|
||||
channels,
|
||||
deque((self.input, chunk) async for chunk in input)
|
||||
if self.input is not None
|
||||
else deque((k, v) async for chunk in input for k, v in chunk.items()),
|
||||
)
|
||||
|
||||
def read(chan: Channel) -> Any:
|
||||
try:
|
||||
return channels[chan]._get()
|
||||
except EmptyChannelError:
|
||||
return None
|
||||
|
||||
# Similarly to Bulk Synchronous Parallel / Pregel model
|
||||
# computation proceeds in steps, while there are channel updates
|
||||
# channel updates from step N are only visible in step N+1,
|
||||
# channels are guaranteed to be immutable for the duration of the step,
|
||||
# channel updates being applied only at the transition between steps
|
||||
for step in range(config["recursion_limit"]):
|
||||
# collect all writes to channels, without applying them yet
|
||||
pending_writes = deque[tuple[Channel, Any]]()
|
||||
|
||||
# execute tasks, and wait for one to fail or all to finish
|
||||
# each task is independent from all other concurrent tasks
|
||||
done, inflight = await asyncio.wait(
|
||||
[
|
||||
asyncio.create_task(
|
||||
proc.ainvoke(
|
||||
input,
|
||||
patch_config(
|
||||
config,
|
||||
callbacks=run_manager.get_child(f"pregel:step:{step}"),
|
||||
configurable={
|
||||
# deque.extend is thread-safe
|
||||
CONFIG_KEY_SEND: pending_writes.extend,
|
||||
CONFIG_KEY_READ: read,
|
||||
CONFIG_KEY_STEP: step,
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
for proc, input in next_tasks
|
||||
],
|
||||
return_when=asyncio.FIRST_EXCEPTION,
|
||||
timeout=self.step_timeout,
|
||||
)
|
||||
|
||||
while done:
|
||||
# if any task failed
|
||||
if exc := done.pop().exception():
|
||||
# cancel all pending tasks
|
||||
while inflight:
|
||||
inflight.pop().cancel()
|
||||
# raise the exception
|
||||
raise exc
|
||||
# TODO this is where retry of an entire step would happen
|
||||
|
||||
if inflight:
|
||||
# if we got here means we timed out
|
||||
while inflight:
|
||||
# cancel all pending tasks
|
||||
inflight.pop().cancel()
|
||||
# raise timeout error
|
||||
raise TimeoutError(f"Timed out at step {step}")
|
||||
|
||||
# apply writes to channels, decide on next step
|
||||
# TODO this is where we'd restore from checkpoint
|
||||
async with AsyncChannelsManager(self.channels) as channels:
|
||||
next_tasks = _apply_writes_and_prepare_next_tasks(
|
||||
processes, channels, pending_writes
|
||||
processes,
|
||||
channels,
|
||||
deque([(self.input, chunk) async for chunk in input])
|
||||
if self.input is not None
|
||||
else deque([(k, v) async for chunk in input for k, v in chunk.items()]),
|
||||
)
|
||||
|
||||
# if any write to output channel in this step, yield current value
|
||||
if isinstance(self.output, str):
|
||||
if any(chan is self.output for chan, _ in pending_writes):
|
||||
yield channels[self.output]._get()
|
||||
else:
|
||||
if updated := {c for c, _ in pending_writes if c in self.output}:
|
||||
yield {chan: channels[chan]._get() for chan in updated}
|
||||
def read(chan: str) -> Any:
|
||||
try:
|
||||
return channels[chan]._get()
|
||||
except EmptyChannelError:
|
||||
return None
|
||||
|
||||
# if no more tasks, we're done
|
||||
if not next_tasks:
|
||||
break
|
||||
# Similarly to Bulk Synchronous Parallel / Pregel model
|
||||
# computation proceeds in steps, while there are channel updates
|
||||
# channel updates from step N are only visible in step N+1,
|
||||
# channels are guaranteed to be immutable for the duration of the step,
|
||||
# channel updates being applied only at the transition between steps
|
||||
for step in range(config["recursion_limit"]):
|
||||
# collect all writes to channels, without applying them yet
|
||||
pending_writes = deque[tuple[str, Any]]()
|
||||
|
||||
# execute tasks, and wait for one to fail or all to finish
|
||||
# each task is independent from all other concurrent tasks
|
||||
done, inflight = await asyncio.wait(
|
||||
[
|
||||
asyncio.create_task(
|
||||
proc.ainvoke(
|
||||
input,
|
||||
patch_config(
|
||||
config,
|
||||
callbacks=run_manager.get_child(
|
||||
f"pregel:step:{step}"
|
||||
),
|
||||
configurable={
|
||||
# deque.extend is thread-safe
|
||||
CONFIG_KEY_SEND: pending_writes.extend,
|
||||
CONFIG_KEY_READ: read,
|
||||
CONFIG_KEY_STEP: step,
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
for proc, input in next_tasks
|
||||
],
|
||||
return_when=asyncio.FIRST_EXCEPTION,
|
||||
timeout=self.step_timeout,
|
||||
)
|
||||
|
||||
while done:
|
||||
# if any task failed
|
||||
if exc := done.pop().exception():
|
||||
# cancel all pending tasks
|
||||
while inflight:
|
||||
inflight.pop().cancel()
|
||||
# raise the exception
|
||||
raise exc
|
||||
# TODO this is where retry of an entire step would happen
|
||||
|
||||
if inflight:
|
||||
# if we got here means we timed out
|
||||
while inflight:
|
||||
# cancel all pending tasks
|
||||
inflight.pop().cancel()
|
||||
# raise timeout error
|
||||
raise TimeoutError(f"Timed out at step {step}")
|
||||
|
||||
# apply writes to channels, decide on next step
|
||||
next_tasks = _apply_writes_and_prepare_next_tasks(
|
||||
processes, channels, pending_writes
|
||||
)
|
||||
|
||||
# if any write to output channel in this step, yield current value
|
||||
if isinstance(self.output, str):
|
||||
if any(chan is self.output for chan, _ in pending_writes):
|
||||
yield channels[self.output]._get()
|
||||
else:
|
||||
if updated := {c for c, _ in pending_writes if c in self.output}:
|
||||
yield {chan: channels[chan]._get() for chan in updated}
|
||||
|
||||
# if no more tasks, we're done
|
||||
if not next_tasks:
|
||||
break
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Output:
|
||||
latest: Output | None = None
|
||||
) -> dict[str, Any] | Any:
|
||||
latest: dict[str, Any] | Any = None
|
||||
for chunk in self.stream(input, config, **kwargs):
|
||||
latest = chunk
|
||||
return latest
|
||||
@@ -489,7 +539,7 @@ class Pregel(Generic[Output], RunnableSerializable[dict[str, Any] | Any, Output]
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[Output]:
|
||||
) -> Iterator[dict[str, Any] | Any]:
|
||||
return self.transform(iter([input]), config, **kwargs)
|
||||
|
||||
def transform(
|
||||
@@ -497,7 +547,7 @@ class Pregel(Generic[Output], RunnableSerializable[dict[str, Any] | Any, Output]
|
||||
input: Iterator[dict[str, Any] | Any],
|
||||
config: RunnableConfig | None = None,
|
||||
**kwargs: Any | None,
|
||||
) -> Iterator[Output]:
|
||||
) -> Iterator[dict[str, Any] | Any]:
|
||||
return self._transform_stream_with_config(
|
||||
input, self._transform, config, **kwargs
|
||||
)
|
||||
@@ -507,8 +557,8 @@ class Pregel(Generic[Output], RunnableSerializable[dict[str, Any] | Any, Output]
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Output:
|
||||
latest: Output | None = None
|
||||
) -> dict[str, Any] | Any:
|
||||
latest: dict[str, Any] | Any = None
|
||||
async for chunk in self.astream(input, config, **kwargs):
|
||||
latest = chunk
|
||||
return latest
|
||||
@@ -518,8 +568,8 @@ class Pregel(Generic[Output], RunnableSerializable[dict[str, Any] | Any, Output]
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[Output]:
|
||||
async def input_stream() -> AsyncIterator[Input]:
|
||||
) -> AsyncIterator[dict[str, Any] | Any]:
|
||||
async def input_stream() -> AsyncIterator[dict[str, Any] | Any]:
|
||||
yield input
|
||||
|
||||
async for chunk in self.atransform(input_stream(), config, **kwargs):
|
||||
@@ -530,7 +580,7 @@ class Pregel(Generic[Output], RunnableSerializable[dict[str, Any] | Any, Output]
|
||||
input: AsyncIterator[dict[str, Any] | Any],
|
||||
config: RunnableConfig | None = None,
|
||||
**kwargs: Any | None,
|
||||
) -> AsyncIterator[Output]:
|
||||
) -> AsyncIterator[dict[str, Any] | Any]:
|
||||
async for chunk in self._atransform_stream_with_config(
|
||||
input, self._atransform, config, **kwargs
|
||||
):
|
||||
@@ -547,7 +597,7 @@ def _apply_writes_and_prepare_next_tasks(
|
||||
for chan, val in pending_writes:
|
||||
pending_writes_by_channel[chan].append(val)
|
||||
|
||||
updated_channels: set[Channel] = set()
|
||||
updated_channels: set[str] = set()
|
||||
# Apply writes to channels
|
||||
for chan, vals in pending_writes_by_channel.items():
|
||||
if chan in channels:
|
||||
@@ -595,7 +645,7 @@ def validate_chains_channels(
|
||||
input: str | None,
|
||||
output: str | Sequence[str],
|
||||
) -> None:
|
||||
subscribed_channels = set()
|
||||
subscribed_channels = set[str]()
|
||||
for chain in chains:
|
||||
if isinstance(chain, PregelInvoke):
|
||||
subscribed_channels.update(chain.channels.values())
|
||||
|
||||
Generated
+1073
-959
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -9,7 +9,7 @@ repository = "https://www.github.com/langchain-ai/permchain"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.8.1,<4.0"
|
||||
langchain = "^0.0.313"
|
||||
langchain = ">=0.0.313"
|
||||
|
||||
|
||||
[tool.poetry.group.test.dependencies]
|
||||
@@ -57,6 +57,7 @@ requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
# --strict-markers will raise errors on unknown marks.
|
||||
# https://docs.pytest.org/en/7.1.x/how-to/mark.html#raising-errors-on-unknown-marks
|
||||
#
|
||||
|
||||
+35
-14
@@ -1,14 +1,15 @@
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from langchain.schema.runnable import RunnablePassthrough
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from permchain import Pregel, channels
|
||||
from permchain.pregel import PregelInvoke
|
||||
|
||||
|
||||
def test_invoke_single_process_in_out(mocker: MockerFixture):
|
||||
def test_invoke_single_process_in_out(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
chain = Pregel.subscribe_to("input") | add_one | Pregel.send_to("output")
|
||||
|
||||
@@ -26,7 +27,7 @@ def test_invoke_single_process_in_out(mocker: MockerFixture):
|
||||
assert app.invoke(2) == 3
|
||||
|
||||
|
||||
def test_invoke_two_processes_in_out(mocker: MockerFixture):
|
||||
def test_invoke_two_processes_in_out(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
chain_one = Pregel.subscribe_to("input") | add_one | Pregel.send_to("inbox")
|
||||
chain_two = Pregel.subscribe_to_each("inbox") | add_one | Pregel.send_to("output")
|
||||
@@ -46,7 +47,7 @@ def test_invoke_two_processes_in_out(mocker: MockerFixture):
|
||||
assert pubsub.invoke(2) == 4
|
||||
|
||||
|
||||
def test_batch_two_processes_in_out():
|
||||
def test_batch_two_processes_in_out() -> None:
|
||||
def add_one_with_delay(inp: int) -> int:
|
||||
time.sleep(inp / 10)
|
||||
return inp + 1
|
||||
@@ -74,7 +75,7 @@ def test_batch_two_processes_in_out():
|
||||
assert pubsub.batch([3, 2, 1, 3, 5]) == [5, 4, 3, 5, 7]
|
||||
|
||||
|
||||
def test_invoke_many_processes_in_out(mocker: MockerFixture):
|
||||
def test_invoke_many_processes_in_out(mocker: MockerFixture) -> None:
|
||||
test_size = 100
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
|
||||
@@ -98,8 +99,13 @@ def test_invoke_many_processes_in_out(mocker: MockerFixture):
|
||||
for _ in range(10):
|
||||
assert app.invoke(2, {"recursion_limit": test_size}) == 2 + test_size
|
||||
|
||||
with ThreadPoolExecutor() as executor:
|
||||
assert [
|
||||
*executor.map(app.invoke, [2] * 10, [{"recursion_limit": test_size}] * 10)
|
||||
] == [2 + test_size] * 10
|
||||
|
||||
def test_batch_many_processes_in_out(mocker: MockerFixture):
|
||||
|
||||
def test_batch_many_processes_in_out(mocker: MockerFixture) -> None:
|
||||
test_size = 100
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
|
||||
@@ -130,8 +136,17 @@ def test_batch_many_processes_in_out(mocker: MockerFixture):
|
||||
5 + test_size,
|
||||
]
|
||||
|
||||
with ThreadPoolExecutor() as executor:
|
||||
assert [
|
||||
*executor.map(
|
||||
app.batch, [[2, 1, 3, 4, 5]] * 10, [{"recursion_limit": test_size}] * 10
|
||||
)
|
||||
] == [
|
||||
[2 + test_size, 1 + test_size, 3 + test_size, 4 + test_size, 5 + test_size]
|
||||
] * 10
|
||||
|
||||
def test_invoke_two_processes_two_in_two_out_invalid(mocker: MockerFixture):
|
||||
|
||||
def test_invoke_two_processes_two_in_two_out_invalid(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
|
||||
chain_one = Pregel.subscribe_to("input") | add_one | Pregel.send_to("output")
|
||||
@@ -153,7 +168,7 @@ def test_invoke_two_processes_two_in_two_out_invalid(mocker: MockerFixture):
|
||||
app.invoke(2)
|
||||
|
||||
|
||||
def test_invoke_two_processes_two_in_two_out_valid(mocker: MockerFixture):
|
||||
def test_invoke_two_processes_two_in_two_out_valid(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
|
||||
chain_one = Pregel.subscribe_to("input") | add_one | Pregel.send_to("output")
|
||||
@@ -174,7 +189,7 @@ def test_invoke_two_processes_two_in_two_out_valid(mocker: MockerFixture):
|
||||
assert app.invoke(2) == (3, 3)
|
||||
|
||||
|
||||
def test_invoke_two_processes_two_in_join_two_out(mocker: MockerFixture):
|
||||
def test_invoke_two_processes_two_in_join_two_out(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
add_10_each = mocker.Mock(side_effect=lambda x: sorted(y + 10 for y in x))
|
||||
|
||||
@@ -201,8 +216,11 @@ def test_invoke_two_processes_two_in_join_two_out(mocker: MockerFixture):
|
||||
for _ in range(100):
|
||||
assert app.invoke(2) == [13, 13]
|
||||
|
||||
with ThreadPoolExecutor() as executor:
|
||||
assert [*executor.map(app.invoke, [2] * 100)] == [[13, 13]] * 100
|
||||
|
||||
def test_invoke_join_then_call_other_pubsub(mocker: MockerFixture):
|
||||
|
||||
def test_invoke_join_then_call_other_pubsub(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
add_10_each = mocker.Mock(side_effect=lambda x: [y + 10 for y in x])
|
||||
|
||||
@@ -245,8 +263,11 @@ def test_invoke_join_then_call_other_pubsub(mocker: MockerFixture):
|
||||
for _ in range(10):
|
||||
assert app.invoke([2, 3]) == 27
|
||||
|
||||
with ThreadPoolExecutor() as executor:
|
||||
assert [*executor.map(app.invoke, [[2, 3]] * 10)] == [27] * 10
|
||||
|
||||
def test_invoke_two_processes_one_in_two_out(mocker: MockerFixture):
|
||||
|
||||
def test_invoke_two_processes_one_in_two_out(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
|
||||
chain_one = (
|
||||
@@ -272,7 +293,7 @@ def test_invoke_two_processes_one_in_two_out(mocker: MockerFixture):
|
||||
assert [c for c in app.stream(2)] == [3, 4]
|
||||
|
||||
|
||||
def test_invoke_two_processes_no_out(mocker: MockerFixture):
|
||||
def test_invoke_two_processes_no_out(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
chain_one = Pregel.subscribe_to("input") | add_one | Pregel.send_to("between")
|
||||
chain_two = Pregel.subscribe_to("between") | add_one
|
||||
@@ -295,14 +316,14 @@ def test_invoke_two_processes_no_out(mocker: MockerFixture):
|
||||
assert app.invoke(2) is None
|
||||
|
||||
|
||||
def test_invoke_two_processes_no_in(mocker: MockerFixture):
|
||||
def test_invoke_two_processes_no_in(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
|
||||
chain_one = Pregel.subscribe_to("between") | add_one | Pregel.send_to("output")
|
||||
chain_two = Pregel.subscribe_to("between") | add_one
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
app = Pregel(
|
||||
Pregel(
|
||||
chain_one,
|
||||
chain_two,
|
||||
channels={
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from langchain.schema.runnable import RunnablePassthrough
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from permchain import Pregel, channels
|
||||
from permchain.pregel import PregelInvoke
|
||||
|
||||
|
||||
async def test_invoke_single_process_in_out(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
chain = Pregel.subscribe_to("input") | add_one | Pregel.send_to("output")
|
||||
|
||||
app = Pregel(
|
||||
(chain,),
|
||||
channels={
|
||||
"input": channels.LastValue[int](),
|
||||
"output": channels.LastValue[int](),
|
||||
},
|
||||
input="input",
|
||||
output="output",
|
||||
)
|
||||
|
||||
assert await app.ainvoke(2) == 3
|
||||
|
||||
|
||||
async def test_invoke_two_processes_in_out(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
chain_one = Pregel.subscribe_to("input") | add_one | Pregel.send_to("inbox")
|
||||
chain_two = Pregel.subscribe_to_each("inbox") | add_one | Pregel.send_to("output")
|
||||
|
||||
app = Pregel(
|
||||
[chain_one, chain_two],
|
||||
channels={
|
||||
"input": channels.LastValue[int](),
|
||||
"output": channels.LastValue[int](),
|
||||
"inbox": channels.Inbox[int](),
|
||||
},
|
||||
input="input",
|
||||
output="output",
|
||||
)
|
||||
|
||||
assert await app.ainvoke(2) == 4
|
||||
|
||||
|
||||
async def test_batch_two_processes_in_out() -> None:
|
||||
async def add_one_with_delay(inp: int) -> int:
|
||||
await asyncio.sleep(inp / 10)
|
||||
return inp + 1
|
||||
|
||||
chain_one = (
|
||||
Pregel.subscribe_to("input") | add_one_with_delay | Pregel.send_to("one")
|
||||
)
|
||||
chain_two = (
|
||||
Pregel.subscribe_to("one") | add_one_with_delay | Pregel.send_to("output")
|
||||
)
|
||||
|
||||
app = Pregel(
|
||||
chain_one,
|
||||
chain_two,
|
||||
channels={
|
||||
"input": channels.LastValue[int](),
|
||||
"output": channels.LastValue[int](),
|
||||
"one": channels.LastValue[int](),
|
||||
},
|
||||
input="input",
|
||||
output="output",
|
||||
)
|
||||
|
||||
assert await app.abatch([3, 2, 1, 3, 5]) == [5, 4, 3, 5, 7]
|
||||
|
||||
|
||||
async def test_invoke_many_processes_in_out(mocker: MockerFixture) -> None:
|
||||
test_size = 100
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
|
||||
chans = {
|
||||
"input": channels.LastValue[int](),
|
||||
"output": channels.LastValue[int](),
|
||||
"-1": channels.LastValue[int](),
|
||||
}
|
||||
chains: list[PregelInvoke] = [
|
||||
Pregel.subscribe_to("input") | add_one | Pregel.send_to("-1")
|
||||
]
|
||||
for i in range(test_size - 2):
|
||||
chans[str(i)] = channels.LastValue[int]()
|
||||
chains.append(
|
||||
Pregel.subscribe_to(str(i - 1)) | add_one | Pregel.send_to(str(i))
|
||||
)
|
||||
chains.append(Pregel.subscribe_to(str(i)) | add_one | Pregel.send_to("output"))
|
||||
|
||||
app = Pregel(*chains, channels=chans, input="input", output="output")
|
||||
|
||||
# No state is left over from previous invocations
|
||||
for _ in range(10):
|
||||
assert await app.ainvoke(2, {"recursion_limit": test_size}) == 2 + test_size
|
||||
|
||||
# Concurrent invocations do not interfere with each other
|
||||
assert await asyncio.gather(
|
||||
*(app.ainvoke(2, {"recursion_limit": test_size}) for _ in range(10))
|
||||
) == [2 + test_size for _ in range(10)]
|
||||
|
||||
|
||||
async def test_batch_many_processes_in_out(mocker: MockerFixture) -> None:
|
||||
test_size = 100
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
|
||||
chans = {
|
||||
"input": channels.LastValue[int](),
|
||||
"output": channels.LastValue[int](),
|
||||
"-1": channels.LastValue[int](),
|
||||
}
|
||||
chains: list[PregelInvoke] = [
|
||||
Pregel.subscribe_to("input") | add_one | Pregel.send_to("-1")
|
||||
]
|
||||
for i in range(test_size - 2):
|
||||
chans[str(i)] = channels.LastValue[int]()
|
||||
chains.append(
|
||||
Pregel.subscribe_to(str(i - 1)) | add_one | Pregel.send_to(str(i))
|
||||
)
|
||||
chains.append(Pregel.subscribe_to(str(i)) | add_one | Pregel.send_to("output"))
|
||||
|
||||
app = Pregel(*chains, channels=chans, input="input", output="output")
|
||||
|
||||
# No state is left over from previous invocations
|
||||
for _ in range(10):
|
||||
# Then invoke pubsub
|
||||
assert await app.abatch([2, 1, 3, 4, 5], {"recursion_limit": test_size}) == [
|
||||
2 + test_size,
|
||||
1 + test_size,
|
||||
3 + test_size,
|
||||
4 + test_size,
|
||||
5 + test_size,
|
||||
]
|
||||
|
||||
# Concurrent invocations do not interfere with each other
|
||||
assert await asyncio.gather(
|
||||
*(
|
||||
app.abatch([2, 1, 3, 4, 5], {"recursion_limit": test_size})
|
||||
for _ in range(10)
|
||||
)
|
||||
) == [
|
||||
[2 + test_size, 1 + test_size, 3 + test_size, 4 + test_size, 5 + test_size]
|
||||
for _ in range(10)
|
||||
]
|
||||
|
||||
|
||||
async def test_invoke_two_processes_two_in_two_out_invalid(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
|
||||
chain_one = Pregel.subscribe_to("input") | add_one | Pregel.send_to("output")
|
||||
chain_two = Pregel.subscribe_to("input") | add_one | Pregel.send_to("output")
|
||||
|
||||
app = Pregel(
|
||||
chain_one,
|
||||
chain_two,
|
||||
channels={
|
||||
"input": channels.LastValue[int](),
|
||||
"output": channels.LastValue[int](),
|
||||
},
|
||||
input="input",
|
||||
output="output",
|
||||
)
|
||||
|
||||
with pytest.raises(channels.InvalidUpdateError):
|
||||
# LastValue channels can only be updated once per iteration
|
||||
await app.ainvoke(2)
|
||||
|
||||
|
||||
async def test_invoke_two_processes_two_in_two_out_valid(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
|
||||
chain_one = Pregel.subscribe_to("input") | add_one | Pregel.send_to("output")
|
||||
chain_two = Pregel.subscribe_to("input") | add_one | Pregel.send_to("output")
|
||||
|
||||
app = Pregel(
|
||||
chain_one,
|
||||
chain_two,
|
||||
channels={
|
||||
"input": channels.LastValue[int](),
|
||||
"output": channels.Inbox[int](),
|
||||
},
|
||||
input="input",
|
||||
output="output",
|
||||
)
|
||||
|
||||
# An Inbox channel accumulates updates into a sequence
|
||||
assert await app.ainvoke(2) == (3, 3)
|
||||
|
||||
|
||||
async def test_invoke_two_processes_two_in_join_two_out(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
add_10_each = mocker.Mock(side_effect=lambda x: sorted(y + 10 for y in x))
|
||||
|
||||
chain_one = Pregel.subscribe_to("input") | add_one | Pregel.send_to("inbox")
|
||||
chain_three = Pregel.subscribe_to("input") | add_one | Pregel.send_to("inbox")
|
||||
chain_four = Pregel.subscribe_to("inbox") | add_10_each | Pregel.send_to("output")
|
||||
|
||||
app = Pregel(
|
||||
chain_one,
|
||||
chain_three,
|
||||
chain_four,
|
||||
channels={
|
||||
"input": channels.LastValue[int](),
|
||||
"output": channels.LastValue[int](),
|
||||
"inbox": channels.Inbox[int](),
|
||||
},
|
||||
input="input",
|
||||
output="output",
|
||||
)
|
||||
|
||||
# Then invoke app
|
||||
# We get a single array result as chain_four waits for all publishers to finish
|
||||
# before operating on all elements published to topic_two as an array
|
||||
for _ in range(100):
|
||||
assert await app.ainvoke(2) == [13, 13]
|
||||
|
||||
assert await asyncio.gather(*(app.ainvoke(2) for _ in range(100))) == [
|
||||
[13, 13] for _ in range(100)
|
||||
]
|
||||
|
||||
|
||||
async def test_invoke_join_then_call_other_pubsub(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
add_10_each = mocker.Mock(side_effect=lambda x: [y + 10 for y in x])
|
||||
|
||||
inner_app = Pregel(
|
||||
Pregel.subscribe_to("input") | add_one | Pregel.send_to("output"),
|
||||
channels={
|
||||
"input": channels.LastValue[int](),
|
||||
"output": channels.LastValue[int](),
|
||||
},
|
||||
input="input",
|
||||
output="output",
|
||||
)
|
||||
|
||||
chain_one = (
|
||||
Pregel.subscribe_to("input") | add_10_each | Pregel.send_to("inbox_one").map()
|
||||
)
|
||||
chain_two = (
|
||||
Pregel.subscribe_to("inbox_one")
|
||||
| inner_app.map()
|
||||
| sorted
|
||||
| Pregel.send_to("outbox_one")
|
||||
)
|
||||
chain_three = Pregel.subscribe_to("outbox_one") | sum | Pregel.send_to("output")
|
||||
|
||||
app = Pregel(
|
||||
chain_one,
|
||||
chain_two,
|
||||
chain_three,
|
||||
channels={
|
||||
"input": channels.LastValue[int](),
|
||||
"output": channels.LastValue[int](),
|
||||
"inbox_one": channels.Inbox[int](),
|
||||
"outbox_one": channels.LastValue[int](),
|
||||
},
|
||||
input="input",
|
||||
output="output",
|
||||
)
|
||||
|
||||
# Then invoke pubsub
|
||||
for _ in range(10):
|
||||
assert await app.ainvoke([2, 3]) == 27
|
||||
|
||||
assert await asyncio.gather(*(app.ainvoke([2, 3]) for _ in range(10))) == [
|
||||
27 for _ in range(10)
|
||||
]
|
||||
|
||||
|
||||
async def test_invoke_two_processes_one_in_two_out(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
|
||||
chain_one = (
|
||||
Pregel.subscribe_to("input")
|
||||
| add_one
|
||||
| Pregel.send_to(output=RunnablePassthrough(), between=RunnablePassthrough())
|
||||
)
|
||||
chain_two = Pregel.subscribe_to("between") | add_one | Pregel.send_to("output")
|
||||
|
||||
app = Pregel(
|
||||
chain_one,
|
||||
chain_two,
|
||||
channels={
|
||||
"input": channels.LastValue[int](),
|
||||
"output": channels.LastValue[int](),
|
||||
"between": channels.LastValue[int](),
|
||||
},
|
||||
input="input",
|
||||
output="output",
|
||||
)
|
||||
|
||||
# Then invoke pubsub
|
||||
assert [c async for c in app.astream(2)] == [3, 4]
|
||||
|
||||
|
||||
async def test_invoke_two_processes_no_out(mocker: MockerFixture) -> None:
|
||||
add_one = mocker.Mock(side_effect=lambda x: x + 1)
|
||||
chain_one = Pregel.subscribe_to("input") | add_one | Pregel.send_to("between")
|
||||
chain_two = Pregel.subscribe_to("between") | add_one
|
||||
|
||||
app = Pregel(
|
||||
chain_one,
|
||||
chain_two,
|
||||
channels={
|
||||
"input": channels.LastValue[int](),
|
||||
"output": channels.LastValue[int](),
|
||||
"between": channels.LastValue[int](),
|
||||
},
|
||||
input="input",
|
||||
output="output",
|
||||
)
|
||||
|
||||
# Then invoke pubsub
|
||||
# It finishes executing (once no more messages being published)
|
||||
# but returns nothing, as nothing was published to OUT topic
|
||||
assert await app.ainvoke(2) is None
|
||||
Reference in New Issue
Block a user