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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: langgraph-checkpoint-postgres
3
- Version: 1.0.11
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 (>=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)
@@ -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__(deserializer=deserializer)
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, SearchOp]] = []
135
+ cursors: list[tuple[AsyncCursor[Any], int]] = []
131
136
 
132
- for (query, params), (idx, op) in zip(queries, search_ops):
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, op))
140
+ cursors.append((cur, idx))
136
141
 
137
- for cur, idx, op in cursors:
142
+ for cur, idx in cursors:
138
143
  rows = cast(list[Row], await cur.fetchall())
139
144
  items = [
140
- _row_to_item(op.namespace_prefix, row, loader=self._deserializer)
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 ltree NOT NULL,
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 listing of namespaces & lookups by namespace with prefix/suffix matching
58
- 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);
59
56
  """,
60
57
  ]
61
58
 
62
59
  C = TypeVar("C", bound=BaseConnection)
63
60
 
64
61
 
65
- class BasePostgresStore(BaseStore, Generic[C]):
62
+ class BasePostgresStore(Generic[C]):
66
63
  MIGRATIONS = MIGRATIONS
67
64
  conn: C
68
- __slots__ = ("_deserializer",)
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 = (_namespace_to_ltree(namespace), *keys)
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 = (_namespace_to_ltree(namespace), *keys)
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
- _namespace_to_ltree(op.namespace),
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 <@ %s
144
+ WHERE prefix LIKE %s
158
145
  """
159
- params: list = [_namespace_to_ltree(op.namespace_prefix)]
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
- query += " LIMIT %s OFFSET %s"
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 = "SELECT DISTINCT subltree(prefix, 0, LEAST(nlevel(prefix), %s)) AS truncated_prefix FROM store"
185
- # https://www.postgresql.org/docs/current/ltree.html
186
- # The length of a label path cannot exceed 65535 labels.
187
- params: list[Any] = [op.max_depth if op.max_depth is not None else 65536]
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 ~ %s::lquery")
194
- lquery_pattern = f"{_namespace_to_ltree(condition.path)}.*"
195
- params.append(lquery_pattern)
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 ~ %s::lquery")
198
- lquery_pattern = f"*.{_namespace_to_ltree(condition.path)}"
199
- params.append(lquery_pattern)
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__(deserializer=deserializer)
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, SearchOp]] = []
310
+ cursors: list[tuple[Cursor[Any], int]] = []
301
311
 
302
- for (query, params), (idx, op) in zip(queries, search_ops):
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, op))
315
+ cursors.append((cur, idx))
306
316
 
307
- for cur, idx, op in cursors:
317
+ for cur, idx in cursors:
308
318
  rows = cast(list[Row], cur.fetchall())
309
319
  items = [
310
- _row_to_item(op.namespace_prefix, row, loader=self._deserializer)
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: bytes
399
+ prefix: str
390
400
  created_at: datetime
391
401
  updated_at: datetime
392
402
 
393
403
 
394
- def _namespace_to_ltree(namespace: tuple[str, ...]) -> str:
395
- """Convert namespace tuple to ltree-compatible string."""
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 = "1.0.11"
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 = "^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"