langgraph-checkpoint-postgres 3.1.0a4__tar.gz → 3.1.2__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.2}/Makefile +2 -5
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/PKG-INFO +43 -37
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/README.md +41 -35
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/checkpoint/postgres/__init__.py +12 -5
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/checkpoint/postgres/aio.py +12 -5
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/checkpoint/postgres/base.py +92 -22
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/checkpoint/postgres/shallow.py +2 -2
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/store/postgres/base.py +89 -18
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/pyproject.toml +16 -12
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/tests/test_async.py +9 -7
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/tests/test_async_store.py +132 -0
- langgraph_checkpoint_postgres-3.1.2/tests/test_conformance_delta.py +30 -0
- langgraph_checkpoint_postgres-3.1.2/tests/test_delta_plain_value_seed.py +204 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/tests/test_store.py +265 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/uv.lock +367 -402
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/.gitignore +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/LICENSE +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/checkpoint/postgres/_ainternal.py +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/checkpoint/postgres/_internal.py +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/checkpoint/postgres/py.typed +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/store/postgres/__init__.py +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/store/postgres/aio.py +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/store/postgres/py.typed +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/tests/__init__.py +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/tests/compose-postgres.yml +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/tests/conftest.py +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/tests/embed_test_utils.py +0 -0
- {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/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.2
|
|
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
|
|
@@ -64,24 +84,12 @@ with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
|
64
84
|
"v": 4,
|
|
65
85
|
"ts": "2024-07-31T20:14:19.804150+00:00",
|
|
66
86
|
"id": "1ef4f797-8335-6428-8001-8a1503f9b875",
|
|
67
|
-
"channel_values": {
|
|
68
|
-
|
|
69
|
-
"node": "node"
|
|
70
|
-
},
|
|
71
|
-
"channel_versions": {
|
|
72
|
-
"__start__": 2,
|
|
73
|
-
"my_key": 3,
|
|
74
|
-
"start:node": 3,
|
|
75
|
-
"node": 3
|
|
76
|
-
},
|
|
87
|
+
"channel_values": {"my_key": "meow", "node": "node"},
|
|
88
|
+
"channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3},
|
|
77
89
|
"versions_seen": {
|
|
78
90
|
"__input__": {},
|
|
79
|
-
"__start__": {
|
|
80
|
-
"
|
|
81
|
-
},
|
|
82
|
-
"node": {
|
|
83
|
-
"start:node": 2
|
|
84
|
-
}
|
|
91
|
+
"__start__": {"__start__": 1},
|
|
92
|
+
"node": {"start:node": 2},
|
|
85
93
|
},
|
|
86
94
|
}
|
|
87
95
|
|
|
@@ -105,24 +113,12 @@ async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
|
105
113
|
"v": 4,
|
|
106
114
|
"ts": "2024-07-31T20:14:19.804150+00:00",
|
|
107
115
|
"id": "1ef4f797-8335-6428-8001-8a1503f9b875",
|
|
108
|
-
"channel_values": {
|
|
109
|
-
|
|
110
|
-
"node": "node"
|
|
111
|
-
},
|
|
112
|
-
"channel_versions": {
|
|
113
|
-
"__start__": 2,
|
|
114
|
-
"my_key": 3,
|
|
115
|
-
"start:node": 3,
|
|
116
|
-
"node": 3
|
|
117
|
-
},
|
|
116
|
+
"channel_values": {"my_key": "meow", "node": "node"},
|
|
117
|
+
"channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3},
|
|
118
118
|
"versions_seen": {
|
|
119
119
|
"__input__": {},
|
|
120
|
-
"__start__": {
|
|
121
|
-
"
|
|
122
|
-
},
|
|
123
|
-
"node": {
|
|
124
|
-
"start:node": 2
|
|
125
|
-
}
|
|
120
|
+
"__start__": {"__start__": 1},
|
|
121
|
+
"node": {"start:node": 2},
|
|
126
122
|
},
|
|
127
123
|
}
|
|
128
124
|
|
|
@@ -135,3 +131,13 @@ async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
|
135
131
|
# list checkpoints
|
|
136
132
|
[c async for c in checkpointer.alist(read_config)]
|
|
137
133
|
```
|
|
134
|
+
|
|
135
|
+
## 📕 Releases & Versioning
|
|
136
|
+
|
|
137
|
+
See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies.
|
|
138
|
+
|
|
139
|
+
## 💁 Contributing
|
|
140
|
+
|
|
141
|
+
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.
|
|
142
|
+
|
|
143
|
+
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
|
|
@@ -47,24 +67,12 @@ with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
|
47
67
|
"v": 4,
|
|
48
68
|
"ts": "2024-07-31T20:14:19.804150+00:00",
|
|
49
69
|
"id": "1ef4f797-8335-6428-8001-8a1503f9b875",
|
|
50
|
-
"channel_values": {
|
|
51
|
-
|
|
52
|
-
"node": "node"
|
|
53
|
-
},
|
|
54
|
-
"channel_versions": {
|
|
55
|
-
"__start__": 2,
|
|
56
|
-
"my_key": 3,
|
|
57
|
-
"start:node": 3,
|
|
58
|
-
"node": 3
|
|
59
|
-
},
|
|
70
|
+
"channel_values": {"my_key": "meow", "node": "node"},
|
|
71
|
+
"channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3},
|
|
60
72
|
"versions_seen": {
|
|
61
73
|
"__input__": {},
|
|
62
|
-
"__start__": {
|
|
63
|
-
"
|
|
64
|
-
},
|
|
65
|
-
"node": {
|
|
66
|
-
"start:node": 2
|
|
67
|
-
}
|
|
74
|
+
"__start__": {"__start__": 1},
|
|
75
|
+
"node": {"start:node": 2},
|
|
68
76
|
},
|
|
69
77
|
}
|
|
70
78
|
|
|
@@ -88,24 +96,12 @@ async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
|
88
96
|
"v": 4,
|
|
89
97
|
"ts": "2024-07-31T20:14:19.804150+00:00",
|
|
90
98
|
"id": "1ef4f797-8335-6428-8001-8a1503f9b875",
|
|
91
|
-
"channel_values": {
|
|
92
|
-
|
|
93
|
-
"node": "node"
|
|
94
|
-
},
|
|
95
|
-
"channel_versions": {
|
|
96
|
-
"__start__": 2,
|
|
97
|
-
"my_key": 3,
|
|
98
|
-
"start:node": 3,
|
|
99
|
-
"node": 3
|
|
100
|
-
},
|
|
99
|
+
"channel_values": {"my_key": "meow", "node": "node"},
|
|
100
|
+
"channel_versions": {"__start__": 2, "my_key": 3, "start:node": 3, "node": 3},
|
|
101
101
|
"versions_seen": {
|
|
102
102
|
"__input__": {},
|
|
103
|
-
"__start__": {
|
|
104
|
-
"
|
|
105
|
-
},
|
|
106
|
-
"node": {
|
|
107
|
-
"start:node": 2
|
|
108
|
-
}
|
|
103
|
+
"__start__": {"__start__": 1},
|
|
104
|
+
"node": {"start:node": 2},
|
|
109
105
|
},
|
|
110
106
|
}
|
|
111
107
|
|
|
@@ -118,3 +114,13 @@ async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
|
|
|
118
114
|
# list checkpoints
|
|
119
115
|
[c async for c in checkpointer.alist(read_config)]
|
|
120
116
|
```
|
|
117
|
+
|
|
118
|
+
## 📕 Releases & Versioning
|
|
119
|
+
|
|
120
|
+
See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies.
|
|
121
|
+
|
|
122
|
+
## 💁 Contributing
|
|
123
|
+
|
|
124
|
+
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.
|
|
125
|
+
|
|
126
|
+
For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview).
|
|
@@ -223,7 +223,7 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
223
223
|
>>> checkpoint_tuple = memory.get_tuple(config)
|
|
224
224
|
>>> print(checkpoint_tuple)
|
|
225
225
|
CheckpointTuple(...)
|
|
226
|
-
"""
|
|
226
|
+
"""
|
|
227
227
|
thread_id = config["configurable"]["thread_id"]
|
|
228
228
|
checkpoint_id = get_checkpoint_id(config)
|
|
229
229
|
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
|
@@ -478,9 +478,11 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
478
478
|
stage1_sql = _build_delta_stage1_sql(channels, paged=True)
|
|
479
479
|
parent_of: dict[str, str | None] = {}
|
|
480
480
|
ver_by_i_by_cid: list[dict[str, str | None]] = [{} for _ in channels]
|
|
481
|
-
|
|
481
|
+
hb_by_i_by_cid: list[dict[str, bool]] = [{} for _ in channels]
|
|
482
|
+
inline_by_i_by_cid: list[dict[str, Any]] = [{} for _ in channels]
|
|
482
483
|
chain_by_ch: dict[str, list[str]] = {ch: [] for ch in channels}
|
|
483
484
|
seed_ver_by_ch: dict[str, str | None] = {ch: None for ch in channels}
|
|
485
|
+
seed_inline_by_ch: dict[str, Any] = {}
|
|
484
486
|
walk_cursor_by_ch: dict[str, str | None] = {}
|
|
485
487
|
seeded: set[str] = set()
|
|
486
488
|
cursor: str | None = None
|
|
@@ -489,7 +491,8 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
489
491
|
while True:
|
|
490
492
|
stage1_params: list[Any] = []
|
|
491
493
|
for ch in channels:
|
|
492
|
-
|
|
494
|
+
# ver_i, blob channel, blob version, inline_i
|
|
495
|
+
stage1_params.extend([ch, ch, ch, ch])
|
|
493
496
|
stage1_params.extend(
|
|
494
497
|
[thread_id, checkpoint_ns, cursor, cursor, _DELTA_PAGE_SIZE]
|
|
495
498
|
)
|
|
@@ -502,16 +505,19 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
502
505
|
channels,
|
|
503
506
|
parent_of,
|
|
504
507
|
ver_by_i_by_cid,
|
|
505
|
-
|
|
508
|
+
hb_by_i_by_cid,
|
|
509
|
+
inline_by_i_by_cid,
|
|
506
510
|
)
|
|
507
511
|
self._try_advance_walks(
|
|
508
512
|
checkpoint_id,
|
|
509
513
|
channels,
|
|
510
514
|
parent_of,
|
|
511
515
|
ver_by_i_by_cid,
|
|
512
|
-
|
|
516
|
+
hb_by_i_by_cid,
|
|
517
|
+
inline_by_i_by_cid,
|
|
513
518
|
chain_by_ch,
|
|
514
519
|
seed_ver_by_ch,
|
|
520
|
+
seed_inline_by_ch,
|
|
515
521
|
walk_cursor_by_ch,
|
|
516
522
|
seeded,
|
|
517
523
|
)
|
|
@@ -546,6 +552,7 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
546
552
|
channels=channels,
|
|
547
553
|
chain_by_ch=chain_by_ch,
|
|
548
554
|
seed_ver_by_ch=seed_ver_by_ch,
|
|
555
|
+
seed_inline_by_ch=seed_inline_by_ch,
|
|
549
556
|
stage2_rows=cast("list[_DeltaStage2Row]", stage2_rows),
|
|
550
557
|
)
|
|
551
558
|
|
|
@@ -426,9 +426,11 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
426
426
|
stage1_sql = _build_delta_stage1_sql(channels, paged=True)
|
|
427
427
|
parent_of: dict[str, str | None] = {}
|
|
428
428
|
ver_by_i_by_cid: list[dict[str, str | None]] = [{} for _ in channels]
|
|
429
|
-
|
|
429
|
+
hb_by_i_by_cid: list[dict[str, bool]] = [{} for _ in channels]
|
|
430
|
+
inline_by_i_by_cid: list[dict[str, Any]] = [{} for _ in channels]
|
|
430
431
|
chain_by_ch: dict[str, list[str]] = {ch: [] for ch in channels}
|
|
431
432
|
seed_ver_by_ch: dict[str, str | None] = {ch: None for ch in channels}
|
|
433
|
+
seed_inline_by_ch: dict[str, Any] = {}
|
|
432
434
|
walk_cursor_by_ch: dict[str, str | None] = {}
|
|
433
435
|
seeded: set[str] = set()
|
|
434
436
|
cursor: str | None = None
|
|
@@ -437,7 +439,8 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
437
439
|
while True:
|
|
438
440
|
stage1_params: list[Any] = []
|
|
439
441
|
for ch in channels:
|
|
440
|
-
|
|
442
|
+
# ver_i, blob channel, blob version, inline_i
|
|
443
|
+
stage1_params.extend([ch, ch, ch, ch])
|
|
441
444
|
stage1_params.extend(
|
|
442
445
|
[thread_id, checkpoint_ns, cursor, cursor, _DELTA_PAGE_SIZE]
|
|
443
446
|
)
|
|
@@ -450,16 +453,19 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
450
453
|
channels,
|
|
451
454
|
parent_of,
|
|
452
455
|
ver_by_i_by_cid,
|
|
453
|
-
|
|
456
|
+
hb_by_i_by_cid,
|
|
457
|
+
inline_by_i_by_cid,
|
|
454
458
|
)
|
|
455
459
|
self._try_advance_walks(
|
|
456
460
|
checkpoint_id,
|
|
457
461
|
channels,
|
|
458
462
|
parent_of,
|
|
459
463
|
ver_by_i_by_cid,
|
|
460
|
-
|
|
464
|
+
hb_by_i_by_cid,
|
|
465
|
+
inline_by_i_by_cid,
|
|
461
466
|
chain_by_ch,
|
|
462
467
|
seed_ver_by_ch,
|
|
468
|
+
seed_inline_by_ch,
|
|
463
469
|
walk_cursor_by_ch,
|
|
464
470
|
seeded,
|
|
465
471
|
)
|
|
@@ -490,6 +496,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
490
496
|
channels=channels,
|
|
491
497
|
chain_by_ch=chain_by_ch,
|
|
492
498
|
seed_ver_by_ch=seed_ver_by_ch,
|
|
499
|
+
seed_inline_by_ch=seed_inline_by_ch,
|
|
493
500
|
stage2_rows=cast("list[_DeltaStage2Row]", stage2_rows),
|
|
494
501
|
)
|
|
495
502
|
|
|
@@ -573,7 +580,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
573
580
|
while True:
|
|
574
581
|
try:
|
|
575
582
|
yield asyncio.run_coroutine_threadsafe(
|
|
576
|
-
anext(aiter_), # type: ignore[arg-type]
|
|
583
|
+
anext(aiter_), # type: ignore[arg-type]
|
|
577
584
|
self.loop,
|
|
578
585
|
).result()
|
|
579
586
|
except StopAsyncIteration:
|
|
@@ -199,27 +199,68 @@ class _DeltaStage2Row(TypedDict, total=False):
|
|
|
199
199
|
|
|
200
200
|
|
|
201
201
|
def _build_delta_stage1_sql(channels: Sequence[str], *, paged: bool) -> str:
|
|
202
|
-
"""Build stage 1 SQL with
|
|
202
|
+
"""Build stage 1 SQL with K parallel version lookups + seed probes.
|
|
203
203
|
|
|
204
204
|
For channels=["messages", "files"] (with `paged=True`) the result is::
|
|
205
205
|
|
|
206
206
|
SELECT checkpoint_id, parent_checkpoint_id,
|
|
207
207
|
checkpoint -> 'channel_versions' ->> %s AS ver_0,
|
|
208
|
-
(
|
|
208
|
+
EXISTS (SELECT 1 FROM checkpoint_blobs b0
|
|
209
|
+
WHERE b0.thread_id = checkpoints.thread_id
|
|
210
|
+
AND b0.checkpoint_ns = checkpoints.checkpoint_ns
|
|
211
|
+
AND b0.channel = %s
|
|
212
|
+
AND b0.version = checkpoint -> 'channel_versions' ->> %s
|
|
213
|
+
AND b0.type <> 'empty') AS hb_0,
|
|
214
|
+
checkpoint -> 'channel_values' -> %s AS inline_0,
|
|
209
215
|
checkpoint -> 'channel_versions' ->> %s AS ver_1,
|
|
210
|
-
(
|
|
216
|
+
EXISTS (...) AS hb_1,
|
|
217
|
+
checkpoint -> 'channel_values' -> %s AS inline_1
|
|
211
218
|
FROM checkpoints
|
|
212
219
|
WHERE thread_id = %s AND checkpoint_ns = %s
|
|
213
220
|
AND (%s::text IS NULL OR checkpoint_id < %s)
|
|
214
221
|
ORDER BY checkpoint_id DESC
|
|
215
222
|
LIMIT %s
|
|
216
223
|
|
|
224
|
+
A stored value for a channel lives in one of two places, because `put`
|
|
225
|
+
splits them:
|
|
226
|
+
|
|
227
|
+
* **blob** — non-primitive values (and `_DeltaSnapshot`) are moved to
|
|
228
|
+
`checkpoint_blobs`. `hb_i` ("has blob") probes for one. The probe hits
|
|
229
|
+
that table's primary key `(thread_id, checkpoint_ns, channel, version)`
|
|
230
|
+
exactly, so it is an index lookup per row per channel.
|
|
231
|
+
* **inline** — `None`, `str`, `int`, `float` and `bool` stay in the
|
|
232
|
+
checkpoint's own `channel_values` and get no blob row at all. `inline_i`
|
|
233
|
+
returns that value.
|
|
234
|
+
|
|
235
|
+
Testing only for a key in `channel_values` (the previous approach) missed
|
|
236
|
+
blob-stored plain values, since `put` leaves an inline marker there for
|
|
237
|
+
`_DeltaSnapshot` but not for a plain value — which is what a thread
|
|
238
|
+
migrated from a pre-delta channel type leaves behind. Probing only the
|
|
239
|
+
blobs table would conversely miss inline primitives. Both are needed, and
|
|
240
|
+
the caller treats "either present" as the seed.
|
|
241
|
+
|
|
242
|
+
`hb_i` also disambiguates the two: for a `_DeltaSnapshot`, `inline_i` is the
|
|
243
|
+
literal `true` marker rather than the value, so a blob must win over an
|
|
244
|
+
inline reading whenever one exists. That ordering is what makes a genuine
|
|
245
|
+
inline `true` (a bool channel) distinguishable from the marker.
|
|
246
|
+
|
|
247
|
+
The `type <> 'empty'` predicate mirrors the check stage 2 already applies
|
|
248
|
+
when resolving the seed blob. `put` does not currently produce `empty` rows
|
|
249
|
+
on this path — `blob_versions` is filtered to keys present in
|
|
250
|
+
`channel_values`, so `_dump_blobs`' empty branch is unreachable from it —
|
|
251
|
+
but without the predicate the two stages could disagree: stage 1 would
|
|
252
|
+
terminate the walk on a row stage 2 then discards, yielding no seed *and* a
|
|
253
|
+
truncated write chain, which is the failure this function exists to avoid.
|
|
254
|
+
|
|
217
255
|
Channel names are passed as `%s` parameters (safe from SQL injection).
|
|
218
|
-
Only the column aliases `ver_i` / `
|
|
219
|
-
SQL string (i is bounded by len(channels)
|
|
256
|
+
Only the column aliases `ver_i` / `hb_i` / `inline_i` and the subquery alias
|
|
257
|
+
`b{i}` are interpolated into the SQL string (i is bounded by len(channels)
|
|
258
|
+
and uses safe identifiers).
|
|
220
259
|
|
|
221
|
-
Caller must extend params with `[ch_0
|
|
222
|
-
|
|
260
|
+
Caller must extend params with `[ch_0 x4, ch_1 x4, ..., thread_id, ns,
|
|
261
|
+
cursor, cursor, page_size]` when `paged=True` — four per channel: the
|
|
262
|
+
version lookup, the blob's channel, the version the blob must match, and the
|
|
263
|
+
inline lookup.
|
|
223
264
|
|
|
224
265
|
When `paged=False`, the WHERE has no cursor predicate and there's no
|
|
225
266
|
LIMIT/ORDER BY — kept as a non-public helper for tests/diagnostics.
|
|
@@ -228,7 +269,13 @@ def _build_delta_stage1_sql(channels: Sequence[str], *, paged: bool) -> str:
|
|
|
228
269
|
for i in range(len(channels)):
|
|
229
270
|
cols.append(
|
|
230
271
|
f"checkpoint -> 'channel_versions' ->> %s AS ver_{i}, "
|
|
231
|
-
f"(
|
|
272
|
+
f"EXISTS (SELECT 1 FROM checkpoint_blobs b{i} "
|
|
273
|
+
f"WHERE b{i}.thread_id = checkpoints.thread_id "
|
|
274
|
+
f"AND b{i}.checkpoint_ns = checkpoints.checkpoint_ns "
|
|
275
|
+
f"AND b{i}.channel = %s "
|
|
276
|
+
f"AND b{i}.version = checkpoint -> 'channel_versions' ->> %s "
|
|
277
|
+
f"AND b{i}.type <> 'empty') AS hb_{i}, "
|
|
278
|
+
f"checkpoint -> 'channel_values' -> %s AS inline_{i}"
|
|
232
279
|
)
|
|
233
280
|
sql = (
|
|
234
281
|
"SELECT checkpoint_id, parent_checkpoint_id, "
|
|
@@ -279,8 +326,8 @@ def _build_delta_stage2_sql(
|
|
|
279
326
|
)
|
|
280
327
|
for _ in channels_with_seed:
|
|
281
328
|
branches.append(
|
|
282
|
-
"SELECT 'b'::text, NULL, channel, "
|
|
283
|
-
"type, blob, NULL, NULL, version "
|
|
329
|
+
"SELECT 'b'::text AS _kind, NULL::text AS checkpoint_id, channel, "
|
|
330
|
+
"type, blob, NULL::text AS task_id, NULL::int AS idx, version "
|
|
284
331
|
"FROM checkpoint_blobs "
|
|
285
332
|
"WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s "
|
|
286
333
|
"AND version = %s"
|
|
@@ -342,7 +389,8 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
|
|
342
389
|
channels: Sequence[str],
|
|
343
390
|
parent_of: dict[str, str | None],
|
|
344
391
|
ver_by_i_by_cid: list[dict[str, str | None]],
|
|
345
|
-
|
|
392
|
+
hb_by_i_by_cid: list[dict[str, bool]],
|
|
393
|
+
inline_by_i_by_cid: list[dict[str, Any]],
|
|
346
394
|
) -> str | None:
|
|
347
395
|
"""Fold one stage-1 page into the running walk-state mappings.
|
|
348
396
|
|
|
@@ -356,7 +404,8 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
|
|
356
404
|
parent_of[cid] = cast("str | None", r["parent_checkpoint_id"])
|
|
357
405
|
for i in range(len(channels)):
|
|
358
406
|
ver_by_i_by_cid[i][cid] = cast("str | None", r.get(f"ver_{i}"))
|
|
359
|
-
|
|
407
|
+
hb_by_i_by_cid[i][cid] = bool(r.get(f"hb_{i}"))
|
|
408
|
+
inline_by_i_by_cid[i][cid] = r.get(f"inline_{i}")
|
|
360
409
|
# Rows are DESC; the last one is the smallest cid in the page.
|
|
361
410
|
oldest = cid
|
|
362
411
|
return oldest
|
|
@@ -367,9 +416,11 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
|
|
367
416
|
channels: Sequence[str],
|
|
368
417
|
parent_of: Mapping[str, str | None],
|
|
369
418
|
ver_by_i_by_cid: Sequence[Mapping[str, str | None]],
|
|
370
|
-
|
|
419
|
+
hb_by_i_by_cid: Sequence[Mapping[str, bool]],
|
|
420
|
+
inline_by_i_by_cid: Sequence[Mapping[str, Any]],
|
|
371
421
|
chain_by_ch: dict[str, list[str]],
|
|
372
422
|
seed_ver_by_ch: dict[str, str | None],
|
|
423
|
+
seed_inline_by_ch: dict[str, Any],
|
|
373
424
|
walk_cursor_by_ch: dict[str, str | None],
|
|
374
425
|
seeded: set[str],
|
|
375
426
|
) -> None:
|
|
@@ -377,14 +428,15 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
|
|
377
428
|
|
|
378
429
|
Uses the partial `parent_of` map accumulated so far. A walk stops
|
|
379
430
|
either because:
|
|
380
|
-
(a) it found a
|
|
431
|
+
(a) it found a stored value for its channel — a blob or an inline
|
|
432
|
+
primitive (channel becomes seeded),
|
|
381
433
|
(b) it reached a real root (parent_of[cid] is None — fully
|
|
382
434
|
materialized at this point), or
|
|
383
435
|
(c) the next ancestor cid isn't in `parent_of` yet (waiting for
|
|
384
436
|
a later page; the cursor stays put).
|
|
385
437
|
|
|
386
|
-
Mutates `chain_by_ch`, `seed_ver_by_ch`, `
|
|
387
|
-
`seeded` in place.
|
|
438
|
+
Mutates `chain_by_ch`, `seed_ver_by_ch`, `seed_inline_by_ch`,
|
|
439
|
+
`walk_cursor_by_ch`, and `seeded` in place.
|
|
388
440
|
"""
|
|
389
441
|
for i, ch in enumerate(channels):
|
|
390
442
|
if ch in seeded:
|
|
@@ -394,15 +446,22 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
|
|
394
446
|
walk_cursor_by_ch[ch] = parent_of.get(target_id)
|
|
395
447
|
cur_cid = walk_cursor_by_ch[ch]
|
|
396
448
|
ch_chain = chain_by_ch[ch]
|
|
397
|
-
|
|
449
|
+
hb_i = hb_by_i_by_cid[i]
|
|
450
|
+
inline_i = inline_by_i_by_cid[i]
|
|
398
451
|
ver_i = ver_by_i_by_cid[i]
|
|
399
452
|
while cur_cid is not None:
|
|
400
453
|
if cur_cid not in parent_of:
|
|
401
454
|
# Need more pages to continue this walk.
|
|
402
455
|
break
|
|
403
456
|
ch_chain.append(cur_cid)
|
|
404
|
-
|
|
457
|
+
has_blob = hb_i.get(cur_cid, False)
|
|
458
|
+
inline = inline_i.get(cur_cid)
|
|
459
|
+
if has_blob or inline is not None:
|
|
460
|
+
# A blob wins: for a `_DeltaSnapshot` the inline reading is
|
|
461
|
+
# the `true` marker, not the value.
|
|
405
462
|
seed_ver_by_ch[ch] = ver_i.get(cur_cid)
|
|
463
|
+
if not has_blob:
|
|
464
|
+
seed_inline_by_ch[ch] = inline
|
|
406
465
|
seeded.add(ch)
|
|
407
466
|
cur_cid = None
|
|
408
467
|
break
|
|
@@ -415,16 +474,23 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
|
|
415
474
|
channels: Sequence[str],
|
|
416
475
|
chain_by_ch: Mapping[str, list[str]],
|
|
417
476
|
seed_ver_by_ch: Mapping[str, str | None],
|
|
477
|
+
seed_inline_by_ch: Mapping[str, Any],
|
|
418
478
|
stage2_rows: Sequence[_DeltaStage2Row],
|
|
419
479
|
) -> dict[str, DeltaChannelHistory]:
|
|
420
480
|
"""Demux stage 2 rows per channel; produce per-channel histories.
|
|
421
481
|
|
|
422
482
|
stage2_rows carry `channel` on every row. We build per-channel
|
|
423
483
|
`writes_by_cid` and per-channel `seed_blob` dicts, then assemble
|
|
424
|
-
a `DeltaChannelHistory` per requested channel.
|
|
425
|
-
|
|
426
|
-
seed
|
|
427
|
-
|
|
484
|
+
a `DeltaChannelHistory` per requested channel.
|
|
485
|
+
|
|
486
|
+
A seed comes from the blobs table when the walk found one there, and
|
|
487
|
+
otherwise from `seed_inline_by_ch` — `put` keeps `None`, `str`, `int`,
|
|
488
|
+
`float` and `bool` values in the checkpoint's own `channel_values` with
|
|
489
|
+
no blob row, so those never appear in `stage2_rows`.
|
|
490
|
+
|
|
491
|
+
The `seed` key is omitted when the walk reached root without finding a
|
|
492
|
+
stored value, or when the seed blob is sentinel "empty" — in both cases
|
|
493
|
+
the consumer treats absence as "start empty".
|
|
428
494
|
"""
|
|
429
495
|
# writes_by_ch_by_cid[channel][cid] = list of (type, blob, task_id, idx)
|
|
430
496
|
writes_by_ch_by_cid: dict[str, dict[str, list[tuple[str, bytes, str, int]]]] = {
|
|
@@ -473,6 +539,10 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
|
|
473
539
|
blob = seed_blob_by_ver.get((ch, seed_version))
|
|
474
540
|
if blob is not None and blob[0] != "empty":
|
|
475
541
|
entry["seed"] = self.serde.loads_typed(blob)
|
|
542
|
+
elif ch in seed_inline_by_ch:
|
|
543
|
+
# Inline primitive: stored in the checkpoint, not the blobs
|
|
544
|
+
# table, so stage 2 never returned a row for it.
|
|
545
|
+
entry["seed"] = seed_inline_by_ch[ch]
|
|
476
546
|
result[ch] = entry
|
|
477
547
|
return result
|
|
478
548
|
|
|
@@ -334,7 +334,7 @@ class ShallowPostgresSaver(BasePostgresSaver):
|
|
|
334
334
|
>>> checkpoint_tuple = memory.get_tuple(config)
|
|
335
335
|
>>> print(checkpoint_tuple)
|
|
336
336
|
CheckpointTuple(...)
|
|
337
|
-
"""
|
|
337
|
+
"""
|
|
338
338
|
thread_id = config["configurable"]["thread_id"]
|
|
339
339
|
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
|
340
340
|
args = (thread_id, checkpoint_ns)
|
|
@@ -885,7 +885,7 @@ class AsyncShallowPostgresSaver(BasePostgresSaver):
|
|
|
885
885
|
while True:
|
|
886
886
|
try:
|
|
887
887
|
yield asyncio.run_coroutine_threadsafe(
|
|
888
|
-
anext(aiter_), # type: ignore[arg-type]
|
|
888
|
+
anext(aiter_), # type: ignore[arg-type]
|
|
889
889
|
self.loop,
|
|
890
890
|
).result()
|
|
891
891
|
except StopAsyncIteration:
|