langgraph-checkpoint-postgres 3.1.3__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.3 → langgraph_checkpoint_postgres-3.2.0}/PKG-INFO +1 -1
  2. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/__init__.py +72 -0
  3. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/aio.py +81 -0
  4. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/base.py +215 -1
  5. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/pyproject.toml +1 -1
  6. langgraph_checkpoint_postgres-3.2.0/tests/test_prune.py +540 -0
  7. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/uv.lock +1 -1
  8. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/.gitignore +0 -0
  9. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/LICENSE +0 -0
  10. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/Makefile +0 -0
  11. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/README.md +0 -0
  12. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/_ainternal.py +0 -0
  13. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/_internal.py +0 -0
  14. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/py.typed +0 -0
  15. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/shallow.py +0 -0
  16. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/store/postgres/__init__.py +0 -0
  17. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/store/postgres/aio.py +0 -0
  18. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/store/postgres/base.py +0 -0
  19. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/store/postgres/py.typed +0 -0
  20. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/__init__.py +0 -0
  21. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/compose-postgres.yml +0 -0
  22. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/conftest.py +0 -0
  23. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/embed_test_utils.py +0 -0
  24. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/test_async.py +0 -0
  25. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/test_async_store.py +0 -0
  26. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/test_conformance_delta.py +0 -0
  27. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/test_delta_pagination.py +0 -0
  28. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/test_delta_plain_value_seed.py +0 -0
  29. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/test_store.py +0 -0
  30. {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/test_sync.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: langgraph-checkpoint-postgres
3
- Version: 3.1.3
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
@@ -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.
@@ -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
@@ -688,6 +742,33 @@ class AsyncPostgresSaver(BasePostgresSaver):
688
742
  self.adelete_thread(thread_id), self.loop
689
743
  ).result()
690
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
+
691
772
  def get_delta_channel_history(
692
773
  self, *, config: RunnableConfig, channels: Sequence[str]
693
774
  ) -> Mapping[str, DeltaChannelHistory]:
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import random
4
4
  import warnings
5
- from collections.abc import Mapping, Sequence
5
+ from collections.abc import Generator, Mapping, Sequence
6
6
  from importlib.metadata import version as get_version
7
7
  from typing import Any, TypedDict, cast
8
8
 
@@ -17,6 +17,8 @@ from langgraph.checkpoint.base import (
17
17
  writes_sort_key,
18
18
  )
19
19
  from langgraph.checkpoint.serde.types import TASKS
20
+ from psycopg import AsyncCursor, Cursor
21
+ from psycopg.rows import DictRow
20
22
  from psycopg.types.json import Jsonb
21
23
 
22
24
  # Page size for stage-1 paged scan in `get_delta_channel_history`. Internal
@@ -160,6 +162,218 @@ INSERT_CHECKPOINT_WRITES_SQL = """
160
162
  """
161
163
 
162
164
 
