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.
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/PKG-INFO +1 -1
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/__init__.py +72 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/aio.py +81 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/base.py +215 -1
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/pyproject.toml +1 -1
- langgraph_checkpoint_postgres-3.2.0/tests/test_prune.py +540 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/uv.lock +1 -1
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/.gitignore +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/LICENSE +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/Makefile +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/README.md +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/_ainternal.py +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/_internal.py +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/py.typed +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/checkpoint/postgres/shallow.py +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/store/postgres/__init__.py +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/store/postgres/aio.py +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/store/postgres/base.py +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/langgraph/store/postgres/py.typed +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/__init__.py +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/compose-postgres.yml +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/conftest.py +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/embed_test_utils.py +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/test_async.py +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/test_async_store.py +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/test_conformance_delta.py +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/test_delta_pagination.py +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/test_delta_plain_value_seed.py +0 -0
- {langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/test_store.py +0 -0
- {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.
|
|
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
|
|
|
@@ -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]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/conftest.py
RENAMED
|
File without changes
|
|
File without changes
|
{langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/test_async.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/test_store.py
RENAMED
|
File without changes
|
{langgraph_checkpoint_postgres-3.1.3 → langgraph_checkpoint_postgres-3.2.0}/tests/test_sync.py
RENAMED
|
File without changes
|