langgraph-checkpoint-postgres 2.0.3__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.3 → langgraph_checkpoint_postgres-2.0.4}/PKG-INFO +1 -1
- {langgraph_checkpoint_postgres-2.0.3 → langgraph_checkpoint_postgres-2.0.4}/langgraph/checkpoint/postgres/__init__.py +8 -18
- 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.3 → langgraph_checkpoint_postgres-2.0.4}/langgraph/checkpoint/postgres/aio.py +30 -35
- langgraph_checkpoint_postgres-2.0.4/langgraph/store/postgres/aio.py +294 -0
- {langgraph_checkpoint_postgres-2.0.3 → langgraph_checkpoint_postgres-2.0.4}/langgraph/store/postgres/base.py +153 -64
- {langgraph_checkpoint_postgres-2.0.3 → langgraph_checkpoint_postgres-2.0.4}/pyproject.toml +1 -1
- langgraph_checkpoint_postgres-2.0.3/langgraph/store/postgres/aio.py +0 -218
- {langgraph_checkpoint_postgres-2.0.3 → langgraph_checkpoint_postgres-2.0.4}/README.md +0 -0
- {langgraph_checkpoint_postgres-2.0.3 → langgraph_checkpoint_postgres-2.0.4}/langgraph/checkpoint/postgres/base.py +0 -0
- {langgraph_checkpoint_postgres-2.0.3 → langgraph_checkpoint_postgres-2.0.4}/langgraph/checkpoint/postgres/py.typed +0 -0
- {langgraph_checkpoint_postgres-2.0.3 → langgraph_checkpoint_postgres-2.0.4}/langgraph/store/postgres/__init__.py +0 -0
- {langgraph_checkpoint_postgres-2.0.3 → langgraph_checkpoint_postgres-2.0.4}/langgraph/store/postgres/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
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
6
|
from psycopg import Capabilities, Connection, Cursor, Pipeline
|
|
@@ -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:
|
|
@@ -73,9 +63,9 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
73
63
|
) as conn:
|
|
74
64
|
if pipeline:
|
|
75
65
|
with conn.pipeline() as pipe:
|
|
76
|
-
yield
|
|
66
|
+
yield cls(conn, pipe)
|
|
77
67
|
else:
|
|
78
|
-
yield
|
|
68
|
+
yield cls(conn)
|
|
79
69
|
|
|
80
70
|
def setup(self) -> None:
|
|
81
71
|
"""Set up the checkpoint database asynchronously.
|
|
@@ -373,7 +363,7 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
373
363
|
Will be applied regardless of whether the PostgresSaver instance was initialized with a pipeline.
|
|
374
364
|
If pipeline mode is not supported, will fall back to using transaction context manager.
|
|
375
365
|
"""
|
|
376
|
-
with
|
|
366
|
+
with _internal.get_connection(self.conn) as conn:
|
|
377
367
|
if self.pipe:
|
|
378
368
|
# a connection in pipeline mode can be used concurrently
|
|
379
369
|
# in multiple threads/coroutines, but only one cursor can be
|
|
@@ -403,4 +393,4 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
403
393
|
yield cur
|
|
404
394
|
|
|
405
395
|
|
|
406
|
-
__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,6 +1,6 @@
|
|
|
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
6
|
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
|
|
@@ -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:
|
|
@@ -80,9 +68,9 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
80
68
|
) as conn:
|
|
81
69
|
if pipeline:
|
|
82
70
|
async with conn.pipeline() as pipe:
|
|
83
|
-
yield
|
|
71
|
+
yield cls(conn=conn, pipe=pipe, serde=serde)
|
|
84
72
|
else:
|
|
85
|
-
yield
|
|
73
|
+
yield cls(conn=conn, serde=serde)
|
|
86
74
|
|
|
87
75
|
async def setup(self) -> None:
|
|
88
76
|
"""Set up the checkpoint database asynchronously.
|
|
@@ -157,15 +145,17 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
157
145
|
value["pending_sends"],
|
|
158
146
|
),
|
|
159
147
|
self._load_metadata(value["metadata"]),
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
"
|
|
163
|
-
|
|
164
|
-
|
|
148
|
+
(
|
|
149
|
+
{
|
|
150
|
+
"configurable": {
|
|
151
|
+
"thread_id": value["thread_id"],
|
|
152
|
+
"checkpoint_ns": value["checkpoint_ns"],
|
|
153
|
+
"checkpoint_id": value["parent_checkpoint_id"],
|
|
154
|
+
}
|
|
165
155
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
156
|
+
if value["parent_checkpoint_id"]
|
|
157
|
+
else None
|
|
158
|
+
),
|
|
169
159
|
await asyncio.to_thread(self._load_writes, value["pending_writes"]),
|
|
170
160
|
)
|
|
171
161
|
|
|
@@ -216,15 +206,17 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
216
206
|
value["pending_sends"],
|
|
217
207
|
),
|
|
218
208
|
self._load_metadata(value["metadata"]),
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
"
|
|
222
|
-
|
|
223
|
-
|
|
209
|
+
(
|
|
210
|
+
{
|
|
211
|
+
"configurable": {
|
|
212
|
+
"thread_id": thread_id,
|
|
213
|
+
"checkpoint_ns": checkpoint_ns,
|
|
214
|
+
"checkpoint_id": value["parent_checkpoint_id"],
|
|
215
|
+
}
|
|
224
216
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
217
|
+
if value["parent_checkpoint_id"]
|
|
218
|
+
else None
|
|
219
|
+
),
|
|
228
220
|
await asyncio.to_thread(self._load_writes, value["pending_writes"]),
|
|
229
221
|
)
|
|
230
222
|
|
|
@@ -331,7 +323,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
331
323
|
Will be applied regardless of whether the AsyncPostgresSaver instance was initialized with a pipeline.
|
|
332
324
|
If pipeline mode is not supported, will fall back to using transaction context manager.
|
|
333
325
|
"""
|
|
334
|
-
async with
|
|
326
|
+
async with _ainternal.get_connection(self.conn) as conn:
|
|
335
327
|
if self.pipe:
|
|
336
328
|
# a connection in pipeline mode can be used concurrently
|
|
337
329
|
# in multiple threads/coroutines, but only one cursor can be
|
|
@@ -467,3 +459,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
467
459
|
return asyncio.run_coroutine_threadsafe(
|
|
468
460
|
self.aput_writes(config, writes, task_id), self.loop
|
|
469
461
|
).result()
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
__all__ = ["AsyncPostgresSaver", "Conn"]
|
|
@@ -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
|
|
File without changes
|