Files
langgraph/permchain/pubsub.py
T
2023-09-12 15:48:02 +01:00

162 lines
5.6 KiB
Python

from __future__ import annotations
from abc import ABC
from concurrent.futures import CancelledError, Future
from functools import partial
from itertools import groupby
from typing import Any, Iterator, List, Optional, Sequence, Set, TypeVar
from langchain.callbacks.manager import CallbackManagerForChainRun
from langchain.load.serializable import Serializable
from langchain.schema.runnable import Runnable, RunnableConfig, patch_config
from langchain.schema.runnable.base import Runnable
from langchain.schema.runnable.config import get_executor_for_config
from permchain.connection import PubSubConnection
from permchain.constants import CONFIG_GET_KEY, CONFIG_SEND_KEY
from permchain.topic import INPUT_TOPIC, OUTPUT_TOPIC, RunnableSubscriber
T = TypeVar("T")
T_in = TypeVar("T_in")
T_out = TypeVar("T_out")
class PubSub(Serializable, Runnable[Any, Any], ABC):
processes: Sequence[RunnableSubscriber[Any]]
connection: PubSubConnection
class Config:
arbitrary_types_allowed = True
def with_retry(self, **kwargs: Any) -> Runnable[Any, Any]:
return self.__class__(
processes=[p.with_retry(**kwargs) for p in self.processes],
connection=self.connection,
)
def _transform(
self,
input: Iterator[Any],
run_manager: CallbackManagerForChainRun,
config: RunnableConfig,
) -> Iterator[Any]:
# Consume input iterator into a single value
input_value = None
for chunk in input:
if input_value is None:
input_value = chunk
else:
input_value += chunk
with get_executor_for_config(config) as executor:
# Namespace topics for each run
topic_prefix = str(run_manager.parent_run_id or run_manager.run_id)
# Track inflight futures
inflight: Set[Future] = set()
# Track exceptions
exceptions: List[Exception] = []
def run_once(process: RunnableSubscriber[Any], value: Any) -> None:
"""Run a process once."""
def cleanup_run(fut: Future) -> None:
"""Cleanup after a process runs."""
inflight.discard(fut)
try:
exc = fut.exception()
except CancelledError:
exc = None
except Exception as e:
exc = e
if exc is not None:
exceptions.append(exc)
# Close output iterator if
# - all processes are done, or
# - an exception occurred
if not inflight or exc is not None:
self.connection.disconnect(topic_prefix)
def get(topic_name: str) -> Any:
if topic_name == INPUT_TOPIC:
return input_value
elif topic_name == process.topic.name:
return value
else:
raise ValueError(
f"Cannot get value for {topic_name} in this context"
)
# Run process once in executor
fut = executor.submit(
process.invoke,
value,
config={
**patch_config(
config,
callbacks=run_manager.get_child(),
run_name=f"Topic: {process.topic.name}",
),
CONFIG_SEND_KEY: partial(self.connection.send, topic_prefix),
CONFIG_GET_KEY: get,
},
)
# Add callback to cleanup
inflight.add(fut)
fut.add_done_callback(cleanup_run)
# Listen on all subscribed topics
processes_by_topic = groupby(
sorted(self.processes, key=lambda p: p.topic.name),
lambda p: p.topic.name,
)
for topic_name, processes in processes_by_topic:
self.connection.listen(
topic_prefix,
topic_name,
[partial(run_once, process) for process in processes],
)
# Send input to input processes
self.connection.send(topic_prefix, INPUT_TOPIC, input_value)
try:
if inflight:
# Yield output until all processes are done
# This blocks the current thread, all other work needs to go
# through the executor
for chunk in self.connection.iterate(topic_prefix, OUTPUT_TOPIC):
yield chunk
else:
self.connection.disconnect(topic_prefix)
finally:
# Cancel all inflight futures
while inflight:
inflight.pop().cancel()
# Raise exceptions if any
if exceptions:
raise exceptions[0]
def stream(
self,
input: Any,
config: Optional[RunnableConfig] = None,
**kwargs: Optional[Any],
) -> Iterator[Any]:
yield from self._transform_stream_with_config(
iter([input]), self._transform, config, **kwargs
)
def invoke(self, input: Any, config: Optional[RunnableConfig] = None) -> Any:
collected = []
for chunk in self.stream(input, config):
collected.append(chunk)
return collected
PubSub.update_forward_refs()