langgraph-checkpoint-postgres 1.0.10__tar.gz → 2.0.0__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-1.0.10 → langgraph_checkpoint_postgres-2.0.0}/PKG-INFO +2 -2
- {langgraph_checkpoint_postgres-1.0.10 → langgraph_checkpoint_postgres-2.0.0}/langgraph/store/postgres/aio.py +36 -11
- {langgraph_checkpoint_postgres-1.0.10 → langgraph_checkpoint_postgres-2.0.0}/langgraph/store/postgres/base.py +107 -39
- {langgraph_checkpoint_postgres-1.0.10 → langgraph_checkpoint_postgres-2.0.0}/pyproject.toml +2 -2
- {langgraph_checkpoint_postgres-1.0.10 → langgraph_checkpoint_postgres-2.0.0}/README.md +0 -0
- {langgraph_checkpoint_postgres-1.0.10 → langgraph_checkpoint_postgres-2.0.0}/langgraph/checkpoint/postgres/__init__.py +0 -0
- {langgraph_checkpoint_postgres-1.0.10 → langgraph_checkpoint_postgres-2.0.0}/langgraph/checkpoint/postgres/aio.py +0 -0
- {langgraph_checkpoint_postgres-1.0.10 → langgraph_checkpoint_postgres-2.0.0}/langgraph/checkpoint/postgres/base.py +0 -0
- {langgraph_checkpoint_postgres-1.0.10 → langgraph_checkpoint_postgres-2.0.0}/langgraph/checkpoint/py.typed +0 -0
- {langgraph_checkpoint_postgres-1.0.10 → langgraph_checkpoint_postgres-2.0.0}/langgraph/store/postgres/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: langgraph-checkpoint-postgres
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: Library with a Postgres implementation of LangGraph checkpoint saver.
|
|
5
5
|
Home-page: https://www.github.com/langchain-ai/langgraph
|
|
6
6
|
License: MIT
|
|
@@ -11,7 +11,7 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.10
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.11
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
-
Requires-Dist: langgraph-checkpoint (>=
|
|
14
|
+
Requires-Dist: langgraph-checkpoint (>=2.0.0,<3.0.0)
|
|
15
15
|
Requires-Dist: orjson (>=3.10.1)
|
|
16
16
|
Requires-Dist: psycopg (>=3.0.0,<4.0.0)
|
|
17
17
|
Requires-Dist: psycopg-pool (>=3.0.0,<4.0.0)
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import logging
|
|
3
3
|
from contextlib import asynccontextmanager
|
|
4
|
-
from typing import
|
|
4
|
+
from typing import (
|
|
5
|
+
Any,
|
|
6
|
+
AsyncIterator,
|
|
7
|
+
Callable,
|
|
8
|
+
Iterable,
|
|
9
|
+
Optional,
|
|
10
|
+
Sequence,
|
|
11
|
+
Union,
|
|
12
|
+
cast,
|
|
13
|
+
)
|
|
5
14
|
|
|
15
|
+
import orjson
|
|
6
16
|
from psycopg import AsyncConnection, AsyncCursor
|
|
7
17
|
from psycopg.errors import UndefinedTable
|
|
8
18
|
from psycopg.rows import dict_row
|
|
@@ -11,6 +21,7 @@ from langgraph.store.base import GetOp, ListNamespacesOp, Op, PutOp, Result, Sea
|
|
|
11
21
|
from langgraph.store.postgres.base import (
|
|
12
22
|
BasePostgresStore,
|
|
13
23
|
Row,
|
|
24
|
+
_decode_ns_bytes,
|
|
14
25
|
_group_ops,
|
|
15
26
|
_row_to_item,
|
|
16
27
|
)
|
|
@@ -19,7 +30,16 @@ logger = logging.getLogger(__name__)
|
|
|
19
30
|
|
|
20
31
|
|
|
21
32
|
class AsyncPostgresStore(BasePostgresStore[AsyncConnection]):
|
|
22
|
-
def __init__(
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
conn: AsyncConnection[Any],
|
|
36
|
+
*,
|
|
37
|
+
deserializer: Optional[
|
|
38
|
+
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
|
|
39
|
+
] = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
super().__init__(deserializer=deserializer)
|
|
42
|
+
self.conn = conn
|
|
23
43
|
self.conn = conn
|
|
24
44
|
self.loop = asyncio.get_running_loop()
|
|
25
45
|
|
|
@@ -87,7 +107,9 @@ class AsyncPostgresStore(BasePostgresStore[AsyncConnection]):
|
|
|
87
107
|
for idx, key in items:
|
|
88
108
|
row = key_to_row.get(key)
|
|
89
109
|
if row:
|
|
90
|
-
results[idx] = _row_to_item(
|
|
110
|
+
results[idx] = _row_to_item(
|
|
111
|
+
namespace, row, loader=self._deserializer
|
|
112
|
+
)
|
|
91
113
|
else:
|
|
92
114
|
results[idx] = None
|
|
93
115
|
|
|
@@ -106,16 +128,21 @@ class AsyncPostgresStore(BasePostgresStore[AsyncConnection]):
|
|
|
106
128
|
results: list[Result],
|
|
107
129
|
) -> None:
|
|
108
130
|
queries = self._get_batch_search_queries(search_ops)
|
|
109
|
-
cursors: list[tuple[AsyncCursor[Any], int
|
|
131
|
+
cursors: list[tuple[AsyncCursor[Any], int]] = []
|
|
110
132
|
|
|
111
|
-
for (query, params), (idx,
|
|
133
|
+
for (query, params), (idx, _) in zip(queries, search_ops):
|
|
112
134
|
cur = self.conn.cursor(binary=True)
|
|
113
135
|
await cur.execute(query, params)
|
|
114
|
-
cursors.append((cur, idx
|
|
136
|
+
cursors.append((cur, idx))
|
|
115
137
|
|
|
116
|
-
for cur, idx
|
|
138
|
+
for cur, idx in cursors:
|
|
117
139
|
rows = cast(list[Row], await cur.fetchall())
|
|
118
|
-
items = [
|
|
140
|
+
items = [
|
|
141
|
+
_row_to_item(
|
|
142
|
+
_decode_ns_bytes(row["prefix"]), row, loader=self._deserializer
|
|
143
|
+
)
|
|
144
|
+
for row in rows
|
|
145
|
+
]
|
|
119
146
|
results[idx] = items
|
|
120
147
|
|
|
121
148
|
async def _batch_list_namespaces_ops(
|
|
@@ -132,9 +159,7 @@ class AsyncPostgresStore(BasePostgresStore[AsyncConnection]):
|
|
|
132
159
|
|
|
133
160
|
for cur, idx in cursors:
|
|
134
161
|
rows = cast(list[dict], await cur.fetchall())
|
|
135
|
-
namespaces = [
|
|
136
|
-
tuple(row["truncated_prefix"].decode()[1:].split(".")) for row in rows
|
|
137
|
-
]
|
|
162
|
+
namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows]
|
|
138
163
|
results[idx] = namespaces
|
|
139
164
|
|
|
140
165
|
@classmethod
|
|
@@ -4,7 +4,18 @@ import logging
|
|
|
4
4
|
from collections import defaultdict
|
|
5
5
|
from contextlib import contextmanager
|
|
6
6
|
from datetime import datetime
|
|
7
|
-
from typing import
|
|
7
|
+
from typing import (
|
|
8
|
+
Any,
|
|
9
|
+
Callable,
|
|
10
|
+
Generic,
|
|
11
|
+
Iterable,
|
|
12
|
+
Iterator,
|
|
13
|
+
Optional,
|
|
14
|
+
Sequence,
|
|
15
|
+
TypeVar,
|
|
16
|
+
Union,
|
|
17
|
+
cast,
|
|
18
|
+
)
|
|
8
19
|
|
|
9
20
|
import orjson
|
|
10
21
|
from psycopg import BaseConnection, Connection, Cursor
|
|
@@ -29,12 +40,9 @@ logger = logging.getLogger(__name__)
|
|
|
29
40
|
|
|
30
41
|
MIGRATIONS = [
|
|
31
42
|
"""
|
|
32
|
-
CREATE EXTENSION IF NOT EXISTS ltree;
|
|
33
|
-
""",
|
|
34
|
-
"""
|
|
35
43
|
CREATE TABLE IF NOT EXISTS store (
|
|
36
44
|
-- 'prefix' represents the doc's 'namespace'
|
|
37
|
-
prefix
|
|
45
|
+
prefix text NOT NULL,
|
|
38
46
|
key text NOT NULL,
|
|
39
47
|
value jsonb NOT NULL,
|
|
40
48
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
@@ -43,8 +51,8 @@ CREATE TABLE IF NOT EXISTS store (
|
|
|
43
51
|
);
|
|
44
52
|
""",
|
|
45
53
|
"""
|
|
46
|
-
-- For faster
|
|
47
|
-
CREATE INDEX IF NOT EXISTS store_prefix_idx ON store USING
|
|
54
|
+
-- For faster lookups by prefix
|
|
55
|
+
CREATE INDEX IF NOT EXISTS store_prefix_idx ON store USING btree (prefix text_pattern_ops);
|
|
48
56
|
""",
|
|
49
57
|
]
|
|
50
58
|
|
|
@@ -54,6 +62,17 @@ C = TypeVar("C", bound=BaseConnection)
|
|
|
54
62
|
class BasePostgresStore(BaseStore, Generic[C]):
|
|
55
63
|
MIGRATIONS = MIGRATIONS
|
|
56
64
|
conn: C
|
|
65
|
+
__slots__ = ("_deserializer",)
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
*,
|
|
70
|
+
deserializer: Optional[
|
|
71
|
+
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
|
|
72
|
+
] = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
super().__init__()
|
|
75
|
+
self._deserializer = deserializer
|
|
57
76
|
|
|
58
77
|
def _get_batch_GET_ops_queries(
|
|
59
78
|
self,
|
|
@@ -71,7 +90,7 @@ class BasePostgresStore(BaseStore, Generic[C]):
|
|
|
71
90
|
FROM store
|
|
72
91
|
WHERE prefix = %s AND key IN ({keys_to_query})
|
|
73
92
|
"""
|
|
74
|
-
params = (
|
|
93
|
+
params = (_namespace_to_text(namespace), *keys)
|
|
75
94
|
results.append((query, params, namespace, items))
|
|
76
95
|
return results
|
|
77
96
|
|
|
@@ -98,7 +117,7 @@ class BasePostgresStore(BaseStore, Generic[C]):
|
|
|
98
117
|
query = (
|
|
99
118
|
f"DELETE FROM store WHERE prefix = %s AND key IN ({placeholders})"
|
|
100
119
|
)
|
|
101
|
-
params = (
|
|
120
|
+
params = (_namespace_to_text(namespace), *keys)
|
|
102
121
|
queries.append((query, params))
|
|
103
122
|
if inserts:
|
|
104
123
|
values = []
|
|
@@ -107,7 +126,7 @@ class BasePostgresStore(BaseStore, Generic[C]):
|
|
|
107
126
|
values.append("(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")
|
|
108
127
|
insertion_params.extend(
|
|
109
128
|
[
|
|
110
|
-
|
|
129
|
+
_namespace_to_text(op.namespace),
|
|
111
130
|
op.key,
|
|
112
131
|
Jsonb(op.value),
|
|
113
132
|
]
|
|
@@ -130,11 +149,11 @@ class BasePostgresStore(BaseStore, Generic[C]):
|
|
|
130
149
|
queries: list[tuple[str, Sequence]] = []
|
|
131
150
|
for _, op in search_ops:
|
|
132
151
|
query = """
|
|
133
|
-
SELECT key, value, created_at, updated_at
|
|
152
|
+
SELECT prefix, key, value, created_at, updated_at
|
|
134
153
|
FROM store
|
|
135
|
-
WHERE prefix
|
|
154
|
+
WHERE prefix LIKE %s
|
|
136
155
|
"""
|
|
137
|
-
params: list = [
|
|
156
|
+
params: list = [f"{_namespace_to_text(op.namespace_prefix)}%"]
|
|
138
157
|
|
|
139
158
|
if op.filter:
|
|
140
159
|
filter_conditions = []
|
|
@@ -159,22 +178,39 @@ class BasePostgresStore(BaseStore, Generic[C]):
|
|
|
159
178
|
) -> list[tuple[str, Sequence]]:
|
|
160
179
|
queries: list[tuple[str, Sequence]] = []
|
|
161
180
|
for _, op in list_ops:
|
|
162
|
-
query = "
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
181
|
+
query = """
|
|
182
|
+
SELECT DISTINCT ON (truncated_prefix) truncated_prefix, prefix
|
|
183
|
+
FROM (
|
|
184
|
+
SELECT
|
|
185
|
+
prefix,
|
|
186
|
+
CASE
|
|
187
|
+
WHEN %s::integer IS NOT NULL THEN
|
|
188
|
+
(SELECT STRING_AGG(part, '.' ORDER BY idx)
|
|
189
|
+
FROM (
|
|
190
|
+
SELECT part, ROW_NUMBER() OVER () AS idx
|
|
191
|
+
FROM UNNEST(REGEXP_SPLIT_TO_ARRAY(prefix, '\.')) AS part
|
|
192
|
+
LIMIT %s::integer
|
|
193
|
+
) subquery
|
|
194
|
+
)
|
|
195
|
+
ELSE prefix
|
|
196
|
+
END AS truncated_prefix
|
|
197
|
+
FROM store
|
|
198
|
+
"""
|
|
199
|
+
params: list[Any] = [op.max_depth, op.max_depth]
|
|
166
200
|
|
|
167
201
|
conditions = []
|
|
168
202
|
if op.match_conditions:
|
|
169
203
|
for condition in op.match_conditions:
|
|
170
204
|
if condition.match_type == "prefix":
|
|
171
|
-
conditions.append("prefix
|
|
172
|
-
|
|
173
|
-
|
|
205
|
+
conditions.append("prefix LIKE %s")
|
|
206
|
+
params.append(
|
|
207
|
+
f"{_namespace_to_text(condition.path, handle_wildcards=True)}%"
|
|
208
|
+
)
|
|
174
209
|
elif condition.match_type == "suffix":
|
|
175
|
-
conditions.append("prefix
|
|
176
|
-
|
|
177
|
-
|
|
210
|
+
conditions.append("prefix LIKE %s")
|
|
211
|
+
params.append(
|
|
212
|
+
f"%{_namespace_to_text(condition.path, handle_wildcards=True)}"
|
|
213
|
+
)
|
|
178
214
|
else:
|
|
179
215
|
logger.warning(
|
|
180
216
|
f"Unknown match_type in list_namespaces: {condition.match_type}"
|
|
@@ -182,16 +218,25 @@ class BasePostgresStore(BaseStore, Generic[C]):
|
|
|
182
218
|
|
|
183
219
|
if conditions:
|
|
184
220
|
query += " WHERE " + " AND ".join(conditions)
|
|
221
|
+
query += ") AS subquery "
|
|
185
222
|
|
|
186
223
|
query += " ORDER BY truncated_prefix LIMIT %s OFFSET %s"
|
|
187
224
|
params.extend([op.limit, op.offset])
|
|
188
|
-
|
|
189
225
|
queries.append((query, params))
|
|
226
|
+
|
|
190
227
|
return queries
|
|
191
228
|
|
|
192
229
|
|
|
193
230
|
class PostgresStore(BasePostgresStore[Connection]):
|
|
194
|
-
def __init__(
|
|
231
|
+
def __init__(
|
|
232
|
+
self,
|
|
233
|
+
conn: Connection[Any],
|
|
234
|
+
*,
|
|
235
|
+
deserializer: Optional[
|
|
236
|
+
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
|
|
237
|
+
] = None,
|
|
238
|
+
) -> None:
|
|
239
|
+
super().__init__(deserializer=deserializer)
|
|
195
240
|
self.conn = conn
|
|
196
241
|
|
|
197
242
|
def batch(self, ops: Iterable[Op]) -> list[Result]:
|
|
@@ -246,7 +291,9 @@ class PostgresStore(BasePostgresStore[Connection]):
|
|
|
246
291
|
for idx, key in items:
|
|
247
292
|
row = key_to_row.get(key)
|
|
248
293
|
if row:
|
|
249
|
-
results[idx] = _row_to_item(
|
|
294
|
+
results[idx] = _row_to_item(
|
|
295
|
+
namespace, row, loader=self._deserializer
|
|
296
|
+
)
|
|
250
297
|
else:
|
|
251
298
|
results[idx] = None
|
|
252
299
|
|
|
@@ -265,16 +312,21 @@ class PostgresStore(BasePostgresStore[Connection]):
|
|
|
265
312
|
results: list[Result],
|
|
266
313
|
) -> None:
|
|
267
314
|
queries = self._get_batch_search_queries(search_ops)
|
|
268
|
-
cursors: list[tuple[Cursor[Any], int
|
|
315
|
+
cursors: list[tuple[Cursor[Any], int]] = []
|
|
269
316
|
|
|
270
|
-
for (query, params), (idx,
|
|
317
|
+
for (query, params), (idx, _) in zip(queries, search_ops):
|
|
271
318
|
cur = self.conn.cursor(binary=True)
|
|
272
319
|
cur.execute(query, params)
|
|
273
|
-
cursors.append((cur, idx
|
|
320
|
+
cursors.append((cur, idx))
|
|
274
321
|
|
|
275
|
-
for cur, idx
|
|
322
|
+
for cur, idx in cursors:
|
|
276
323
|
rows = cast(list[Row], cur.fetchall())
|
|
277
|
-
items = [
|
|
324
|
+
items = [
|
|
325
|
+
_row_to_item(
|
|
326
|
+
_decode_ns_bytes(row["prefix"]), row, loader=self._deserializer
|
|
327
|
+
)
|
|
328
|
+
for row in rows
|
|
329
|
+
]
|
|
278
330
|
results[idx] = items
|
|
279
331
|
|
|
280
332
|
def _batch_list_namespaces_ops(
|
|
@@ -291,9 +343,7 @@ class PostgresStore(BasePostgresStore[Connection]):
|
|
|
291
343
|
|
|
292
344
|
for cur, idx in cursors:
|
|
293
345
|
rows = cast(list[dict], cur.fetchall())
|
|
294
|
-
namespaces = [
|
|
295
|
-
tuple(row["truncated_prefix"].decode()[1:].split(".")) for row in rows
|
|
296
|
-
]
|
|
346
|
+
namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows]
|
|
297
347
|
results[idx] = namespaces
|
|
298
348
|
|
|
299
349
|
@classmethod
|
|
@@ -351,21 +401,31 @@ class PostgresStore(BasePostgresStore[Connection]):
|
|
|
351
401
|
class Row(TypedDict):
|
|
352
402
|
key: str
|
|
353
403
|
value: Any
|
|
354
|
-
prefix:
|
|
404
|
+
prefix: str
|
|
355
405
|
created_at: datetime
|
|
356
406
|
updated_at: datetime
|
|
357
407
|
|
|
358
408
|
|
|
359
|
-
def
|
|
360
|
-
|
|
409
|
+
def _namespace_to_text(
|
|
410
|
+
namespace: tuple[str, ...], handle_wildcards: bool = False
|
|
411
|
+
) -> str:
|
|
412
|
+
"""Convert namespace tuple to text string."""
|
|
413
|
+
if handle_wildcards:
|
|
414
|
+
namespace = tuple("%" if val == "*" else val for val in namespace)
|
|
361
415
|
return ".".join(namespace)
|
|
362
416
|
|
|
363
417
|
|
|
364
|
-
def _row_to_item(
|
|
418
|
+
def _row_to_item(
|
|
419
|
+
namespace: tuple[str, ...],
|
|
420
|
+
row: Row,
|
|
421
|
+
*,
|
|
422
|
+
loader: Optional[Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]] = None,
|
|
423
|
+
) -> Item:
|
|
365
424
|
"""Convert a row from the database into an Item."""
|
|
425
|
+
loader = loader or _json_loads
|
|
366
426
|
val = row["value"]
|
|
367
427
|
return Item(
|
|
368
|
-
value=val if isinstance(val, dict) else
|
|
428
|
+
value=val if isinstance(val, dict) else loader(val),
|
|
369
429
|
key=row["key"],
|
|
370
430
|
namespace=namespace,
|
|
371
431
|
created_at=row["created_at"],
|
|
@@ -392,3 +452,11 @@ def _json_loads(content: Union[bytes, orjson.Fragment]) -> Any:
|
|
|
392
452
|
else:
|
|
393
453
|
content = content.contents.encode()
|
|
394
454
|
return orjson.loads(cast(bytes, content))
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _decode_ns_bytes(namespace: Union[str, bytes, list]) -> tuple[str, ...]:
|
|
458
|
+
if isinstance(namespace, list):
|
|
459
|
+
return tuple(namespace)
|
|
460
|
+
if isinstance(namespace, bytes):
|
|
461
|
+
namespace = namespace.decode()[1:]
|
|
462
|
+
return tuple(namespace.split("."))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "langgraph-checkpoint-postgres"
|
|
3
|
-
version = "
|
|
3
|
+
version = "2.0.0"
|
|
4
4
|
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
|
|
5
5
|
authors = []
|
|
6
6
|
license = "MIT"
|
|
@@ -10,7 +10,7 @@ packages = [{ include = "langgraph" }]
|
|
|
10
10
|
|
|
11
11
|
[tool.poetry.dependencies]
|
|
12
12
|
python = "^3.9.0,<4.0"
|
|
13
|
-
langgraph-checkpoint = "^
|
|
13
|
+
langgraph-checkpoint = "^2.0.0"
|
|
14
14
|
orjson = ">=3.10.1"
|
|
15
15
|
psycopg = "^3.0.0"
|
|
16
16
|
psycopg-pool = "^3.0.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|