langgraph-checkpoint-postgres 3.0.4__tar.gz → 3.1.0a1__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 (26) hide show
  1. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/.gitignore +1 -0
  2. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/Makefile +4 -1
  3. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/PKG-INFO +9 -4
  4. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/README.md +5 -0
  5. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/checkpoint/postgres/__init__.py +50 -2
  6. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/checkpoint/postgres/aio.py +48 -2
  7. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/checkpoint/postgres/base.py +137 -1
  8. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/store/postgres/aio.py +8 -7
  9. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/pyproject.toml +4 -4
  10. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/tests/test_async.py +46 -2
  11. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/tests/test_async_store.py +54 -0
  12. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/uv.lock +421 -278
  13. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/LICENSE +0 -0
  14. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/checkpoint/postgres/_ainternal.py +0 -0
  15. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/checkpoint/postgres/_internal.py +0 -0
  16. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/checkpoint/postgres/py.typed +0 -0
  17. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/checkpoint/postgres/shallow.py +0 -0
  18. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/store/postgres/__init__.py +0 -0
  19. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/store/postgres/base.py +0 -0
  20. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/store/postgres/py.typed +0 -0
  21. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/tests/__init__.py +0 -0
  22. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/tests/compose-postgres.yml +0 -0
  23. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/tests/conftest.py +0 -0
  24. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/tests/embed_test_utils.py +0 -0
  25. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/tests/test_store.py +0 -0
  26. {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/tests/test_sync.py +0 -0
@@ -100,3 +100,4 @@ dmypy.json
100
100
  .turbo
101
101
  .editorconfig
102
102
  .scratch
103
+ .worktrees/
@@ -1,4 +1,4 @@
1
- .PHONY: test test_watch lint format
1
+ .PHONY: test test_watch lint type format
2
2
 
3
3
  ######################
4
4
  # TESTING AND COVERAGE
@@ -61,6 +61,9 @@ lint lint_diff lint_package lint_tests:
61
61
  [ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE)
62
62
  [ "$(PYTHON_FILES)" = "" ] || uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
63
63
 
64
+ type:
65
+ mkdir -p $(MYPY_CACHE) && uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
66
+
64
67
  format format_diff:
65
68
  uv run ruff format $(PYTHON_FILES)
66
69
  uv run ruff check --select I --fix $(PYTHON_FILES)
@@ -1,16 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langgraph-checkpoint-postgres
3
- Version: 3.0.4
3
+ Version: 3.1.0a1
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
- Project-URL: Twitter, https://x.com/LangChain
6
+ Project-URL: Twitter, https://x.com/langchain_oss
7
7
  Project-URL: Slack, https://www.langchain.com/join-community
8
8
  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,>=2.1.2
13
- Requires-Dist: orjson>=3.10.1
12
+ Requires-Dist: langgraph-checkpoint<5.0.0,>=4.1.0a1
13
+ Requires-Dist: orjson>=3.11.5
14
14
  Requires-Dist: psycopg-pool>=3.2.0
15
15
  Requires-Dist: psycopg>=3.2.0
16
16
  Description-Content-Type: text/markdown
@@ -23,6 +23,11 @@ Implementation of LangGraph CheckpointSaver that uses Postgres.
23
23
 
24
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]`).
25
25
 
26
+ ## Security
27
+
28
+ > [!IMPORTANT]
29
+ > Set `LANGGRAPH_STRICT_MSGPACK=true` or pass an explicit `allowed_msgpack_modules` list when creating your checkpointer. This restricts checkpoint deserialization to known-safe types, preventing code execution if the database is compromised. See the [langgraph-checkpoint README](https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint#serde) for details.
30
+
26
31
  ## Usage
27
32
 
28
33
  > [!IMPORTANT]
@@ -6,6 +6,11 @@ Implementation of LangGraph CheckpointSaver that uses Postgres.
6
6
 
7
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]`).
8
8
 