165
+ PRUNE_STRATEGIES = ("keep_latest", "delete")
166
+
167
+ PRUNE_DELETE_THREADS_SQL = (
168
+ "DELETE FROM checkpoints WHERE thread_id = ANY(%s)",
169
+ "DELETE FROM checkpoint_blobs WHERE thread_id = ANY(%s)",
170
+ "DELETE FROM checkpoint_writes WHERE thread_id = ANY(%s)",
171
+ )
172
+
173
+ # The prune statements below never join two sets of a thread's rows: a join is
174
+ # planned from per-thread row estimates, which are worst for the few huge
175
+ # threads worth pruning, and can go quadratic. They look rows up by primary key
176
+ # instead (`LATERAL ... LIMIT 1`, `= ANY`).
177
+
178
+ # A `DeltaChannel` leaves update counters in checkpoint metadata, or a
179
+ # snapshot: an inline `true` marker with a blob behind it, which a plain `True`
180
+ # value never has. LangGraph.js inlines no values, but marks the threads its
181
+ # DeltaChannel graphs start with `delta_writes_versioned`.
182
+ PRUNE_DELTA_THREADS_SQL = """
183
+ SELECT thread_id FROM checkpoints
184
+ WHERE thread_id = ANY(%s) AND (
185
+ metadata ? 'counters_since_delta_snapshot'
186
+ OR metadata ? 'delta_writes_versioned'
187
+ )
188
+ UNION
189
+ SELECT c.thread_id
190
+ FROM checkpoints c
191
+ CROSS JOIN LATERAL jsonb_each(c.checkpoint -> 'channel_values') AS v
192
+ CROSS JOIN LATERAL (
193
+ SELECT 1 FROM checkpoint_blobs b
194
+ WHERE b.thread_id = c.thread_id
195
+ AND b.checkpoint_ns = c.checkpoint_ns
196
+ AND b.channel = v.key
197
+ AND b.version = c.checkpoint -> 'channel_versions' ->> v.key
198
+ LIMIT 1
199
+ ) AS snapshot
200
+ WHERE c.thread_id = ANY(%s) AND v.value = 'true'::jsonb
201
+ """
202
+
203
+ # Per namespace: the latest checkpoint, the parent it reads its pending sends
204
+ # from when it predates v4, and the root graph's checkpoint its subgraph call
205
+ # started from, with whether that one is saved.
206
+ PRUNE_HEADS_SQL = f"""
207
+ SELECT
208
+ h.checkpoint_ns,
209
+ h.checkpoint_id AS head,
210
+ CASE WHEN (c.checkpoint ->> 'v')::int < 4 AND EXISTS (
211
+ SELECT 1 FROM checkpoint_writes w
212
+ WHERE w.thread_id = %s
213
+ AND w.checkpoint_ns = h.checkpoint_ns
214
+ AND w.checkpoint_id = c.parent_checkpoint_id
215
+ AND w.channel = '{TASKS}'
216
+ ) THEN c.parent_checkpoint_id END AS sends_parent,
217
+ c.metadata -> 'parents' ->> '' AS root,
218
+ EXISTS (
219
+ SELECT 1 FROM checkpoints r
220
+ WHERE r.thread_id = %s AND r.checkpoint_ns = ''
221
+ AND r.checkpoint_id = c.metadata -> 'parents' ->> ''
222
+ ) AS root_saved
223
+ FROM (
224
+ SELECT DISTINCT ON (checkpoint_ns) checkpoint_ns, checkpoint_id
225
+ FROM checkpoints
226
+ WHERE thread_id = %s
227
+ ORDER BY checkpoint_ns DESC, checkpoint_id DESC
228
+ ) AS h
229
+ CROSS JOIN LATERAL (
230
+ SELECT checkpoint, parent_checkpoint_id, metadata FROM checkpoints
231
+ WHERE thread_id = %s
232
+ AND checkpoint_ns = h.checkpoint_ns
233
+ AND checkpoint_id = h.checkpoint_id
234
+ ) AS c
235
+ """
236
+
237
+ PRUNE_SNAPSHOT_SQL = "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ"
238
+
239
+ PRUNE_UNKEPT_SQL = """
240
+ SELECT checkpoint_id FROM checkpoints
241
+ WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id <> ALL(%s)
242
+ """
243
+
244
+ # A run saves a superstep's writes in the background, where they can commit
245
+ # before their checkpoint does, so they go by the checkpoints deleted.
246
+ PRUNE_WRITES_SQL = """
247
+ DELETE FROM checkpoint_writes
248
+ WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id = ANY(%s)
249
+ """
250
+
251
+ PRUNE_CHECKPOINTS_SQL = """
252
+ WITH deleted AS (
253
+ DELETE FROM checkpoints
254
+ WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id <> ALL(%s)
255
+ RETURNING checkpoint -> 'channel_versions' AS versions
256
+ )
257
+ SELECT DISTINCT v.key AS channel, v.value AS version
258
+ FROM deleted
259
+ CROSS JOIN LATERAL jsonb_each_text(deleted.versions) AS v
260
+ """
261
+
262
+ PRUNE_PARENTS_SQL = """
263
+ UPDATE checkpoints SET parent_checkpoint_id = NULL
264
+ WHERE thread_id = %s AND checkpoint_ns = %s
265
+ AND checkpoint_id = ANY(%s) AND parent_checkpoint_id <> ALL(%s)
266
+ """
267
+
268
+ PRUNE_REFERENCED_BLOBS_SQL = """
269
+ SELECT DISTINCT v.key AS channel, v.value AS version
270
+ FROM checkpoints c
271
+ CROSS JOIN LATERAL jsonb_each_text(c.checkpoint -> 'channel_versions') AS v
272
+ WHERE c.thread_id = %s AND c.checkpoint_ns = %s
273
+ """
274
+
275
+ PRUNE_BLOBS_SQL = """
276
+ DELETE FROM checkpoint_blobs
277
+ WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s AND version = ANY(%s)
278
+ """
279
+
280
+ PRUNE_SUBGRAPH_CALLS_SQL = (
281
+ "DELETE FROM checkpoints WHERE thread_id = %s AND checkpoint_ns = ANY(%s)",
282
+ "DELETE FROM checkpoint_writes WHERE thread_id = %s AND checkpoint_ns = ANY(%s)",
283
+ "DELETE FROM checkpoint_blobs WHERE thread_id = %s AND checkpoint_ns = ANY(%s)",
284
+ )
285
+
286
+
287
+ def _garbage_blobs(
288
+ freed: Sequence[Mapping[str, Any]], referenced: Sequence[Mapping[str, Any]]
289
+ ) -> dict[str, list[str]]:
290
+ """Blobs a deleted checkpoint used and no kept one does, by channel."""
291
+ kept = {(r["channel"], r["version"]) for r in referenced}
292
+ garbage: dict[str, list[str]] = {}
293
+ for r in freed:
294
+ if (r["channel"], r["version"]) not in kept:
295
+ garbage.setdefault(r["channel"], []).append(r["version"])
296
+ return garbage
297
+
298
+
299
+ _Statements = Generator[tuple[str, Sequence[Any], bool], list[DictRow], None]
300
+
301
+
302
+ def _refuse_delta_threads(thread_ids: Sequence[str]) -> _Statements:
303
+ rows = yield PRUNE_DELTA_THREADS_SQL, (thread_ids, thread_ids), True
304
+ if delta := sorted(r["thread_id"] for r in rows):
305
+ raise NotImplementedError(
306
+ f"keep_latest can't prune threads that use a DeltaChannel: {', '.join(delta)}"
307
+ )
308
+
309
+
310
+ def _keep_latest(thread_id: str) -> _Statements:
311
+ """The statements of one thread's keep_latest transaction, for `_execute`
312
+ and `_aexecute` to run. Each comes with whether its rows are sent back.
313
+
314
+ They all read one snapshot, so what a run saves meanwhile is neither seen
315
+ nor deleted, whatever its checkpoint ID.
316
+ """
317
+ yield PRUNE_SNAPSHOT_SQL, (), False
318
+ heads = yield PRUNE_HEADS_SQL, (thread_id,) * 4, True
319
+ # Catches a thread that started using a DeltaChannel after the batch check.
320
+ yield from _refuse_delta_threads([thread_id])
321
+ kept = {
322
+ h["checkpoint_ns"]: [i for i in (h["head"], h["sends_parent"]) if i]
323
+ for h in heads
324
+ }
325
+ # A subgraph call's namespace has its task ID after a ":", unlike a
326
+ # `checkpointer=True` subgraph's, whose state outlives each call. A call
327
+ # goes with the root checkpoint it started from, but only once that one is
328
+ # saved: runs save in the background, so a call can commit first.
329
+ calls = {
330
+ h["checkpoint_ns"]
331
+ for h in heads
332
+ if ":" in h["checkpoint_ns"]
333
+ and h["root_saved"]
334
+ and h["root"] not in kept.get("", ())
335
+ }
336
+ if calls:
337
+ for sql in PRUNE_SUBGRAPH_CALLS_SQL:
338
+ yield sql, (thread_id, list(calls)), False
339
+ for ns, ids in kept.items():
340
+ if ns in calls:
341
+ continue
342
+ unkept = yield PRUNE_UNKEPT_SQL, (thread_id, ns, ids), True
343
+ if unkept:
344
+ deleted = [r["checkpoint_id"] for r in unkept]
345
+ yield PRUNE_WRITES_SQL, (thread_id, ns, deleted), False
346
+ freed = yield PRUNE_CHECKPOINTS_SQL, (thread_id, ns, ids), True
347
+ yield PRUNE_PARENTS_SQL, (thread_id, ns, ids, ids), False
348
+ if freed:
349
+ referenced = yield PRUNE_REFERENCED_BLOBS_SQL, (thread_id, ns), True
350
+ for channel, versions in _garbage_blobs(freed, referenced).items():
351
+ yield PRUNE_BLOBS_SQL, (thread_id, ns, channel, versions), False
352
+
353
+
354
+ # Unprepared, so Postgres plans each statement knowing its arrays: a cached
355
+ # generic plan checks a row against an `= ANY(...)` array one element at a time.
356
+ def _execute(cur: Cursor[DictRow], statements: _Statements) -> None:
357
+ try:
358
+ sql, params, fetch = next(statements)
359
+ while True:
360
+ cur.execute(sql, params, prepare=False)
361
+ sql, params, fetch = statements.send(cur.fetchall() if fetch else [])
362
+ except StopIteration:
363
+ pass
364
+
365
+
366
+ async def _aexecute(cur: AsyncCursor[DictRow], statements: _Statements) -> None:
367
+ try:
368
+ sql, params, fetch = next(statements)
369
+ while True:
370
+ await cur.execute(sql, params, prepare=False)
371
+ rows = await cur.fetchall() if fetch else []
372
+ sql, params, fetch = statements.send(rows)
373
+ except StopIteration:
374
+ pass
375
+
376
+
163
377
  class _DeltaStage2Row(TypedDict, total=False):
