Merge pull request #5 from langchain-ai/nc/single-out-message

Nc/single out message
This commit is contained in:
Nuno Campos
2023-10-05 10:00:47 +01:00
committed by GitHub
4 changed files with 170 additions and 136 deletions
+26 -23
View File
@@ -2,61 +2,64 @@ import asyncio
from abc import ABC, abstractmethod
from typing import Any, Callable, Iterator, TypedDict
PubSubListener = Callable[[Any], None]
class LogMessage(TypedDict):
class PubSubMessage(TypedDict):
topic: str
value: Any
published_at: str
correlation_id: str
PubSubListener = Callable[[PubSubMessage], None]
class PubSubConnection(ABC):
def full_topic_name(self, prefix: str, *parts: str) -> str:
def full_name(self, prefix: str, *parts: str) -> str:
"""Return the full topic name for a given prefix and topic name."""
return ":".join(map(str, [prefix, *parts]))
@abstractmethod
def iterate(self, prefix: str, topic_name: str, wait: bool) -> Iterator[Any]:
"""Iterate over all currently queued messages for a topic, consuming them."""
def observe(self, prefix: str) -> Iterator[PubSubMessage]:
"""Iterate over messages for all topics under this prefix,
without affecting listeners/iterators on each topic.
This method waits for new messages to arrive."""
...
@abstractmethod
def iterate(
self, prefix: str, topic: str, *, wait: bool
) -> Iterator[PubSubMessage]:
"""Iterate over all currently queued messages for a topic, consuming them.
Optionally wait for new messages to arrive."""
...
# TODO add aiterate() method
@abstractmethod
def listen(
self, prefix: str, topic_name: str, listeners: list[PubSubListener]
) -> None:
def listen(self, prefix: str, topic: str, listeners: list[PubSubListener]) -> None:
...
async def alisten(
self, prefix: str, topic_name: str, listeners: list[PubSubListener]
self, prefix: str, topic: str, listeners: list[PubSubListener]
) -> None:
return await asyncio.get_event_loop().run_in_executor(
None, self.listen, prefix, topic_name, listeners
None, self.listen, prefix, topic, listeners
)
@abstractmethod
def send(self, prefix: str, topic_name: str, message: Any) -> None:
def send(self, prefix: str, topic: str, value: Any) -> None:
...
async def asend(self, prefix: str, topic_name: str, message: Any) -> None:
async def asend(self, prefix: str, topic: str, value: Any) -> None:
return await asyncio.get_event_loop().run_in_executor(
None, self.send, prefix, topic_name, message
None, self.send, prefix, topic, value
)
@abstractmethod
def disconnect(self, prefix: str) -> None:
def disconnect(self, name: str) -> None:
...
async def adisconnect(self, prefix: str) -> None:
async def adisconnect(self, name: str) -> None:
return await asyncio.get_event_loop().run_in_executor(
None, self.disconnect, prefix
None, self.disconnect, name
)
@abstractmethod
def peek(self, prefix: str) -> Iterator[LogMessage]:
"""Iterate over all previously published messages for all topics,
without consuming them."""
...
+46 -39
View File
@@ -2,18 +2,25 @@ import queue
import threading
from collections import defaultdict
from datetime import datetime
from typing import Any, Iterator
from typing import Any, Iterator, cast
from permchain.connection import LogMessage, PubSubConnection, PubSubListener
from permchain.connection import PubSubConnection, PubSubListener, PubSubMessage
class IterableQueue(queue.SimpleQueue):
done_sentinel = object()
def get(self, block: bool = True, timeout: float | None = None) -> Any:
def put(
self, item: PubSubMessage, block: bool = True, timeout: float | None = None
) -> None:
return super().put(item, block, timeout)
def get(
self, block: bool = True, timeout: float | None = None
) -> PubSubMessage | object:
return super().get(block=block, timeout=timeout)
def __iter__(self) -> Iterator[Any]:
def __iter__(self) -> Iterator[PubSubMessage]:
return iter(self.get, self.done_sentinel)
def close(self) -> None:
@@ -22,23 +29,25 @@ class IterableQueue(queue.SimpleQueue):
class InMemoryPubSubConnection(PubSubConnection):
clear_on_disconnect: bool
logs: defaultdict[str, list[Any]]
logs: defaultdict[str, IterableQueue]
topics: defaultdict[str, IterableQueue]
listeners: defaultdict[str, list[PubSubListener]]
lock: threading.RLock
def __init__(self, clear_on_disconnect: bool = True) -> None:
self.clear_on_disconnect = clear_on_disconnect
self.logs = defaultdict(list)
self.logs = defaultdict(IterableQueue)
self.topics = defaultdict(IterableQueue)
self.listeners = defaultdict(list)
self.lock = threading.RLock()
def peek(self, prefix: str) -> Iterator[LogMessage]:
def observe(self, prefix: str) -> Iterator[PubSubMessage]:
return iter(self.logs[str(prefix)])
def iterate(self, prefix: str, topic_name: str, wait: bool) -> Iterator[Any]:
topic = self.full_topic_name(prefix, topic_name)
def iterate(
self, prefix: str, topic: str, *, wait: bool
) -> Iterator[PubSubMessage]:
topic = self.full_name(prefix, topic)
# This connection doesn't support iterating over topics with listeners connected
with self.lock:
@@ -53,56 +62,54 @@ class InMemoryPubSubConnection(PubSubConnection):
return iter(self.topics[topic])
def listen(
self, prefix: str, topic_name: str, listeners: list[PubSubListener]
) -> None:
topic = self.full_topic_name(prefix, topic_name)
self.disconnect(topic)
def listen(self, prefix: str, topic: str, listeners: list[PubSubListener]) -> None:
full_name = self.full_name(prefix, topic)
self.disconnect(full_name)
with self.lock:
# Add the listeners for future messages
self.listeners[topic].extend(listeners)
self.listeners[full_name].extend(listeners)
# Send any pending messages to the listeners
topic_queue = self.topics[topic]
topic_queue = self.topics[full_name]
while not topic_queue.empty():
message = topic_queue.get()
if message is not topic_queue.done_sentinel:
for listener in self.listeners[topic]:
listener(message)
for listener in self.listeners[full_name]:
listener(cast(PubSubMessage, message))
def send(self, prefix: str, topic_name: str, message: Any) -> None:
topic = self.full_topic_name(prefix, topic_name)
def send(self, prefix: str, topic: str, value: Any) -> None:
full_name = self.full_name(prefix, topic)
message = PubSubMessage(
value=value,
topic=topic,
correlation_id=str(prefix),
published_at=datetime.now().isoformat(),
)
# Add the message to the log
self.logs[str(prefix)].put(message)
with self.lock:
# Add the message to the log
self.logs[str(prefix)].append(
LogMessage(
value=message,
topic=topic_name,
correlation_id=str(prefix),
published_at=datetime.now().isoformat(),
)
)
listeners = self.listeners[topic]
listeners = self.listeners[full_name]
if listeners:
# Send the message to listeners if any are connected
for listener in listeners:
listener(message)
else:
# Otherwise add the message to the topic queue for later
self.topics[topic].put(message)
self.topics[full_name].put(message)
def disconnect(self, prefix_or_topic: str) -> None:
def disconnect(self, name: str) -> None:
with self.lock:
if self.clear_on_disconnect:
if prefix_or_topic in self.logs:
del self.logs[prefix_or_topic]
if name in self.logs:
self.logs[name].close()
if self.clear_on_disconnect:
del self.logs[name]
to_delete = []
for topic, queue in self.topics.items():
if topic.startswith(prefix_or_topic):
queue.close()
for topic, q in self.topics.items():
if topic.startswith(name):
q.close()
if self.clear_on_disconnect:
to_delete.append(topic)
# can't delete while iterating
@@ -111,7 +118,7 @@ class InMemoryPubSubConnection(PubSubConnection):
to_delete = []
for topic in self.listeners:
if topic.startswith(prefix_or_topic):
if topic.startswith(name):
to_delete.append(topic)
# can't delete while iterating
for topic in to_delete:
+18 -13
View File
@@ -8,10 +8,9 @@ from typing import Any, Iterator, List, Optional, Sequence, Set, TypeVar
from langchain.callbacks.manager import CallbackManagerForChainRun
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.connection import PubSubConnection, PubSubMessage
from permchain.constants import CONFIG_GET_KEY, CONFIG_SEND_KEY
from permchain.topic import (
INPUT_TOPIC,
@@ -84,7 +83,7 @@ class PubSub(Runnable[Any, Any], ABC):
# Track inflight futures
inflight: Set[Future] = set()
# Track exceptions
exceptions: List[Exception] = []
exceptions: List[BaseException] = []
def on_idle() -> None:
"""Called when all subscribed topics are empty.
@@ -127,9 +126,15 @@ class PubSub(Runnable[Any, Any], ABC):
on_idle()
def run_once(
process: RunnableSubscriber[Any] | RunnableReducer[Any], value: Any
process: RunnableSubscriber[Any] | RunnableReducer[Any],
messages: PubSubMessage | list[PubSubMessage],
) -> None:
"""Run a process once."""
value = (
[m["value"] for m in messages]
if isinstance(messages, list)
else messages["value"]
)
def get(topic_name: str) -> Any:
if topic_name == INPUT_TOPIC:
@@ -151,10 +156,10 @@ class PubSub(Runnable[Any, Any], ABC):
callbacks=run_manager.get_child(),
run_name=f"Topic: {process.topic.name}",
),
"correlation_id": self.connection.full_topic_name(
"correlation_id": self.connection.full_name(
topic_prefix,
process.topic.name,
self.processes.index(process),
str(self.processes.index(process)),
),
CONFIG_SEND_KEY: partial(self.connection.send, topic_prefix),
CONFIG_GET_KEY: get,
@@ -181,10 +186,11 @@ class PubSub(Runnable[Any, Any], ABC):
# 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, wait=True
):
for chunk in self.connection.observe(topic_prefix):
yield chunk
if chunk["topic"] == OUTPUT_TOPIC:
self.connection.disconnect(topic_prefix)
break
else:
on_idle()
finally:
@@ -201,13 +207,12 @@ class PubSub(Runnable[Any, Any], ABC):
input: Any,
config: Optional[RunnableConfig] = None,
**kwargs: Optional[Any],
) -> Iterator[Any]:
) -> Iterator[PubSubMessage]:
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
if chunk["topic"] == OUTPUT_TOPIC:
return chunk["value"]
+80 -61
View File
@@ -1,13 +1,24 @@
from typing import Iterator
from uuid import uuid4
import pytest
from pytest_mock import MockerFixture
from permchain.connection import PubSubMessage
from permchain.connection_inmemory import InMemoryPubSubConnection
from permchain.pubsub import PubSub
from permchain.topic import RunnableSubscriber, Topic
def clean_log(
logs: Iterator[PubSubMessage], correlation_id: bool | None = None
) -> list[PubSubMessage]:
if correlation_id is False:
return [{**m, "published_at": None, "correlation_id": None} for m in logs]
else:
return [{**m, "published_at": None} for m in logs]
def test_invoke_single_process_in_out(mocker: MockerFixture):
add_one = mocker.Mock(side_effect=lambda x: x + 1)
chain = Topic.IN.subscribe() | add_one | Topic.OUT.publish()
@@ -22,7 +33,7 @@ def test_invoke_single_process_in_out(mocker: MockerFixture):
# If we start with 0 listeners
assert conn.listeners == {}
# Then invoke pubsub
assert pubsub.invoke(2) == [3]
assert pubsub.invoke(2) == 3
# After invoke returns the listeners were cleaned up
assert conn.listeners == {}
@@ -44,7 +55,7 @@ def test_invoke_two_processes_in_out(mocker: MockerFixture):
# If we start with 0 listeners
assert conn.listeners == {}
# Then invoke pubsub
assert pubsub.invoke(2) == [4]
assert pubsub.invoke(2) == 4
# After invoke returns the listeners were cleaned up
assert conn.listeners == {}
@@ -71,12 +82,7 @@ def test_invoke_two_processes_in_out_interrupt(mocker: MockerFixture):
correlation_id = uuid4()
# invoke() step 1
assert pubsub_one.invoke(2, {"correlation_id": correlation_id}) == []
# listeners are still cleared, even though state is preserved
assert conn.listeners == {}
# The log contains all messages published to all topics, in order
assert [{**m, "published_at": None} for m in conn.peek(correlation_id)] == [
assert clean_log(pubsub_one.stream(2, {"correlation_id": correlation_id})) == [
{
"value": 2,
"topic": "__in__",
@@ -90,33 +96,17 @@ def test_invoke_two_processes_in_out_interrupt(mocker: MockerFixture):
"published_at": None,
},
]
# IN, OUT, one
assert len(conn.topics) == 3
topic_one_full_name = conn.full_topic_name(correlation_id, topic_one.name)
# IN, one
assert len(conn.topics) == 2
topic_one_full_name = conn.full_name(correlation_id, topic_one.name)
# the actual message publishd by chain_one, and a sentinel "end" value
assert conn.topics[topic_one_full_name].qsize() == 2
# invoke() step 2
# this picks up where the first left off, and produces same result as
# `test_invoke_two_processes_in_out`
assert pubsub_two.invoke(None, {"correlation_id": correlation_id}) == [4]
# listeners are still cleared, even though state is preserved
assert conn.listeners == {}
# The log contains all messages published to all topics, in order
assert [{**m, "published_at": None} for m in conn.peek(correlation_id)] == [
{
"value": 2,
"topic": "__in__",
"correlation_id": str(correlation_id),
"published_at": None,
},
{
"value": 3,
"topic": "one",
"correlation_id": str(correlation_id),
"published_at": None,
},
assert clean_log(pubsub_two.stream(None, {"correlation_id": correlation_id})) == [
{
"value": None,
"topic": "__in__",
@@ -130,6 +120,8 @@ def test_invoke_two_processes_in_out_interrupt(mocker: MockerFixture):
"published_at": None,
},
]
# listeners are still cleared, even though state is preserved
assert conn.listeners == {}
# IN, OUT, one
assert len(conn.topics) == 3
@@ -142,8 +134,8 @@ def test_invoke_two_processes_in_out_interrupt(mocker: MockerFixture):
# Empty because this was consumed by invoke()
assert queue.qsize() == 0
if topic_name.endswith("one"):
# Contains sentinel "end" value
assert queue.qsize() == 1
# Contains 2 sentinel "end" values
assert queue.qsize() == 2
def test_invoke_many_processes_in_out(mocker: MockerFixture):
@@ -170,7 +162,7 @@ def test_invoke_many_processes_in_out(mocker: MockerFixture):
# If we start with 0 listeners
assert conn.listeners == {}
# Then invoke pubsub
assert pubsub.invoke(2) == [2 + test_size]
assert pubsub.invoke(2) == 2 + test_size
# After invoke returns the listeners were cleaned up
assert conn.listeners == {}
@@ -192,8 +184,9 @@ def test_invoke_two_processes_two_in_two_out(mocker: MockerFixture):
assert conn.listeners == {}
# Then invoke pubsub
# We get two equal results as the two chains do the same thing
assert pubsub.invoke(2) == [3, 3]
# We get only one of the two return values, as computation is closed
# as soon as we publish to OUT for the first time
assert pubsub.invoke(2) == 3
# After invoke returns the listeners were cleaned up
assert conn.listeners == {}
@@ -223,7 +216,7 @@ def test_invoke_two_processes_two_in_join_two_out(mocker: MockerFixture):
# Then invoke pubsub
# 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
assert pubsub.invoke(2) == [[13, 14]]
assert pubsub.invoke(2) == [13, 14]
# After invoke returns the listeners were cleaned up
assert conn.listeners == {}
@@ -255,8 +248,7 @@ def test_invoke_join_then_subscribe(mocker: MockerFixture):
# Then invoke pubsub
# 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
assert pubsub.invoke([2, 3], {"correlation_id": correlation_id}) == [26]
assert [{**m, "published_at": None} for m in conn.peek(correlation_id)] == [
assert clean_log(pubsub.stream([2, 3], {"correlation_id": correlation_id})) == [
{
"value": [2, 3],
"topic": "__in__",
@@ -293,7 +285,6 @@ def test_invoke_join_then_subscribe(mocker: MockerFixture):
assert conn.listeners == {}
@pytest.mark.skip("TODO")
def test_invoke_join_then_call_other_pubsub(mocker: MockerFixture):
conn = InMemoryPubSubConnection(clear_on_disconnect=False)
add_one = mocker.Mock(side_effect=lambda x: x + 1)
@@ -308,8 +299,8 @@ def test_invoke_join_then_call_other_pubsub(mocker: MockerFixture):
topic_two = Topic("two")
chain_one = Topic.IN.subscribe() | add_10_each | topic_one.publish_each()
chain_two = topic_one.join() | inner_pubsub.map() | topic_two.publish()
chain_three = topic_two.subscribe() | Topic.OUT.publish()
chain_two = topic_one.join() | inner_pubsub.map() | sorted | topic_two.publish()
chain_three = topic_two.subscribe() | sum | Topic.OUT.publish()
correlation_id = uuid4()
pubsub = PubSub((chain_one, chain_two, chain_three), connection=conn)
@@ -319,21 +310,37 @@ def test_invoke_join_then_call_other_pubsub(mocker: MockerFixture):
assert conn.listeners == {}
# Then invoke pubsub
# 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
# assert pubsub.invoke([2, 3], {"correlation_id": correlation_id}) == [[13, 14]]
pubsub.invoke([2, 3], {"correlation_id": correlation_id})
assert [{**m, "started_at": None} for m in conn.peek(correlation_id)] == [
{"message": [2, 3], "topic_name": "__in__", "started_at": None},
{"message": 12, "topic_name": "one", "started_at": None},
{"message": 13, "topic_name": "one", "started_at": None},
{"message": 25, "topic_name": "two", "started_at": None},
{"message": 26, "topic_name": "__out__", "started_at": None},
{"message": [2, 3], "started_at": None, "topic_name": "__in__"},
{"message": 12, "started_at": None, "topic_name": "one"},
{"message": 13, "started_at": None, "topic_name": "one"},
{"message": 25, "started_at": None, "topic_name": "two"},
{"message": 26, "started_at": None, "topic_name": "__out__"},
assert clean_log(pubsub.stream([2, 3], {"correlation_id": correlation_id})) == [
{
"value": [2, 3],
"topic": "__in__",
"correlation_id": str(correlation_id),
"published_at": None,
},
{
"value": 12,
"topic": "one",
"correlation_id": str(correlation_id),
"published_at": None,
},
{
"value": 13,
"topic": "one",
"correlation_id": str(correlation_id),
"published_at": None,
},
{
"value": [13, 14],
"topic": "two",
"correlation_id": str(correlation_id),
"published_at": None,
},
{
"value": 27,
"topic": "__out__",
"correlation_id": str(correlation_id),
"published_at": None,
},
]
# After invoke returns the listeners were cleaned up
@@ -361,9 +368,21 @@ def test_invoke_two_processes_one_in_two_out(mocker: MockerFixture):
assert conn.listeners == {}
# Then invoke pubsub
# pubsub didn't stop executing after getting the first return value
# the values arrive in the order they are produced
assert pubsub.invoke(2) == [3, 4]
# pubsub stopped executing after publishing to OUT, so only one value is returned
assert clean_log(pubsub.stream(2), correlation_id=False) == [
{
"value": 2,
"topic": "__in__",
"correlation_id": None,
"published_at": None,
},
{
"value": 3,
"topic": "__out__",
"correlation_id": None,
"published_at": None,
},
]
# After invoke returns the listeners were cleaned up
assert conn.listeners == {}
@@ -389,7 +408,7 @@ def test_invoke_two_processes_no_out(mocker: MockerFixture):
# Then invoke pubsub
# It finishes executing (once no more messages being published)
# but returns nothing, as nothing was published to OUT topic
assert pubsub.invoke(2) == []
assert pubsub.invoke(2) is None
# After invoke returns the listeners were cleaned up
assert conn.listeners == {}
@@ -414,14 +433,14 @@ def test_invoke_two_processes_no_in(mocker: MockerFixture):
# Then invoke pubsub
# It returns without any output as there is nothing to run
assert pubsub.invoke(2) == []
assert pubsub.invoke(2) is None
# After invoke returns the listeners were cleaned up
assert conn.listeners == {}
@pytest.mark.skip("TODO")
def test_invoke_two_processes_simple_cycle(mocker: MockerFixture):
def test_invoke_two_processes_simple_cycle(mocker: MockerFixture) -> None:
add_one = mocker.Mock(side_effect=lambda x: x + 1)
topic_one = Topic("one")
chain_one = Topic.IN.subscribe() | add_one | topic_one.publish()