9
+ ## Security
10
+
11
+ > [!IMPORTANT]
12
+ > Set `LANGGRAPH_STRICT_MSGPACK=true` or pass an explicit `allowed_msgpack_modules` list when creating your checkpointer. This restricts checkpoint deserialization to known-safe types, preventing code execution if the database is compromised. See the [langgraph-checkpoint README](https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint#serde) for details.
13
+
9
14
  ## Usage
10
15
 
11
16
  > [!IMPORTANT]
@@ -4,15 +4,17 @@ import threading
4
4
  from collections import defaultdict
5
5
  from collections.abc import Iterator, Sequence
6
6
  from contextlib import contextmanager
7
- from typing import Any
7
+ from typing import Any, cast
8
8
 
9
9
  from langchain_core.runnables import RunnableConfig
10
10
  from langgraph.checkpoint.base import (
11
+ DELTA_SENTINEL,
11
12
  WRITES_IDX_MAP,
12
13
  ChannelVersions,
13
14
  Checkpoint,
14
15
  CheckpointMetadata,
15
16
  CheckpointTuple,
17
+ _ChannelWritesHistory,
16
18
  get_checkpoint_id,
17
19
  get_serializable_checkpoint_metadata,
18
20
  )
@@ -23,7 +25,11 @@ from psycopg.types.json import Jsonb
23
25
  from psycopg_pool import ConnectionPool
24
26
 
25
27
  from langgraph.checkpoint.postgres import _internal
26
- from langgraph.checkpoint.postgres.base import BasePostgresSaver
28
+ from langgraph.checkpoint.postgres.base import (
29
+ SELECT_DELTA_COMBINED_SQL,
30
+ BasePostgresSaver,
31
+ _DeltaCombinedRow,
32
+ )
27
33
  from langgraph.checkpoint.postgres.shallow import ShallowPostgresSaver
28
34
 
29
35
  Conn = _internal.Conn # For backward compatibility
@@ -430,6 +436,48 @@ class PostgresSaver(BasePostgresSaver):
430
436
  with conn.cursor(binary=True, row_factory=dict_row) as cur:
431
437
  yield cur
432
438
 
439
+ def _get_channel_writes_history(
440
+ self, config: RunnableConfig, channel: str
441
+ ) -> _ChannelWritesHistory:
442
+ """Fast-path override of `BaseCheckpointSaver._get_channel_writes_history`.
443
+
444
+ One combined UNION ALL query (`SELECT_DELTA_COMBINED_SQL`) fetches rows
445
+ from `checkpoints`, `checkpoint_writes`, and `checkpoint_blobs` in a
446
+ single roundtrip; the ancestor walk runs in Python.
447
+ """
448
+ thread_id = config["configurable"]["thread_id"]
449
+ checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
450
+ checkpoint_id = get_checkpoint_id(config)
451
+ if checkpoint_id is None:
452
+ # Caller didn't specify a target — resolve to the latest
453
+ # checkpoint on the thread. `get_tuple` without `checkpoint_id`
454
+ # returns the newest; its config carries the resolved id.
455
+ target = self.get_tuple(config)
456
+ if target is None:
457
+ return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
458
+ checkpoint_id = target.config["configurable"]["checkpoint_id"]
459
+ with self._cursor() as cur:
460
+ cur.execute(
461
+ SELECT_DELTA_COMBINED_SQL,
462
+ (
463
+ channel,
464
+ thread_id,
465
+ checkpoint_ns,
466
+ thread_id,
467
+ checkpoint_ns,
468
+ channel,
469
+ thread_id,
470
+ checkpoint_ns,
471
+ channel,
472
+ ),
473
+ )
474
+ rows = cur.fetchall()
475
+ return self._build_delta_channel_writes_history(
476
+ channel=channel,
477
+ target_id=checkpoint_id,
478
+ rows=cast("list[_DeltaCombinedRow]", rows),
479
+ )
480
+
433
481
  def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
434
482
  """
435
483
  Convert a database row into a CheckpointTuple object.
@@ -4,15 +4,17 @@ import asyncio
4
4
  from collections import defaultdict
5
5
  from collections.abc import AsyncIterator, Iterator, Sequence
6
6
  from contextlib import asynccontextmanager
7
- from typing import Any
7
+ from typing import Any, cast
8
8
 
9
9
  from langchain_core.runnables import RunnableConfig
10
10
  from langgraph.checkpoint.base import (
11
+ DELTA_SENTINEL,
11
12
  WRITES_IDX_MAP,
12
13
  ChannelVersions,
13
14
  Checkpoint,
14
15
  CheckpointMetadata,
15
16
  CheckpointTuple,
17
+ _ChannelWritesHistory,
16
18
  get_checkpoint_id,
17
19
  get_serializable_checkpoint_metadata,
18
20
  )
@@ -23,7 +25,11 @@ from psycopg.types.json import Jsonb
23
25
  from psycopg_pool import AsyncConnectionPool
24
26
 
25
27
  from langgraph.checkpoint.postgres import _ainternal
26
- from langgraph.checkpoint.postgres.base import BasePostgresSaver
28
+ from langgraph.checkpoint.postgres.base import (
29
+ SELECT_DELTA_COMBINED_SQL,
30
+ BasePostgresSaver,
31
+ _DeltaCombinedRow,
32
+ )
27
33
  from langgraph.checkpoint.postgres.shallow import AsyncShallowPostgresSaver
28
34
 
29
35
  Conn = _ainternal.Conn # For backward compatibility
@@ -391,6 +397,46 @@ class AsyncPostgresSaver(BasePostgresSaver):
391
397
  async with conn.cursor(binary=True, row_factory=dict_row) as cur:
392
398
  yield cur
393
399
 
400
+ async def _aget_channel_writes_history(
401
+ self, config: RunnableConfig, channel: str
402
+ ) -> _ChannelWritesHistory:
403
+ """Fast-path override of `BaseCheckpointSaver._aget_channel_writes_history`.
404
+
405
+ One combined UNION ALL query (`SELECT_DELTA_COMBINED_SQL`) fetches rows
406
+ from `checkpoints`, `checkpoint_writes`, and `checkpoint_blobs` in a
407
+ single roundtrip; rows are assembled by the shared pure helper on
408
+ `BasePostgresSaver`.
409
+ """
410
+ thread_id = config["configurable"]["thread_id"]
411
+ checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
412
+ checkpoint_id = get_checkpoint_id(config)
413
+ if checkpoint_id is None:
414
+ target = await self.aget_tuple(config)
415
+ if target is None:
416
+ return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
417
+ checkpoint_id = target.config["configurable"]["checkpoint_id"]
418
+ async with self._cursor() as cur:
419
+ await cur.execute(
420
+ SELECT_DELTA_COMBINED_SQL,
421
+ (
422
+ channel,
423
+ thread_id,
424
+ checkpoint_ns,
425
+ thread_id,
426
+ checkpoint_ns,
427
+ channel,
428
+ thread_id,
429
+ checkpoint_ns,
430
+ channel,
431
+ ),
432
+ )
433
+ rows = await cur.fetchall()
434
+ return self._build_delta_channel_writes_history(
435
+ channel=channel,
436
+ target_id=checkpoint_id,
437
+ rows=cast("list[_DeltaCombinedRow]", rows),
438
+ )
439
+
394
440
  async def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
395
441
  """
396
442
  Convert a database row into a CheckpointTuple object.
@@ -4,13 +4,16 @@ import random
4
4
  import warnings
5
5
  from collections.abc import Sequence
6
6
  from importlib.metadata import version as get_version
7
- from typing import Any, cast
7
+ from typing import Any, TypedDict, cast
8
8
 
9
9
  from langchain_core.runnables import RunnableConfig
10
10
  from langgraph.checkpoint.base import (
11
+ DELTA_SENTINEL,
11
12
  WRITES_IDX_MAP,
12
13
  BaseCheckpointSaver,
13
14
  ChannelVersions,
15
+ PendingWrite,
16
+ _ChannelWritesHistory,
14
17
  get_checkpoint_id,
15
18
  )
16
19
  from langgraph.checkpoint.serde.types import TASKS
@@ -153,6 +156,62 @@ INSERT_CHECKPOINT_WRITES_SQL = """
153
156
  """
154
157
 
155
158
 
159
+ class _DeltaCombinedRow(TypedDict, total=False):
160
+ """One row from `SELECT_DELTA_COMBINED_SQL` (a UNION ALL of three tables).
161
+
162
+ Every row carries `_kind` ("p" / "w" / "b") plus whichever columns are
163
+ relevant for that kind; irrelevant columns are NULL and typed as `None`.
164
+ """
165
+
166
+ _kind: str # always present: "p", "w", or "b"
167
+ # checkpoint row ("p")
168
+ checkpoint_id: str | None
169
+ parent_checkpoint_id: str | None
170
+ ver: str | None
171
+ # write / blob rows ("w", "b")
172
+ type: str | None
173
+ blob: bytes | None
174
+ # write row only ("w")
175
+ task_id: str | None
176
+ idx: int | None
177
+ # blob row only ("b")
178
+ version: str | None
179
+
180
+
181
+ # DeltaChannel reconstruction: one UNION ALL query fetches checkpoints,
182
+ # writes, and blobs for `channel` in one roundtrip; the ancestor walk runs
183
+ # in Python in `_build_delta_channel_writes_history`.
184
+ #
185
+ # Parameter order: (channel, thread_id, checkpoint_ns,
186
+ # thread_id, checkpoint_ns, channel,
187
+ # thread_id, checkpoint_ns, channel)
188
+ SELECT_DELTA_COMBINED_SQL = """
189
+ SELECT 'p'::text AS _kind,
190
+ checkpoint_id,
191
+ parent_checkpoint_id,
192
+ checkpoint -> 'channel_versions' ->> %s AS ver,
193
+ NULL::text AS type,
194
+ NULL::bytea AS blob,
195
+ NULL::text AS task_id,
196
+ NULL::int AS idx,
197
+ NULL::text AS version
198
+ FROM checkpoints
199
+ WHERE thread_id = %s AND checkpoint_ns = %s
200
+ UNION ALL
201
+ SELECT 'w',
202
+ checkpoint_id, NULL, NULL,
203
+ type, blob, task_id, idx, NULL
204
+ FROM checkpoint_writes
205
+ WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s
206
+ UNION ALL
207
+ SELECT 'b',
208
+ NULL, NULL, NULL,
209
+ type, blob, NULL, NULL, version
210
+ FROM checkpoint_blobs
211
+ WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s
212
+ """
213
+
214
+
156
215
  class BasePostgresSaver(BaseCheckpointSaver[str]):
157
216
  SELECT_SQL = SELECT_SQL
158
217
  SELECT_PENDING_SENDS_SQL = SELECT_PENDING_SENDS_SQL
@@ -195,6 +254,83 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
195
254
  if t.decode() != "empty"
196
255
  }
