langgraph-checkpoint-postgres 3.0.5__tar.gz → 3.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/.gitignore +1 -0
  2. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/PKG-INFO +8 -3
  3. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/README.md +5 -0
  4. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/langgraph/checkpoint/postgres/__init__.py +123 -4
  5. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/langgraph/checkpoint/postgres/aio.py +106 -4
  6. langgraph_checkpoint_postgres-3.1.0/langgraph/checkpoint/postgres/base.py +596 -0
  7. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/pyproject.toml +3 -3
  8. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/tests/test_async.py +46 -2
  9. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/uv.lock +377 -217
  10. langgraph_checkpoint_postgres-3.0.5/langgraph/checkpoint/postgres/base.py +0 -315
  11. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/LICENSE +0 -0
  12. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/Makefile +0 -0
  13. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/langgraph/checkpoint/postgres/_ainternal.py +0 -0
  14. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/langgraph/checkpoint/postgres/_internal.py +0 -0
  15. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/langgraph/checkpoint/postgres/py.typed +0 -0
  16. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/langgraph/checkpoint/postgres/shallow.py +0 -0
  17. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/langgraph/store/postgres/__init__.py +0 -0
  18. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/langgraph/store/postgres/aio.py +0 -0
  19. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/langgraph/store/postgres/base.py +0 -0
  20. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/langgraph/store/postgres/py.typed +0 -0
  21. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/tests/__init__.py +0 -0
  22. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/tests/compose-postgres.yml +0 -0
  23. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/tests/conftest.py +0 -0
  24. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/tests/embed_test_utils.py +0 -0
  25. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/tests/test_async_store.py +0 -0
  26. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/tests/test_store.py +0 -0
  27. {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0}/tests/test_sync.py +0 -0
@@ -100,3 +100,4 @@ dmypy.json
100
100
  .turbo
101
101
  .editorconfig
102
102
  .scratch
103
+ .worktrees/
@@ -1,15 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langgraph-checkpoint-postgres
3
- Version: 3.0.5
3
+ Version: 3.1.0
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
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
@@ -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]
@@ -2,9 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  import threading
4
4
  from collections import defaultdict
5
- from collections.abc import Iterator, Sequence
5
+ from collections.abc import Iterator, Mapping, 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 (
@@ -13,17 +13,25 @@ from langgraph.checkpoint.base import (
13
13
  Checkpoint,
14
14
  CheckpointMetadata,
15
15
  CheckpointTuple,
16
+ DeltaChannelHistory,
16
17
  get_checkpoint_id,
17
18
  get_serializable_checkpoint_metadata,
18
19
  )
19
20
  from langgraph.checkpoint.serde.base import SerializerProtocol
21
+ from langgraph.checkpoint.serde.types import _DeltaSnapshot
20
22
  from psycopg import Capabilities, Connection, Cursor, Pipeline
21
23
  from psycopg.rows import DictRow, dict_row
22
24
  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
+ _DELTA_PAGE_SIZE,
30
+ BasePostgresSaver,
31
+ _build_delta_stage1_sql,
32
+ _build_delta_stage2_sql,
33
+ _DeltaStage2Row,
34
+ )
27
35
  from langgraph.checkpoint.postgres.shallow import ShallowPostgresSaver
28
36
 
29
37
  Conn = _internal.Conn # For backward compatibility
@@ -302,7 +310,10 @@ class PostgresSaver(BasePostgresSaver):
302
310
  # others are stored in blobs table
303
311
  blob_values = {}
304
312
  for k, v in checkpoint["channel_values"].items():
305
- if v is None or isinstance(v, (str, int, float, bool)):
313
+ if isinstance(v, _DeltaSnapshot):
314
+ blob_values[k] = copy["channel_values"].pop(k)
315
+ copy["channel_values"][k] = True
316
+ elif v is None or isinstance(v, (str, int, float, bool)):
306
317
  pass
307
318
  else:
