langgraph-checkpoint-postgres 1.0.11__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.11
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)
@@ -21,6 +21,7 @@ from langgraph.store.base import GetOp, ListNamespacesOp, Op, PutOp, Result, Sea
21
21
  from langgraph.store.postgres.base import (
22
22
  BasePostgresStore,
23
23
  Row,
24
+ _decode_ns_bytes,
24
25
  _group_ops,
25
26
  _row_to_item,
26
27
  )
@@ -127,17 +128,19 @@ class AsyncPostgresStore(BasePostgresStore[AsyncConnection]):
127
128
  results: list[Result],
128
129
  ) -> None:
129
130
  queries = self._get_batch_search_queries(search_ops)
130
- cursors: list[tuple[AsyncCursor[Any], int, SearchOp]] = []
131
+ cursors: list[tuple[AsyncCursor[Any], int]] = []
131
132
 
132
- for (query, params), (idx, op) in zip(queries, search_ops):
133
+ for (query, params), (idx, _) in zip(queries, search_ops):
133
134
  cur = self.conn.cursor(binary=True)
134
135
  await cur.execute(query, params)
135
- cursors.append((cur, idx, op))
136
+ cursors.append((cur, idx))
136
137
 
137
- for cur, idx, op in cursors:
138
+ for cur, idx in cursors:
138
139
  rows = cast(list[Row], await cur.fetchall())
139
140
  items = [
140
- _row_to_item(op.namespace_prefix, row, loader=self._deserializer)
141
+ _row_to_item(
142
+ _decode_ns_bytes(row["prefix"]), row, loader=self._deserializer
143
+ )
141
144
  for row in rows
142
145
  ]
143
146
  results[idx] = items
@@ -156,9 +159,7 @@ class AsyncPostgresStore(BasePostgresStore[AsyncConnection]):
156
159
 
157
160
  for cur, idx in cursors:
158
161
  rows = cast(list[dict], await cur.fetchall())
159
- namespaces = [
160
- tuple(row["truncated_prefix"].decode()[1:].split(".")) for row in rows
161
- ]
162
+ namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows]
162
163
  results[idx] = namespaces
163
164
 
164
165
  @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,8 +51,8 @@ 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
 
@@ -93,7 +90,7 @@ class BasePostgresStore(BaseStore, Generic[C]):
93
90
  FROM store
94
91
  WHERE prefix = %s AND key IN ({keys_to_query})
95
92
  """
96
- params = (_namespace_to_ltree(namespace), *keys)
93
+ params = (_namespace_to_text(namespace), *keys)
97
94
  results.append((query, params, namespace, items))
98
95
  return results
99
96
 
@@ -120,7 +117,7 @@ class BasePostgresStore(BaseStore, Generic[C]):
120
117
  query = (
121
118
  f"DELETE FROM store WHERE prefix = %s AND key IN ({placeholders})"
122
119
  )
123
- params = (_namespace_to_ltree(namespace), *keys)
120
+ params = (_namespace_to_text(namespace), *keys)
124
121
  queries.append((query, params))
125
122
  if inserts:
126
123
  values = []
@@ -129,7 +126,7 @@ class BasePostgresStore(BaseStore, Generic[C]):
129
126
  values.append("(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")
130
127
  insertion_params.extend(
131
128
  [
132
- _namespace_to_ltree(op.namespace),
129
+ _namespace_to_text(op.namespace),
133
130
  op.key,
134
131
  Jsonb(op.value),
135
132
  ]
@@ -152,11 +149,11 @@ class BasePostgresStore(BaseStore, Generic[C]):
152
149
  queries: list[tuple[str, Sequence]] = []
153
150
  for _, op in search_ops:
154
151
  query = """
155
- SELECT key, value, created_at, updated_at
152
+ SELECT prefix, key, value, created_at, updated_at
156
153
  FROM store
