langgraph-checkpoint-postgres 1.0.11__tar.gz → 2.0.1__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.11 → langgraph_checkpoint_postgres-2.0.1}/PKG-INFO +2 -2
- {langgraph_checkpoint_postgres-1.0.11 → langgraph_checkpoint_postgres-2.0.1}/langgraph/store/postgres/aio.py +15 -10
- {langgraph_checkpoint_postgres-1.0.11 → langgraph_checkpoint_postgres-2.0.1}/langgraph/store/postgres/base.py +71 -49
- {langgraph_checkpoint_postgres-1.0.11 → langgraph_checkpoint_postgres-2.0.1}/pyproject.toml +2 -2
- {langgraph_checkpoint_postgres-1.0.11 → langgraph_checkpoint_postgres-2.0.1}/README.md +0 -0
- {langgraph_checkpoint_postgres-1.0.11 → langgraph_checkpoint_postgres-2.0.1}/langgraph/checkpoint/postgres/__init__.py +0 -0
- {langgraph_checkpoint_postgres-1.0.11 → langgraph_checkpoint_postgres-2.0.1}/langgraph/checkpoint/postgres/aio.py +0 -0
- {langgraph_checkpoint_postgres-1.0.11 → langgraph_checkpoint_postgres-2.0.1}/langgraph/checkpoint/postgres/base.py +0 -0
- {langgraph_checkpoint_postgres-1.0.11 → langgraph_checkpoint_postgres-2.0.1}/langgraph/checkpoint/py.typed +0 -0
- {langgraph_checkpoint_postgres-1.0.11 → langgraph_checkpoint_postgres-2.0.1}/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.1
|
|
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)
|
|
@@ -18,9 +18,11 @@ from psycopg.errors import UndefinedTable
|
|
|
18
18
|
from psycopg.rows import dict_row
|
|
19
19
|
|
|
20
20
|
from langgraph.store.base import GetOp, ListNamespacesOp, Op, PutOp, Result, SearchOp
|
|
21
|
+
from langgraph.store.base.batch import AsyncBatchedBaseStore
|
|
21
22
|
from langgraph.store.postgres.base import (
|
|
22
23
|
BasePostgresStore,
|
|
23
24
|
Row,
|
|
25
|
+
_decode_ns_bytes,
|
|
24
26
|
_group_ops,
|
|
25
27
|
_row_to_item,
|
|
26
28
|
)
|
|
@@ -28,7 +30,9 @@ from langgraph.store.postgres.base import (
|
|
|
28
30
|
logger = logging.getLogger(__name__)
|
|
29
31
|
|
|
30
32
|
|
|
31
|
-
class AsyncPostgresStore(BasePostgresStore[AsyncConnection]):
|
|
33
|
+
class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[AsyncConnection]):
|
|
34
|
+
__slots__ = ("_deserializer",)
|
|
35
|
+
|
|
32
36
|
def __init__(
|
|
33
37
|
self,
|
|
34
38
|
conn: AsyncConnection[Any],
|
|
@@ -37,7 +41,8 @@ class AsyncPostgresStore(BasePostgresStore[AsyncConnection]):
|
|
|
37
41
|
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
|
|
38
42
|
] = None,
|
|
39
43
|
) -> None:
|
|
40
|
-
super().__init__(
|
|
44
|
+
super().__init__()
|
|
45
|
+
self._deserializer = deserializer
|
|
41
46
|
self.conn = conn
|
|
42
47
|
self.conn = conn
|
|
43
48
|
self.loop = asyncio.get_running_loop()
|
|
@@ -127,17 +132,19 @@ class AsyncPostgresStore(BasePostgresStore[AsyncConnection]):
|
|
|
127
132
|
results: list[Result],
|
|
128
133
|
) -> None:
|
|
129
134
|
queries = self._get_batch_search_queries(search_ops)
|
|
130
|
-
cursors: list[tuple[AsyncCursor[Any], int
|
|
135
|
+
cursors: list[tuple[AsyncCursor[Any], int]] = []
|
|
131
136
|
|
|
132
|
-
for (query, params), (idx,
|
|
137
|
+
for (query, params), (idx, _) in zip(queries, search_ops):
|
|
133
138
|
cur = self.conn.cursor(binary=True)
|
|
134
139
|
await cur.execute(query, params)
|
|
135
|
-
cursors.append((cur, idx
|
|
140
|
+
cursors.append((cur, idx))
|
|
136
141
|
|
|
137
|
-
for cur, idx
|
|
142
|
+
for cur, idx in cursors:
|
|
138
143
|
rows = cast(list[Row], await cur.fetchall())
|
|
139
144
|
items = [
|
|
140
|
-
_row_to_item(
|
|
145
|
+
_row_to_item(
|
|
146
|
+
_decode_ns_bytes(row["prefix"]), row, loader=self._deserializer
|
|
147
|
+
)
|
|
141
148
|
for row in rows
|
|
142
149
|
]
|
|
143
150
|
results[idx] = items
|
|
@@ -156,9 +163,7 @@ class AsyncPostgresStore(BasePostgresStore[AsyncConnection]):
|
|
|
156
163
|
|
|
157
164
|
for cur, idx in cursors:
|
|
158
165
|
rows = cast(list[dict], await cur.fetchall())
|
|
159
|
-
namespaces = [
|
|
160
|
-
tuple(row["truncated_prefix"].decode()[1:].split(".")) for row in rows
|
|
161
|
-
]
|
|
166
|
+
namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows]
|
|
162
167
|
results[idx] = namespaces
|
|
163
168
|
|
|
164
169
|
@classmethod
|
|
@@ -40,12 +40,9 @@ logger = logging.getLogger(__name__)
|
|
|
40
40
|
|
|
41
41
|
MIGRATIONS = [
|
|
42
42
|
"""
|
|
43
|
-
CREATE EXTENSION IF NOT EXISTS ltree;
|
|
44
|
-
""",
|
|
45
|
-
"""
|
|
46
43
|
CREATE TABLE IF NOT EXISTS store (
|
|
47
44
|
-- 'prefix' represents the doc's 'namespace'
|
|
48
|
-
prefix
|
|
45
|
+
prefix text NOT NULL,
|
|
49
46
|
key text NOT NULL,
|
|
50
47
|
value jsonb NOT NULL,
|
|
51
48
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
@@ -54,28 +51,18 @@ CREATE TABLE IF NOT EXISTS store (
|
|
|
54
51
|
);
|
|
55
52
|
""",
|
|
56
53
|
"""
|
|
57
|
-
-- For faster
|
|
58
|
-
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);
|
|
59
56
|
""",
|
|
60
57
|
]
|
|
61
58
|
|
|
62
59
|
C = TypeVar("C", bound=BaseConnection)
|
|
63
60
|
|
|
64
61
|
|
|
65
|
-
class BasePostgresStore(
|
|
62
|
+
class BasePostgresStore(Generic[C]):
|
|
66
63
|
MIGRATIONS = MIGRATIONS
|
|
67
64
|
conn: C
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def __init__(
|
|
71
|
-
self,
|
|
72
|
-
*,
|
|
73
|
-
deserializer: Optional[
|
|
74
|
-
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
|
|
75
|
-
] = None,
|
|
76
|
-
) -> None:
|
|
77
|
-
super().__init__()
|
|
78
|
-
self._deserializer = deserializer
|
|
65
|
+
_deserializer: Optional[Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]]
|
|
79
66
|
|
|
80
67
|
def _get_batch_GET_ops_queries(
|
|
81
68
|
self,
|
|
@@ -93,7 +80,7 @@ class BasePostgresStore(BaseStore, Generic[C]):
|
|
|
93
80
|
FROM store
|
|
94
81
|
WHERE prefix = %s AND key IN ({keys_to_query})
|
|
95
82
|
"""
|
|
96
|
-
params = (
|
|
83
|
+
params = (_namespace_to_text(namespace), *keys)
|
|
97
84
|
results.append((query, params, namespace, items))
|
|
98
85
|
return results
|
|
99
86
|
|
|
@@ -120,7 +107,7 @@ class BasePostgresStore(BaseStore, Generic[C]):
|
|
|
120
107
|
query = (
|
|
121
108
|
f"DELETE FROM store WHERE prefix = %s AND key IN ({placeholders})"
|
|
122
109
|
)
|
|
123
|
-
params = (
|
|
110
|
+
params = (_namespace_to_text(namespace), *keys)
|
|
124
111
|
queries.append((query, params))
|
|
125
112
|
if inserts:
|
|
126
113
|
values = []
|
|
@@ -129,7 +116,7 @@ class BasePostgresStore(BaseStore, Generic[C]):
|
|
|
129
116
|
values.append("(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")
|
|
130
117
|
insertion_params.extend(
|
|
131
118
|
[
|
|
132
|
-
|
|
119
|
+
_namespace_to_text(op.namespace),
|
|
133
120
|
op.key,
|
|
134
121
|
Jsonb(op.value),
|
|
135
122
|
]
|
|
@@ -152,11 +139,11 @@ class BasePostgresStore(BaseStore, Generic[C]):
|
|
|
152
139
|
queries: list[tuple[str, Sequence]] = []
|
|
153
140
|
for _, op in search_ops:
|
|
154
141
|
query = """
|
|
155
|
-
SELECT key, value, created_at, updated_at
|
|
142
|
+
SELECT prefix, key, value, created_at, updated_at
|
|
156
143
|
FROM store
|
|
157
|
-
WHERE prefix
|
|
144
|
+
WHERE prefix LIKE %s
|
|
158
145
|
"""
|
|
159
|
-
params: list = [
|
|
146
|
+
params: list = [f"{_namespace_to_text(op.namespace_prefix)}%"]
|
|
160
147
|
|
|
161
148
|
if op.filter:
|
|
162
149
|
filter_conditions = []
|
|
@@ -169,7 +156,9 @@ class BasePostgresStore(BaseStore, Generic[C]):
|
|
|
169
156
|
params.extend([key, json.dumps(value)])
|
|
170
157
|
query += " AND " + " AND ".join(filter_conditions)
|
|
171
158
|
|
|
172
|
-
|
|
159
|
+
# Note: we will need to not do this if sim/keyword search
|
|
160
|
+
# is used
|
|
161
|
+
query += " ORDER BY updated_at DESC LIMIT %s OFFSET %s"
|
|
173
162
|
params.extend([op.limit, op.offset])
|
|
174
163
|
|
|
175
164
|
queries.append((query, params))
|
|
@@ -181,22 +170,39 @@ class BasePostgresStore(BaseStore, Generic[C]):
|
|
|
181
170
|
) -> list[tuple[str, Sequence]]:
|
|
182
171
|
queries: list[tuple[str, Sequence]] = []
|
|
183
172
|
for _, op in list_ops:
|
|
184
|
-
query = "
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
173
|
+
query = """
|
|
174
|
+
SELECT DISTINCT ON (truncated_prefix) truncated_prefix, prefix
|
|
175
|
+
FROM (
|
|
176
|
+
SELECT
|
|
177
|
+
prefix,
|
|
178
|
+
CASE
|
|
179
|
+
WHEN %s::integer IS NOT NULL THEN
|
|
180
|
+
(SELECT STRING_AGG(part, '.' ORDER BY idx)
|
|
181
|
+
FROM (
|
|
182
|
+
SELECT part, ROW_NUMBER() OVER () AS idx
|
|
183
|
+
FROM UNNEST(REGEXP_SPLIT_TO_ARRAY(prefix, '\.')) AS part
|
|
184
|
+
LIMIT %s::integer
|
|
185
|
+
) subquery
|
|
186
|
+
)
|
|
187
|
+
ELSE prefix
|
|
188
|
+
END AS truncated_prefix
|
|
189
|
+
FROM store
|
|
190
|
+
"""
|
|
191
|
+
params: list[Any] = [op.max_depth, op.max_depth]
|
|
188
192
|
|
|
189
193
|
conditions = []
|
|
190
194
|
if op.match_conditions:
|
|
191
195
|
for condition in op.match_conditions:
|
|
192
196
|
if condition.match_type == "prefix":
|
|
193
|
-
conditions.append("prefix
|
|
194
|
-
|
|
195
|
-
|
|
197
|
+
conditions.append("prefix LIKE %s")
|
|
198
|
+
params.append(
|
|
199
|
+
f"{_namespace_to_text(condition.path, handle_wildcards=True)}%"
|
|
200
|
+
)
|
|
196
201
|
elif condition.match_type == "suffix":
|
|
197
|
-
conditions.append("prefix
|
|
198
|
-
|
|
199
|
-
|
|
202
|
+
conditions.append("prefix LIKE %s")
|
|
203
|
+
params.append(
|
|
204
|
+
f"%{_namespace_to_text(condition.path, handle_wildcards=True)}"
|
|
205
|
+
)
|
|
200
206
|
else:
|
|
201
207
|
logger.warning(
|
|
202
208
|
f"Unknown match_type in list_namespaces: {condition.match_type}"
|
|
@@ -204,15 +210,18 @@ class BasePostgresStore(BaseStore, Generic[C]):
|
|
|
204
210
|
|
|
205
211
|
if conditions:
|
|
206
212
|
query += " WHERE " + " AND ".join(conditions)
|
|
213
|
+
query += ") AS subquery "
|
|
207
214
|
|
|
208
215
|
query += " ORDER BY truncated_prefix LIMIT %s OFFSET %s"
|
|
209
216
|
params.extend([op.limit, op.offset])
|
|
210
|
-
|
|
211
217
|
queries.append((query, params))
|
|
218
|
+
|
|
212
219
|
return queries
|
|
213
220
|
|
|
214
221
|
|
|
215
|
-
class PostgresStore(BasePostgresStore[Connection]):
|
|
222
|
+
class PostgresStore(BaseStore, BasePostgresStore[Connection]):
|
|
223
|
+
__slots__ = ("_deserializer",)
|
|
224
|
+
|
|
216
225
|
def __init__(
|
|
217
226
|
self,
|
|
218
227
|
conn: Connection[Any],
|
|
@@ -221,7 +230,8 @@ class PostgresStore(BasePostgresStore[Connection]):
|
|
|
221
230
|
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
|
|
222
231
|
] = None,
|
|
223
232
|
) -> None:
|
|
224
|
-
super().__init__(
|
|
233
|
+
super().__init__()
|
|
234
|
+
self._deserializer = deserializer
|
|
225
235
|
self.conn = conn
|
|
226
236
|
|
|
227
237
|
def batch(self, ops: Iterable[Op]) -> list[Result]:
|
|
@@ -297,17 +307,19 @@ class PostgresStore(BasePostgresStore[Connection]):
|
|
|
297
307
|
results: list[Result],
|
|
298
308
|
) -> None:
|
|
299
309
|
queries = self._get_batch_search_queries(search_ops)
|
|
300
|
-
cursors: list[tuple[Cursor[Any], int
|
|
310
|
+
cursors: list[tuple[Cursor[Any], int]] = []
|
|
301
311
|
|
|
302
|
-
for (query, params), (idx,
|
|
312
|
+
for (query, params), (idx, _) in zip(queries, search_ops):
|
|
303
313
|
cur = self.conn.cursor(binary=True)
|
|
304
314
|
cur.execute(query, params)
|
|
305
|
-
cursors.append((cur, idx
|
|
315
|
+
cursors.append((cur, idx))
|
|
306
316
|
|
|
307
|
-
for cur, idx
|
|
317
|
+
for cur, idx in cursors:
|
|
308
318
|
rows = cast(list[Row], cur.fetchall())
|
|
309
319
|
items = [
|
|
310
|
-
_row_to_item(
|
|
320
|
+
_row_to_item(
|
|
321
|
+
_decode_ns_bytes(row["prefix"]), row, loader=self._deserializer
|
|
322
|
+
)
|
|
311
323
|
for row in rows
|
|
312
324
|
]
|
|
313
325
|
results[idx] = items
|
|
@@ -326,9 +338,7 @@ class PostgresStore(BasePostgresStore[Connection]):
|
|
|
326
338
|
|
|
327
339
|
for cur, idx in cursors:
|
|
328
340
|
rows = cast(list[dict], cur.fetchall())
|
|
329
|
-
namespaces = [
|
|
330
|
-
tuple(row["truncated_prefix"].decode()[1:].split(".")) for row in rows
|
|
331
|
-
]
|
|
341
|
+
namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows]
|
|
332
342
|
results[idx] = namespaces
|
|
333
343
|
|
|
334
344
|
@classmethod
|
|
@@ -386,13 +396,17 @@ class PostgresStore(BasePostgresStore[Connection]):
|
|
|
386
396
|
class Row(TypedDict):
|
|
387
397
|
key: str
|
|
388
398
|
value: Any
|
|
389
|
-
prefix:
|
|
399
|
+
prefix: str
|
|
390
400
|
created_at: datetime
|
|
391
401
|
updated_at: datetime
|
|
392
402
|
|
|
393
403
|
|
|
394
|
-
def
|
|
395
|
-
|
|
404
|
+
def _namespace_to_text(
|
|
405
|
+
namespace: tuple[str, ...], handle_wildcards: bool = False
|
|
406
|
+
) -> str:
|
|
407
|
+
"""Convert namespace tuple to text string."""
|
|
408
|
+
if handle_wildcards:
|
|
409
|
+
namespace = tuple("%" if val == "*" else val for val in namespace)
|
|
396
410
|
return ".".join(namespace)
|
|
397
411
|
|
|
398
412
|
|
|
@@ -433,3 +447,11 @@ def _json_loads(content: Union[bytes, orjson.Fragment]) -> Any:
|
|
|
433
447
|
else:
|
|
434
448
|
content = content.contents.encode()
|
|
435
449
|
return orjson.loads(cast(bytes, content))
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _decode_ns_bytes(namespace: Union[str, bytes, list]) -> tuple[str, ...]:
|
|
453
|
+
if isinstance(namespace, list):
|
|
454
|
+
return tuple(namespace)
|
|
455
|
+
if isinstance(namespace, bytes):
|
|
456
|
+
namespace = namespace.decode()[1:]
|
|
457
|
+
return tuple(namespace.split("."))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "langgraph-checkpoint-postgres"
|
|
3
|
-
version = "
|
|
3
|
+
version = "2.0.1"
|
|
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
|