Implement LangGraph Scheduler for Kafka

- Orchestrator and Executor classes to run LangGraph in a distributed fashion using Kafka as a message bus for communication
- Orchestrator and Executor run on-demand when a new message is published to the topic they listen to
- Orchestrator is responsible for running the Pregel algorithm (deciding next tasks to run) and sending messages to the executor topic
- Executor is responsible for executing each task (node), and sending messages to the orchestrator topic when done
This commit is contained in:
Nuno Campos
2024-09-10 16:17:22 -07:00
parent 5d4b276af2
commit 841aabf5b3
17 changed files with 1749 additions and 1 deletions
+2
View File
@@ -9,6 +9,7 @@ CONFIG_KEY_STREAM = "__pregel_stream"
CONFIG_KEY_STORE = "__pregel_store"
CONFIG_KEY_RESUMING = "__pregel_resuming"
CONFIG_KEY_TASK_ID = "__pregel_task_id"
CONFIG_KEY_DEDUPE_TASKS = "__pregel_dedupe_tasks"
# this one part of public API so more readable
CONFIG_KEY_CHECKPOINT_MAP = "checkpoint_map"
INTERRUPT = "__interrupt__"
@@ -30,6 +31,7 @@ RESERVED = {
CONFIG_KEY_STORE,
CONFIG_KEY_RESUMING,
CONFIG_KEY_TASK_ID,
CONFIG_KEY_DEDUPE_TASKS,
INPUT,
RUNTIME_PLACEHOLDER,
}
+7 -1
View File
@@ -39,6 +39,7 @@ from langgraph.checkpoint.base import (
)
from langgraph.constants import (
CONFIG_KEY_CHECKPOINT_MAP,
CONFIG_KEY_DEDUPE_TASKS,
CONFIG_KEY_RESUMING,
CONFIG_KEY_STREAM,
CONFIG_KEY_TASK_ID,
@@ -179,7 +180,10 @@ class PregelLoop:
self.output_keys = output_keys
self.stream_keys = stream_keys
self.is_nested = CONFIG_KEY_TASK_ID in self.config.get("configurable", {})
self.skip_done_tasks = "checkpoint_id" not in config["configurable"]
self.skip_done_tasks = (
"checkpoint_id" not in config["configurable"]
or CONFIG_KEY_DEDUPE_TASKS in config["configurable"]
)
self.debug = debug
if CONFIG_KEY_STREAM in config["configurable"]:
self.stream = DuplexStream(
@@ -595,6 +599,7 @@ class SyncPregelLoop(PregelLoop, ContextManager):
**self.config,
**saved.config,
"configurable": {
"checkpoint_ns": "",
**self.config.get("configurable", {}),
**saved.config.get("configurable", {}),
},
@@ -697,6 +702,7 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager):
**self.config,
**saved.config,
"configurable": {
"checkpoint_ns": "",
**self.config.get("configurable", {}),
**saved.config.get("configurable", {}),
},
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 LangChain, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+46
View File
@@ -0,0 +1,46 @@
.PHONY: test test_watch lint format
######################
# TESTING AND COVERAGE
######################
start-services:
docker compose -f testss/compose.yml up -V --force-recreate --wait --remove-orphans
stop-services:
docker compose -f testss/compose.yml down
test:
make start-services && poetry run pytest; \
EXIT_CODE=$$?; \
make stop-services; \
exit $$EXIT_CODE
test_watch:
make start-services && poetry run ptw .; \
EXIT_CODE=$$?; \
make stop-services; \
exit $$EXIT_CODE
######################
# LINTING AND FORMATTING
######################
# Define a variable for Python and notebook files.
PYTHON_FILES=.
MYPY_CACHE=.mypy_cache
lint format: PYTHON_FILES=.
lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --relative --diff-filter=d main . | grep -E '\.py$$|\.ipynb$$')
lint_package: PYTHON_FILES=langgraph
lint_tests: PYTHON_FILES=tests
lint_tests: MYPY_CACHE=.mypy_cache_test
lint lint_diff lint_package lint_tests:
poetry run ruff check .
[ "$(PYTHON_FILES)" = "" ] || poetry run ruff format $(PYTHON_FILES) --diff
[ "$(PYTHON_FILES)" = "" ] || poetry run ruff check --select I $(PYTHON_FILES)
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE) || poetry run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
format format_diff:
poetry run ruff format $(PYTHON_FILES)
poetry run ruff check --select I --fix $(PYTHON_FILES)
+3
View File
@@ -0,0 +1,3 @@
# LangGraph Scheduler for Kafka
...
@@ -0,0 +1,94 @@
import asyncio
from contextlib import AbstractAsyncContextManager
from typing import Any
import aiokafka
import langgraph.scheduler.kafka.serde as serde
from langgraph.constants import ERROR
from langgraph.errors import TaskNotFound
from langgraph.pregel import Pregel
from langgraph.pregel.algo import prepare_single_task
from langgraph.pregel.executor import AsyncBackgroundExecutor
from langgraph.pregel.manager import AsyncChannelsManager
from langgraph.pregel.runner import PregelRunner
from langgraph.scheduler.kafka.types import (
MessageToExecutor,
MessageToOrchestrator,
Topics,
)
class KafkaExecutor(AbstractAsyncContextManager):
def __init__(self, graph: Pregel, topics: Topics, **kwargs: Any) -> None:
self.graph = graph
self.topics = topics
self.consumer = aiokafka.AIOKafkaConsumer(
topics.executor, value_deserializer=serde.loads, **kwargs
)
self.producer = aiokafka.AIOKafkaProducer(
value_serializer=serde.dumps, **kwargs
)
async def __aenter__(self) -> "KafkaExecutor":
await self.consumer.start()
await self.producer.start()
return self
async def __aexit__(self, *args: Any) -> None:
await self.consumer.stop()
await self.producer.stop()
def __aiter__(self) -> "KafkaExecutor":
return self
async def __anext__(self) -> Any:
# wait for next message
try:
rec = await self.consumer.getone()
msg: MessageToExecutor = rec.value
except aiokafka.ConsumerStoppedError:
raise StopAsyncIteration from None
# process message
saved = await self.graph.checkpointer.aget_tuple(msg["config"])
if saved is None:
raise RuntimeError("Checkpoint not found")
async with AsyncChannelsManager(
self.graph.channels, saved.checkpoint, msg["config"], self.graph.store
) as (channels, managed), AsyncBackgroundExecutor() as submit:
def put_writes(task_id: str, writes: list[tuple[str, Any]]) -> None:
print("put_writes", task_id, writes)
return submit(
self.graph.checkpointer.aput_writes, msg["config"], writes, task_id
)
if task := await asyncio.to_thread(
prepare_single_task,
msg["task"]["path"],
msg["task"]["id"],
checkpoint=saved.checkpoint,
processes=self.graph.nodes,
channels=channels,
managed=managed,
config=msg["config"],
step=msg["task"]["step"],
for_execution=True,
is_resuming=msg["task"]["resuming"],
):
# execute task, saving writes
runner = PregelRunner(submit=submit, put_writes=put_writes)
async for _ in runner.atick([task]):
pass
else:
# task was not found
await self.graph.checkpointer.put_writes(
msg["config"], [(ERROR, TaskNotFound())]
)
# notify orchestrator
await self.producer.send(
self.topics.orchestrator,
value=MessageToOrchestrator(input=None, config=msg["config"]),
)
# return message
return msg
@@ -0,0 +1,94 @@
from contextlib import AbstractAsyncContextManager
from typing import Any
import aiokafka
from langchain_core.runnables import ensure_config
import langgraph.scheduler.kafka.serde as serde
from langgraph.constants import CONFIG_KEY_DEDUPE_TASKS, SCHEDULED
from langgraph.pregel import Pregel
from langgraph.pregel.loop import INPUT_RESUMING, AsyncPregelLoop
from langgraph.scheduler.kafka.types import (
ExecutorTask,
MessageToExecutor,
MessageToOrchestrator,
Topics,
)
from langgraph.utils.config import patch_configurable
class KafkaOrchestrator(AbstractAsyncContextManager):
def __init__(self, graph: Pregel, topics: Topics, **kwargs: Any) -> None:
self.graph = graph
self.topics = topics
self.consumer = aiokafka.AIOKafkaConsumer(
topics.orchestrator, value_deserializer=serde.loads, **kwargs
)
self.producer = aiokafka.AIOKafkaProducer(
value_serializer=serde.dumps, **kwargs
)
async def __aenter__(self) -> "KafkaOrchestrator":
await self.consumer.start()
await self.producer.start()
return self
async def __aexit__(self, *args: Any) -> None:
await self.consumer.stop()
await self.producer.stop()
def __aiter__(self) -> "KafkaOrchestrator":
return self
async def __anext__(self) -> Any:
# wait for next message
try:
rec = await self.consumer.getone()
msg: MessageToOrchestrator = rec.value
except aiokafka.ConsumerStoppedError:
raise StopAsyncIteration from None
# process message
async with AsyncPregelLoop(
msg["input"],
config=ensure_config(msg["config"]),
stream=None,
store=self.graph.store,
checkpointer=self.graph.checkpointer,
nodes=self.graph.nodes,
specs=self.graph.channels,
output_keys=self.graph.output_channels,
stream_keys=self.graph.stream_channels,
) as loop:
if loop.tick(input_keys=self.graph.input_channels):
if hasattr(loop, "_put_checkpoint_fut"):
await loop._put_checkpoint_fut
if new_tasks := [t for t in loop.tasks.values() if not t.scheduled]:
# send messages to executor
for task in new_tasks:
if task.scheduled:
continue
await self.producer.send(
self.topics.executor,
value=MessageToExecutor(
config=patch_configurable(
loop.config,
{
**loop.checkpoint_config["configurable"],
CONFIG_KEY_DEDUPE_TASKS: True,
},
),
task=ExecutorTask(
id=task.id,
path=task.path,
step=loop.step,
resuming=loop.input is INPUT_RESUMING,
),
),
)
# flush producer
await self.producer.flush()
# mark as scheduled
for task in new_tasks:
loop.put_writes(task.id, [(SCHEDULED, None)])
# return message
return msg
@@ -0,0 +1,16 @@
from typing import Any
import orjson
def loads(v: bytes) -> Any:
return orjson.loads(v)
def dumps(v: Any) -> bytes:
return orjson.dumps(v, default=_default)
def _default(v: Any) -> Any:
# things we don't know how to serialize (eg. functions) ignore
return None
@@ -0,0 +1,25 @@
from typing import Any, NamedTuple, Optional, TypedDict
from langchain_core.runnables import RunnableConfig
class Topics(NamedTuple):
orchestrator: str
executor: str
class MessageToOrchestrator(TypedDict):
input: Optional[dict[str, Any]]
config: RunnableConfig
class ExecutorTask(TypedDict):
id: str
path: tuple[str, ...]
step: int
resuming: bool
class MessageToExecutor(TypedDict):
config: RunnableConfig
task: ExecutorTask
+1182
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
[tool.poetry]
name = "langgraph-scheduler-kafka"
version = "1.0.0"
description = "Library with Kafka-based work scheduler."
authors = []
license = "MIT"
readme = "README.md"
repository = "https://www.github.com/langchain-ai/langgraph"
packages = [{ include = "langgraph" }]
[tool.poetry.dependencies]
python = "^3.9.0,<4.0"
orjson = "^3.10.7"
crc32c = "^2.7.post1"
aiokafka = "^0.11.0"
kafka-python-ng = "^2.2.2"
[tool.poetry.group.dev.dependencies]
ruff = "^0.6.2"
codespell = "^2.2.0"
pytest = "^7.2.1"
pytest-mock = "^3.11.1"
pytest-watcher = "^0.4.1"
mypy = "^1.10.0"
langgraph = {path = "../langgraph", develop = true}
langgraph-checkpoint-postgres = {path = "../checkpoint-postgres", develop = true}
langgraph-checkpoint = {path = "../checkpoint", develop = true}
[tool.pytest.ini_options]
# --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
#
# https://docs.pytest.org/en/7.1.x/reference/reference.html
# --strict-config any warnings encountered while parsing the `pytest`
# section of the configuration file raise errors.
addopts = "--strict-markers --strict-config --durations=5 -vv"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.ruff]
lint.select = [
"E", # pycodestyle
"F", # Pyflakes
"UP", # pyupgrade
"B", # flake8-bugbear
"I", # isort
]
lint.ignore = ["E501", "B008", "UP007", "UP006"]
[tool.pytest-watcher]
now = true
delay = 0.1
runner_args = ["-s", "--ff", "-v", "--tb", "short"]
patterns = ["*.py"]
+20
View File
@@ -0,0 +1,20 @@
services:
broker:
image: apache/kafka:latest
ports:
- "9092:9092"
postgres:
image: postgres:16
ports:
- "5443:5432"
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
healthcheck:
test: pg_isready -U postgres
start_period: 10s
timeout: 1s
retries: 5
interval: 60s
start_interval: 1s
+58
View File
@@ -0,0 +1,58 @@
from typing import AsyncIterator, Iterator
from uuid import uuid4
import kafka.admin
import pytest
from psycopg import AsyncConnection
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from langgraph.scheduler.kafka.types import Topics
DEFAULT_POSTGRES_URI = "postgres://postgres:postgres@localhost:5443/"
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.fixture
def topics() -> Iterator[Topics]:
o = f"test_{uuid4().hex[:16]}"
e = f"test_{uuid4().hex[:16]}"
admin = kafka.admin.KafkaAdminClient()
# create topics
admin.create_topics(
[
kafka.admin.NewTopic(name=o, num_partitions=1, replication_factor=1),
kafka.admin.NewTopic(name=e, num_partitions=1, replication_factor=1),
]
)
# yield topics
yield Topics(orchestrator=o, executor=e)
# delete topics
admin.delete_topics([o, e])
admin.close()
@pytest.fixture
async def checkpointer() -> AsyncIterator[AsyncPostgresSaver]:
database = f"test_{uuid4().hex[:16]}"
# create unique db
async with await AsyncConnection.connect(
DEFAULT_POSTGRES_URI, autocommit=True
) as conn:
await conn.execute(f"CREATE DATABASE {database}")
try:
# yield checkpointer
async with AsyncPostgresSaver.from_conn_string(
DEFAULT_POSTGRES_URI + database
) as checkpointer:
await checkpointer.setup()
yield checkpointer
finally:
# drop unique db
async with await AsyncConnection.connect(
DEFAULT_POSTGRES_URI, autocommit=True
) as conn:
await conn.execute(f"DROP DATABASE {database}")
@@ -0,0 +1,124 @@
import asyncio
import operator
from typing import Annotated, TypedDict, Union
import pytest
from aiokafka import AIOKafkaProducer
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.graph.state import StateGraph
from langgraph.pregel import Pregel
from langgraph.scheduler.kafka import serde
from langgraph.scheduler.kafka.executor import KafkaExecutor
from langgraph.scheduler.kafka.orchestrator import KafkaOrchestrator
from langgraph.scheduler.kafka.types import MessageToOrchestrator, Topics
pytestmark = pytest.mark.anyio
def mk_fanout_graph(checkpointer: BaseCheckpointSaver) -> Pregel:
# copied from test_in_one_fan_out_state_graph_waiting_edge_multiple_cond_edge
def sorted_add(
x: list[str], y: Union[list[str], list[tuple[str, str]]]
) -> list[str]:
if isinstance(y[0], tuple):
for rem, _ in y:
x.remove(rem)
y = [t[1] for t in y]
return sorted(operator.add(x, y))
class State(TypedDict, total=False):
query: str
answer: str
docs: Annotated[list[str], sorted_add]
async def rewrite_query(data: State) -> State:
print("rewrite_query", data)
return {"query": f'query: {data["query"]}'}
async def retriever_picker(data: State) -> list[str]:
return ["analyzer_one", "retriever_two"]
async def analyzer_one(data: State) -> State:
return {"query": f'analyzed: {data["query"]}'}
async def retriever_one(data: State) -> State:
return {"docs": ["doc1", "doc2"]}
async def retriever_two(data: State) -> State:
await asyncio.sleep(0.1)
return {"docs": ["doc3", "doc4"]}
async def qa(data: State) -> State:
return {"answer": ",".join(data["docs"])}
async def decider(data: State) -> None:
return None
def decider_cond(data: State) -> str:
if data["query"].count("analyzed") > 1:
return "qa"
else:
return "rewrite_query"
builder = StateGraph(State)
builder.add_node("rewrite_query", rewrite_query)
builder.add_node("analyzer_one", analyzer_one)
builder.add_node("retriever_one", retriever_one)
builder.add_node("retriever_two", retriever_two)
builder.add_node("decider", decider)
builder.add_node("qa", qa)
builder.set_entry_point("rewrite_query")
builder.add_conditional_edges("rewrite_query", retriever_picker)
builder.add_edge("analyzer_one", "retriever_one")
builder.add_edge(["retriever_one", "retriever_two"], "decider")
builder.add_conditional_edges("decider", decider_cond)
builder.set_finish_point("qa")
return builder.compile(checkpointer)
async def test_fanout_graph(topics: Topics, checkpointer: BaseCheckpointSaver) -> None:
graph = mk_fanout_graph(checkpointer)
n_orch_msgs = 0
n_exec_msgs = 0
async def orchestrator() -> None:
nonlocal n_orch_msgs
async with KafkaOrchestrator(graph, topics) as orch:
async for msg in orch:
n_orch_msgs += 1
print("orch", msg)
async def executor() -> None:
nonlocal n_exec_msgs
async with KafkaExecutor(graph, topics) as exec:
async for msg in exec:
n_exec_msgs += 1
print("exec", msg)
async with asyncio.TaskGroup() as tg:
o = tg.create_task(orchestrator(), name="orchestrator")
e = tg.create_task(executor(), name="executor")
# start a new run
producer = AIOKafkaProducer(value_serializer=serde.dumps)
await producer.start()
await producer.send_and_wait(
topics.orchestrator,
MessageToOrchestrator(
input={"query": "what is weather in sf"},
config={"configurable": {"thread_id": "1"}},
),
)
await producer.stop()
# wait for the run to finish
await asyncio.sleep(5)
o.cancel()
e.cancel()
assert n_orch_msgs == 13
assert n_exec_msgs == 12