164
378
  """One row from `_build_delta_stage2_sql` (a UNION ALL of writes and blobs)."""
165
379
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "langgraph-checkpoint-postgres"
7
- version = "3.1.3"
7
+ version = "3.2.0"
8
8
  description = "Library with a Postgres implementation of LangGraph checkpoint saver."
9
9
  authors = []
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,540 @@
1
+ import asyncio
2
+ from collections.abc import Awaitable, Callable
3
+ from contextlib import asynccontextmanager
4
+ from typing import Any
5
+ from uuid import uuid4
6
+
7
+ import pytest
8
+ from langgraph.checkpoint.base import empty_checkpoint
9
+ from langgraph.checkpoint.base.id import uuid6
10
+ from langgraph.checkpoint.conformance import validate
11
+ from langgraph.checkpoint.conformance.initializer import checkpointer_test
12
+ from langgraph.checkpoint.serde.types import TASKS, _DeltaSnapshot
13
+ from psycopg import AsyncConnection
14
+
15
+ from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
16
+ from langgraph.checkpoint.postgres.base import (
17
+ PRUNE_DELTA_THREADS_SQL,
18
+ PRUNE_HEADS_SQL,
19
+ PRUNE_REFERENCED_BLOBS_SQL,
20
+ )
21
+ from tests.conftest import DEFAULT_POSTGRES_URI, DEFAULT_URI
22
+ from tests.test_async import _saver as _async_saver
23
+ from tests.test_sync import _saver as _sync_saver
24
+
25
+ SAVERS = ["base", "pool", "pipe", "aio_base", "aio_pool", "aio_pipe"]
26
+
27
+
28
+ @asynccontextmanager
29
+ async def _saver(name: str):
30
+ if name.startswith("aio_"):
31
+ async with _async_saver(name.removeprefix("aio_")) as saver:
32
+ yield saver
33
+ else:
34
+ with _sync_saver(name) as saver:
35
+ yield saver
36
+
37
+
38
+ async def _call(saver: Any, method: str, *args: Any, **kwargs: Any) -> Any:
39
+ if isinstance(saver, AsyncPostgresSaver):
40
+ return await getattr(saver, f"a{method}")(*args, **kwargs)
41
+ return getattr(saver, method)(*args, **kwargs)
42
+
43
+
44
+ async def _rows(saver: Any, thread_id: str) -> dict[str, list[tuple]]:
45
+ queries = {
46
+ "checkpoints": "SELECT checkpoint_ns, checkpoint_id FROM checkpoints"
47
+ " WHERE thread_id = %s ORDER BY 1, 2",
48
+ "writes": "SELECT checkpoint_ns, checkpoint_id, task_id, idx"
49
+ " FROM checkpoint_writes WHERE thread_id = %s ORDER BY 1, 2, 3, 4",
50
+ "blobs": "SELECT checkpoint_ns, channel, version FROM checkpoint_blobs"
51
+ " WHERE thread_id = %s ORDER BY 1, 2, 3",
52
+ }
53
+ rows = {}
54
+ for table, sql in queries.items():
55
+ if isinstance(saver, AsyncPostgresSaver):
56
+ async with saver._cursor() as cur:
57
+ await cur.execute(sql, (thread_id,))
58
+ rows[table] = [tuple(r.values()) for r in await cur.fetchall()]
59
+ else:
60
+ with saver._cursor() as cur:
61
+ cur.execute(sql, (thread_id,))
62
+ rows[table] = [tuple(r.values()) for r in cur.fetchall()]
63
+ return rows
64
+
65
+
66
+ async def _put(
67
+ saver: Any,
68
+ thread_id: str,
69
+ parent: dict | None,
70
+ step: int,
71
+ values: dict[str, Any],
72
+ versions: dict[str, str],
73
+ *,
74
+ ns: str = "",
75
+ metadata: dict[str, Any] | None = None,
76
+ v: int = 4,
77
+ checkpoint_id: str | None = None,
78
+ ) -> dict:
79
+ config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ns}}
80
+ if parent is not None:
81
+ config["configurable"]["checkpoint_id"] = parent["configurable"][
82
+ "checkpoint_id"
83
+ ]
84
+ checkpoint = empty_checkpoint()
85
+ checkpoint.update(
86
+ v=v,
87
+ id=checkpoint_id or str(uuid6(clock_seq=step)),
88
+ channel_values=values,
89
+ channel_versions=versions,
90
+ )
91
+ new_versions = {k: versions[k] for k in values}
92
+ return await _call(
93
+ saver,
94
+ "put",
95
+ config,
96
+ checkpoint,
97
+ {"source": "loop", "step": step, "parents": {}, **(metadata or {})},
98
+ new_versions,
99
+ )
100
+
101
+
102
+ async def _three_checkpoints(
103
+ saver: Any,
104
+ thread_id: str,
105
+ ns: str = "",
106
+ metadata: dict[str, Any] | None = None,
107
+ ) -> dict:
108
+ parent = None
109
+ steps = [
110
+ ({"a": [1], "s": ["shared"]}, {"a": "1", "s": "1"}),
111
+ ({"a": [1, 2]}, {"a": "2", "s": "1"}),
112
+ ({"a": [1, 2, 3]}, {"a": "3", "s": "1"}),
113
+ ]
114
+ for step, (values, versions) in enumerate(steps):
115
+ parent = await _put(
116
+ saver, thread_id, parent, step, values, versions, ns=ns, metadata=metadata
117
+ )
118
+ await _call(saver, "put_writes", parent, [("a", step)], str(uuid4()))
119
+ return parent
120
+
121
+
122
+ @asynccontextmanager
123
+ async def _second_saver(saver: AsyncPostgresSaver):
124
+ assert isinstance(saver.conn, AsyncConnection)
125
+ uri = DEFAULT_POSTGRES_URI + saver.conn.info.dbname
126
+ async with AsyncPostgresSaver.from_conn_string(uri) as other:
127
+ yield other
128
+
129
+
130
+ def _run_once_after(
131
+ saver: AsyncPostgresSaver,
132
+ sql: str,
133
+ action: Callable[[], Awaitable[Any]],
134
+ *,
135
+ nth: int = 1,
136
+ ) -> None:
137
+ seen = 0
138
+ cursor = saver._cursor
139
+
140
+ class Hooked:
141
+ def __init__(self, cur: Any) -> None:
142
+ self.cur = cur
143
+
144
+ def __getattr__(self, name: str) -> Any:
145
+ return getattr(self.cur, name)
146
+
147
+ async def execute(self, query: Any, params: Any = None, **kwargs: Any) -> None:
148
+ nonlocal seen
149
+ await self.cur.execute(query, params, **kwargs)
150
+ if query is sql:
151
+ seen += 1
152
+ if seen == nth:
153
+ await action()
154
+
155
+ @asynccontextmanager
156
+ async def hooked(**kwargs: Any):
157
+ async with cursor(**kwargs) as cur:
158
+ yield Hooked(cur)
159
+
160
+ saver._cursor = hooked
161
+
162
+
163
+ async def _delta_thread(saver: Any, thread_id: str, evidence: str) -> None:
164
+ if evidence == "counters":
165
+ first = await _put(saver, thread_id, None, 0, {"m": [1]}, {"m": "1"})
166
+ await _put(
167
+ saver,
168
+ thread_id,
169
+ first,
170
+ 1,
171
+ {},
172
+ {"m": "2"},
173
+ metadata={"counters_since_delta_snapshot": {"m": [1, 1]}},
174
+ )
175
+ elif evidence == "snapshot":
176
+ first = await _put(
177
+ saver, thread_id, None, 0, {"m": _DeltaSnapshot([1])}, {"m": "1"}
178
+ )
179
+ await _put(saver, thread_id, first, 1, {}, {"m": "2"})
180
+ else:
181
+ marker = {"delta_writes_versioned": True}
182
+ first = await _put(
183
+ saver, thread_id, None, 0, {"m": [1]}, {"m": "1"}, metadata=marker
184
+ )
185
+ await _put(
186
+ saver, thread_id, first, 1, {"m": [1, 2]}, {"m": "2"}, metadata=marker
187
+ )
188
+
189
+
190
+ @pytest.mark.parametrize("name", SAVERS)
191
+ async def test_keep_latest_keeps_each_namespace_head_with_its_writes_and_blobs(
192
+ name: str,
193
+ ) -> None:
194
+ async with _saver(name) as saver:
195
+ thread_id, other_id = str(uuid4()), str(uuid4())
196
+ heads = {
197
+ ns: await _three_checkpoints(saver, thread_id, ns) for ns in ("", "sub:1")
198
+ }
199
+ await _three_checkpoints(saver, other_id)
200
+ before = {ns: await _call(saver, "get_tuple", h) for ns, h in heads.items()}
201
+ other_before = await _rows(saver, other_id)
202
+
203
+ await _call(saver, "prune", [thread_id])
204
+
205
+ rows = await _rows(saver, thread_id)
206
+ assert rows["checkpoints"] == [
207
+ (ns, heads[ns]["configurable"]["checkpoint_id"]) for ns in ("", "sub:1")
208
+ ]
209
+ assert [(ns, cid) for ns, cid, *_ in rows["writes"]] == rows["checkpoints"]
210
+ assert rows["blobs"] == [
211
+ (ns, ch, ver)
212
+ for ns in ("", "sub:1")
213
+ for ch, ver in (("a", "3"), ("s", "1"))
214
+ ]
215
+ for ns, head in heads.items():
216
+ assert await _call(saver, "get_tuple", head) == before[ns]._replace(
217
+ parent_config=None
218
+ )
219
+ assert await _rows(saver, other_id) == other_before
220
+
221
+
222
+ @pytest.mark.parametrize("evidence", ["counters", "snapshot", "versioned"])
223
+ @pytest.mark.parametrize("name", SAVERS)
224
+ async def test_keep_latest_refuses_a_batch_with_a_delta_channel_thread(
225
+ name: str, evidence: str
226
+ ) -> None:
227
+ async with _saver(name) as saver:
228
+ ordinary_id, delta_id = str(uuid4()), str(uuid4())
229
+ await _three_checkpoints(saver, ordinary_id)
230
+ await _delta_thread(saver, delta_id, evidence)
231
+ before = [await _rows(saver, t) for t in (ordinary_id, delta_id)]
232
+
233
+ with pytest.raises(NotImplementedError, match=delta_id):
234
+ await _call(saver, "prune", [ordinary_id, delta_id])
235
+
236
+ assert [await _rows(saver, t) for t in (ordinary_id, delta_id)] == before
237
+
238
+
239
+ @pytest.mark.parametrize("name", SAVERS)
240
+ async def test_keep_latest_prunes_a_thread_storing_a_plain_true(name: str) -> None:
241
+ async with _saver(name) as saver:
242
+ thread_id = str(uuid4())
243
+ first = await _put(
244
+ saver, thread_id, None, 0, {"flag": True, "a": [1]}, {"flag": "1", "a": "1"}
245
+ )
246
+ head = await _put(
247
+ saver, thread_id, first, 1, {"flag": True}, {"flag": "2", "a": "1"}
248
+ )
249
+
250
+ await _call(saver, "prune", [thread_id])
251
+
252
+ assert (await _rows(saver, thread_id))["checkpoints"] == [
253
+ ("", head["configurable"]["checkpoint_id"])
254
+ ]
255
+
256
+
257
+ @pytest.mark.parametrize("name", SAVERS)
258
+ async def test_keep_latest_keeps_the_parent_holding_a_pre_v4_heads_sends(
259
+ name: str,
260
+ ) -> None:
261
+ async with _saver(name) as saver:
262
+ thread_id = str(uuid4())
263
+ first = await _put(saver, thread_id, None, 0, {}, {}, v=1)
264
+ await _call(saver, "put_writes", first, [(TASKS, "send-1")], str(uuid4()))
265
+ second = await _put(saver, thread_id, first, 1, {}, {}, v=1)
266
+ head = await _put(saver, thread_id, second, 2, {}, {}, v=1)
267
+ await _call(saver, "put_writes", second, [(TASKS, "send-2")], str(uuid4()))
268
+
269
+ await _call(saver, "prune", [thread_id])
270
+
271
+ assert (await _rows(saver, thread_id))["checkpoints"] == [
272
+ ("", second["configurable"]["checkpoint_id"]),
273
+ ("", head["configurable"]["checkpoint_id"]),
274
+ ]
275
+ loaded = await _call(saver, "get_tuple", head)
276
+ assert loaded.checkpoint["channel_values"][TASKS] == ["send-2"]
277
+ assert loaded.parent_config == second
278
+ assert (await _call(saver, "get_tuple", second)).parent_config is None
279
+
280
+
281
+ @pytest.mark.parametrize("name", SAVERS)
282
+ async def test_delete_removes_every_row_of_the_threads(name: str) -> None:
283
+ async with _saver(name) as saver:
284
+ ordinary_id, delta_id, other_id = str(uuid4()), str(uuid4()), str(uuid4())
285
+ await _three_checkpoints(saver, ordinary_id)
286
+ await _delta_thread(saver, delta_id, "counters")
287
+ await _three_checkpoints(saver, other_id)
288
+ other_before = await _rows(saver, other_id)
289
+
290
+ await _call(saver, "prune", [ordinary_id, delta_id], strategy="delete")
291
+
292
+ empty = {"checkpoints": [], "writes": [], "blobs": []}
293
+ assert await _rows(saver, ordinary_id) == empty
294
+ assert await _rows(saver, delta_id) == empty
295
+ assert await _rows(saver, other_id) == other_before
296
+
297
+
298
+ @pytest.mark.parametrize("name", SAVERS)
299
+ async def test_unknown_strategy_changes_nothing(name: str) -> None:
300
+ async with _saver(name) as saver:
301
+ thread_id = str(uuid4())
302
+ await _three_checkpoints(saver, thread_id)
303
+ before = await _rows(saver, thread_id)
304
+
305
+ with pytest.raises(ValueError, match="keep_all"):
306
+ await _call(saver, "prune", [thread_id], strategy="keep_all")
307
+
308
+ assert await _rows(saver, thread_id) == before
309
+
310
+
311
+ async def test_async_saver_prune_runs_from_another_thread_only() -> None:
312
+ async with _saver("aio_base") as saver:
313
+ thread_id = str(uuid4())
314
+ head = await _three_checkpoints(saver, thread_id)
315
+
316
+ with pytest.raises(asyncio.InvalidStateError):
317
+ saver.prune([thread_id])
318
+ await asyncio.to_thread(saver.prune, [thread_id])
319
+
320
+ assert (await _rows(saver, thread_id))["checkpoints"] == [
321
+ ("", head["configurable"]["checkpoint_id"])
322
+ ]
323
+
324
+
325
+ async def test_prune_conformance() -> None:
326
+ @checkpointer_test(name="AsyncPostgresSaver")
327
+ async def postgres_saver():
328
+ async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
329
+ await saver.setup()
330
+ yield saver
331
+
332
+ report = await validate(postgres_saver, capabilities={"prune"})
333
+
334
+ prune = report.results["prune"]
335
+ if not prune.passed:
336
+ details = "\n".join(prune.failures)
337
+ pytest.fail(f"Capability prune failed (passed={prune.passed}):\n{details}")
338
+
339
+
340
+ @pytest.mark.parametrize("strategy", ["keep_latest", "delete"])
341
+ @pytest.mark.parametrize("name", ["base", "aio_base"])
342
+ async def test_prune_rejects_a_single_thread_id_string(
343
+ name: str, strategy: str
344
+ ) -> None:
345
+ async with _saver(name) as saver:
346
+ for thread_id in ("ab", "a", "b"):
347
+ await _three_checkpoints(saver, thread_id)
348
+ before = [await _rows(saver, t) for t in ("ab", "a", "b")]
349
+
350
+ with pytest.raises(TypeError, match="not a str"):
351
+ await _call(saver, "prune", "ab", strategy=strategy)
352
+
353
+ assert [await _rows(saver, t) for t in ("ab", "a", "b")] == before
354
+
355
+
356
+ @pytest.mark.parametrize(
357
+ "after", [PRUNE_HEADS_SQL, PRUNE_REFERENCED_BLOBS_SQL], ids=["heads", "blobs"]
358
+ )
359
+ async def test_keep_latest_keeps_a_checkpoint_saved_while_it_runs(after: str) -> None:
360
+ async with _saver("aio_base") as saver, _second_saver(saver) as other:
361
+ thread_id = str(uuid4())
362
+ head = await _three_checkpoints(saver, thread_id)
363
+ saved: list[dict] = []
364
+
365
+ async def save_a_child() -> None:
366
+ saved.append(
367
+ await _put(
368
+ other, thread_id, head, 3, {"a": [1, 2, 3, 4]}, {"a": "4", "s": "1"}
369
+ )
370
+ )
371
+
372
+ _run_once_after(saver, after, save_a_child)
373
+ await saver.aprune([thread_id])
374
+
375
+ loaded = await saver.aget_tuple(saved[0])
376
+ assert loaded is not None, "the checkpoint saved during the prune was deleted"
377
+ assert loaded.checkpoint["channel_values"] == {
378
+ "a": [1, 2, 3, 4],
379
+ "s": ["shared"],
380
+ }
381
+ assert loaded.parent_config == head
382
+
383
+
384
+ async def test_keep_latest_refuses_a_thread_that_turns_delta_after_the_batch_check() -> (
385
+ None
386
+ ):
387
+ async with _saver("aio_base") as saver, _second_saver(saver) as other:
388
+ thread_id = str(uuid4())
389
+ head = await _three_checkpoints(saver, thread_id)
390
+
391
+ async def save_a_delta_checkpoint() -> None:
392
+ await _put(
393
+ other,
394
+ thread_id,
395
+ head,
396
+ 3,
397
+ {},
398
+ {"a": "4", "s": "1"},
399
+ metadata={"counters_since_delta_snapshot": {"a": [1, 1]}},
400
+ )
401
+
402
+ _run_once_after(saver, PRUNE_DELTA_THREADS_SQL, save_a_delta_checkpoint)
403
+ with pytest.raises(NotImplementedError, match=thread_id):
404
+ await saver.aprune([thread_id])
405
+
406
+ assert len((await _rows(saver, thread_id))["checkpoints"]) == 4
407
+
408
+
409
+ async def test_keep_latest_keeps_the_history_of_a_delta_checkpoint_saved_after_the_thread_check() -> (
410
+ None
411
+ ):
412
+ async with _saver("aio_base") as saver, _second_saver(saver) as other:
413
+ thread_id = str(uuid4())
414
+ head = await _three_checkpoints(saver, thread_id)
415
+ saved: list[dict] = []
416
+
417
+ async def save_a_delta_checkpoint() -> None:
418
+ saved.append(
419
+ await _put(
420
+ other,
421
+ thread_id,
422
+ head,
423
+ 3,
424
+ {},
425
+ {"a": "4", "s": "1"},
426
+ metadata={"counters_since_delta_snapshot": {"a": [1, 1]}},
427
+ )
428
+ )
429
+
430
+ _run_once_after(saver, PRUNE_DELTA_THREADS_SQL, save_a_delta_checkpoint, nth=2)
431
+ await saver.aprune([thread_id])
432
+
433
+ assert (await _rows(saver, thread_id))["checkpoints"] == [
434
+ ("", head["configurable"]["checkpoint_id"]),
435
+ ("", saved[0]["configurable"]["checkpoint_id"]),
436
+ ]
437
+ history = await saver.aget_delta_channel_history(
438
+ config=saved[0], channels=["a"]
439
+ )
440
+ assert history["a"]["seed"] == [1, 2, 3]
441
+ assert [value for _, _, value in history["a"]["writes"]] == [2]
442
+
443
+
444
+ @pytest.mark.parametrize("name", SAVERS)
445
+ async def test_keep_latest_deletes_a_subgraph_call_with_the_checkpoint_it_started_from(
446
+ name: str,
447
+ ) -> None:
448
+ async with _saver(name) as saver:
449
+ thread_id = str(uuid4())
450
+ older = await _put(saver, thread_id, None, 0, {}, {})
451
+ head = await _put(saver, thread_id, older, 1, {}, {})
452
+ unsaved = str(uuid6(clock_seq=2))
453
+ started_from = {
454
+ "finished:1": older["configurable"]["checkpoint_id"],
455
+ "interrupted:2": head["configurable"]["checkpoint_id"],
456
+ "running:3": unsaved,
457
+ "checkpointer_true": older["configurable"]["checkpoint_id"],
458
+ }
459
+ for ns, root in started_from.items():
460
+ await _three_checkpoints(
461
+ saver, thread_id, ns, metadata={"parents": {"": root}}
462
+ )
463
+
464
+ await _call(saver, "prune", [thread_id])
465
+
466
+ rows = await _rows(saver, thread_id)
467
+ assert {ns for ns, *_ in rows["checkpoints"]} == {
468
+ "",
469
+ "interrupted:2",
470
+ "running:3",
471
+ "checkpointer_true",
472
+ }
473
+ assert {ns for ns, *_ in rows["writes"]} == {
474
+ "interrupted:2",
475
+ "running:3",
476
+ "checkpointer_true",
477
+ }
478
+ assert {ns for ns, *_ in rows["blobs"]} == {
479
+ "interrupted:2",
480
+ "running:3",
481
+ "checkpointer_true",
482
+ }
483
+
484
+
485
+ async def test_keep_latest_keeps_a_checkpoint_saved_meanwhile_whatever_its_id() -> None:
486
+ async with _saver("aio_base") as saver, _second_saver(saver) as other:
487
+ thread_id = str(uuid4())
488
+ head = await _three_checkpoints(saver, thread_id)
489
+ lower_id = "0" + head["configurable"]["checkpoint_id"][1:]
490
+ saved: list[dict] = []
491
+
492
+ async def save_a_child_with_a_lower_id() -> None:
493
+ child = await _put(
494
+ other,
495
+ thread_id,
496
+ head,
497
+ 3,
498
+ {"a": [1, 2, 3, 4]},
499
+ {"a": "4", "s": "1"},
500
+ checkpoint_id=lower_id,
501
+ )
502
+ await _call(other, "put_writes", child, [("a", 4)], str(uuid4()))
503
+ saved.append(child)
504
+
505
+ _run_once_after(saver, PRUNE_HEADS_SQL, save_a_child_with_a_lower_id)
506
+ await saver.aprune([thread_id])
507
+
508
+ loaded = await saver.aget_tuple(saved[0])
509
+ assert loaded is not None, "the checkpoint saved during the prune was deleted"
510
+ assert loaded.checkpoint["channel_values"] == {
511
+ "a": [1, 2, 3, 4],
512
+ "s": ["shared"],
513
+ }
514
+ assert [value for _, _, value in loaded.pending_writes or []] == [4]
515
+
516
+
517
+ @pytest.mark.parametrize("name", SAVERS)
518
+ async def test_keep_latest_keeps_writes_saved_before_their_checkpoint(
519
+ name: str,
520
+ ) -> None:
521
+ async with _saver(name) as saver:
522
+ thread_id = str(uuid4())
523
+ head = await _three_checkpoints(saver, thread_id)
524
+ next_id = str(uuid6(clock_seq=3))
525
+ next_config = {
526
+ "configurable": {
527
+ "thread_id": thread_id,
528
+ "checkpoint_ns": "",
529
+ "checkpoint_id": next_id,
530
+ }
531
+ }
532
+ await _call(saver, "put_writes", next_config, [("a", 4)], str(uuid4()))
533
+
534
+ await _call(saver, "prune", [thread_id])
535
+ await _put(
536
+ saver, thread_id, head, 3, {}, {"a": "3", "s": "1"}, checkpoint_id=next_id
537
+ )
538
+
539
+ loaded = await _call(saver, "get_tuple", next_config)
540
+ assert [value for _, _, value in loaded.pending_writes] == [4]
@@ -353,7 +353,7 @@ test = [
353
353
 
354
354
  [[package]]
355
355
  name = "langgraph-checkpoint-postgres"
356
- version = "3.1.3"
356
+ version = "3.2.0"
357
357
  source = { editable = "." }
358
358
  dependencies = [
359
359
  { name = "langgraph-checkpoint" },