308
319
  blob_values[k] = copy["channel_values"].pop(k)
@@ -430,6 +441,114 @@ class PostgresSaver(BasePostgresSaver):
430
441
  with conn.cursor(binary=True, row_factory=dict_row) as cur:
431
442
  yield cur
432
443
 
444
+ def get_delta_channel_history(
445
+ self, *, config: RunnableConfig, channels: Sequence[str]
446
+ ) -> Mapping[str, DeltaChannelHistory]:
447
+ """Fast-path override of `BaseCheckpointSaver.get_delta_channel_history`.
448
+
449
+ Two-stage query, both stages cover ALL requested channels:
450
+
451
+ * Stage 1 (paged): dynamic SELECT over `checkpoints` with K parallel
452
+ JSONB key lookups (one column pair per channel) — no subquery, no
453
+ aggregation. Pages newest-first by `checkpoint_id` with a cursor;
454
+ page size is `_DELTA_PAGE_SIZE`. Stops paging when every channel
455
+ has found its seed or the chain is exhausted.
456
+
457
+ * Stage 2 (per-channel UNION ALL): one branch per channel reading
458
+ `checkpoint_writes` filtered to that channel's specific
459
+ `chain_cids`, plus one branch per channel that has a seed reading
460
+ `checkpoint_blobs` for that channel + version. Avoids the
461
+ over-fetch of a single `channel = ANY(channels)` filter when
462
+ channels have different chain depths.
463
+ """
464
+ if not channels:
465
+ return {}
466
+ channels = list(channels)
467
+ thread_id = config["configurable"]["thread_id"]
468
+ checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
469
+ checkpoint_id = get_checkpoint_id(config)
470
+ if checkpoint_id is None:
471
+ target = self.get_tuple(config)
472
+ if target is None:
473
+ return {ch: {"writes": []} for ch in channels}
474
+ checkpoint_id = target.config["configurable"]["checkpoint_id"]
475
+
476
+ # Stage 1: paged K-JSONB-lookup scan, walking the parent chain in
477
+ # Python after each page. Stops as soon as every channel has its seed.
478
+ stage1_sql = _build_delta_stage1_sql(channels, paged=True)
479
+ parent_of: dict[str, str | None] = {}
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]
482
+ chain_by_ch: dict[str, list[str]] = {ch: [] for ch in channels}
483
+ seed_ver_by_ch: dict[str, str | None] = {ch: None for ch in channels}
484
+ walk_cursor_by_ch: dict[str, str | None] = {}
485
+ seeded: set[str] = set()
486
+ cursor: str | None = None
487
+
488
+ with self._cursor() as cur:
489
+ while True:
490
+ stage1_params: list[Any] = []
491
+ for ch in channels:
492
+ stage1_params.extend([ch, ch])
493
+ stage1_params.extend(
494
+ [thread_id, checkpoint_ns, cursor, cursor, _DELTA_PAGE_SIZE]
495
+ )
496
+ cur.execute(stage1_sql, stage1_params)
497
+ page = cur.fetchall()
498
+ if not page:
499
+ break
500
+ oldest = self._ingest_stage1_page(
501
+ cast("list[Mapping[str, Any]]", page),
502
+ channels,
503
+ parent_of,
504
+ ver_by_i_by_cid,
505
+ hs_by_i_by_cid,
506
+ )
507
+ self._try_advance_walks(
508
+ checkpoint_id,
509
+ channels,
510
+ parent_of,
511
+ ver_by_i_by_cid,
512
+ hs_by_i_by_cid,
513
+ chain_by_ch,
514
+ seed_ver_by_ch,
515
+ walk_cursor_by_ch,
516
+ seeded,
517
+ )
518
+ # Stop if every channel is seeded, or the page was short
519
+ # (chain exhausted — no more rows to fetch).
520
+ if len(seeded) == len(channels) or len(page) < _DELTA_PAGE_SIZE:
521
+ break
522
+ cursor = oldest
523
+
524
+ # Stage 2: per-channel UNION ALL — one writes branch per channel
525
+ # with non-empty chain, plus one blob branch per seeded channel.
526
+ channels_with_chain = [ch for ch in channels if chain_by_ch[ch]]
527
+ channels_with_seed = [ch for ch in channels if seed_ver_by_ch[ch] is not None]
528
+ stage2_sql = _build_delta_stage2_sql(
529
+ channels_with_chain=channels_with_chain,
530
+ channels_with_seed=channels_with_seed,
531
+ )
532
+
533
+ if stage2_sql:
534
+ stage2_params: list[Any] = []
535
+ for ch in channels_with_chain:
536
+ stage2_params.extend([thread_id, checkpoint_ns, ch, chain_by_ch[ch]])
537
+ for ch in channels_with_seed:
538
+ stage2_params.extend([thread_id, checkpoint_ns, ch, seed_ver_by_ch[ch]])
539
+ with self._cursor() as cur:
540
+ cur.execute(stage2_sql, stage2_params)
541
+ stage2_rows = cur.fetchall()
542
+ else:
543
+ stage2_rows = []
544
+
545
+ return self._build_delta_channels_writes_history(
546
+ channels=channels,
547
+ chain_by_ch=chain_by_ch,
548
+ seed_ver_by_ch=seed_ver_by_ch,
549
+ stage2_rows=cast("list[_DeltaStage2Row]", stage2_rows),
550
+ )
551
+
433
552
  def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
