langgraph-checkpoint-postgres 2.0.2__tar.gz → 2.0.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {langgraph_checkpoint_postgres-2.0.2 → langgraph_checkpoint_postgres-2.0.4}/PKG-INFO +1 -1
- {langgraph_checkpoint_postgres-2.0.2 → langgraph_checkpoint_postgres-2.0.4}/langgraph/checkpoint/postgres/__init__.py +28 -23
- langgraph_checkpoint_postgres-2.0.4/langgraph/checkpoint/postgres/_ainternal.py +23 -0
- langgraph_checkpoint_postgres-2.0.4/langgraph/checkpoint/postgres/_internal.py +21 -0
- {langgraph_checkpoint_postgres-2.0.2 → langgraph_checkpoint_postgres-2.0.4}/langgraph/checkpoint/postgres/aio.py +50 -40
- {langgraph_checkpoint_postgres-2.0.2 → langgraph_checkpoint_postgres-2.0.4}/langgraph/checkpoint/postgres/base.py +2 -1
- langgraph_checkpoint_postgres-2.0.4/langgraph/store/postgres/aio.py +294 -0
- {langgraph_checkpoint_postgres-2.0.2 → langgraph_checkpoint_postgres-2.0.4}/langgraph/store/postgres/base.py +153 -64
- {langgraph_checkpoint_postgres-2.0.2 → langgraph_checkpoint_postgres-2.0.4}/pyproject.toml +1 -1
- langgraph_checkpoint_postgres-2.0.2/langgraph/store/postgres/aio.py +0 -218
- {langgraph_checkpoint_postgres-2.0.2 → langgraph_checkpoint_postgres-2.0.4}/README.md +0 -0
- {langgraph_checkpoint_postgres-2.0.2 → langgraph_checkpoint_postgres-2.0.4}/langgraph/checkpoint/postgres/py.typed +0 -0
- {langgraph_checkpoint_postgres-2.0.2 → langgraph_checkpoint_postgres-2.0.4}/langgraph/store/postgres/__init__.py +0 -0
- {langgraph_checkpoint_postgres-2.0.2 → langgraph_checkpoint_postgres-2.0.4}/langgraph/store/postgres/py.typed +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import threading
|
|
2
2
|
from contextlib import contextmanager
|
|
3
|
-
from typing import Any, Iterator, Optional, Sequence
|
|
3
|
+
from typing import Any, Iterator, Optional, Sequence
|
|
4
4
|
|
|
5
5
|
from langchain_core.runnables import RunnableConfig
|
|
6
|
-
from psycopg import Connection, Cursor, Pipeline
|
|
6
|
+
from psycopg import Capabilities, Connection, Cursor, Pipeline
|
|
7
7
|
from psycopg.errors import UndefinedTable
|
|
8
8
|
from psycopg.rows import DictRow, dict_row
|
|
9
9
|
from psycopg.types.json import Jsonb
|
|
@@ -17,21 +17,11 @@ from langgraph.checkpoint.base import (
|
|
|
17
17
|
CheckpointTuple,
|
|
18
18
|
get_checkpoint_id,
|
|
19
19
|
)
|
|
20
|
+
from langgraph.checkpoint.postgres import _internal
|
|
20
21
|
from langgraph.checkpoint.postgres.base import BasePostgresSaver
|
|
21
22
|
from langgraph.checkpoint.serde.base import SerializerProtocol
|
|
22
23
|
|
|
23
|
-
Conn =
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
@contextmanager
|
|
27
|
-
def _get_connection(conn: Conn) -> Iterator[Connection[DictRow]]:
|
|
28
|
-
if isinstance(conn, Connection):
|
|
29
|
-
yield conn
|
|
30
|
-
elif isinstance(conn, ConnectionPool):
|
|
31
|
-
with conn.connection() as conn:
|
|
32
|
-
yield conn
|
|
33
|
-
else:
|
|
34
|
-
raise TypeError(f"Invalid connection type: {type(conn)}")
|
|
24
|
+
Conn = _internal.Conn # For backward compatibility
|
|
35
25
|
|
|
36
26
|
|
|
37
27
|
class PostgresSaver(BasePostgresSaver):
|
|
@@ -39,7 +29,7 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
39
29
|
|
|
40
30
|
def __init__(
|
|
41
31
|
self,
|
|
42
|
-
conn: Conn,
|
|
32
|
+
conn: _internal.Conn,
|
|
43
33
|
pipe: Optional[Pipeline] = None,
|
|
44
34
|
serde: Optional[SerializerProtocol] = None,
|
|
45
35
|
) -> None:
|
|
@@ -52,6 +42,7 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
52
42
|
self.conn = conn
|
|
53
43
|
self.pipe = pipe
|
|
54
44
|
self.lock = threading.Lock()
|
|
45
|
+
self.supports_pipeline = Capabilities().has_pipeline()
|
|
55
46
|
|
|
56
47
|
@classmethod
|
|
57
48
|
@contextmanager
|
|
@@ -72,9 +63,9 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
72
63
|
) as conn:
|
|
73
64
|
if pipeline:
|
|
74
65
|
with conn.pipeline() as pipe:
|
|
75
|
-
yield
|
|
66
|
+
yield cls(conn, pipe)
|
|
76
67
|
else:
|
|
77
|
-
yield
|
|
68
|
+
yield cls(conn)
|
|
78
69
|
|
|
79
70
|
def setup(self) -> None:
|
|
80
71
|
"""Set up the checkpoint database asynchronously.
|
|
@@ -365,7 +356,14 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
365
356
|
|
|
366
357
|
@contextmanager
|
|
367
358
|
def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor[DictRow]]:
|
|
368
|
-
|
|
359
|
+
"""Create a database cursor as a context manager.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
pipeline (bool): whether to use pipeline for the DB operations inside the context manager.
|
|
363
|
+
Will be applied regardless of whether the PostgresSaver instance was initialized with a pipeline.
|
|
364
|
+
If pipeline mode is not supported, will fall back to using transaction context manager.
|
|
365
|
+
"""
|
|
366
|
+
with _internal.get_connection(self.conn) as conn:
|
|
369
367
|
if self.pipe:
|
|
370
368
|
# a connection in pipeline mode can be used concurrently
|
|
371
369
|
# in multiple threads/coroutines, but only one cursor can be
|
|
@@ -379,13 +377,20 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
379
377
|
elif pipeline:
|
|
380
378
|
# a connection not in pipeline mode can only be used by one
|
|
381
379
|
# thread/coroutine at a time, so we acquire a lock
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
380
|
+
if self.supports_pipeline:
|
|
381
|
+
with self.lock, conn.pipeline(), conn.cursor(
|
|
382
|
+
binary=True, row_factory=dict_row
|
|
383
|
+
) as cur:
|
|
384
|
+
yield cur
|
|
385
|
+
else:
|
|
386
|
+
# Use connection's transaction context manager when pipeline mode not supported
|
|
387
|
+
with self.lock, conn.transaction(), conn.cursor(
|
|
388
|
+
binary=True, row_factory=dict_row
|
|
389
|
+
) as cur:
|
|
390
|
+
yield cur
|
|
386
391
|
else:
|
|
387
392
|
with self.lock, conn.cursor(binary=True, row_factory=dict_row) as cur:
|
|
388
393
|
yield cur
|
|
389
394
|
|
|
390
395
|
|
|
391
|
-
__all__ = ["PostgresSaver", "Conn"]
|
|
396
|
+
__all__ = ["PostgresSaver", "BasePostgresSaver", "Conn"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Shared async utility functions for the Postgres checkpoint & storage classes."""
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from typing import AsyncIterator, Union
|
|
5
|
+
|
|
6
|
+
from psycopg import AsyncConnection
|
|
7
|
+
from psycopg.rows import DictRow
|
|
8
|
+
from psycopg_pool import AsyncConnectionPool
|
|
9
|
+
|
|
10
|
+
Conn = Union[AsyncConnection[DictRow], AsyncConnectionPool[AsyncConnection[DictRow]]]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@asynccontextmanager
|
|
14
|
+
async def get_connection(
|
|
15
|
+
conn: Conn,
|
|
16
|
+
) -> AsyncIterator[AsyncConnection[DictRow]]:
|
|
17
|
+
if isinstance(conn, AsyncConnection):
|
|
18
|
+
yield conn
|
|
19
|
+
elif isinstance(conn, AsyncConnectionPool):
|
|
20
|
+
async with conn.connection() as conn:
|
|
21
|
+
yield conn
|
|
22
|
+
else:
|
|
23
|
+
raise TypeError(f"Invalid connection type: {type(conn)}")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Shared utility functions for the Postgres checkpoint & storage classes."""
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from typing import Iterator, Union
|
|
5
|
+
|
|
6
|
+
from psycopg import Connection
|
|
7
|
+
from psycopg.rows import DictRow
|
|
8
|
+
from psycopg_pool import ConnectionPool
|
|
9
|
+
|
|
10
|
+
Conn = Union[Connection[DictRow], ConnectionPool[Connection[DictRow]]]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@contextmanager
|
|
14
|
+
def get_connection(conn: Conn) -> Iterator[Connection[DictRow]]:
|
|
15
|
+
if isinstance(conn, Connection):
|
|
16
|
+
yield conn
|
|
17
|
+
elif isinstance(conn, ConnectionPool):
|
|
18
|
+
with conn.connection() as conn:
|
|
19
|
+
yield conn
|
|
20
|
+
else:
|
|
21
|
+
raise TypeError(f"Invalid connection type: {type(conn)}")
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
from contextlib import asynccontextmanager
|
|
3
|
-
from typing import Any, AsyncIterator, Iterator, Optional, Sequence
|
|
3
|
+
from typing import Any, AsyncIterator, Iterator, Optional, Sequence
|
|
4
4
|
|
|
5
5
|
from langchain_core.runnables import RunnableConfig
|
|
6
|
-
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline
|
|
6
|
+
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
|
|
7
7
|
from psycopg.errors import UndefinedTable
|
|
8
8
|
from psycopg.rows import DictRow, dict_row
|
|
9
9
|
from psycopg.types.json import Jsonb
|
|
@@ -17,23 +17,11 @@ from langgraph.checkpoint.base import (
|
|
|
17
17
|
CheckpointTuple,
|
|
18
18
|
get_checkpoint_id,
|
|
19
19
|
)
|
|
20
|
+
from langgraph.checkpoint.postgres import _ainternal
|
|
20
21
|
from langgraph.checkpoint.postgres.base import BasePostgresSaver
|
|
21
22
|
from langgraph.checkpoint.serde.base import SerializerProtocol
|
|
22
23
|
|
|
23
|
-
Conn =
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
@asynccontextmanager
|
|
27
|
-
async def _get_connection(
|
|
28
|
-
conn: Conn,
|
|
29
|
-
) -> AsyncIterator[AsyncConnection[DictRow]]:
|
|
30
|
-
if isinstance(conn, AsyncConnection):
|
|
31
|
-
yield conn
|
|
32
|
-
elif isinstance(conn, AsyncConnectionPool):
|
|
33
|
-
async with conn.connection() as conn:
|
|
34
|
-
yield conn
|
|
35
|
-
else:
|
|
36
|
-
raise TypeError(f"Invalid connection type: {type(conn)}")
|
|
24
|
+
Conn = _ainternal.Conn # For backward compatibility
|
|
37
25
|
|
|
38
26
|
|
|
39
27
|
class AsyncPostgresSaver(BasePostgresSaver):
|
|
@@ -41,7 +29,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
41
29
|
|
|
42
30
|
def __init__(
|
|
43
31
|
self,
|
|
44
|
-
conn: Conn,
|
|
32
|
+
conn: _ainternal.Conn,
|
|
45
33
|
pipe: Optional[AsyncPipeline] = None,
|
|
46
34
|
serde: Optional[SerializerProtocol] = None,
|
|
47
35
|
) -> None:
|
|
@@ -55,6 +43,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
55
43
|
self.pipe = pipe
|
|
56
44
|
self.lock = asyncio.Lock()
|
|
57
45
|
self.loop = asyncio.get_running_loop()
|
|
46
|
+
self.supports_pipeline = Capabilities().has_pipeline()
|
|
58
47
|
|
|
59
48
|
@classmethod
|
|
60
49
|
@asynccontextmanager
|
|
@@ -79,9 +68,9 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
79
68
|
) as conn:
|
|
80
69
|
if pipeline:
|
|
81
70
|
async with conn.pipeline() as pipe:
|
|
82
|
-
yield
|
|
71
|
+
yield cls(conn=conn, pipe=pipe, serde=serde)
|
|
83
72
|
else:
|
|
84
|
-
yield
|
|
73
|
+
yield cls(conn=conn, serde=serde)
|
|
85
74
|
|
|
86
75
|
async def setup(self) -> None:
|
|
87
76
|
"""Set up the checkpoint database asynchronously.
|
|
@@ -156,15 +145,17 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
156
145
|
value["pending_sends"],
|
|
157
146
|
),
|
|
158
147
|
self._load_metadata(value["metadata"]),
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
"
|
|
162
|
-
|
|
163
|
-
|
|
148
|
+
(
|
|
149
|
+
{
|
|
150
|
+
"configurable": {
|
|
151
|
+
"thread_id": value["thread_id"],
|
|
152
|
+
"checkpoint_ns": value["checkpoint_ns"],
|
|
153
|
+
"checkpoint_id": value["parent_checkpoint_id"],
|
|
154
|
+
}
|
|
164
155
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
156
|
+
if value["parent_checkpoint_id"]
|
|
157
|
+
else None
|
|
158
|
+
),
|
|
168
159
|
await asyncio.to_thread(self._load_writes, value["pending_writes"]),
|
|
169
160
|
)
|
|
170
161
|
|
|
@@ -215,15 +206,17 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
215
206
|
value["pending_sends"],
|
|
216
207
|
),
|
|
217
208
|
self._load_metadata(value["metadata"]),
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
"
|
|
221
|
-
|
|
222
|
-
|
|
209
|
+
(
|
|
210
|
+
{
|
|
211
|
+
"configurable": {
|
|
212
|
+
"thread_id": thread_id,
|
|
213
|
+
"checkpoint_ns": checkpoint_ns,
|
|
214
|
+
"checkpoint_id": value["parent_checkpoint_id"],
|
|
215
|
+
}
|
|
223
216
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
217
|
+
if value["parent_checkpoint_id"]
|
|
218
|
+
else None
|
|
219
|
+
),
|
|
227
220
|
await asyncio.to_thread(self._load_writes, value["pending_writes"]),
|
|
228
221
|
)
|
|
229
222
|
|
|
@@ -323,7 +316,14 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
323
316
|
async def _cursor(
|
|
324
317
|
self, *, pipeline: bool = False
|
|
325
318
|
) -> AsyncIterator[AsyncCursor[DictRow]]:
|
|
326
|
-
|
|
319
|
+
"""Create a database cursor as a context manager.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
pipeline (bool): whether to use pipeline for the DB operations inside the context manager.
|
|
323
|
+
Will be applied regardless of whether the AsyncPostgresSaver instance was initialized with a pipeline.
|
|
324
|
+
If pipeline mode is not supported, will fall back to using transaction context manager.
|
|
325
|
+
"""
|
|
326
|
+
async with _ainternal.get_connection(self.conn) as conn:
|
|
327
327
|
if self.pipe:
|
|
328
328
|
# a connection in pipeline mode can be used concurrently
|
|
329
329
|
# in multiple threads/coroutines, but only one cursor can be
|
|
@@ -337,10 +337,17 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
337
337
|
elif pipeline:
|
|
338
338
|
# a connection not in pipeline mode can only be used by one
|
|
339
339
|
# thread/coroutine at a time, so we acquire a lock
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
340
|
+
if self.supports_pipeline:
|
|
341
|
+
async with self.lock, conn.pipeline(), conn.cursor(
|
|
342
|
+
binary=True, row_factory=dict_row
|
|
343
|
+
) as cur:
|
|
344
|
+
yield cur
|
|
345
|
+
else:
|
|
346
|
+
# Use connection's transaction context manager when pipeline mode not supported
|
|
347
|
+
async with self.lock, conn.transaction(), conn.cursor(
|
|
348
|
+
binary=True, row_factory=dict_row
|
|
349
|
+
) as cur:
|
|
350
|
+
yield cur
|
|
344
351
|
else:
|
|
345
352
|
async with self.lock, conn.cursor(
|
|
346
353
|
binary=True, row_factory=dict_row
|
|
@@ -452,3 +459,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
452
459
|
return asyncio.run_coroutine_threadsafe(
|
|
453
460
|
self.aput_writes(config, writes, task_id), self.loop
|
|
454
461
|
).result()
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
__all__ = ["AsyncPostgresSaver", "Conn"]
|
|
@@ -84,7 +84,7 @@ select
|
|
|
84
84
|
and cw.checkpoint_id = checkpoints.checkpoint_id
|
|
85
85
|
) as pending_writes,
|
|
86
86
|
(
|
|
87
|
-
select array_agg(array[cw.type::bytea, cw.blob] order by cw.idx)
|
|
87
|
+
select array_agg(array[cw.type::bytea, cw.blob] order by cw.task_id, cw.idx)
|
|
88
88
|
from checkpoint_writes cw
|
|
89
89
|
where cw.thread_id = checkpoints.thread_id
|
|
90
90
|
and cw.checkpoint_ns = checkpoints.checkpoint_ns
|
|
@@ -133,6 +133,7 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
|
|
133
133
|
INSERT_CHECKPOINT_WRITES_SQL = INSERT_CHECKPOINT_WRITES_SQL
|
|
134
134
|
|
|
135
135
|
jsonplus_serde = JsonPlusSerializer()
|
|
136
|
+
supports_pipeline: bool
|
|
136
137
|
|
|
137
138
|
def _load_checkpoint(
|
|
138
139
|
self,
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from typing import (
|
|
5
|
+
Any,
|
|
6
|
+
AsyncIterator,
|
|
7
|
+
Callable,
|
|
8
|
+
Iterable,
|
|
9
|
+
Optional,
|
|
10
|
+
Sequence,
|
|
11
|
+
Union,
|
|
12
|
+
cast,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
import orjson
|
|
16
|
+
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
|
|
17
|
+
from psycopg.errors import UndefinedTable
|
|
18
|
+
from psycopg.rows import DictRow, dict_row
|
|
19
|
+
from psycopg_pool import AsyncConnectionPool
|
|
20
|
+
|
|
21
|
+
from langgraph.checkpoint.postgres import _ainternal
|
|
22
|
+
from langgraph.store.base import GetOp, ListNamespacesOp, Op, PutOp, Result, SearchOp
|
|
23
|
+
from langgraph.store.base.batch import AsyncBatchedBaseStore
|
|
24
|
+
from langgraph.store.postgres.base import (
|
|
25
|
+
BasePostgresStore,
|
|
26
|
+
PoolConfig,
|
|
27
|
+
Row,
|
|
28
|
+
_decode_ns_bytes,
|
|
29
|
+
_group_ops,
|
|
30
|
+
_row_to_item,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Conn]):
|
|
37
|
+
__slots__ = ("_deserializer", "pipe", "lock", "supports_pipeline")
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
conn: _ainternal.Conn,
|
|
42
|
+
*,
|
|
43
|
+
pipe: Optional[AsyncPipeline] = None,
|
|
44
|
+
deserializer: Optional[
|
|
45
|
+
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
|
|
46
|
+
] = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
if isinstance(conn, AsyncConnectionPool) and pipe is not None:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"Pipeline should be used only with a single AsyncConnection, not AsyncConnectionPool."
|
|
51
|
+
)
|
|
52
|
+
super().__init__()
|
|
53
|
+
self._deserializer = deserializer
|
|
54
|
+
self.conn = conn
|
|
55
|
+
self.pipe = pipe
|
|
56
|
+
self.lock = asyncio.Lock()
|
|
57
|
+
self.loop = asyncio.get_running_loop()
|
|
58
|
+
self.supports_pipeline = Capabilities().has_pipeline()
|
|
59
|
+
|
|
60
|
+
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
|
|
61
|
+
grouped_ops, num_ops = _group_ops(ops)
|
|
62
|
+
results: list[Result] = [None] * num_ops
|
|
63
|
+
|
|
64
|
+
async with _ainternal.get_connection(self.conn) as conn:
|
|
65
|
+
if self.pipe:
|
|
66
|
+
async with self.pipe:
|
|
67
|
+
await self._execute_batch(grouped_ops, results, conn)
|
|
68
|
+
else:
|
|
69
|
+
await self._execute_batch(grouped_ops, results, conn)
|
|
70
|
+
|
|
71
|
+
return results
|
|
72
|
+
|
|
73
|
+
async def _execute_batch(
|
|
74
|
+
self,
|
|
75
|
+
grouped_ops: dict,
|
|
76
|
+
results: list[Result],
|
|
77
|
+
conn: AsyncConnection[DictRow],
|
|
78
|
+
) -> None:
|
|
79
|
+
async with self._cursor(conn, pipeline=True) as cur:
|
|
80
|
+
if GetOp in grouped_ops:
|
|
81
|
+
await self._batch_get_ops(
|
|
82
|
+
cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]),
|
|
83
|
+
results,
|
|
84
|
+
cur,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if SearchOp in grouped_ops:
|
|
88
|
+
await self._batch_search_ops(
|
|
89
|
+
cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]),
|
|
90
|
+
results,
|
|
91
|
+
cur,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if ListNamespacesOp in grouped_ops:
|
|
95
|
+
await self._batch_list_namespaces_ops(
|
|
96
|
+
cast(
|
|
97
|
+
Sequence[tuple[int, ListNamespacesOp]],
|
|
98
|
+
grouped_ops[ListNamespacesOp],
|
|
99
|
+
),
|
|
100
|
+
results,
|
|
101
|
+
cur,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if PutOp in grouped_ops:
|
|
105
|
+
await self._batch_put_ops(
|
|
106
|
+
cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp]),
|
|
107
|
+
cur,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
async def _batch_get_ops(
|
|
111
|
+
self,
|
|
112
|
+
get_ops: Sequence[tuple[int, GetOp]],
|
|
113
|
+
results: list[Result],
|
|
114
|
+
cur: AsyncCursor[DictRow],
|
|
115
|
+
) -> None:
|
|
116
|
+
for query, params, namespace, items in self._get_batch_GET_ops_queries(get_ops):
|
|
117
|
+
await cur.execute(query, params)
|
|
118
|
+
rows = cast(list[Row], await cur.fetchall())
|
|
119
|
+
key_to_row = {row["key"]: row for row in rows}
|
|
120
|
+
for idx, key in items:
|
|
121
|
+
row = key_to_row.get(key)
|
|
122
|
+
if row:
|
|
123
|
+
results[idx] = _row_to_item(
|
|
124
|
+
namespace, row, loader=self._deserializer
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
results[idx] = None
|
|
128
|
+
|
|
129
|
+
async def _batch_put_ops(
|
|
130
|
+
self,
|
|
131
|
+
put_ops: Sequence[tuple[int, PutOp]],
|
|
132
|
+
cur: AsyncCursor[DictRow],
|
|
133
|
+
) -> None:
|
|
134
|
+
queries = self._get_batch_PUT_queries(put_ops)
|
|
135
|
+
for query, params in queries:
|
|
136
|
+
await cur.execute(query, params)
|
|
137
|
+
|
|
138
|
+
async def _batch_search_ops(
|
|
139
|
+
self,
|
|
140
|
+
search_ops: Sequence[tuple[int, SearchOp]],
|
|
141
|
+
results: list[Result],
|
|
142
|
+
cur: AsyncCursor[DictRow],
|
|
143
|
+
) -> None:
|
|
144
|
+
queries = self._get_batch_search_queries(search_ops)
|
|
145
|
+
for (query, params), (idx, _) in zip(queries, search_ops):
|
|
146
|
+
await cur.execute(query, params)
|
|
147
|
+
rows = cast(list[Row], await cur.fetchall())
|
|
148
|
+
items = [
|
|
149
|
+
_row_to_item(
|
|
150
|
+
_decode_ns_bytes(row["prefix"]), row, loader=self._deserializer
|
|
151
|
+
)
|
|
152
|
+
for row in rows
|
|
153
|
+
]
|
|
154
|
+
results[idx] = items
|
|
155
|
+
|
|
156
|
+
async def _batch_list_namespaces_ops(
|
|
157
|
+
self,
|
|
158
|
+
list_ops: Sequence[tuple[int, ListNamespacesOp]],
|
|
159
|
+
results: list[Result],
|
|
160
|
+
cur: AsyncCursor[DictRow],
|
|
161
|
+
) -> None:
|
|
162
|
+
queries = self._get_batch_list_namespaces_queries(list_ops)
|
|
163
|
+
for (query, params), (idx, _) in zip(queries, list_ops):
|
|
164
|
+
await cur.execute(query, params)
|
|
165
|
+
rows = cast(list[dict], await cur.fetchall())
|
|
166
|
+
namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows]
|
|
167
|
+
results[idx] = namespaces
|
|
168
|
+
|
|
169
|
+
@asynccontextmanager
|
|
170
|
+
async def _cursor(
|
|
171
|
+
self, conn: AsyncConnection[DictRow], *, pipeline: bool = False
|
|
172
|
+
) -> AsyncIterator[AsyncCursor[Any]]:
|
|
173
|
+
"""Create a database cursor as a context manager.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
conn: The database connection to use
|
|
177
|
+
pipeline: whether to use pipeline for the DB operations inside the context manager.
|
|
178
|
+
Will be applied regardless of whether the PostgresStore instance was initialized with a pipeline.
|
|
179
|
+
If pipeline mode is not supported, will fall back to using transaction context manager.
|
|
180
|
+
"""
|
|
181
|
+
if self.pipe:
|
|
182
|
+
# a connection in pipeline mode can be used concurrently
|
|
183
|
+
# in multiple threads/coroutines, but only one cursor can be
|
|
184
|
+
# used at a time
|
|
185
|
+
async with conn.cursor(binary=True) as cur:
|
|
186
|
+
try:
|
|
187
|
+
yield cur
|
|
188
|
+
finally:
|
|
189
|
+
if pipeline:
|
|
190
|
+
await self.pipe.sync()
|
|
191
|
+
elif pipeline:
|
|
192
|
+
# a connection not in pipeline mode can only be used by one
|
|
193
|
+
# thread/coroutine at a time, so we acquire a lock
|
|
194
|
+
if self.supports_pipeline:
|
|
195
|
+
async with self.lock, conn.pipeline(), conn.cursor(binary=True) as cur:
|
|
196
|
+
yield cur
|
|
197
|
+
else:
|
|
198
|
+
async with self.lock, conn.transaction(), conn.cursor(
|
|
199
|
+
binary=True
|
|
200
|
+
) as cur:
|
|
201
|
+
yield cur
|
|
202
|
+
else:
|
|
203
|
+
async with conn.cursor(binary=True) as cur:
|
|
204
|
+
yield cur
|
|
205
|
+
|
|
206
|
+
def batch(self, ops: Iterable[Op]) -> list[Result]:
|
|
207
|
+
return asyncio.run_coroutine_threadsafe(self.abatch(ops), self.loop).result()
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
@asynccontextmanager
|
|
211
|
+
async def from_conn_string(
|
|
212
|
+
cls,
|
|
213
|
+
conn_string: str,
|
|
214
|
+
*,
|
|
215
|
+
pipeline: bool = False,
|
|
216
|
+
pool_config: Optional[PoolConfig] = None,
|
|
217
|
+
) -> AsyncIterator["AsyncPostgresStore"]:
|
|
218
|
+
"""Create a new AsyncPostgresStore instance from a connection string.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
conn_string (str): The Postgres connection info string.
|
|
222
|
+
pipeline (bool): Whether to use AsyncPipeline (only for single connections)
|
|
223
|
+
pool_config (Optional[PoolConfig]): Configuration for the connection pool.
|
|
224
|
+
If provided, will create a connection pool and use it instead of a single connection.
|
|
225
|
+
This overrides the `pipeline` argument.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
AsyncPostgresStore: A new AsyncPostgresStore instance.
|
|
229
|
+
"""
|
|
230
|
+
if pool_config is not None:
|
|
231
|
+
pc = pool_config.copy()
|
|
232
|
+
async with cast(
|
|
233
|
+
AsyncConnectionPool[AsyncConnection[DictRow]],
|
|
234
|
+
AsyncConnectionPool(
|
|
235
|
+
conn_string,
|
|
236
|
+
min_size=pc.pop("min_size", 1),
|
|
237
|
+
max_size=pc.pop("max_size", None),
|
|
238
|
+
kwargs={
|
|
239
|
+
"autocommit": True,
|
|
240
|
+
"prepare_threshold": 0,
|
|
241
|
+
"row_factory": dict_row,
|
|
242
|
+
**(pc.pop("kwargs", None) or {}),
|
|
243
|
+
},
|
|
244
|
+
**cast(dict, pc),
|
|
245
|
+
),
|
|
246
|
+
) as pool:
|
|
247
|
+
yield cls(conn=pool)
|
|
248
|
+
else:
|
|
249
|
+
async with await AsyncConnection.connect(
|
|
250
|
+
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
|
|
251
|
+
) as conn:
|
|
252
|
+
if pipeline:
|
|
253
|
+
async with conn.pipeline() as pipe:
|
|
254
|
+
yield cls(conn=conn, pipe=pipe)
|
|
255
|
+
else:
|
|
256
|
+
yield cls(conn=conn)
|
|
257
|
+
|
|
258
|
+
async def setup(self) -> None:
|
|
259
|
+
"""Set up the store database asynchronously.
|
|
260
|
+
|
|
261
|
+
This method creates the necessary tables in the Postgres database if they don't
|
|
262
|
+
already exist and runs database migrations. It MUST be called directly by the user
|
|
263
|
+
the first time the store is used.
|
|
264
|
+
"""
|
|
265
|
+
async with _ainternal.get_connection(self.conn) as conn:
|
|
266
|
+
async with conn.cursor() as cur:
|
|
267
|
+
try:
|
|
268
|
+
await cur.execute(
|
|
269
|
+
"SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1"
|
|
270
|
+
)
|
|
271
|
+
row = cast(dict, await cur.fetchone())
|
|
272
|
+
if row is None:
|
|
273
|
+
version = -1
|
|
274
|
+
else:
|
|
275
|
+
version = row["v"]
|
|
276
|
+
except UndefinedTable:
|
|
277
|
+
version = -1
|
|
278
|
+
# Create store_migrations table if it doesn't exist
|
|
279
|
+
await cur.execute(
|
|
280
|
+
"""
|
|
281
|
+
CREATE TABLE IF NOT EXISTS store_migrations (
|
|
282
|
+
v INTEGER PRIMARY KEY
|
|
283
|
+
)
|
|
284
|
+
"""
|
|
285
|
+
)
|
|
286
|
+
for v, migration in enumerate(
|
|
287
|
+
self.MIGRATIONS[version + 1 :], start=version + 1
|
|
288
|
+
):
|
|
289
|
+
await cur.execute(migration)
|
|
290
|
+
await cur.execute(
|
|
291
|
+
"INSERT INTO store_migrations (v) VALUES (%s)", (v,)
|
|
292
|
+
)
|
|
293
|
+
if self.pipe:
|
|
294
|
+
await self.pipe.sync()
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import json
|
|
3
3
|
import logging
|
|
4
|
+
import threading
|
|
4
5
|
from collections import defaultdict
|
|
5
6
|
from contextlib import contextmanager
|
|
6
7
|
from datetime import datetime
|
|
@@ -18,12 +19,15 @@ from typing import (
|
|
|
18
19
|
)
|
|
19
20
|
|
|
20
21
|
import orjson
|
|
21
|
-
from psycopg import
|
|
22
|
+
from psycopg import Capabilities, Connection, Cursor, Pipeline
|
|
22
23
|
from psycopg.errors import UndefinedTable
|
|
23
|
-
from psycopg.rows import dict_row
|
|
24
|
+
from psycopg.rows import DictRow, dict_row
|
|
24
25
|
from psycopg.types.json import Jsonb
|
|
26
|
+
from psycopg_pool import ConnectionPool
|
|
25
27
|
from typing_extensions import TypedDict
|
|
26
28
|
|
|
29
|
+
from langgraph.checkpoint.postgres import _ainternal as _ainternal
|
|
30
|
+
from langgraph.checkpoint.postgres import _internal as _pg_internal
|
|
27
31
|
from langgraph.store.base import (
|
|
28
32
|
BaseStore,
|
|
29
33
|
GetOp,
|
|
@@ -56,7 +60,32 @@ CREATE INDEX IF NOT EXISTS store_prefix_idx ON store USING btree (prefix text_pa
|
|
|
56
60
|
""",
|
|
57
61
|
]
|
|
58
62
|
|
|
59
|
-
C = TypeVar("C", bound=
|
|
63
|
+
C = TypeVar("C", bound=Union[_pg_internal.Conn, _ainternal.Conn])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class PoolConfig(TypedDict, total=False):
|
|
67
|
+
"""Connection pool settings for PostgreSQL connections.
|
|
68
|
+
|
|
69
|
+
Controls connection lifecycle and resource utilization:
|
|
70
|
+
- Small pools (1-5) suit low-concurrency workloads
|
|
71
|
+
- Larger pools handle concurrent requests but consume more resources
|
|
72
|
+
- Setting max_size prevents resource exhaustion under load
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
min_size: int
|
|
76
|
+
"""Minimum number of connections maintained in the pool. Defaults to 1."""
|
|
77
|
+
|
|
78
|
+
max_size: Optional[int]
|
|
79
|
+
"""Maximum number of connections allowed in the pool. None means unlimited."""
|
|
80
|
+
|
|
81
|
+
kwargs: dict
|
|
82
|
+
"""Additional connection arguments passed to each connection in the pool.
|
|
83
|
+
|
|
84
|
+
Default kwargs set automatically:
|
|
85
|
+
- autocommit: True
|
|
86
|
+
- prepare_threshold: 0
|
|
87
|
+
- row_factory: dict_row
|
|
88
|
+
"""
|
|
60
89
|
|
|
61
90
|
|
|
62
91
|
class BasePostgresStore(Generic[C]):
|
|
@@ -88,9 +117,14 @@ class BasePostgresStore(Generic[C]):
|
|
|
88
117
|
self,
|
|
89
118
|
put_ops: Sequence[tuple[int, PutOp]],
|
|
90
119
|
) -> list[tuple[str, Sequence]]:
|
|
120
|
+
# Last-write wins
|
|
121
|
+
dedupped_ops: dict[tuple[tuple[str, ...], str], PutOp] = {}
|
|
122
|
+
for _, op in put_ops:
|
|
123
|
+
dedupped_ops[(op.namespace, op.key)] = op
|
|
124
|
+
|
|
91
125
|
inserts: list[PutOp] = []
|
|
92
126
|
deletes: list[PutOp] = []
|
|
93
|
-
for
|
|
127
|
+
for op in dedupped_ops.values():
|
|
94
128
|
if op.value is None:
|
|
95
129
|
deletes.append(op)
|
|
96
130
|
else:
|
|
@@ -219,13 +253,14 @@ class BasePostgresStore(Generic[C]):
|
|
|
219
253
|
return queries
|
|
220
254
|
|
|
221
255
|
|
|
222
|
-
class PostgresStore(BaseStore, BasePostgresStore[
|
|
223
|
-
__slots__ = ("_deserializer",)
|
|
256
|
+
class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
|
|
257
|
+
__slots__ = ("_deserializer", "pipe", "lock", "supports_pipeline")
|
|
224
258
|
|
|
225
259
|
def __init__(
|
|
226
260
|
self,
|
|
227
|
-
conn:
|
|
261
|
+
conn: _pg_internal.Conn,
|
|
228
262
|
*,
|
|
263
|
+
pipe: Optional[Pipeline] = None,
|
|
229
264
|
deserializer: Optional[
|
|
230
265
|
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
|
|
231
266
|
] = None,
|
|
@@ -233,26 +268,110 @@ class PostgresStore(BaseStore, BasePostgresStore[Connection]):
|
|
|
233
268
|
super().__init__()
|
|
234
269
|
self._deserializer = deserializer
|
|
235
270
|
self.conn = conn
|
|
271
|
+
self.pipe = pipe
|
|
272
|
+
self.supports_pipeline = Capabilities().has_pipeline()
|
|
273
|
+
self.lock = threading.Lock()
|
|
274
|
+
|
|
275
|
+
@classmethod
|
|
276
|
+
@contextmanager
|
|
277
|
+
def from_conn_string(
|
|
278
|
+
cls,
|
|
279
|
+
conn_string: str,
|
|
280
|
+
*,
|
|
281
|
+
pipeline: bool = False,
|
|
282
|
+
pool_config: Optional[PoolConfig] = None,
|
|
283
|
+
) -> Iterator["PostgresStore"]:
|
|
284
|
+
"""Create a new PostgresStore instance from a connection string.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
conn_string (str): The Postgres connection info string.
|
|
288
|
+
pipeline (bool): whether to use Pipeline (only for single connections)
|
|
289
|
+
pool_config (Optional[PoolArgs]): Configuration for the connection pool.
|
|
290
|
+
If provided, will create a connection pool and use it instead of a single connection.
|
|
291
|
+
This overrides the `pipeline` argument.
|
|
292
|
+
Returns:
|
|
293
|
+
PostgresStore: A new PostgresStore instance.
|
|
294
|
+
"""
|
|
295
|
+
if pool_config is not None:
|
|
296
|
+
pc = pool_config.copy()
|
|
297
|
+
with cast(
|
|
298
|
+
ConnectionPool[Connection[DictRow]],
|
|
299
|
+
ConnectionPool(
|
|
300
|
+
conn_string,
|
|
301
|
+
min_size=pc.pop("min_size", 1),
|
|
302
|
+
max_size=pc.pop("max_size", None),
|
|
303
|
+
kwargs={
|
|
304
|
+
"autocommit": True,
|
|
305
|
+
"prepare_threshold": 0,
|
|
306
|
+
"row_factory": dict_row,
|
|
307
|
+
**(pc.pop("kwargs", None) or {}),
|
|
308
|
+
},
|
|
309
|
+
**cast(dict, pc),
|
|
310
|
+
),
|
|
311
|
+
) as pool:
|
|
312
|
+
yield cls(conn=pool)
|
|
313
|
+
else:
|
|
314
|
+
with Connection.connect(
|
|
315
|
+
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
|
|
316
|
+
) as conn:
|
|
317
|
+
if pipeline:
|
|
318
|
+
with conn.pipeline() as pipe:
|
|
319
|
+
yield cls(conn, pipe=pipe)
|
|
320
|
+
else:
|
|
321
|
+
yield cls(conn)
|
|
322
|
+
|
|
323
|
+
@contextmanager
|
|
324
|
+
def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor[DictRow]]:
|
|
325
|
+
"""Create a database cursor as a context manager.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
pipeline (bool): whether to use pipeline for the DB operations inside the context manager.
|
|
329
|
+
Will be applied regardless of whether the PostgresStore instance was initialized with a pipeline.
|
|
330
|
+
If pipeline mode is not supported, will fall back to using transaction context manager.
|
|
331
|
+
"""
|
|
332
|
+
with _pg_internal.get_connection(self.conn) as conn:
|
|
333
|
+
if self.pipe:
|
|
334
|
+
# a connection in pipeline mode can be used concurrently
|
|
335
|
+
# in multiple threads/coroutines, but only one cursor can be
|
|
336
|
+
# used at a time
|
|
337
|
+
try:
|
|
338
|
+
with conn.cursor(binary=True, row_factory=dict_row) as cur:
|
|
339
|
+
yield cur
|
|
340
|
+
finally:
|
|
341
|
+
if pipeline:
|
|
342
|
+
self.pipe.sync()
|
|
343
|
+
elif pipeline:
|
|
344
|
+
# a connection not in pipeline mode can only be used by one
|
|
345
|
+
# thread/coroutine at a time, so we acquire a lock
|
|
346
|
+
if self.supports_pipeline:
|
|
347
|
+
with self.lock, conn.pipeline(), conn.cursor(
|
|
348
|
+
binary=True, row_factory=dict_row
|
|
349
|
+
) as cur:
|
|
350
|
+
yield cur
|
|
351
|
+
else:
|
|
352
|
+
with self.lock, conn.transaction(), conn.cursor(
|
|
353
|
+
binary=True, row_factory=dict_row
|
|
354
|
+
) as cur:
|
|
355
|
+
yield cur
|
|
356
|
+
else:
|
|
357
|
+
with conn.cursor(binary=True, row_factory=dict_row) as cur:
|
|
358
|
+
yield cur
|
|
236
359
|
|
|
237
360
|
def batch(self, ops: Iterable[Op]) -> list[Result]:
|
|
238
361
|
grouped_ops, num_ops = _group_ops(ops)
|
|
239
362
|
results: list[Result] = [None] * num_ops
|
|
240
363
|
|
|
241
|
-
with self.
|
|
364
|
+
with self._cursor(pipeline=True) as cur:
|
|
242
365
|
if GetOp in grouped_ops:
|
|
243
366
|
self._batch_get_ops(
|
|
244
|
-
cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results
|
|
245
|
-
)
|
|
246
|
-
|
|
247
|
-
if PutOp in grouped_ops:
|
|
248
|
-
self._batch_put_ops(
|
|
249
|
-
cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp])
|
|
367
|
+
cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results, cur
|
|
250
368
|
)
|
|
251
369
|
|
|
252
370
|
if SearchOp in grouped_ops:
|
|
253
371
|
self._batch_search_ops(
|
|
254
372
|
cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]),
|
|
255
373
|
results,
|
|
374
|
+
cur,
|
|
256
375
|
)
|
|
257
376
|
|
|
258
377
|
if ListNamespacesOp in grouped_ops:
|
|
@@ -262,25 +381,23 @@ class PostgresStore(BaseStore, BasePostgresStore[Connection]):
|
|
|
262
381
|
grouped_ops[ListNamespacesOp],
|
|
263
382
|
),
|
|
264
383
|
results,
|
|
384
|
+
cur,
|
|
385
|
+
)
|
|
386
|
+
if PutOp in grouped_ops:
|
|
387
|
+
self._batch_put_ops(
|
|
388
|
+
cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp]), cur
|
|
265
389
|
)
|
|
266
390
|
|
|
267
391
|
return results
|
|
268
392
|
|
|
269
|
-
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
|
|
270
|
-
return await asyncio.get_running_loop().run_in_executor(None, self.batch, ops)
|
|
271
|
-
|
|
272
393
|
def _batch_get_ops(
|
|
273
394
|
self,
|
|
274
395
|
get_ops: Sequence[tuple[int, GetOp]],
|
|
275
396
|
results: list[Result],
|
|
397
|
+
cur: Cursor[DictRow],
|
|
276
398
|
) -> None:
|
|
277
|
-
cursors = []
|
|
278
399
|
for query, params, namespace, items in self._get_batch_GET_ops_queries(get_ops):
|
|
279
|
-
cur = self.conn.cursor(binary=True)
|
|
280
400
|
cur.execute(query, params)
|
|
281
|
-
cursors.append((cur, namespace, items))
|
|
282
|
-
|
|
283
|
-
for cur, namespace, items in cursors:
|
|
284
401
|
rows = cast(list[Row], cur.fetchall())
|
|
285
402
|
key_to_row = {row["key"]: row for row in rows}
|
|
286
403
|
for idx, key in items:
|
|
@@ -295,70 +412,44 @@ class PostgresStore(BaseStore, BasePostgresStore[Connection]):
|
|
|
295
412
|
def _batch_put_ops(
|
|
296
413
|
self,
|
|
297
414
|
put_ops: Sequence[tuple[int, PutOp]],
|
|
415
|
+
cur: Cursor[DictRow],
|
|
298
416
|
) -> None:
|
|
299
417
|
queries = self._get_batch_PUT_queries(put_ops)
|
|
300
418
|
for query, params in queries:
|
|
301
|
-
cur = self.conn.cursor(binary=True)
|
|
302
419
|
cur.execute(query, params)
|
|
303
420
|
|
|
304
421
|
def _batch_search_ops(
|
|
305
422
|
self,
|
|
306
423
|
search_ops: Sequence[tuple[int, SearchOp]],
|
|
307
424
|
results: list[Result],
|
|
425
|
+
cur: Cursor[DictRow],
|
|
308
426
|
) -> None:
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
for (query, params), (idx, _) in zip(queries, search_ops):
|
|
313
|
-
cur = self.conn.cursor(binary=True)
|
|
427
|
+
for (query, params), (idx, _) in zip(
|
|
428
|
+
self._get_batch_search_queries(search_ops), search_ops
|
|
429
|
+
):
|
|
314
430
|
cur.execute(query, params)
|
|
315
|
-
cursors.append((cur, idx))
|
|
316
|
-
|
|
317
|
-
for cur, idx in cursors:
|
|
318
431
|
rows = cast(list[Row], cur.fetchall())
|
|
319
|
-
|
|
432
|
+
results[idx] = [
|
|
320
433
|
_row_to_item(
|
|
321
434
|
_decode_ns_bytes(row["prefix"]), row, loader=self._deserializer
|
|
322
435
|
)
|
|
323
436
|
for row in rows
|
|
324
437
|
]
|
|
325
|
-
results[idx] = items
|
|
326
438
|
|
|
327
439
|
def _batch_list_namespaces_ops(
|
|
328
440
|
self,
|
|
329
441
|
list_ops: Sequence[tuple[int, ListNamespacesOp]],
|
|
330
442
|
results: list[Result],
|
|
443
|
+
cur: Cursor[DictRow],
|
|
331
444
|
) -> None:
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
cur = self.conn.cursor(binary=True)
|
|
445
|
+
for (query, params), (idx, _) in zip(
|
|
446
|
+
self._get_batch_list_namespaces_queries(list_ops), list_ops
|
|
447
|
+
):
|
|
336
448
|
cur.execute(query, params)
|
|
337
|
-
|
|
449
|
+
results[idx] = [_decode_ns_bytes(row["truncated_prefix"]) for row in cur]
|
|
338
450
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows]
|
|
342
|
-
results[idx] = namespaces
|
|
343
|
-
|
|
344
|
-
@classmethod
|
|
345
|
-
@contextmanager
|
|
346
|
-
def from_conn_string(
|
|
347
|
-
cls,
|
|
348
|
-
conn_string: str,
|
|
349
|
-
) -> Iterator["PostgresStore"]:
|
|
350
|
-
"""Create a new BasePostgresStore instance from a connection string.
|
|
351
|
-
|
|
352
|
-
Args:
|
|
353
|
-
conn_string (str): The Postgres connection info string.
|
|
354
|
-
|
|
355
|
-
Returns:
|
|
356
|
-
BasePostgresStore: A new BasePostgresStore instance.
|
|
357
|
-
"""
|
|
358
|
-
with Connection.connect(
|
|
359
|
-
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
|
|
360
|
-
) as conn:
|
|
361
|
-
yield cls(conn=conn)
|
|
451
|
+
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
|
|
452
|
+
return await asyncio.get_running_loop().run_in_executor(None, self.batch, ops)
|
|
362
453
|
|
|
363
454
|
def setup(self) -> None:
|
|
364
455
|
"""Set up the store database.
|
|
@@ -367,7 +458,7 @@ class PostgresStore(BaseStore, BasePostgresStore[Connection]):
|
|
|
367
458
|
already exist and runs database migrations. It MUST be called directly by the user
|
|
368
459
|
the first time the store is used.
|
|
369
460
|
"""
|
|
370
|
-
with self.
|
|
461
|
+
with self._cursor() as cur:
|
|
371
462
|
try:
|
|
372
463
|
cur.execute("SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1")
|
|
373
464
|
row = cast(dict, cur.fetchone())
|
|
@@ -376,9 +467,7 @@ class PostgresStore(BaseStore, BasePostgresStore[Connection]):
|
|
|
376
467
|
else:
|
|
377
468
|
version = row["v"]
|
|
378
469
|
except UndefinedTable:
|
|
379
|
-
self.conn.rollback()
|
|
380
470
|
version = -1
|
|
381
|
-
# Create store_migrations table if it doesn't exist
|
|
382
471
|
cur.execute(
|
|
383
472
|
"""
|
|
384
473
|
CREATE TABLE IF NOT EXISTS store_migrations (
|
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import logging
|
|
3
|
-
from contextlib import asynccontextmanager
|
|
4
|
-
from typing import (
|
|
5
|
-
Any,
|
|
6
|
-
AsyncIterator,
|
|
7
|
-
Callable,
|
|
8
|
-
Iterable,
|
|
9
|
-
Optional,
|
|
10
|
-
Sequence,
|
|
11
|
-
Union,
|
|
12
|
-
cast,
|
|
13
|
-
)
|
|
14
|
-
|
|
15
|
-
import orjson
|
|
16
|
-
from psycopg import AsyncConnection, AsyncCursor
|
|
17
|
-
from psycopg.errors import UndefinedTable
|
|
18
|
-
from psycopg.rows import dict_row
|
|
19
|
-
|
|
20
|
-
from langgraph.store.base import GetOp, ListNamespacesOp, Op, PutOp, Result, SearchOp
|
|
21
|
-
from langgraph.store.base.batch import AsyncBatchedBaseStore
|
|
22
|
-
from langgraph.store.postgres.base import (
|
|
23
|
-
BasePostgresStore,
|
|
24
|
-
Row,
|
|
25
|
-
_decode_ns_bytes,
|
|
26
|
-
_group_ops,
|
|
27
|
-
_row_to_item,
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
logger = logging.getLogger(__name__)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[AsyncConnection]):
|
|
34
|
-
__slots__ = ("_deserializer",)
|
|
35
|
-
|
|
36
|
-
def __init__(
|
|
37
|
-
self,
|
|
38
|
-
conn: AsyncConnection[Any],
|
|
39
|
-
*,
|
|
40
|
-
deserializer: Optional[
|
|
41
|
-
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
|
|
42
|
-
] = None,
|
|
43
|
-
) -> None:
|
|
44
|
-
super().__init__()
|
|
45
|
-
self._deserializer = deserializer
|
|
46
|
-
self.conn = conn
|
|
47
|
-
self.loop = asyncio.get_running_loop()
|
|
48
|
-
|
|
49
|
-
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
|
|
50
|
-
grouped_ops, num_ops = _group_ops(ops)
|
|
51
|
-
results: list[Result] = [None] * num_ops
|
|
52
|
-
|
|
53
|
-
async with self.conn.pipeline():
|
|
54
|
-
tasks = []
|
|
55
|
-
|
|
56
|
-
if GetOp in grouped_ops:
|
|
57
|
-
tasks.append(
|
|
58
|
-
self._batch_get_ops(
|
|
59
|
-
cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results
|
|
60
|
-
)
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
if PutOp in grouped_ops:
|
|
64
|
-
tasks.append(
|
|
65
|
-
self._batch_put_ops(
|
|
66
|
-
cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp])
|
|
67
|
-
)
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
if SearchOp in grouped_ops:
|
|
71
|
-
tasks.append(
|
|
72
|
-
self._batch_search_ops(
|
|
73
|
-
cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]),
|
|
74
|
-
results,
|
|
75
|
-
)
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
if ListNamespacesOp in grouped_ops:
|
|
79
|
-
tasks.append(
|
|
80
|
-
self._batch_list_namespaces_ops(
|
|
81
|
-
cast(
|
|
82
|
-
Sequence[tuple[int, ListNamespacesOp]],
|
|
83
|
-
grouped_ops[ListNamespacesOp],
|
|
84
|
-
),
|
|
85
|
-
results,
|
|
86
|
-
)
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
await asyncio.gather(*tasks)
|
|
90
|
-
|
|
91
|
-
return results
|
|
92
|
-
|
|
93
|
-
def batch(self, ops: Iterable[Op]) -> list[Result]:
|
|
94
|
-
return asyncio.run_coroutine_threadsafe(self.abatch(ops), self.loop).result()
|
|
95
|
-
|
|
96
|
-
async def _batch_get_ops(
|
|
97
|
-
self,
|
|
98
|
-
get_ops: Sequence[tuple[int, GetOp]],
|
|
99
|
-
results: list[Result],
|
|
100
|
-
) -> None:
|
|
101
|
-
cursors = []
|
|
102
|
-
for query, params, namespace, items in self._get_batch_GET_ops_queries(get_ops):
|
|
103
|
-
cur = self.conn.cursor(binary=True)
|
|
104
|
-
await cur.execute(query, params)
|
|
105
|
-
cursors.append((cur, namespace, items))
|
|
106
|
-
|
|
107
|
-
for cur, namespace, items in cursors:
|
|
108
|
-
rows = cast(list[Row], await cur.fetchall())
|
|
109
|
-
key_to_row = {row["key"]: row for row in rows}
|
|
110
|
-
for idx, key in items:
|
|
111
|
-
row = key_to_row.get(key)
|
|
112
|
-
if row:
|
|
113
|
-
results[idx] = _row_to_item(
|
|
114
|
-
namespace, row, loader=self._deserializer
|
|
115
|
-
)
|
|
116
|
-
else:
|
|
117
|
-
results[idx] = None
|
|
118
|
-
|
|
119
|
-
async def _batch_put_ops(
|
|
120
|
-
self,
|
|
121
|
-
put_ops: Sequence[tuple[int, PutOp]],
|
|
122
|
-
) -> None:
|
|
123
|
-
queries = self._get_batch_PUT_queries(put_ops)
|
|
124
|
-
for query, params in queries:
|
|
125
|
-
cur = self.conn.cursor(binary=True)
|
|
126
|
-
await cur.execute(query, params)
|
|
127
|
-
|
|
128
|
-
async def _batch_search_ops(
|
|
129
|
-
self,
|
|
130
|
-
search_ops: Sequence[tuple[int, SearchOp]],
|
|
131
|
-
results: list[Result],
|
|
132
|
-
) -> None:
|
|
133
|
-
queries = self._get_batch_search_queries(search_ops)
|
|
134
|
-
cursors: list[tuple[AsyncCursor[Any], int]] = []
|
|
135
|
-
|
|
136
|
-
for (query, params), (idx, _) in zip(queries, search_ops):
|
|
137
|
-
cur = self.conn.cursor(binary=True)
|
|
138
|
-
await cur.execute(query, params)
|
|
139
|
-
cursors.append((cur, idx))
|
|
140
|
-
|
|
141
|
-
for cur, idx in cursors:
|
|
142
|
-
rows = cast(list[Row], await cur.fetchall())
|
|
143
|
-
items = [
|
|
144
|
-
_row_to_item(
|
|
145
|
-
_decode_ns_bytes(row["prefix"]), row, loader=self._deserializer
|
|
146
|
-
)
|
|
147
|
-
for row in rows
|
|
148
|
-
]
|
|
149
|
-
results[idx] = items
|
|
150
|
-
|
|
151
|
-
async def _batch_list_namespaces_ops(
|
|
152
|
-
self,
|
|
153
|
-
list_ops: Sequence[tuple[int, ListNamespacesOp]],
|
|
154
|
-
results: list[Result],
|
|
155
|
-
) -> None:
|
|
156
|
-
queries = self._get_batch_list_namespaces_queries(list_ops)
|
|
157
|
-
cursors: list[tuple[AsyncCursor[Any], int]] = []
|
|
158
|
-
for (query, params), (idx, _) in zip(queries, list_ops):
|
|
159
|
-
cur = self.conn.cursor(binary=True)
|
|
160
|
-
await cur.execute(query, params)
|
|
161
|
-
cursors.append((cur, idx))
|
|
162
|
-
|
|
163
|
-
for cur, idx in cursors:
|
|
164
|
-
rows = cast(list[dict], await cur.fetchall())
|
|
165
|
-
namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows]
|
|
166
|
-
results[idx] = namespaces
|
|
167
|
-
|
|
168
|
-
@classmethod
|
|
169
|
-
@asynccontextmanager
|
|
170
|
-
async def from_conn_string(
|
|
171
|
-
cls,
|
|
172
|
-
conn_string: str,
|
|
173
|
-
) -> AsyncIterator["AsyncPostgresStore"]:
|
|
174
|
-
"""Create a new AsyncPostgresStore instance from a connection string.
|
|
175
|
-
|
|
176
|
-
Args:
|
|
177
|
-
conn_string (str): The Postgres connection info string.
|
|
178
|
-
|
|
179
|
-
Returns:
|
|
180
|
-
AsyncPostgresStore: A new AsyncPostgresStore instance.
|
|
181
|
-
"""
|
|
182
|
-
async with await AsyncConnection.connect(
|
|
183
|
-
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
|
|
184
|
-
) as conn:
|
|
185
|
-
yield cls(conn=conn)
|
|
186
|
-
|
|
187
|
-
async def setup(self) -> None:
|
|
188
|
-
"""Set up the store database asynchronously.
|
|
189
|
-
|
|
190
|
-
This method creates the necessary tables in the Postgres database if they don't
|
|
191
|
-
already exist and runs database migrations. It MUST be called directly by the user
|
|
192
|
-
the first time the store is used.
|
|
193
|
-
"""
|
|
194
|
-
async with self.conn.cursor() as cur:
|
|
195
|
-
try:
|
|
196
|
-
await cur.execute(
|
|
197
|
-
"SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1"
|
|
198
|
-
)
|
|
199
|
-
row = cast(dict, await cur.fetchone())
|
|
200
|
-
if row is None:
|
|
201
|
-
version = -1
|
|
202
|
-
else:
|
|
203
|
-
version = row["v"]
|
|
204
|
-
except UndefinedTable:
|
|
205
|
-
version = -1
|
|
206
|
-
# Create store_migrations table if it doesn't exist
|
|
207
|
-
await cur.execute(
|
|
208
|
-
"""
|
|
209
|
-
CREATE TABLE IF NOT EXISTS store_migrations (
|
|
210
|
-
v INTEGER PRIMARY KEY
|
|
211
|
-
)
|
|
212
|
-
"""
|
|
213
|
-
)
|
|
214
|
-
for v, migration in enumerate(
|
|
215
|
-
self.MIGRATIONS[version + 1 :], start=version + 1
|
|
216
|
-
):
|
|
217
|
-
await cur.execute(migration)
|
|
218
|
-
await cur.execute("INSERT INTO store_migrations (v) VALUES (%s)", (v,))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|