langgraph-checkpoint-postgres 3.1.0a4__tar.gz → 3.1.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-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/Makefile +2 -5
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/PKG-INFO +35 -5
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/README.md +33 -3
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/checkpoint/postgres/base.py +2 -2
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/store/postgres/base.py +89 -18
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/pyproject.toml +12 -12
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/tests/test_async_store.py +132 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/tests/test_store.py +265 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/uv.lock +332 -398
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/.gitignore +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/LICENSE +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/checkpoint/postgres/__init__.py +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/checkpoint/postgres/_ainternal.py +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/checkpoint/postgres/_internal.py +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/checkpoint/postgres/aio.py +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/checkpoint/postgres/py.typed +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/checkpoint/postgres/shallow.py +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/store/postgres/__init__.py +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/store/postgres/aio.py +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/langgraph/store/postgres/py.typed +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/tests/__init__.py +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/tests/compose-postgres.yml +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/tests/conftest.py +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/tests/embed_test_utils.py +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/tests/test_async.py +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/tests/test_sync.py +0 -0
|
@@ -47,22 +47,19 @@ test_watch:
|
|
|
47
47
|
|
|
48
48
|
# Define a variable for Python and notebook files.
|
|
49
49
|
PYTHON_FILES=.
|
|
50
|
-
MYPY_CACHE=.mypy_cache
|
|
51
50
|
lint format: PYTHON_FILES=.
|
|
52
51
|
lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --relative --diff-filter=d main . | grep -E '\.py$$|\.ipynb$$')
|
|
53
52
|
lint_package: PYTHON_FILES=langgraph
|
|
54
53
|
lint_tests: PYTHON_FILES=tests
|
|
55
|
-
lint_tests: MYPY_CACHE=.mypy_cache_test
|
|
56
54
|
|
|
57
55
|
lint lint_diff lint_package lint_tests:
|
|
58
56
|
uv run ruff check .
|
|
59
57
|
[ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff
|
|
60
58
|
[ "$(PYTHON_FILES)" = "" ] || uv run ruff check --select I $(PYTHON_FILES)
|
|
61
|
-
[ "$(PYTHON_FILES)" = "" ] ||
|
|
62
|
-
[ "$(PYTHON_FILES)" = "" ] || uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
|
59
|
+
[ "$(PYTHON_FILES)" = "" ] || uv run ty check $(PYTHON_FILES)
|
|
63
60
|
|
|
64
61
|
type:
|
|
65
|
-
|
|
62
|
+
uv run ty check $(PYTHON_FILES)
|
|
66
63
|
|
|
67
64
|
format format_diff:
|
|
68
65
|
uv run ruff format $(PYTHON_FILES)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: langgraph-checkpoint-postgres
|
|
3
|
-
Version: 3.1.
|
|
3
|
+
Version: 3.1.1
|
|
4
4
|
Summary: Library with a Postgres implementation of LangGraph checkpoint saver.
|
|
5
5
|
Project-URL: Source, https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres
|
|
6
6
|
Project-URL: Twitter, https://x.com/langchain_oss
|
|
@@ -9,7 +9,7 @@ Project-URL: Reddit, https://www.reddit.com/r/LangChain/
|
|
|
9
9
|
License-Expression: MIT
|
|
10
10
|
License-File: LICENSE
|
|
11
11
|
Requires-Python: >=3.10
|
|
12
|
-
Requires-Dist: langgraph-checkpoint<5.0.0,>=4.1.
|
|
12
|
+
Requires-Dist: langgraph-checkpoint<5.0.0,>=4.1.0
|
|
13
13
|
Requires-Dist: orjson>=3.11.5
|
|
14
14
|
Requires-Dist: psycopg-pool>=3.2.0
|
|
15
15
|
Requires-Dist: psycopg>=3.2.0
|
|
@@ -17,11 +17,29 @@ Description-Content-Type: text/markdown
|
|
|
17
17
|
|
|
18
18
|
# LangGraph Checkpoint Postgres
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
[](https://pypi.org/project/langgraph-checkpoint-postgres/#history)
|
|
21
|
+
[](https://opensource.org/licenses/MIT)
|
|
22
|
+
[](https://pypistats.org/packages/langgraph-checkpoint-postgres)
|
|
23
|
+
[](https://x.com/langchain_oss)
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
To help you ship LangGraph apps to production faster, check out [LangSmith](https://www.langchain.com/langsmith).
|
|
26
|
+
[LangSmith](https://www.langchain.com/langsmith) is a unified developer platform for building, testing, and monitoring LLM applications.
|
|
23
27
|
|
|
24
|
-
|
|
28
|
+
## Quick Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv add langgraph-checkpoint-postgres
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 🤔 What is this?
|
|
35
|
+
|
|
36
|
+
This library provides a Postgres implementation of LangGraph's checkpoint saver. Use it when you want LangGraph state persistence backed by Postgres for durable, long-running workflows and agents.
|
|
37
|
+
|
|
38
|
+
By default, `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) without any extras. You can choose a specific installation that best suits your needs in the [Psycopg installation docs](https://www.psycopg.org/psycopg3/docs/basic/install.html), for example `psycopg[binary]`.
|
|
39
|
+
|
|
40
|
+
## 📖 Documentation
|
|
41
|
+
|
|
42
|
+
For full documentation, see the [API reference](https://reference.langchain.com/python/langgraph.checkpoint.postgres). For conceptual guides on persistence and memory, see the [LangGraph Docs](https://docs.langchain.com/oss/python/langgraph/overview).
|
|
25
43
|
|
|
26
44
|
## Security
|
|
27
45
|
|
|
@@ -37,10 +55,12 @@ By default `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) withou
|
|
|
37
55
|
> When manually creating Postgres connections and passing them to `PostgresSaver` or `AsyncPostgresSaver`, make sure to include `autocommit=True` and `row_factory=dict_row` (`from psycopg.rows import dict_row`). See a full example in this [how-to guide](https://langchain-ai.github.io/langgraph/how-tos/persistence_postgres/).
|
|
38
56
|
>
|
|
39
57
|
> **Why these parameters are required:**
|
|
58
|
+
>
|
|
40
59
|
> - `autocommit=True`: Required for the `.setup()` method to properly commit the checkpoint tables to the database. Without this, table creation may not be persisted.
|
|
41
60
|
> - `row_factory=dict_row`: Required because the PostgresSaver implementation accesses database rows using dictionary-style syntax (e.g., `row["column_name"]`). The default `tuple_row` factory returns tuples that only support index-based access (e.g., `row[0]`), which will cause `TypeError` exceptions when the checkpointer tries to access columns by name.
|
|
42
61
|
>
|
|
43
62
|
> **Example of incorrect usage:**
|
|
63
|
+
>
|
|
44
64
|
> ```python
|
|
45
65
|
> # ❌ This will fail with TypeError during checkpointer operations
|
|
46
66
|
> with psycopg.connect(DB_URI) as conn: # Missing autocommit=True and row_factory=dict_row
|
|
@@ -135,3 +155,13 @@ async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
|
135
155
|
# list checkpoints
|
|
136
156
|
[c async for c in checkpointer.alist(read_config)]
|
|
137
157
|
```
|
|
158
|
+
|
|
159
|
+
## 📕 Releases & Versioning
|
|
160
|
+
|
|
161
|
+
See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies.
|
|
162
|
+
|
|
163
|
+
## 💁 Contributing
|
|
164
|
+
|
|
165
|
+
As an open-source project in a rapidly developing field, we are extremely open to contributions, whether it be in the form of a new feature, improved infrastructure, or better documentation.
|
|
166
|
+
|
|
167
|
+
For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview).
|
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
# LangGraph Checkpoint Postgres
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://pypi.org/project/langgraph-checkpoint-postgres/#history)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://pypistats.org/packages/langgraph-checkpoint-postgres)
|
|
6
|
+
[](https://x.com/langchain_oss)
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
To help you ship LangGraph apps to production faster, check out [LangSmith](https://www.langchain.com/langsmith).
|
|
9
|
+
[LangSmith](https://www.langchain.com/langsmith) is a unified developer platform for building, testing, and monitoring LLM applications.
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
## Quick Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uv add langgraph-checkpoint-postgres
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 🤔 What is this?
|
|
18
|
+
|
|
19
|
+
This library provides a Postgres implementation of LangGraph's checkpoint saver. Use it when you want LangGraph state persistence backed by Postgres for durable, long-running workflows and agents.
|
|
20
|
+
|
|
21
|
+
By default, `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) without any extras. You can choose a specific installation that best suits your needs in the [Psycopg installation docs](https://www.psycopg.org/psycopg3/docs/basic/install.html), for example `psycopg[binary]`.
|
|
22
|
+
|
|
23
|
+
## 📖 Documentation
|
|
24
|
+
|
|
25
|
+
For full documentation, see the [API reference](https://reference.langchain.com/python/langgraph.checkpoint.postgres). For conceptual guides on persistence and memory, see the [LangGraph Docs](https://docs.langchain.com/oss/python/langgraph/overview).
|
|
8
26
|
|
|
9
27
|
## Security
|
|
10
28
|
|
|
@@ -20,10 +38,12 @@ By default `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) withou
|
|
|
20
38
|
> When manually creating Postgres connections and passing them to `PostgresSaver` or `AsyncPostgresSaver`, make sure to include `autocommit=True` and `row_factory=dict_row` (`from psycopg.rows import dict_row`). See a full example in this [how-to guide](https://langchain-ai.github.io/langgraph/how-tos/persistence_postgres/).
|
|
21
39
|
>
|
|
22
40
|
> **Why these parameters are required:**
|
|
41
|
+
>
|
|
23
42
|
> - `autocommit=True`: Required for the `.setup()` method to properly commit the checkpoint tables to the database. Without this, table creation may not be persisted.
|
|
24
43
|
> - `row_factory=dict_row`: Required because the PostgresSaver implementation accesses database rows using dictionary-style syntax (e.g., `row["column_name"]`). The default `tuple_row` factory returns tuples that only support index-based access (e.g., `row[0]`), which will cause `TypeError` exceptions when the checkpointer tries to access columns by name.
|
|
25
44
|
>
|
|
26
45
|
> **Example of incorrect usage:**
|
|
46
|
+
>
|
|
27
47
|
> ```python
|
|
28
48
|
> # ❌ This will fail with TypeError during checkpointer operations
|
|
29
49
|
> with psycopg.connect(DB_URI) as conn: # Missing autocommit=True and row_factory=dict_row
|
|
@@ -118,3 +138,13 @@ async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
|
118
138
|
# list checkpoints
|
|
119
139
|
[c async for c in checkpointer.alist(read_config)]
|
|
120
140
|
```
|
|
141
|
+
|
|
142
|
+
## 📕 Releases & Versioning
|
|
143
|
+
|
|
144
|
+
See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies.
|
|
145
|
+
|
|
146
|
+
## 💁 Contributing
|
|
147
|
+
|
|
148
|
+
As an open-source project in a rapidly developing field, we are extremely open to contributions, whether it be in the form of a new feature, improved infrastructure, or better documentation.
|
|
149
|
+
|
|
150
|
+
For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview).
|
|
@@ -279,8 +279,8 @@ def _build_delta_stage2_sql(
|
|
|
279
279
|
)
|
|
280
280
|
for _ in channels_with_seed:
|
|
281
281
|
branches.append(
|
|
282
|
-
"SELECT 'b'::text, NULL, channel, "
|
|
283
|
-
"type, blob, NULL, NULL, version "
|
|
282
|
+
"SELECT 'b'::text AS _kind, NULL::text AS checkpoint_id, channel, "
|
|
283
|
+
"type, blob, NULL::text AS task_id, NULL::int AS idx, version "
|
|
284
284
|
"FROM checkpoint_blobs "
|
|
285
285
|
"WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s "
|
|
286
286
|
"AND version = %s"
|
|
@@ -4,6 +4,7 @@ import asyncio
|
|
|
4
4
|
import concurrent.futures
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
+
import re
|
|
7
8
|
import threading
|
|
8
9
|
from collections import defaultdict
|
|
9
10
|
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
@@ -238,6 +239,12 @@ class BasePostgresStore(Generic[C]):
|
|
|
238
239
|
conn: C
|
|
239
240
|
_deserializer: Callable[[bytes | orjson.Fragment], dict[str, Any]] | None
|
|
240
241
|
index_config: PostgresIndexConfig | None
|
|
242
|
+
ttl_config: TTLConfig | None
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def _omit_expired(self) -> bool:
|
|
246
|
+
"""Whether expired-but-unswept rows should be filtered from reads."""
|
|
247
|
+
return bool(self.ttl_config and self.ttl_config.get("omit_expired"))
|
|
241
248
|
|
|
242
249
|
def _get_batch_GET_ops_queries(
|
|
243
250
|
self,
|
|
@@ -258,12 +265,17 @@ class BasePostgresStore(Generic[C]):
|
|
|
258
265
|
namespace_groups[op.namespace].append((idx, op.key))
|
|
259
266
|
refresh_ttls[op.namespace].append(op.refresh_ttl)
|
|
260
267
|
|
|
268
|
+
omit_expired = self._omit_expired
|
|
269
|
+
expiry_clause = (
|
|
270
|
+
"AND (s.expires_at IS NULL OR s.expires_at > NOW())" if omit_expired else ""
|
|
271
|
+
)
|
|
272
|
+
|
|
261
273
|
results = []
|
|
262
274
|
for namespace, items in namespace_groups.items():
|
|
263
275
|
_, keys = zip(*items, strict=False)
|
|
264
276
|
this_refresh_ttls = refresh_ttls[namespace]
|
|
265
277
|
|
|
266
|
-
query = """
|
|
278
|
+
query = f"""
|
|
267
279
|
WITH passed_in AS (
|
|
268
280
|
SELECT unnest(%s::text[]) AS key,
|
|
269
281
|
unnest(%s::bool[]) AS do_refresh
|
|
@@ -276,12 +288,14 @@ class BasePostgresStore(Generic[C]):
|
|
|
276
288
|
AND s.key = p.key
|
|
277
289
|
AND p.do_refresh = TRUE
|
|
278
290
|
AND s.ttl_minutes IS NOT NULL
|
|
291
|
+
{expiry_clause}
|
|
279
292
|
RETURNING s.key
|
|
280
293
|
)
|
|
281
294
|
SELECT s.key, s.value, s.created_at, s.updated_at
|
|
282
295
|
FROM store s
|
|
283
296
|
JOIN passed_in p ON s.key = p.key
|
|
284
297
|
WHERE s.prefix = %s
|
|
298
|
+
{expiry_clause}
|
|
285
299
|
"""
|
|
286
300
|
ns_text = _namespace_to_text(namespace)
|
|
287
301
|
params = (
|
|
@@ -422,6 +436,13 @@ class BasePostgresStore(Generic[C]):
|
|
|
422
436
|
- embedding_requests: list of (original_index_in_search_ops, text_query)
|
|
423
437
|
"""
|
|
424
438
|
|
|
439
|
+
omit_expired = self._omit_expired
|
|
440
|
+
search_expiry_clause = (
|
|
441
|
+
"AND (store.expires_at IS NULL OR store.expires_at > NOW())"
|
|
442
|
+
if omit_expired
|
|
443
|
+
else ""
|
|
444
|
+
)
|
|
445
|
+
|
|
425
446
|
queries = []
|
|
426
447
|
embedding_requests = []
|
|
427
448
|
for idx, (_, op) in enumerate(search_ops):
|
|
@@ -443,8 +464,9 @@ class BasePostgresStore(Generic[C]):
|
|
|
443
464
|
ns_condition = "TRUE"
|
|
444
465
|
ns_param: Sequence[str] | None = None
|
|
445
466
|
if op.namespace_prefix:
|
|
446
|
-
ns_condition =
|
|
447
|
-
|
|
467
|
+
ns_condition, ns_param = _namespace_prefix_condition(
|
|
468
|
+
op.namespace_prefix
|
|
469
|
+
)
|
|
448
470
|
else:
|
|
449
471
|
ns_param = ()
|
|
450
472
|
|
|
@@ -491,7 +513,7 @@ class BasePostgresStore(Generic[C]):
|
|
|
491
513
|
{score_operator} AS neg_score
|
|
492
514
|
FROM store
|
|
493
515
|
JOIN store_vectors sv ON store.prefix = sv.prefix AND store.key = sv.key
|
|
494
|
-
WHERE {ns_condition} {extra_filters}
|
|
516
|
+
WHERE {ns_condition} {extra_filters} {search_expiry_clause}
|
|
495
517
|
ORDER BY {score_operator} ASC
|
|
496
518
|
LIMIT %s
|
|
497
519
|
"""
|
|
@@ -527,7 +549,7 @@ class BasePostgresStore(Generic[C]):
|
|
|
527
549
|
base_query = f"""
|
|
528
550
|
SELECT store.prefix, store.key, store.value, store.created_at, store.updated_at, NULL AS score
|
|
529
551
|
FROM store
|
|
530
|
-
WHERE {ns_condition} {extra_filters}
|
|
552
|
+
WHERE {ns_condition} {extra_filters} {search_expiry_clause}
|
|
531
553
|
ORDER BY store.updated_at DESC
|
|
532
554
|
LIMIT %s
|
|
533
555
|
OFFSET %s
|
|
@@ -591,18 +613,23 @@ class BasePostgresStore(Generic[C]):
|
|
|
591
613
|
"""
|
|
592
614
|
params: list[Any] = [op.max_depth, op.max_depth]
|
|
593
615
|
|
|
616
|
+
omit_expired = self._omit_expired
|
|
594
617
|
conditions = []
|
|
618
|
+
if omit_expired:
|
|
619
|
+
conditions.append("(expires_at IS NULL OR expires_at > NOW())")
|
|
595
620
|
if op.match_conditions:
|
|
596
621
|
for condition in op.match_conditions:
|
|
597
|
-
if condition.match_type
|
|
598
|
-
|
|
622
|
+
if condition.match_type in ("prefix", "suffix"):
|
|
623
|
+
if not condition.path:
|
|
624
|
+
# An empty path constrains nothing; skipping keeps it a
|
|
625
|
+
# no-op rather than emitting a pattern that matches no
|
|
626
|
+
# namespace at all.
|
|
627
|
+
continue
|
|
628
|
+
conditions.append("prefix ~ %s")
|
|
599
629
|
params.append(
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
conditions.append("prefix LIKE %s")
|
|
604
|
-
params.append(
|
|
605
|
-
f"%{_namespace_to_text(condition.path, handle_wildcards=True)}"
|
|
630
|
+
_namespace_match_pattern(
|
|
631
|
+
condition.path, condition.match_type
|
|
632
|
+
)
|
|
606
633
|
)
|
|
607
634
|
else:
|
|
608
635
|
logger.warning(
|
|
@@ -1248,15 +1275,59 @@ def _get_index_params(store: Any) -> tuple[str, dict[str, Any]]:
|
|
|
1248
1275
|
return kind, sanitized
|
|
1249
1276
|
|
|
1250
1277
|
|
|
1251
|
-
def _namespace_to_text(
|
|
1252
|
-
namespace: tuple[str, ...], handle_wildcards: bool = False
|
|
1253
|
-
) -> str:
|
|
1278
|
+
def _namespace_to_text(namespace: tuple[str, ...]) -> str:
|
|
1254
1279
|
"""Convert namespace tuple to text string."""
|
|
1255
|
-
if handle_wildcards:
|
|
1256
|
-
namespace = tuple("%" if val == "*" else val for val in namespace)
|
|
1257
1280
|
return ".".join(namespace)
|
|
1258
1281
|
|
|
1259
1282
|
|
|
1283
|
+
def _escape_like_literal(text: str) -> str:
|
|
1284
|
+
"""Escape LIKE metacharacters so `text` is matched literally.
|
|
1285
|
+
|
|
1286
|
+
Namespace labels may contain `_` and `%`, which would otherwise act as
|
|
1287
|
+
wildcards: `("user_1",)` would match `("userX1",)`. Backslash is escaped
|
|
1288
|
+
first so it cannot escape the following character. Requires an explicit
|
|
1289
|
+
`ESCAPE '\\'` clause on the pattern.
|
|
1290
|
+
"""
|
|
1291
|
+
return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def _namespace_prefix_condition(namespace_prefix: tuple[str, ...]) -> tuple[str, tuple]:
|
|
1295
|
+
"""Build the SQL scoping a search to a namespace and its descendants.
|
|
1296
|
+
|
|
1297
|
+
Matches the namespace exactly or requires the `.` separator before any
|
|
1298
|
+
remainder, so a prefix of `("foo",)` does not also match `("foobar",)`.
|
|
1299
|
+
|
|
1300
|
+
Both arms stay index-friendly: equality on the `(prefix, key)` primary key,
|
|
1301
|
+
the anchored LIKE on the `prefix text_pattern_ops` index.
|
|
1302
|
+
|
|
1303
|
+
Only the LIKE arm is escaped -- equality does not interpret metacharacters,
|
|
1304
|
+
so escaping it would stop `("user_1",)` from matching itself.
|
|
1305
|
+
"""
|
|
1306
|
+
path = _namespace_to_text(namespace_prefix)
|
|
1307
|
+
condition = r"(store.prefix = %s OR store.prefix LIKE %s ESCAPE '\')"
|
|
1308
|
+
return condition, (path, f"{_escape_like_literal(path)}.%")
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def _namespace_match_pattern(path: tuple[str, ...], match_type: str) -> str:
|
|
1312
|
+
"""Build a POSIX regex matching the dot-joined prefix on whole segments.
|
|
1313
|
+
|
|
1314
|
+
Needed because `LIKE` cannot express "any character except the separator".
|
|
1315
|
+
Matches how `InMemoryStore` compares namespaces element-wise.
|
|
1316
|
+
|
|
1317
|
+
`*` matches exactly one segment. Prefix matches stay open-ended but must end
|
|
1318
|
+
on a separator; suffix matches anchor at the end and begin on one.
|
|
1319
|
+
|
|
1320
|
+
Examples:
|
|
1321
|
+
prefix ("uid", "*", "alice") -> ^uid\\.[^.]+\\.alice(\\.|\\Z)
|
|
1322
|
+
suffix ("alice",) -> (^|\\.)alice\\Z
|
|
1323
|
+
"""
|
|
1324
|
+
segments = ("[^.]+" if part == "*" else re.escape(part) for part in path)
|
|
1325
|
+
body = r"\.".join(segments)
|
|
1326
|
+
if match_type == "suffix":
|
|
1327
|
+
return rf"(^|\.){body}\Z"
|
|
1328
|
+
return rf"^{body}(\.|\Z)"
|
|
1329
|
+
|
|
1330
|
+
|
|
1260
1331
|
def _row_to_item(
|
|
1261
1332
|
namespace: tuple[str, ...],
|
|
1262
1333
|
row: Row,
|
{langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.1}/pyproject.toml
RENAMED
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "langgraph-checkpoint-postgres"
|
|
7
|
-
version = "3.1.
|
|
7
|
+
version = "3.1.1"
|
|
8
8
|
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
|
|
9
9
|
authors = []
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -12,7 +12,7 @@ readme = "README.md"
|
|
|
12
12
|
license = "MIT"
|
|
13
13
|
license-files = ['LICENSE']
|
|
14
14
|
dependencies = [
|
|
15
|
-
"langgraph-checkpoint>=4.1.
|
|
15
|
+
"langgraph-checkpoint>=4.1.0,<5.0.0",
|
|
16
16
|
"orjson>=3.11.5",
|
|
17
17
|
"psycopg>=3.2.0",
|
|
18
18
|
"psycopg-pool>=3.2.0",
|
|
@@ -37,7 +37,7 @@ test = [
|
|
|
37
37
|
lint = [
|
|
38
38
|
"ruff",
|
|
39
39
|
"codespell",
|
|
40
|
-
"
|
|
40
|
+
"ty",
|
|
41
41
|
]
|
|
42
42
|
dev = [
|
|
43
43
|
{include-group = "test"},
|
|
@@ -69,15 +69,15 @@ lint.select = [
|
|
|
69
69
|
lint.ignore = ["E501", "B008"]
|
|
70
70
|
target-version = "py310"
|
|
71
71
|
|
|
72
|
-
[tool.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
72
|
+
[tool.ty.rules]
|
|
73
|
+
invalid-argument-type = "ignore"
|
|
74
|
+
invalid-assignment = "ignore"
|
|
75
|
+
invalid-key = "ignore"
|
|
76
|
+
invalid-return-type = "ignore"
|
|
77
|
+
invalid-typed-dict-field = "ignore"
|
|
78
|
+
invalid-yield = "ignore"
|
|
79
|
+
no-matching-overload = "ignore"
|
|
80
|
+
unused-type-ignore-comment = "ignore"
|
|
81
81
|
|
|
82
82
|
[tool.pytest-watcher]
|
|
83
83
|
now = true
|
|
@@ -739,3 +739,135 @@ async def test_store_ttl(store):
|
|
|
739
739
|
# Now has been (TTL_SECONDS-2)*2 > TTL_SECONDS + TTL_SECONDS/2
|
|
740
740
|
results = await store.asearch(ns, query="bar", refresh_ttl=False)
|
|
741
741
|
assert len(results) == 0
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
async def _aexpire_now(
|
|
745
|
+
store: AsyncPostgresStore, ns: tuple[str, ...], key: str
|
|
746
|
+
) -> None:
|
|
747
|
+
"""Backdate a row's expires_at into the past without deleting it (unswept)."""
|
|
748
|
+
async with store._cursor() as cur:
|
|
749
|
+
await cur.execute(
|
|
750
|
+
"UPDATE store SET expires_at = NOW() - INTERVAL '1 minute' "
|
|
751
|
+
"WHERE prefix = %s AND key = %s",
|
|
752
|
+
(".".join(ns), key),
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
async def _arow_exists(
|
|
757
|
+
store: AsyncPostgresStore, ns: tuple[str, ...], key: str
|
|
758
|
+
) -> bool:
|
|
759
|
+
async with store._cursor() as cur:
|
|
760
|
+
await cur.execute(
|
|
761
|
+
"SELECT COUNT(*) AS n FROM store WHERE prefix = %s AND key = %s",
|
|
762
|
+
(".".join(ns), key),
|
|
763
|
+
)
|
|
764
|
+
return (await cur.fetchone())["n"] == 1
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
async def _astored_expires_at(store: AsyncPostgresStore, ns: tuple[str, ...], key: str):
|
|
768
|
+
async with store._cursor() as cur:
|
|
769
|
+
await cur.execute(
|
|
770
|
+
"SELECT expires_at FROM store WHERE prefix = %s AND key = %s",
|
|
771
|
+
(".".join(ns), key),
|
|
772
|
+
)
|
|
773
|
+
return (await cur.fetchone())["expires_at"]
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
async def test_omit_expired_filters_read_paths(store: AsyncPostgresStore) -> None:
|
|
777
|
+
await store.stop_ttl_sweeper() # deterministic: no background deletion
|
|
778
|
+
store.ttl_config["omit_expired"] = True
|
|
779
|
+
|
|
780
|
+
expired_ns = ("omit", "expired")
|
|
781
|
+
control_ns = ("omit", "control")
|
|
782
|
+
await store.aput(expired_ns, "e", {"data": "gone"}, ttl=TTL_MINUTES)
|
|
783
|
+
await store.aput(control_ns, "c", {"data": "keep"}, ttl=None)
|
|
784
|
+
await _aexpire_now(store, expired_ns, "e")
|
|
785
|
+
|
|
786
|
+
# The row is expired but physically still present (unswept).
|
|
787
|
+
assert await _arow_exists(store, expired_ns, "e")
|
|
788
|
+
|
|
789
|
+
# aget omits it; the never-expiring control is still returned.
|
|
790
|
+
assert await store.aget(expired_ns, "e") is None
|
|
791
|
+
assert await store.aget(control_ns, "c") is not None
|
|
792
|
+
|
|
793
|
+
# asearch omits it but returns the control.
|
|
794
|
+
assert await store.asearch(expired_ns) == []
|
|
795
|
+
assert [i.key for i in await store.asearch(control_ns)] == ["c"]
|
|
796
|
+
|
|
797
|
+
# alist_namespaces drops the expired-only namespace, keeps the control.
|
|
798
|
+
namespaces = await store.alist_namespaces(prefix=("omit",))
|
|
799
|
+
assert expired_ns not in namespaces
|
|
800
|
+
assert control_ns in namespaces
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
@pytest.mark.parametrize("omit", [None, False], ids=["default", "explicit-false"])
|
|
804
|
+
async def test_omit_expired_disabled_preserves_expired_rows(
|
|
805
|
+
store: AsyncPostgresStore, omit
|
|
806
|
+
) -> None:
|
|
807
|
+
await store.stop_ttl_sweeper()
|
|
808
|
+
if omit is not None:
|
|
809
|
+
store.ttl_config["omit_expired"] = omit
|
|
810
|
+
|
|
811
|
+
ns = ("keep",)
|
|
812
|
+
await store.aput(ns, "k", {"data": "still-here"}, ttl=TTL_MINUTES)
|
|
813
|
+
await _aexpire_now(store, ns, "k")
|
|
814
|
+
|
|
815
|
+
assert await store.aget(ns, "k", refresh_ttl=False) is not None
|
|
816
|
+
assert [i.key for i in await store.asearch(ns, refresh_ttl=False)] == ["k"]
|
|
817
|
+
assert ns in await store.alist_namespaces(prefix=("keep",))
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
async def test_omit_expired_refresh_ttl_only_refreshes_live_rows(
|
|
821
|
+
store: AsyncPostgresStore,
|
|
822
|
+
) -> None:
|
|
823
|
+
await store.stop_ttl_sweeper()
|
|
824
|
+
store.ttl_config["omit_expired"] = True
|
|
825
|
+
|
|
826
|
+
ns = ("refresh",)
|
|
827
|
+
await store.aput(ns, "expired", {"n": 0}, ttl=TTL_MINUTES)
|
|
828
|
+
await store.aput(ns, "live_get", {"n": 1}, ttl=TTL_MINUTES)
|
|
829
|
+
await store.aput(ns, "live_search", {"n": 2}, ttl=TTL_MINUTES)
|
|
830
|
+
await _aexpire_now(store, ns, "expired")
|
|
831
|
+
|
|
832
|
+
expired_before = await _astored_expires_at(store, ns, "expired")
|
|
833
|
+
get_before = await _astored_expires_at(store, ns, "live_get")
|
|
834
|
+
search_before = await _astored_expires_at(store, ns, "live_search")
|
|
835
|
+
|
|
836
|
+
# refresh_ttl=True must NOT resurrect the expired row (via aget or asearch)...
|
|
837
|
+
assert await store.aget(ns, "expired", refresh_ttl=True) is None
|
|
838
|
+
live_keys = [i.key for i in await store.asearch(ns, refresh_ttl=True)]
|
|
839
|
+
assert "expired" not in live_keys
|
|
840
|
+
assert await _astored_expires_at(store, ns, "expired") == expired_before
|
|
841
|
+
|
|
842
|
+
# ...but must still extend the live rows that were read.
|
|
843
|
+
assert await store.aget(ns, "live_get", refresh_ttl=True) is not None
|
|
844
|
+
assert await _astored_expires_at(store, ns, "live_get") > get_before
|
|
845
|
+
assert await _astored_expires_at(store, ns, "live_search") > search_before
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
async def test_omit_expired_search_pagination(store: AsyncPostgresStore) -> None:
|
|
849
|
+
await store.stop_ttl_sweeper()
|
|
850
|
+
store.ttl_config["omit_expired"] = True
|
|
851
|
+
|
|
852
|
+
ns = ("page",)
|
|
853
|
+
for k in ("a", "b", "c"):
|
|
854
|
+
await store.aput(ns, k, {"k": k}, ttl=TTL_MINUTES)
|
|
855
|
+
await store.aput(ns, "expired", {"k": "x"}, ttl=TTL_MINUTES)
|
|
856
|
+
await _aexpire_now(store, ns, "expired")
|
|
857
|
+
|
|
858
|
+
seconds_ago = {"a": 1, "expired": 2, "b": 3, "c": 4}
|
|
859
|
+
# updated_at DESC orders these a, expired, b, c, so the expired row sits inside
|
|
860
|
+
# the first limit=2 window. Correct (pre-LIMIT) filtering yields live pages
|
|
861
|
+
# [a, b] then [c]; post-LIMIT filtering would underfill page 1 to just [a].
|
|
862
|
+
async with store._cursor() as cur:
|
|
863
|
+
for key, secs in seconds_ago.items():
|
|
864
|
+
await cur.execute(
|
|
865
|
+
"UPDATE store SET updated_at = NOW() - (%s * INTERVAL '1 second') "
|
|
866
|
+
"WHERE prefix = %s AND key = %s",
|
|
867
|
+
(secs, ".".join(ns), key),
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
page1 = await store.asearch(ns, limit=2, offset=0)
|
|
871
|
+
page2 = await store.asearch(ns, limit=2, offset=2)
|
|
872
|
+
assert [i.key for i in page1] == ["a", "b"]
|
|
873
|
+
assert [i.key for i in page2] == ["c"]
|