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.
Files changed (28) hide show
  1. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/Makefile +2 -5
  2. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/PKG-INFO +43 -37
  3. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/README.md +41 -35
  4. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/checkpoint/postgres/__init__.py +12 -5
  5. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/checkpoint/postgres/aio.py +12 -5
  6. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/checkpoint/postgres/base.py +92 -22
  7. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/checkpoint/postgres/shallow.py +2 -2
  8. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/store/postgres/base.py +89 -18
  9. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/pyproject.toml +16 -12
  10. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/tests/test_async.py +9 -7
  11. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/tests/test_async_store.py +132 -0
  12. langgraph_checkpoint_postgres-3.1.2/tests/test_conformance_delta.py +30 -0
  13. langgraph_checkpoint_postgres-3.1.2/tests/test_delta_plain_value_seed.py +204 -0
  14. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/tests/test_store.py +265 -0
  15. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/uv.lock +367 -402
  16. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/.gitignore +0 -0
  17. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/LICENSE +0 -0
  18. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/checkpoint/postgres/_ainternal.py +0 -0
  19. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/checkpoint/postgres/_internal.py +0 -0
  20. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/checkpoint/postgres/py.typed +0 -0
  21. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/store/postgres/__init__.py +0 -0
  22. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/store/postgres/aio.py +0 -0
  23. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/langgraph/store/postgres/py.typed +0 -0
  24. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/tests/__init__.py +0 -0
  25. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/tests/compose-postgres.yml +0 -0
  26. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/tests/conftest.py +0 -0
  27. {langgraph_checkpoint_postgres-3.1.0a4 → langgraph_checkpoint_postgres-3.1.2}/tests/embed_test_utils.py +0 -0
  28. {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)" = "" ] || mkdir -p $(MYPY_CACHE)
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
- mkdir -p $(MYPY_CACHE) && uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
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.0a4
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.0a4
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
- Implementation of LangGraph CheckpointSaver that uses Postgres.
20
+ [![PyPI - Version](https://img.shields.io/pypi/v/langgraph-checkpoint-postgres?label=%20)](https://pypi.org/project/langgraph-checkpoint-postgres/#history)
21
+ [![PyPI - License](https://img.shields.io/pypi/l/langgraph-checkpoint-postgres)](https://opensource.org/licenses/MIT)
22
+ [![PyPI - Downloads](https://img.shields.io/pepy/dt/langgraph-checkpoint-postgres)](https://pypistats.org/packages/langgraph-checkpoint-postgres)
23
+ [![Twitter](https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain)](https://x.com/langchain_oss)
21
24
 
22
- ## Dependencies
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
- By default `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) without any extras. However, you can choose a specific installation that best suits your needs [here](https://www.psycopg.org/psycopg3/docs/basic/install.html) (for example, `psycopg[binary]`).
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
- "my_key": "meow",
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
- "__start__": 1
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
- "my_key": "meow",
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
- "__start__": 1
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
- Implementation of LangGraph CheckpointSaver that uses Postgres.
3
+ [![PyPI - Version](https://img.shields.io/pypi/v/langgraph-checkpoint-postgres?label=%20)](https://pypi.org/project/langgraph-checkpoint-postgres/#history)
4
+ [![PyPI - License](https://img.shields.io/pypi/l/langgraph-checkpoint-postgres)](https://opensource.org/licenses/MIT)
5
+ [![PyPI - Downloads](https://img.shields.io/pepy/dt/langgraph-checkpoint-postgres)](https://pypistats.org/packages/langgraph-checkpoint-postgres)
6
+ [![Twitter](https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain)](https://x.com/langchain_oss)
4
7
 
5
- ## Dependencies
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
- By default `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) without any extras. However, you can choose a specific installation that best suits your needs [here](https://www.psycopg.org/psycopg3/docs/basic/install.html) (for example, `psycopg[binary]`).
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
- "my_key": "meow",
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
- "__start__": 1
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
- "my_key": "meow",
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
- "__start__": 1
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
- """ # noqa
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
- hs_by_i_by_cid: list[dict[str, bool]] = [{} for _ in channels]
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
- stage1_params.extend([ch, ch])
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
- hs_by_i_by_cid,
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
- hs_by_i_by_cid,
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
- hs_by_i_by_cid: list[dict[str, bool]] = [{} for _ in channels]
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
- stage1_params.extend([ch, ch])
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
- hs_by_i_by_cid,
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
- hs_by_i_by_cid,
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] # noqa: F821
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 2K parallel JSONB key lookups.
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
- (checkpoint -> 'channel_values' -> %s) IS NOT NULL AS hs_0,
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
- (checkpoint -> 'channel_values' -> %s) IS NOT NULL AS hs_1
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` / `hs_i` are interpolated into the
219
- SQL string (i is bounded by len(channels) and uses safe identifiers).
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, ch_0, ch_1, ch_1, ...,
222
- thread_id, ns, cursor, cursor, page_size]` when `paged=True`.
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"(checkpoint -> 'channel_values' -> %s) IS NOT NULL AS hs_{i}"
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
- hs_by_i_by_cid: list[dict[str, bool]],
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
- hs_by_i_by_cid[i][cid] = bool(r.get(f"hs_{i}"))
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
- hs_by_i_by_cid: Sequence[Mapping[str, bool]],
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 snapshot for its channel (channel becomes seeded),
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`, `walk_cursor_by_ch`, and
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
- hs_i = hs_by_i_by_cid[i]
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
- if hs_i.get(cur_cid, False):
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. The `seed` key is omitted
425
- when the walk reached root with no snapshot found, or when the
426
- seed blob is sentinel "empty" — in both cases the consumer treats
427
- absence as "start empty".
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
- """ # noqa
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] # noqa: F821
888
+ anext(aiter_), # type: ignore[arg-type]
889
889
  self.loop,
890
890
  ).result()
891
891
  except StopAsyncIteration: