langgraph-checkpoint-postgres 3.1.2__tar.gz → 3.2.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 (30) hide show
  1. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/.gitignore +6 -0
  2. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/PKG-INFO +3 -3
  3. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/__init__.py +84 -9
  4. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/aio.py +104 -4
  5. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/base.py +280 -67
  6. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/shallow.py +1 -1
  7. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/langgraph/store/postgres/base.py +4 -2
  8. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/pyproject.toml +2 -2
  9. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/tests/test_async_store.py +64 -0
  10. langgraph_checkpoint_postgres-3.2.0/tests/test_delta_pagination.py +211 -0
  11. langgraph_checkpoint_postgres-3.2.0/tests/test_prune.py +540 -0
  12. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/tests/test_store.py +49 -0
  13. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/uv.lock +219 -196
  14. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/LICENSE +0 -0
  15. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/Makefile +0 -0
  16. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/README.md +0 -0
  17. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/_ainternal.py +0 -0
  18. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/_internal.py +0 -0
  19. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/py.typed +0 -0
  20. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/langgraph/store/postgres/__init__.py +0 -0
  21. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/langgraph/store/postgres/aio.py +0 -0
  22. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/langgraph/store/postgres/py.typed +0 -0
  23. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/tests/__init__.py +0 -0
  24. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/tests/compose-postgres.yml +0 -0
  25. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/tests/conftest.py +0 -0
  26. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/tests/embed_test_utils.py +0 -0
  27. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/tests/test_async.py +0 -0
  28. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/tests/test_conformance_delta.py +0 -0
  29. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/tests/test_delta_plain_value_seed.py +0 -0
  30. {langgraph_checkpoint_postgres-3.1.2 → langgraph_checkpoint_postgres-3.2.0}/tests/test_sync.py +0 -0
@@ -76,7 +76,12 @@ __pypackages__/
76
76
 
77
77
  # Environments
78
78
  .env
79
+ .env.*
79
80
  .envrc
81
+ *.crt
82
+ *.key
83
+ *.pem
84
+ credentials.json
80
85
  .venv
81
86
  .venvs
82
87
  env/
@@ -98,6 +103,7 @@ dmypy.json
98
103
 
99
104
  .vercel
100
105
  .turbo
106
+ node_modules/
101
107
  .editorconfig
102
108
  .scratch
103
109
  .worktrees/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: langgraph-checkpoint-postgres
3
- Version: 3.1.2
3
+ Version: 3.2.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
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.0
12
+ Requires-Dist: langgraph-checkpoint<5.0.0,>=4.3.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
@@ -27,10 +27,15 @@ from psycopg_pool import ConnectionPool
27
27
  from langgraph.checkpoint.postgres import _internal
28
28
  from langgraph.checkpoint.postgres.base import (
29
29
  _DELTA_PAGE_SIZE,
30
+ PRUNE_DELETE_THREADS_SQL,
31
+ PRUNE_STRATEGIES,
30
32
  BasePostgresSaver,
31
33
  _build_delta_stage1_sql,
32
34
  _build_delta_stage2_sql,
33
35
  _DeltaStage2Row,
36
+ _execute,
37
+ _keep_latest,
38
+ _refuse_delta_threads,
34
39
  )
35
40
  from langgraph.checkpoint.postgres.shallow import ShallowPostgresSaver
36
41
 
@@ -401,6 +406,73 @@ class PostgresSaver(BasePostgresSaver):
401
406
  (str(thread_id),),
402
407
  )
403
408
 
409
+ def prune(
410
+ self,
411
+ thread_ids: Sequence[str],
412
+ *,
413
+ strategy: str = "keep_latest",
414
+ ) -> None:
415
+ """Prune checkpoints for the given threads.
416
+
417
+ `"delete"` removes every checkpoint, write and blob of the threads, in
418
+ one transaction.
419
+
420
+ `"keep_latest"` keeps the latest checkpoint of each namespace (the one
421
+ `get_tuple` loads, plus its parent when it predates v4 and has pending
422
+ sends), with its pending writes and the blobs it references, and
423
+ deletes the rest. A kept checkpoint whose parent it deletes is left with
424
+ no parent. A subgraph call's namespace is deleted with the root
425
+ checkpoint the call started from, except a `checkpointer=True`
426
+ subgraph's, which keeps its latest checkpoint. Each thread is pruned in
427
+ its own transaction, on one snapshot of it, so a run appending to the
428
+ thread meanwhile is safe; forking or replaying from a checkpoint being
429
+ deleted is not. A thread that fails because another transaction changed
430
+ it leaves the threads before it pruned, and retrying is safe.
431
+
432
+ `"keep_latest"` refuses threads that use a `DeltaChannel`, since it
433
+ rebuilds the latest value from older checkpoints: if any requested
434
+ thread does, nothing is pruned. A thread that starts using one before
435
+ its turn in the batch is refused then, after the threads before it.
436
+
437
+ Args:
438
+ thread_ids: The thread IDs to prune.
439
+ strategy: `"keep_latest"` or `"delete"`.
440
+
441
+ Raises:
442
+ TypeError: If `thread_ids` is a single string.
443
+ ValueError: If `strategy` is unknown.
444
+ NotImplementedError: If `strategy` is `"keep_latest"` and a thread
445
+ uses a `DeltaChannel`.
446
+ psycopg.errors.SerializationFailure: If another transaction, such
447
+ as a second prune, changes a thread's rows while `"keep_latest"`
448
+ prunes it.
449
+ psycopg.errors.DeadlockDetected: If a second prune of a thread runs
450
+ at the same time.
451
+ """
452
+ if isinstance(thread_ids, str):
453
+ raise TypeError("thread_ids must be a sequence of thread IDs, not a str")
454
+ if strategy not in PRUNE_STRATEGIES:
455
+ raise ValueError(f"Unknown prune strategy: {strategy!r}")
456
+ thread_ids = [str(t) for t in thread_ids]
457
+ if not thread_ids:
458
+ return
459
+ if strategy == "delete":
460
+ with self._cursor() as cur, cur.connection.transaction():
461
+ for sql in PRUNE_DELETE_THREADS_SQL:
462
+ cur.execute(sql, (thread_ids,))
463
+ return
464
+ with self._cursor() as cur:
465
+ _execute(cur, _refuse_delta_threads(thread_ids))
466
+ for thread_id in thread_ids:
467
+ with self._cursor() as cur:
468
+ if self.pipe:
469
+ # The transaction has to start with its isolation level, and
470
+ # in pipeline mode it would take in what ran since the last
471
+ # sync.
472
+ self.pipe.sync()
473
+ with cur.connection.transaction():
474
+ _execute(cur, _keep_latest(thread_id))
475
+
404
476
  @contextmanager
405
477
  def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor[DictRow]]:
406
478
  """Create a database cursor as a context manager.
@@ -448,11 +520,13 @@ class PostgresSaver(BasePostgresSaver):
448
520
 
449
521
  Two-stage query, both stages cover ALL requested channels:
450
522
 
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.
523
+ * Stage 1 (paged): dynamic SELECT over `checkpoints` with three
524
+ columns per channel: its version, an `EXISTS` probe for a stored
525
+ blob at that version, and its inline value. Pages newest-first by
526
+ `checkpoint_id`, starting at the target; page size is
527
+ `_DELTA_PAGE_SIZE`.
528
+ Stops paging when every channel has found its seed or a page comes
529
+ back short.
456
530
 
457
531
  * Stage 2 (per-channel UNION ALL): one branch per channel reading
458
532
  `checkpoint_writes` filtered to that channel's specific
@@ -467,7 +541,7 @@ class PostgresSaver(BasePostgresSaver):
467
541
  thread_id = config["configurable"]["thread_id"]
468
542
  checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
469
543
  checkpoint_id = get_checkpoint_id(config)
470
- if checkpoint_id is None:
544
+ if not checkpoint_id:
471
545
  target = self.get_tuple(config)
472
546
  if target is None:
473
547
  return {ch: {"writes": []} for ch in channels}
@@ -475,7 +549,7 @@ class PostgresSaver(BasePostgresSaver):
475
549
 
476
550
  # Stage 1: paged K-JSONB-lookup scan, walking the parent chain in
477
551
  # Python after each page. Stops as soon as every channel has its seed.
478
- stage1_sql = _build_delta_stage1_sql(channels, paged=True)
552
+ stage1_sql = _build_delta_stage1_sql(channels, paged=True, include_cursor=True)
479
553
  parent_of: dict[str, str | None] = {}
480
554
  ver_by_i_by_cid: list[dict[str, str | None]] = [{} for _ in channels]
481
555
  hb_by_i_by_cid: list[dict[str, bool]] = [{} for _ in channels]
@@ -485,7 +559,7 @@ class PostgresSaver(BasePostgresSaver):
485
559
  seed_inline_by_ch: dict[str, Any] = {}
486
560
  walk_cursor_by_ch: dict[str, str | None] = {}
487
561
  seeded: set[str] = set()
488
- cursor: str | None = None
562
+ cursor: str | None = checkpoint_id
489
563
 
490
564
  with self._cursor() as cur:
491
565
  while True:
@@ -494,7 +568,7 @@ class PostgresSaver(BasePostgresSaver):
494
568
  # ver_i, blob channel, blob version, inline_i
495
569
  stage1_params.extend([ch, ch, ch, ch])
496
570
  stage1_params.extend(
497
- [thread_id, checkpoint_ns, cursor, cursor, _DELTA_PAGE_SIZE]
571
+ [thread_id, checkpoint_ns, cursor, _DELTA_PAGE_SIZE]
498
572
  )
499
573
  cur.execute(stage1_sql, stage1_params)
500
574
  page = cur.fetchall()
@@ -526,6 +600,7 @@ class PostgresSaver(BasePostgresSaver):
526
600
  if len(seeded) == len(channels) or len(page) < _DELTA_PAGE_SIZE:
527
601
  break
528
602
  cursor = oldest
603
+ stage1_sql = _build_delta_stage1_sql(channels, paged=True)
529
604
 
530
605
  # Stage 2: per-channel UNION ALL — one writes branch per channel
531
606
  # with non-empty chain, plus one blob branch per seeded channel.
@@ -27,10 +27,15 @@ from psycopg_pool import AsyncConnectionPool
27
27
  from langgraph.checkpoint.postgres import _ainternal
28
28
  from langgraph.checkpoint.postgres.base import (
29
29
  _DELTA_PAGE_SIZE,
30
+ PRUNE_DELETE_THREADS_SQL,
31
+ PRUNE_STRATEGIES,
30
32
  BasePostgresSaver,
33
+ _aexecute,
31
34
  _build_delta_stage1_sql,
32
35
  _build_delta_stage2_sql,
33
36
  _DeltaStage2Row,
37
+ _keep_latest,
38
+ _refuse_delta_threads,
34
39
  )
35
40
  from langgraph.checkpoint.postgres.shallow import AsyncShallowPostgresSaver
36
41
 
@@ -360,6 +365,55 @@ class AsyncPostgresSaver(BasePostgresSaver):
360
365
  (str(thread_id),),
361
366
  )
362
367
 
368
+ async def aprune(
369
+ self,
370
+ thread_ids: Sequence[str],
371
+ *,
372
+ strategy: str = "keep_latest",
373
+ ) -> None:
374
+ """Prune checkpoints for the given threads.
375
+
376
+ See `PostgresSaver.prune` for what each strategy keeps.
377
+
378
+ Args:
379
+ thread_ids: The thread IDs to prune.
380
+ strategy: `"keep_latest"` or `"delete"`.
381
+
382
+ Raises:
383
+ TypeError: If `thread_ids` is a single string.
384
+ ValueError: If `strategy` is unknown.
385
+ NotImplementedError: If `strategy` is `"keep_latest"` and a thread
386
+ uses a `DeltaChannel`.
387
+ psycopg.errors.SerializationFailure: If another transaction, such
388
+ as a second prune, changes a thread's rows while `"keep_latest"`
389
+ prunes it.
390
+ psycopg.errors.DeadlockDetected: If a second prune of a thread runs
391
+ at the same time.
392
+ """
393
+ if isinstance(thread_ids, str):
394
+ raise TypeError("thread_ids must be a sequence of thread IDs, not a str")
395
+ if strategy not in PRUNE_STRATEGIES:
396
+ raise ValueError(f"Unknown prune strategy: {strategy!r}")
397
+ thread_ids = [str(t) for t in thread_ids]
398
+ if not thread_ids:
399
+ return
400
+ if strategy == "delete":
401
+ async with self._cursor() as cur, cur.connection.transaction():
402
+ for sql in PRUNE_DELETE_THREADS_SQL:
403
+ await cur.execute(sql, (thread_ids,))
404
+ return
405
+ async with self._cursor() as cur:
406
+ await _aexecute(cur, _refuse_delta_threads(thread_ids))
407
+ for thread_id in thread_ids:
408
+ async with self._cursor() as cur:
409
+ if self.pipe:
410
+ # The transaction has to start with its isolation level, and
411
+ # in pipeline mode it would take in what ran since the last
412
+ # sync.
413
+ await self.pipe.sync()
414
+ async with cur.connection.transaction():
415
+ await _aexecute(cur, _keep_latest(thread_id))
416
+
363
417
  @asynccontextmanager
364
418
  async def _cursor(
365
419
  self, *, pipeline: bool = False
@@ -417,13 +471,13 @@ class AsyncPostgresSaver(BasePostgresSaver):
417
471
  thread_id = config["configurable"]["thread_id"]
418
472
  checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
419
473
  checkpoint_id = get_checkpoint_id(config)
420
- if checkpoint_id is None:
474
+ if not checkpoint_id:
421
475
  target = await self.aget_tuple(config)
422
476
  if target is None:
423
477
  return {ch: {"writes": []} for ch in channels}
424
478
  checkpoint_id = target.config["configurable"]["checkpoint_id"]
425
479
 
426
- stage1_sql = _build_delta_stage1_sql(channels, paged=True)
480
+ stage1_sql = _build_delta_stage1_sql(channels, paged=True, include_cursor=True)
427
481
  parent_of: dict[str, str | None] = {}
428
482
  ver_by_i_by_cid: list[dict[str, str | None]] = [{} for _ in channels]
429
483
  hb_by_i_by_cid: list[dict[str, bool]] = [{} for _ in channels]
@@ -433,7 +487,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
433
487
  seed_inline_by_ch: dict[str, Any] = {}
434
488
  walk_cursor_by_ch: dict[str, str | None] = {}
435
489
  seeded: set[str] = set()
436
- cursor: str | None = None
490
+ cursor: str | None = checkpoint_id
437
491
 
438
492
  async with self._cursor() as cur:
439
493
  while True:
@@ -442,7 +496,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
442
496
  # ver_i, blob channel, blob version, inline_i
443
497
  stage1_params.extend([ch, ch, ch, ch])
444
498
  stage1_params.extend(
445
- [thread_id, checkpoint_ns, cursor, cursor, _DELTA_PAGE_SIZE]
499
+ [thread_id, checkpoint_ns, cursor, _DELTA_PAGE_SIZE]
446
500
  )
447
501
  await cur.execute(stage1_sql, stage1_params)
448
502
  page = await cur.fetchall()
@@ -472,6 +526,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
472
526
  if len(seeded) == len(channels) or len(page) < _DELTA_PAGE_SIZE:
473
527
  break
474
528
  cursor = oldest
529
+ stage1_sql = _build_delta_stage1_sql(channels, paged=True)
475
530
 
476
531
  channels_with_chain = [ch for ch in channels if chain_by_ch[ch]]
477
532
  channels_with_seed = [ch for ch in channels if seed_ver_by_ch[ch] is not None]
@@ -687,5 +742,50 @@ class AsyncPostgresSaver(BasePostgresSaver):
687
742
  self.adelete_thread(thread_id), self.loop
688
743
  ).result()
689
744
 
745
+ def prune(
746
+ self,
747
+ thread_ids: Sequence[str],
748
+ *,
749
+ strategy: str = "keep_latest",
750
+ ) -> None:
751
+ """Prune checkpoints for the given threads.
752
+
753
+ See `PostgresSaver.prune` for what each strategy keeps.
754
+
755
+ Args:
756
+ thread_ids: The thread IDs to prune.
757
+ strategy: `"keep_latest"` or `"delete"`.
758
+ """
759
+ try:
760
+ if asyncio.get_running_loop() is self.loop:
761
+ raise asyncio.InvalidStateError(
762
+ "Synchronous calls to AsyncPostgresSaver are only allowed from a "
763
+ "different thread. From the main thread, use the async interface. "
764
+ "For example, use `await checkpointer.aprune(...)`."
765
+ )
766
+ except RuntimeError:
767
+ pass
768
+ return asyncio.run_coroutine_threadsafe(
769
+ self.aprune(thread_ids, strategy=strategy), self.loop
770
+ ).result()
771
+
772
+ def get_delta_channel_history(
773
+ self, *, config: RunnableConfig, channels: Sequence[str]
774
+ ) -> Mapping[str, DeltaChannelHistory]:
775
+ """Sync bridge to `aget_delta_channel_history`, guarded like `get_tuple`."""
776
+ try:
777
+ if asyncio.get_running_loop() is self.loop:
778
+ raise asyncio.InvalidStateError(
779
+ "Synchronous calls to AsyncPostgresSaver are only allowed from a "
780
+ "different thread. From the main thread, use the async interface. "
781
+ "For example, use `await checkpointer.aget_delta_channel_history(...)`."
782
+ )
783
+ except RuntimeError:
784
+ pass
785
+ return asyncio.run_coroutine_threadsafe(
786
+ self.aget_delta_channel_history(config=config, channels=channels),
787
+ self.loop,
788
+ ).result()
789
+
690
790
 
691
791
  __all__ = ["AsyncPostgresSaver", "AsyncShallowPostgresSaver", "Conn"]