157
- WHERE prefix <@ %s
154
+ WHERE prefix LIKE %s
158
155
  """
159
- params: list = [_namespace_to_ltree(op.namespace_prefix)]
156
+ params: list = [f"{_namespace_to_text(op.namespace_prefix)}%"]
160
157
 
161
158
  if op.filter:
162
159
  filter_conditions = []
@@ -181,22 +178,39 @@ class BasePostgresStore(BaseStore, Generic[C]):
181
178
  ) -> list[tuple[str, Sequence]]:
182
179
  queries: list[tuple[str, Sequence]] = []
183
180
  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]
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]
188
200
 
189
201
  conditions = []
190
202
  if op.match_conditions:
191
203
  for condition in op.match_conditions:
192
204
  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)
205
+ conditions.append("prefix LIKE %s")
206
+ params.append(
207
+ f"{_namespace_to_text(condition.path, handle_wildcards=True)}%"
208
+ )
196
209
  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)
210
+ conditions.append("prefix LIKE %s")
211
+ params.append(
212
+ f"%{_namespace_to_text(condition.path, handle_wildcards=True)}"
213
+ )
200
214
  else:
201
215
  logger.warning(
202
216
  f"Unknown match_type in list_namespaces: {condition.match_type}"
@@ -204,11 +218,12 @@ class BasePostgresStore(BaseStore, Generic[C]):
204
218
 
205
219
  if conditions:
206
220
  query += " WHERE " + " AND ".join(conditions)
221
+ query += ") AS subquery "
207
222
 
208
223
  query += " ORDER BY truncated_prefix LIMIT %s OFFSET %s"
209
224
  params.extend([op.limit, op.offset])
210
-
211
225
  queries.append((query, params))
226
+
212
227
  return queries
213
228
 
214
229
 
@@ -297,17 +312,19 @@ class PostgresStore(BasePostgresStore[Connection]):
297
312
  results: list[Result],
298
313
  ) -> None:
299
314
  queries = self._get_batch_search_queries(search_ops)
300
- cursors: list[tuple[Cursor[Any], int, SearchOp]] = []
315
+ cursors: list[tuple[Cursor[Any], int]] = []
301
316
 
302
- for (query, params), (idx, op) in zip(queries, search_ops):
317
+ for (query, params), (idx, _) in zip(queries, search_ops):
303
318
  cur = self.conn.cursor(binary=True)
304
319
  cur.execute(query, params)
305
- cursors.append((cur, idx, op))
320
+ cursors.append((cur, idx))
306
321
 
307
- for cur, idx, op in cursors:
322
+ for cur, idx in cursors:
308
323
  rows = cast(list[Row], cur.fetchall())
309
324
  items = [
310
- _row_to_item(op.namespace_prefix, row, loader=self._deserializer)
325
+ _row_to_item(
326
+ _decode_ns_bytes(row["prefix"]), row, loader=self._deserializer
327
+ )
311
328
  for row in rows
312
329
  ]
313
330
  results[idx] = items
@@ -326,9 +343,7 @@ class PostgresStore(BasePostgresStore[Connection]):
326
343
 
327
344
  for cur, idx in cursors:
328
345
  rows = cast(list[dict], cur.fetchall())
329
- namespaces = [
330
- tuple(row["truncated_prefix"].decode()[1:].split(".")) for row in rows
331
- ]
346
+ namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows]
332
347
  results[idx] = namespaces
333
348
 
334
349
  @classmethod
@@ -386,13 +401,17 @@ class PostgresStore(BasePostgresStore[Connection]):
386
401
  class Row(TypedDict):
387
402
  key: str
388
403
  value: Any
389
- prefix: bytes
404
+ prefix: str
390
405
  created_at: datetime
391
406
  updated_at: datetime
392
407
 
393
408
 
394
- def _namespace_to_ltree(namespace: tuple[str, ...]) -> str:
395
- """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)
396
415
  return ".".join(namespace)
397
416
 
398
417
 
@@ -433,3 +452,11 @@ def _json_loads(content: Union[bytes, orjson.Fragment]) -> Any:
433
452
  else:
434
453
  content = content.contents.encode()
435
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.11"
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"