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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: langgraph-checkpoint-postgres
3
- Version: 1.0.10
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 (>=1.0.11,<2.0.0)
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 Any, AsyncIterator, Iterable, Sequence, cast
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__(self, conn: AsyncConnection[Any]) -> None:
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(namespace, row)
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, SearchOp]] = []
131
+ cursors: list[tuple[AsyncCursor[Any], int]] = []
110
132
 
111
- for (query, params), (idx, op) in zip(queries, search_ops):
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, op))
136
+ cursors.append((cur, idx))
115
137
 
116
- for cur, idx, op in cursors:
138
+ for cur, idx in cursors:
117
139
  rows = cast(list[Row], await cur.fetchall())
118
- items = [_row_to_item(op.namespace_prefix, row) for row in rows]
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 Any, Generic, Iterable, Iterator, Sequence, TypeVar, Union, cast
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 ltree NOT NULL,
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 listing of namespaces & lookups by namespace with prefix/suffix matching
47
- CREATE INDEX IF NOT EXISTS store_prefix_idx ON store USING gist (prefix);
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 = (_namespace_to_ltree(namespace), *keys)
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 = (_namespace_to_ltree(namespace), *keys)
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
- _namespace_to_ltree(op.namespace),
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 <@ %s
154
+ WHERE prefix LIKE %s
136
155
  """
137
- params: list = [_namespace_to_ltree(op.namespace_prefix)]
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 = "SELECT DISTINCT subltree(prefix, 0, LEAST(nlevel(prefix), %s)) AS truncated_prefix FROM store"
163
- # https://www.postgresql.org/docs/current/ltree.html
164
- # The length of a label path cannot exceed 65535 labels.
165
- params: list[Any] = [op.max_depth if op.max_depth is not None else 65536]
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 ~ %s::lquery")
172
- lquery_pattern = f"{_namespace_to_ltree(condition.path)}.*"
173
- params.append(lquery_pattern)
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 ~ %s::lquery")
176
- lquery_pattern = f"*.{_namespace_to_ltree(condition.path)}"
177
- params.append(lquery_pattern)
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__(self, conn: Connection[Any]) -> None:
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(namespace, row)
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, SearchOp]] = []
315
+ cursors: list[tuple[Cursor[Any], int]] = []
269
316
 
270
- for (query, params), (idx, op) in zip(queries, search_ops):
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, op))
320
+ cursors.append((cur, idx))
274
321
 
275
- for cur, idx, op in cursors:
322
+ for cur, idx in cursors:
276
323
  rows = cast(list[Row], cur.fetchall())
277
- items = [_row_to_item(op.namespace_prefix, row) for row in rows]
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: bytes
404
+ prefix: str
355
405
  created_at: datetime
356
406
  updated_at: datetime
357
407
 
358
408
 
359
- def _namespace_to_ltree(namespace: tuple[str, ...]) -> str:
360
- """Convert namespace tuple to ltree-compatible string."""
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(namespace: tuple[str, ...], row: Row) -> 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 _json_loads(val),
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 = "1.0.10"
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 = "^1.0.11"
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"