197
256
 
257
+ def _build_delta_channel_writes_history(
258
+ self,
259
+ *,
260
+ channel: str,
261
+ target_id: str,
262
+ rows: Sequence[_DeltaCombinedRow],
263
+ ) -> _ChannelWritesHistory:
264
+ """Reconstruct one delta channel's history from the combined UNION ALL rows.
265
+
266
+ Pure data transform shared by sync (`PostgresSaver`) and async
267
+ (`AsyncPostgresSaver`); both paths run `SELECT_DELTA_COMBINED_SQL`
268
+ and feed the tagged rows here.
269
+
270
+ Walk is newest → oldest from the target's parent. A non-sentinel
271
+ blob in `checkpoint_blobs` (a pre-delta snapshot) terminates the
272
+ walk and is returned as the seed so replay starts from it.
273
+
274
+ Writes stored at `target_id` itself are pending writes for the next
275
+ step and are excluded — the walk begins at the target's parent.
276
+ """
277
+ parent_of: dict[str, str | None] = {}
278
+ ver_of: dict[str, str | None] = {}
279
+ writes_by_cid: dict[str, list[tuple[str, bytes, str, int]]] = {}
280
+ blob_by_ver: dict[str, tuple[str, bytes]] = {}
281
+
282
+ for r in rows:
283
+ kind = r["_kind"]
284
+ if kind == "p":
285
+ cid = cast(str, r["checkpoint_id"])
286
+ parent_of[cid] = r["parent_checkpoint_id"]
287
+ ver_of[cid] = r["ver"]
288
+ elif kind == "w":
289
+ cid = cast(str, r["checkpoint_id"])
290
+ writes_by_cid.setdefault(cid, []).append(
291
+ cast(
292
+ "tuple[str, bytes, str, int]",
293
+ (r["type"], r["blob"], r["task_id"], r["idx"]),
294
+ )
295
+ )
296
+ else: # kind == "b"
297
+ blob_by_ver[cast(str, r["version"])] = cast(
298
+ "tuple[str, bytes]", (r["type"], r["blob"])
299
+ )
300
+
301
+ # newest write first per ancestor (task_id DESC, idx DESC)
302
+ for ws in writes_by_cid.values():
303
+ ws.sort(key=lambda w: (w[2], w[3]), reverse=True)
304
+
305
+ ancestors: list[str] = []
306
+ cur_cid: str | None = parent_of.get(target_id)
307
+ while cur_cid is not None:
308
+ ancestors.append(cur_cid)
309
+ cur_cid = parent_of.get(cur_cid)
310
+ if not ancestors:
311
+ return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
312
+
313
+ collected: list[PendingWrite] = [] # newest first; reversed at the end
314
+ for cid in ancestors:
315
+ # Collect writes first — they encode the transition FROM this
316
+ # ancestor's state to its child's and must be included even if
317
+ # this ancestor is also the seed checkpoint.
318
+ for type_tag, write_blob, task_id, _idx in writes_by_cid.get(cid, []):
319
+ val = self.serde.loads_typed((type_tag, write_blob))
320
+ collected.append((task_id, channel, val))
321
+ # Then check seed terminator.
322
+ ver = ver_of.get(cid)
323
+ if ver is not None:
324
+ seed_blob = blob_by_ver.get(ver)
325
+ if seed_blob is not None and seed_blob[0] != "empty":
326
+ blob_value = self.serde.loads_typed(seed_blob)
327
+ if blob_value is not DELTA_SENTINEL:
328
+ collected.reverse()
329
+ return _ChannelWritesHistory(seed=blob_value, writes=collected)
330
+
331
+ collected.reverse() # oldest → newest
332
+ return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=collected)
333
+
198
334
  def _dump_blobs(
199
335
  self,
200
336
  thread_id: str,
@@ -164,12 +164,11 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
164
164
  grouped_ops, num_ops = _group_ops(ops)
165
165
  results: list[Result] = [None] * num_ops
166
166
 
167
- async with _ainternal.get_connection(self.conn) as conn:
168
- if self.pipe:
169
- async with self.pipe:
170
- await self._execute_batch(grouped_ops, results, conn)
171
- else:
172
- await self._execute_batch(grouped_ops, results, conn)
167
+ if self.pipe:
168
+ async with self.pipe:
169
+ await self._execute_batch(grouped_ops, results)
170
+ else:
171
+ await self._execute_batch(grouped_ops, results)
173
172
 
174
173
  return results
175
174
 
@@ -410,8 +409,10 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
410
409
  self,
411
410
  grouped_ops: dict,
412
411
  results: list[Result],
413
- conn: AsyncConnection[DictRow],
412
+ conn: AsyncConnection[DictRow] | None = None,
414
413
  ) -> None:
414
+ # Keep `conn` for compatibility with subclasses overriding this private hook.
415
+ # All database I/O goes through `_cursor()`, which owns connection acquisition.
415
416
  async with self._cursor(pipeline=True) as cur:
416
417
  if GetOp in grouped_ops:
417
418
  await self._batch_get_ops(
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "langgraph-checkpoint-postgres"
7
- version = "3.0.4"
7
+ version = "3.1.0a1"
8
8
  description = "Library with a Postgres implementation of LangGraph checkpoint saver."
9
9
  authors = []
10
10
  requires-python = ">=3.10"
@@ -12,15 +12,15 @@ readme = "README.md"
12
12
  license = "MIT"
13
13
  license-files = ['LICENSE']
14
14
  dependencies = [
15
- "langgraph-checkpoint>=2.1.2,<5.0.0",
16
- "orjson>=3.10.1",
15
+ "langgraph-checkpoint>=4.1.0a1,<5.0.0",
16
+ "orjson>=3.11.5",
17
17
  "psycopg>=3.2.0",
18
18
  "psycopg-pool>=3.2.0",
19
19
  ]
20
20
 
21
21
  [project.urls]
22
22
  Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres"
23
- Twitter = "https://x.com/LangChain"
23
+ Twitter = "https://x.com/langchain_oss"
24
24
  Slack = "https://www.langchain.com/join-community"
25
25
  Reddit = "https://www.reddit.com/r/LangChain/"
26
26
 
@@ -361,9 +361,9 @@ async def test_get_checkpoint_no_channel_values(
361
361
 
362
362
  load_checkpoint_tuple = saver._load_checkpoint_tuple
363
363
 
364
- def patched_load_checkpoint_tuple(value):
364
+ async def patched_load_checkpoint_tuple(value):
365
365
  value["checkpoint"].pop("channel_values", None)
366
- return load_checkpoint_tuple(value)
366
+ return await load_checkpoint_tuple(value)
367
367
 
368
368
  monkeypatch.setattr(
369
369
  saver, "_load_checkpoint_tuple", patched_load_checkpoint_tuple
@@ -371,3 +371,47 @@ async def test_get_checkpoint_no_channel_values(
371
371
 
372
372
  checkpoint = await saver.aget_tuple(config)
373
373
  assert checkpoint.checkpoint["channel_values"] == {}
374
+
375
+
376
+ @pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
377
+ async def test_delta_channel_chain_reconstruction(saver_name: str) -> None:
378
+ """AsyncPostgresSaver reconstructs DeltaChannel chain via point-lookup traversal."""
379
+ pytest.importorskip(
380
+ "langgraph.channels.delta", reason="langgraph core not installed"
381
+ )
382
+
383
+ from typing import Annotated
384
+
385
+ from langchain_core.messages import AIMessage, HumanMessage
386
+ from langgraph.channels.delta import DeltaChannel
387
+ from langgraph.graph import START, StateGraph
388
+ from langgraph.graph.message import _messages_delta_reducer
389
+ from typing_extensions import TypedDict
390
+
391
+ class State(TypedDict):
392
+ messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
393
+
394
+ def respond(state: State) -> dict:
395
+ n = len(state["messages"])
396
+ return {"messages": [AIMessage(content=f"reply-{n}", id=f"ai-{n}")]}
397
+
398
+ builder = StateGraph(State)
399
+ builder.add_node("respond", respond)
400
+ builder.add_edge(START, "respond")
401
+
402
+ async with _saver(saver_name) as saver:
403
+ graph = builder.compile(checkpointer=saver)
404
+ config = {"configurable": {"thread_id": "diff-channel-test-1"}}
405
+
406
+ await graph.ainvoke({"messages": [HumanMessage(content="hi", id="h1")]}, config)
407
+ await graph.ainvoke(
408
+ {"messages": [HumanMessage(content="there", id="h2")]}, config
409
+ )
410
+
411
+ state = await graph.aget_state(config)
412
+ msgs = state.values["messages"]
413
+ assert len(msgs) == 4, f"expected 4, got {len(msgs)}: {msgs}"
414
+ assert msgs[0].content == "hi"
415
+ assert msgs[1].content == "reply-1"
416
+ assert msgs[2].content == "there"
417
+ assert msgs[3].content == "reply-3"
@@ -20,6 +20,7 @@ from langgraph.store.base import (
20
20
  )
21
21
  from psycopg import AsyncConnection
22
22
 
23
+ from langgraph.checkpoint.postgres import _ainternal
23
24
  from langgraph.store.postgres import AsyncPostgresStore
24
25
  from tests.conftest import (
25
26
  DEFAULT_URI,
@@ -346,6 +347,59 @@ async def test_batch_list_namespaces_ops(store: AsyncPostgresStore) -> None:
346
347
  assert ("test", "namespace2") in results[0]
347
348
 
348
349
 
350
+ @asynccontextmanager
351
+ async def _create_pool_store() -> AsyncIterator[AsyncPostgresStore]:
352
+ database = f"test_{uuid.uuid4().hex[:16]}"
353
+ uri_parts = DEFAULT_URI.split("/")
354
+ uri_base = "/".join(uri_parts[:-1])
355
+ query_params = ""
356
+ if "?" in uri_parts[-1]:
357
+ _, query_params = uri_parts[-1].split("?", 1)
358
+ query_params = "?" + query_params
359
+
360
+ conn_string = f"{uri_base}/{database}{query_params}"
361
+ admin_conn_string = DEFAULT_URI
362
+ async with await AsyncConnection.connect(
363
+ admin_conn_string, autocommit=True
364
+ ) as conn:
365
+ await conn.execute(f"CREATE DATABASE {database}")
366
+ try:
367
+ async with AsyncPostgresStore.from_conn_string(
368
+ conn_string, pool_config={"min_size": 1, "max_size": 1}
369
+ ) as store:
370
+ await store.setup()
371
+ yield store
372
+ finally:
373
+ async with await AsyncConnection.connect(
374
+ admin_conn_string, autocommit=True
375
+ ) as conn:
376
+ await conn.execute(f"DROP DATABASE {database}")
377
+
378
+
379
+ async def test_abatch_uses_single_pool_checkout(monkeypatch) -> None:
380
+ async with _create_pool_store() as store:
381
+ await store.aput(("test",), "key1", {"data": "value1"})
382
+
383
+ original_get_connection = _ainternal.get_connection
384
+ checkout_count = 0
385
+
386
+ @asynccontextmanager
387
+ async def counting_get_connection(conn):
388
+ nonlocal checkout_count
389
+ checkout_count += 1
390
+ async with original_get_connection(conn) as checked_out_conn:
391
+ yield checked_out_conn
392
+
393
+ monkeypatch.setattr(_ainternal, "get_connection", counting_get_connection)
394
+
395
+ results = await store.abatch([GetOp(namespace=("test",), key="key1")])
396
+
397
+ assert len(results) == 1
398
+ assert results[0] is not None
399
+ assert results[0].value == {"data": "value1"}
400
+ assert checkout_count == 1
401
+
402
+
349
403
  @asynccontextmanager
350
404
  async def _create_vector_store(
351
405
  vector_type: str,