Compare commits

...
Author SHA1 Message Date
William Fu-Hinthorn 054ca2f78d Lint and fix 2024-12-06 10:05:29 -08:00
William Fu-Hinthorn 4406828c39 test 2024-12-05 18:27:47 -08:00
6 changed files with 405 additions and 20 deletions
@@ -19,7 +19,9 @@ from langgraph.store.base import (
Result, Result,
SearchOp, SearchOp,
) )
from langgraph.store.base.batch import AsyncBatchedBaseStore from langgraph.store.base.batch import (
BatchedBaseStore,
)
from langgraph.store.postgres.base import ( from langgraph.store.postgres.base import (
_PLACEHOLDER, _PLACEHOLDER,
BasePostgresStore, BasePostgresStore,
@@ -36,7 +38,7 @@ from langgraph.store.postgres.base import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Conn]): class AsyncPostgresStore(BatchedBaseStore, BasePostgresStore[_ainternal.Conn]):
"""Asynchronous Postgres-backed store with optional vector search using pgvector. """Asynchronous Postgres-backed store with optional vector search using pgvector.
!!! example "Examples" !!! example "Examples"
@@ -30,7 +30,6 @@ from typing_extensions import TypedDict
from langgraph.checkpoint.postgres import _ainternal as _ainternal from langgraph.checkpoint.postgres import _ainternal as _ainternal
from langgraph.checkpoint.postgres import _internal as _pg_internal from langgraph.checkpoint.postgres import _internal as _pg_internal
from langgraph.store.base import ( from langgraph.store.base import (
BaseStore,
GetOp, GetOp,
IndexConfig, IndexConfig,
Item, Item,
@@ -44,6 +43,7 @@ from langgraph.store.base import (
get_text_at_path, get_text_at_path,
tokenize_path, tokenize_path,
) )
from langgraph.store.base.batch import SyncBatchedBaseStore
if TYPE_CHECKING: if TYPE_CHECKING:
from langchain_core.embeddings import Embeddings from langchain_core.embeddings import Embeddings
@@ -533,7 +533,7 @@ class BasePostgresStore(Generic[C]):
raise ValueError(f"Unsupported operator: {op}") raise ValueError(f"Unsupported operator: {op}")
class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]): class PostgresStore(SyncBatchedBaseStore, BasePostgresStore[_pg_internal.Conn]):
"""Postgres-backed store with optional vector search using pgvector. """Postgres-backed store with optional vector search using pgvector.
!!! example "Examples" !!! example "Examples"
@@ -1,8 +1,10 @@
# type: ignore # type: ignore
import asyncio
import itertools import itertools
import sys import sys
import uuid import uuid
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any, Optional from typing import Any, Optional
@@ -63,6 +65,96 @@ async def store(request) -> AsyncIterator[AsyncPostgresStore]:
await conn.execute(f"DROP DATABASE {database}") await conn.execute(f"DROP DATABASE {database}")
def test_large_batches(store: AsyncPostgresStore) -> None:
N = 1000
M = 10
with ThreadPoolExecutor(max_workers=10) as executor:
for m in range(M):
for i in range(N):
_ = [
executor.submit(
store.put,
("test", "foo", "bar", "baz", str(m % 2)),
f"key{i}",
value={"foo": "bar" + str(i)},
),
executor.submit(
store.get,
("test", "foo", "bar", "baz", str(m % 2)),
f"key{i}",
),
executor.submit(
store.list_namespaces,
prefix=None,
max_depth=m + 1,
),
executor.submit(
store.search,
("test",),
),
executor.submit(
store.put,
("test", "foo", "bar", "baz", str(m % 2)),
f"key{i}",
value={"foo": "bar" + str(i)},
),
executor.submit(
store.put,
("test", "foo", "bar", "baz", str(m % 2)),
f"key{i}",
None,
),
]
async def test_large_batches_async(store: AsyncPostgresStore) -> None:
N = 1000
M = 10
coros = []
for m in range(M):
for i in range(N):
coros.append(
store.aput(
("test", "foo", "bar", "baz", str(m % 2)),
f"key{i}",
value={"foo": "bar" + str(i)},
)
)
coros.append(
store.aget(
("test", "foo", "bar", "baz", str(m % 2)),
f"key{i}",
)
)
coros.append(
store.alist_namespaces(
prefix=None,
max_depth=m + 1,
)
)
coros.append(
store.asearch(
("test",),
)
)
coros.append(
store.aput(
("test", "foo", "bar", "baz", str(m % 2)),
f"key{i}",
value={"foo": "bar" + str(i)},
)
)
coros.append(
store.adelete(
("test", "foo", "bar", "baz", str(m % 2)),
f"key{i}",
)
)
await asyncio.gather(*coros)
async def test_abatch_order(store: AsyncPostgresStore) -> None: async def test_abatch_order(store: AsyncPostgresStore) -> None:
# Setup test data # Setup test data
await store.aput(("test", "foo"), "key1", {"data": "value1"}) await store.aput(("test", "foo"), "key1", {"data": "value1"})
+93 -5
View File
@@ -1,5 +1,7 @@
# type: ignore # type: ignore
import asyncio
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager from contextlib import contextmanager
from typing import Any, Optional from typing import Any, Optional
from uuid import uuid4 from uuid import uuid4
@@ -17,11 +19,7 @@ from langgraph.store.base import (
SearchOp, SearchOp,
) )
from langgraph.store.postgres import PostgresStore from langgraph.store.postgres import PostgresStore
from tests.conftest import ( from tests.conftest import DEFAULT_URI, VECTOR_TYPES, CharacterEmbeddings
DEFAULT_URI,
VECTOR_TYPES,
CharacterEmbeddings,
)
@pytest.fixture(scope="function", params=["default", "pipe", "pool"]) @pytest.fixture(scope="function", params=["default", "pipe", "pool"])
@@ -59,6 +57,96 @@ def store(request) -> PostgresStore:
conn.execute(f"DROP DATABASE {database}") conn.execute(f"DROP DATABASE {database}")
def test_large_batches(store: PostgresStore) -> None:
N = 1000
M = 10
with ThreadPoolExecutor(max_workers=10) as executor:
for m in range(M):
for i in range(N):
_ = [
executor.submit(
store.put,
("test", "foo", "bar", "baz", str(m % 2)),
f"key{i}",
value={"foo": "bar" + str(i)},
),
executor.submit(
store.get,
("test", "foo", "bar", "baz", str(m % 2)),
f"key{i}",
),
executor.submit(
store.list_namespaces,
prefix=None,
max_depth=m + 1,
),
executor.submit(
store.search,
("test",),
),
executor.submit(
store.put,
("test", "foo", "bar", "baz", str(m % 2)),
f"key{i}",
value={"foo": "bar" + str(i)},
),
executor.submit(
store.put,
("test", "foo", "bar", "baz", str(m % 2)),
f"key{i}",
None,
),
]
async def test_large_batches_async(store: PostgresStore) -> None:
N = 1000
M = 10
coros = []
for m in range(M):
for i in range(N):
coros.append(
store.aput(
("test", "foo", "bar", "baz", str(m % 2)),
f"key{i}",
value={"foo": "bar" + str(i)},
)
)
coros.append(
store.aget(
("test", "foo", "bar", "baz", str(m % 2)),
f"key{i}",
)
)
coros.append(
store.alist_namespaces(
prefix=None,
max_depth=m + 1,
)
)
coros.append(
store.asearch(
("test",),
)
)
coros.append(
store.aput(
("test", "foo", "bar", "baz", str(m % 2)),
f"key{i}",
value={"foo": "bar" + str(i)},
)
)
coros.append(
store.adelete(
("test", "foo", "bar", "baz", str(m % 2)),
f"key{i}",
)
)
await asyncio.gather(*coros)
def test_batch_order(store: PostgresStore) -> None: def test_batch_order(store: PostgresStore) -> None:
# Setup test data # Setup test data
store.put(("test", "foo"), "key1", {"data": "value1"}) store.put(("test", "foo"), "key1", {"data": "value1"})
@@ -808,6 +808,8 @@ class BaseStore(ABC):
# [("a", "b", "c"), ("a", "b", "d"), ("a", "b", "f")] # [("a", "b", "c"), ("a", "b", "d"), ("a", "b", "f")]
``` ```
""" """
if max_depth is not None and max_depth <= 0:
raise ValueError("If provided, max_depth must be greater than 0")
match_conditions = [] match_conditions = []
if prefix: if prefix:
match_conditions.append(MatchCondition(match_type="prefix", path=prefix)) match_conditions.append(MatchCondition(match_type="prefix", path=prefix))
@@ -1004,6 +1006,8 @@ class BaseStore(ABC):
# Returns: [("a", "b", "c"), ("a", "b", "d"), ("a", "b", "f")] # Returns: [("a", "b", "c"), ("a", "b", "d"), ("a", "b", "f")]
``` ```
""" """
if max_depth is not None and max_depth <= 0:
raise ValueError("If provided, max_depth must be greater than 0")
match_conditions = [] match_conditions = []
if prefix: if prefix:
match_conditions.append(MatchCondition(match_type="prefix", path=prefix)) match_conditions.append(MatchCondition(match_type="prefix", path=prefix))
+210 -11
View File
@@ -1,6 +1,9 @@
import asyncio import asyncio
import threading
import time
import weakref import weakref
from typing import Any, Literal, Optional, Union from concurrent.futures import Future
from typing import Any, Iterable, Literal, Optional, Union
from langgraph.store.base import ( from langgraph.store.base import (
BaseStore, BaseStore,
@@ -11,24 +14,19 @@ from langgraph.store.base import (
NamespacePath, NamespacePath,
Op, Op,
PutOp, PutOp,
Result,
SearchItem, SearchItem,
SearchOp, SearchOp,
_validate_namespace, _validate_namespace,
) )
class AsyncBatchedBaseStore(BaseStore): class AsyncBatchedBaseStoreMixin:
"""Efficiently batch operations in a background task.""" """Efficiently batch operations in a background task."""
__slots__ = ("_loop", "_aqueue", "_task") _loop: asyncio.AbstractEventLoop
_aqueue: dict[asyncio.Future, Op]
def __init__(self) -> None: _task: asyncio.Task
self._loop = asyncio.get_running_loop()
self._aqueue: dict[asyncio.Future, Op] = {}
self._task = self._loop.create_task(_run(self._aqueue, weakref.ref(self)))
def __del__(self) -> None:
self._task.cancel()
async def aget( async def aget(
self, self,
@@ -100,6 +98,29 @@ class AsyncBatchedBaseStore(BaseStore):
return await fut return await fut
class AsyncBatchedBaseStore(AsyncBatchedBaseStoreMixin, BaseStore):
"""Efficiently batch operations in a background task."""
__slots__ = ("_loop", "_aqueue", "_task")
def __init__(self) -> None:
super().__init__()
self._loop = asyncio.get_running_loop()
self._aqueue: dict[asyncio.Future, Op] = {}
self._task = self._loop.create_task(_run(self._aqueue, weakref.ref(self)))
def __del__(self) -> None:
self._task.cancel()
def batch(self, ops: Iterable[Op]) -> list[Result]:
futures = []
for op in ops:
fut = self._loop.create_future()
self._aqueue[fut] = op
futures.append(fut)
return [fut.result() for fut in asyncio.as_completed(futures)]
def _dedupe_ops(values: list[Op]) -> tuple[Optional[list[int]], list[Op]]: def _dedupe_ops(values: list[Op]) -> tuple[Optional[list[int]], list[Op]]:
"""Dedupe operations while preserving order for results. """Dedupe operations while preserving order for results.
@@ -174,3 +195,181 @@ async def _run(
break break
# remove strong ref to store # remove strong ref to store
del s del s
class SyncBatchedBaseStoreMixin(BaseStore):
"""Efficiently batch operations in a background thread."""
_sync_queue: dict[Future, Op]
_sync_thread: threading.Thread
def get(
self,
namespace: tuple[str, ...],
key: str,
) -> Optional[Item]:
fut: Future[Optional[Item]] = Future()
self._sync_queue[fut] = GetOp(namespace, key)
return fut.result()
def search(
self,
namespace_prefix: tuple[str, ...],
/,
*,
query: Optional[str] = None,
filter: Optional[dict[str, Any]] = None,
limit: int = 10,
offset: int = 0,
) -> list[SearchItem]:
fut: Future[list[SearchItem]] = Future()
self._sync_queue[fut] = SearchOp(namespace_prefix, filter, limit, offset, query)
return fut.result()
def put(
self,
namespace: tuple[str, ...],
key: str,
value: dict[str, Any],
index: Optional[Union[Literal[False], list[str]]] = None,
) -> None:
_validate_namespace(namespace)
fut: Future[None] = Future()
self._sync_queue[fut] = PutOp(namespace, key, value, index)
return fut.result()
def delete(
self,
namespace: tuple[str, ...],
key: str,
) -> None:
fut: Future[None] = Future()
self._sync_queue[fut] = PutOp(namespace, key, None)
return fut.result()
def list_namespaces(
self,
*,
prefix: Optional[NamespacePath] = None,
suffix: Optional[NamespacePath] = None,
max_depth: Optional[int] = None,
limit: int = 100,
offset: int = 0,
) -> list[tuple[str, ...]]:
fut: Future[list[tuple[str, ...]]] = Future()
match_conditions = []
if prefix:
match_conditions.append(MatchCondition(match_type="prefix", path=prefix))
if suffix:
match_conditions.append(MatchCondition(match_type="suffix", path=suffix))
op = ListNamespacesOp(
match_conditions=tuple(match_conditions),
max_depth=max_depth,
limit=limit,
offset=offset,
)
self._sync_queue[fut] = op
return fut.result()
class SyncBatchedBaseStore(SyncBatchedBaseStoreMixin, BaseStore):
"""Efficiently batch operations in a background thread."""
__slots__ = ("_sync_queue", "_sync_thread")
def __init__(self) -> None:
super().__init__()
self._sync_queue: dict[Future, Op] = {}
self._sync_thread = threading.Thread(
target=_sync_run,
args=(self._sync_queue, weakref.ref(self)),
daemon=True,
)
self._sync_thread.start()
def __del__(self) -> None:
# Signal the thread to stop
if self._sync_thread.is_alive():
empty_future: Future = Future()
self._sync_queue[empty_future] = None # type: ignore
self._sync_thread.join()
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
futures = []
for op in ops:
fut: Future[Result] = Future()
self._sync_queue[fut] = op
futures.append(fut)
return [fut.result() for fut in futures]
class BatchedBaseStore(
AsyncBatchedBaseStoreMixin, SyncBatchedBaseStoreMixin, BaseStore
):
__slots__ = (
"_sync_queue",
"_sync_thread",
"_task",
"_loop",
"_aqueue",
)
def __init__(self) -> None:
super().__init__()
# Setup async processing
self._loop = asyncio.get_running_loop()
self._aqueue: dict[asyncio.Future, Op] = {}
self._task = self._loop.create_task(_run(self._aqueue, weakref.ref(self)))
self._sync_queue: dict[Future, Op] = {}
self._sync_thread = threading.Thread(
target=_sync_run,
args=(self._sync_queue, weakref.ref(self)),
daemon=True,
)
self._sync_thread.start()
def __del__(self) -> None:
# Signal the thread to stop
if self._sync_thread.is_alive():
empty_future: Future[None] = Future()
self._sync_queue[empty_future] = None # type: ignore
self._sync_thread.join()
# Signal the thread to stop
if self._task is not None:
self._task.cancel()
def _sync_run(queue: dict[Future, Op], store: weakref.ReferenceType[BaseStore]) -> None:
while True:
time.sleep(0.001) # Yield to other threads
if not queue:
continue
if s := store():
# get the operations to run
taken = queue.copy()
# action each operation
try:
values = list(taken.values())
if None in values: # Exit signal
break
listen, dedupped = _dedupe_ops(values)
results = s.batch(dedupped) # Note: Using sync batch here
if listen is not None:
results = [results[ix] for ix in listen]
# set the results of each operation
for fut, result in zip(taken, results):
fut.set_result(result)
except Exception as e:
for fut in taken:
fut.set_exception(e)
# remove the operations from the queue
for fut in taken:
del queue[fut]
else:
break
# remove strong ref to store
del s