434
553
  """
435
554
  Convert a database row into a CheckpointTuple object.
@@ -2,9 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  from collections import defaultdict
5
- from collections.abc import AsyncIterator, Iterator, Sequence
5
+ from collections.abc import AsyncIterator, Iterator, Mapping, 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 (
@@ -13,17 +13,25 @@ from langgraph.checkpoint.base import (
13
13
  Checkpoint,
14
14
  CheckpointMetadata,
15
15
  CheckpointTuple,
16
+ DeltaChannelHistory,
16
17
  get_checkpoint_id,
17
18
  get_serializable_checkpoint_metadata,
18
19
  )
19
20
  from langgraph.checkpoint.serde.base import SerializerProtocol
21
+ from langgraph.checkpoint.serde.types import _DeltaSnapshot
20
22
  from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
21
23
  from psycopg.rows import DictRow, dict_row
22
24
  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
+ _DELTA_PAGE_SIZE,
30
+ BasePostgresSaver,
31
+ _build_delta_stage1_sql,
32
+ _build_delta_stage2_sql,
33
+ _DeltaStage2Row,
34
+ )
27
35
  from langgraph.checkpoint.postgres.shallow import AsyncShallowPostgresSaver
28
36
 
29
37
  Conn = _ainternal.Conn # For backward compatibility
@@ -261,7 +269,10 @@ class AsyncPostgresSaver(BasePostgresSaver):
261
269
  # others are stored in blobs table
262
270
  blob_values = {}
263
271
  for k, v in checkpoint["channel_values"].items():
264
- if v is None or isinstance(v, (str, int, float, bool)):
272
+ if isinstance(v, _DeltaSnapshot):
273
+ blob_values[k] = copy["channel_values"].pop(k)
274
+ copy["channel_values"][k] = True
275
+ elif v is None or isinstance(v, (str, int, float, bool)):
265
276
  pass
266
277
  else:
267
278
  blob_values[k] = copy["channel_values"].pop(k)
@@ -391,6 +402,97 @@ class AsyncPostgresSaver(BasePostgresSaver):
391
402
  async with conn.cursor(binary=True, row_factory=dict_row) as cur:
392
403
  yield cur
393
404
 
405
+ async def aget_delta_channel_history(
406
+ self, *, config: RunnableConfig, channels: Sequence[str]
407
+ ) -> Mapping[str, DeltaChannelHistory]:
408
+ """Fast-path override of `BaseCheckpointSaver.aget_delta_channel_history`.
409
+
410
+ See `PostgresSaver.get_delta_channel_history` for design notes; this is
411
+ the async equivalent with internal stage-1 paging and per-channel
412
+ UNION ALL stage-2.
413
+ """
414
+ if not channels:
415
+ return {}
416
+ channels = list(channels)
417
+ thread_id = config["configurable"]["thread_id"]
418
+ checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
419
+ checkpoint_id = get_checkpoint_id(config)
420
+ if checkpoint_id is None:
421
+ target = await self.aget_tuple(config)
422
+ if target is None:
423
+ return {ch: {"writes": []} for ch in channels}
424
+ checkpoint_id = target.config["configurable"]["checkpoint_id"]
425
+
426
+ stage1_sql = _build_delta_stage1_sql(channels, paged=True)
427
+ parent_of: dict[str, str | None] = {}
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]
430
+ chain_by_ch: dict[str, list[str]] = {ch: [] for ch in channels}
431
+ seed_ver_by_ch: dict[str, str | None] = {ch: None for ch in channels}
432
+ walk_cursor_by_ch: dict[str, str | None] = {}
433
+ seeded: set[str] = set()
434
+ cursor: str | None = None
435
+
436
+ async with self._cursor() as cur:
437
+ while True:
438
+ stage1_params: list[Any] = []
439
+ for ch in channels:
440
+ stage1_params.extend([ch, ch])
441
+ stage1_params.extend(
442
+ [thread_id, checkpoint_ns, cursor, cursor, _DELTA_PAGE_SIZE]
443
+ )
444
+ await cur.execute(stage1_sql, stage1_params)
445
+ page = await cur.fetchall()
446
+ if not page:
447
+ break
448
+ oldest = self._ingest_stage1_page(
449
+ cast("list[Mapping[str, Any]]", page),
450
+ channels,
451
+ parent_of,
452
+ ver_by_i_by_cid,
453
+ hs_by_i_by_cid,
454
+ )
455
+ self._try_advance_walks(
456
+ checkpoint_id,
457
+ channels,
458
+ parent_of,
459
+ ver_by_i_by_cid,
460
+ hs_by_i_by_cid,
461
+ chain_by_ch,
462
+ seed_ver_by_ch,
463
+ walk_cursor_by_ch,
464
+ seeded,
465
+ )
466
+ if len(seeded) == len(channels) or len(page) < _DELTA_PAGE_SIZE:
467
+ break
468
+ cursor = oldest
469
+
470
+ channels_with_chain = [ch for ch in channels if chain_by_ch[ch]]
471
+ channels_with_seed = [ch for ch in channels if seed_ver_by_ch[ch] is not None]
472
+ stage2_sql = _build_delta_stage2_sql(
473
+ channels_with_chain=channels_with_chain,
474
+ channels_with_seed=channels_with_seed,
475
+ )
476
+
477
+ if stage2_sql:
478
+ stage2_params: list[Any] = []
479
+ for ch in channels_with_chain:
480
+ stage2_params.extend([thread_id, checkpoint_ns, ch, chain_by_ch[ch]])
481
+ for ch in channels_with_seed:
482
+ stage2_params.extend([thread_id, checkpoint_ns, ch, seed_ver_by_ch[ch]])
483
+ async with self._cursor() as cur:
484
+ await cur.execute(stage2_sql, stage2_params)
485
+ stage2_rows = await cur.fetchall()
486
+ else:
487
+ stage2_rows = []
488
+
489
+ return self._build_delta_channels_writes_history(
490
+ channels=channels,
491
+ chain_by_ch=chain_by_ch,
492
+ seed_ver_by_ch=seed_ver_by_ch,
493
+ stage2_rows=cast("list[_DeltaStage2Row]", stage2_rows),
494
+ )
495
+
394
496
  async def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
395
497
  """
396
498
  Convert a database row into a CheckpointTuple object.