langgraph-checkpoint-postgres 3.0.5__tar.gz → 3.1.0a3__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.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/.gitignore +1 -0
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/PKG-INFO +8 -3
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/README.md +5 -0
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/checkpoint/postgres/__init__.py +67 -3
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/checkpoint/postgres/aio.py +67 -3
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/checkpoint/postgres/base.py +142 -1
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/pyproject.toml +3 -3
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/tests/test_async.py +46 -2
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/uv.lock +357 -214
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/LICENSE +0 -0
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/Makefile +0 -0
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/checkpoint/postgres/_ainternal.py +0 -0
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/checkpoint/postgres/_internal.py +0 -0
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/checkpoint/postgres/py.typed +0 -0
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/checkpoint/postgres/shallow.py +0 -0
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/store/postgres/__init__.py +0 -0
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/store/postgres/aio.py +0 -0
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/store/postgres/base.py +0 -0
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/langgraph/store/postgres/py.typed +0 -0
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/tests/__init__.py +0 -0
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/tests/compose-postgres.yml +0 -0
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/tests/conftest.py +0 -0
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/tests/embed_test_utils.py +0 -0
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/tests/test_async_store.py +0 -0
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/tests/test_store.py +0 -0
- {langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/tests/test_sync.py +0 -0
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: langgraph-checkpoint-postgres
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.1.0a3
|
|
4
4
|
Summary: Library with a Postgres implementation of LangGraph checkpoint saver.
|
|
5
5
|
Project-URL: Source, https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres
|
|
6
|
-
Project-URL: Twitter, https://x.com/
|
|
6
|
+
Project-URL: Twitter, https://x.com/langchain_oss
|
|
7
7
|
Project-URL: Slack, https://www.langchain.com/join-community
|
|
8
8
|
Project-URL: Reddit, https://www.reddit.com/r/LangChain/
|
|
9
9
|
License-Expression: MIT
|
|
10
10
|
License-File: LICENSE
|
|
11
11
|
Requires-Python: >=3.10
|
|
12
|
-
Requires-Dist: langgraph-checkpoint<5.0.0,>=
|
|
12
|
+
Requires-Dist: langgraph-checkpoint<5.0.0,>=4.1.0a3
|
|
13
13
|
Requires-Dist: orjson>=3.11.5
|
|
14
14
|
Requires-Dist: psycopg-pool>=3.2.0
|
|
15
15
|
Requires-Dist: psycopg>=3.2.0
|
|
@@ -23,6 +23,11 @@ Implementation of LangGraph CheckpointSaver that uses Postgres.
|
|
|
23
23
|
|
|
24
24
|
By default `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) without any extras. However, you can choose a specific installation that best suits your needs [here](https://www.psycopg.org/psycopg3/docs/basic/install.html) (for example, `psycopg[binary]`).
|
|
25
25
|
|
|
26
|
+
## Security
|
|
27
|
+
|
|
28
|
+
> [!IMPORTANT]
|
|
29
|
+
> Set `LANGGRAPH_STRICT_MSGPACK=true` or pass an explicit `allowed_msgpack_modules` list when creating your checkpointer. This restricts checkpoint deserialization to known-safe types, preventing code execution if the database is compromised. See the [langgraph-checkpoint README](https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint#serde) for details.
|
|
30
|
+
|
|
26
31
|
## Usage
|
|
27
32
|
|
|
28
33
|
> [!IMPORTANT]
|
|
@@ -6,6 +6,11 @@ Implementation of LangGraph CheckpointSaver that uses Postgres.
|
|
|
6
6
|
|
|
7
7
|
By default `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) without any extras. However, you can choose a specific installation that best suits your needs [here](https://www.psycopg.org/psycopg3/docs/basic/install.html) (for example, `psycopg[binary]`).
|
|
8
8
|
|
|
9
|
+
## Security
|
|
10
|
+
|
|
11
|
+
> [!IMPORTANT]
|
|
12
|
+
> Set `LANGGRAPH_STRICT_MSGPACK=true` or pass an explicit `allowed_msgpack_modules` list when creating your checkpointer. This restricts checkpoint deserialization to known-safe types, preventing code execution if the database is compromised. See the [langgraph-checkpoint README](https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint#serde) for details.
|
|
13
|
+
|
|
9
14
|
## Usage
|
|
10
15
|
|
|
11
16
|
> [!IMPORTANT]
|
|
@@ -4,26 +4,35 @@ import threading
|
|
|
4
4
|
from collections import defaultdict
|
|
5
5
|
from collections.abc import Iterator, Sequence
|
|
6
6
|
from contextlib import contextmanager
|
|
7
|
-
from typing import Any
|
|
7
|
+
from typing import Any, cast
|
|
8
8
|
|
|
9
9
|
from langchain_core.runnables import RunnableConfig
|
|
10
10
|
from langgraph.checkpoint.base import (
|
|
11
|
+
DELTA_SENTINEL,
|
|
11
12
|
WRITES_IDX_MAP,
|
|
12
13
|
ChannelVersions,
|
|
13
14
|
Checkpoint,
|
|
14
15
|
CheckpointMetadata,
|
|
15
16
|
CheckpointTuple,
|
|
17
|
+
_ChannelWritesHistory,
|
|
16
18
|
get_checkpoint_id,
|
|
17
19
|
get_serializable_checkpoint_metadata,
|
|
18
20
|
)
|
|
19
21
|
from langgraph.checkpoint.serde.base import SerializerProtocol
|
|
22
|
+
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
|
20
23
|
from psycopg import Capabilities, Connection, Cursor, Pipeline
|
|
21
24
|
from psycopg.rows import DictRow, dict_row
|
|
22
25
|
from psycopg.types.json import Jsonb
|
|
23
26
|
from psycopg_pool import ConnectionPool
|
|
24
27
|
|
|
25
28
|
from langgraph.checkpoint.postgres import _internal
|
|
26
|
-
from langgraph.checkpoint.postgres.base import
|
|
29
|
+
from langgraph.checkpoint.postgres.base import (
|
|
30
|
+
SELECT_DELTA_STAGE1_SQL,
|
|
31
|
+
SELECT_DELTA_STAGE2_SQL,
|
|
32
|
+
BasePostgresSaver,
|
|
33
|
+
_DeltaStage1Row,
|
|
34
|
+
_DeltaStage2Row,
|
|
35
|
+
)
|
|
27
36
|
from langgraph.checkpoint.postgres.shallow import ShallowPostgresSaver
|
|
28
37
|
|
|
29
38
|
Conn = _internal.Conn # For backward compatibility
|
|
@@ -302,7 +311,12 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
302
311
|
# others are stored in blobs table
|
|
303
312
|
blob_values = {}
|
|
304
313
|
for k, v in checkpoint["channel_values"].items():
|
|
305
|
-
if v is
|
|
314
|
+
if v is DELTA_SENTINEL:
|
|
315
|
+
copy["channel_values"].pop(k)
|
|
316
|
+
elif isinstance(v, _DeltaSnapshot):
|
|
317
|
+
blob_values[k] = copy["channel_values"].pop(k)
|
|
318
|
+
copy["channel_values"][k] = True
|
|
319
|
+
elif v is None or isinstance(v, (str, int, float, bool)):
|
|
306
320
|
pass
|
|
307
321
|
else:
|
|
308
322
|
blob_values[k] = copy["channel_values"].pop(k)
|
|
@@ -430,6 +444,56 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
430
444
|
with conn.cursor(binary=True, row_factory=dict_row) as cur:
|
|
431
445
|
yield cur
|
|
432
446
|
|
|
447
|
+
def _get_channel_writes_history(
|
|
448
|
+
self, config: RunnableConfig, channel: str
|
|
449
|
+
) -> _ChannelWritesHistory:
|
|
450
|
+
"""Fast-path override of `BaseCheckpointSaver._get_channel_writes_history`.
|
|
451
|
+
|
|
452
|
+
Two-stage query: stage 1 scans checkpoint metadata to walk the parent
|
|
453
|
+
chain and locate the nearest snapshot; stage 2 fetches only the
|
|
454
|
+
chain-limited writes and single seed blob.
|
|
455
|
+
"""
|
|
456
|
+
thread_id = config["configurable"]["thread_id"]
|
|
457
|
+
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
|
458
|
+
checkpoint_id = get_checkpoint_id(config)
|
|
459
|
+
if checkpoint_id is None:
|
|
460
|
+
target = self.get_tuple(config)
|
|
461
|
+
if target is None:
|
|
462
|
+
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
|
|
463
|
+
checkpoint_id = target.config["configurable"]["checkpoint_id"]
|
|
464
|
+
|
|
465
|
+
with self._cursor() as cur:
|
|
466
|
+
cur.execute(
|
|
467
|
+
SELECT_DELTA_STAGE1_SQL,
|
|
468
|
+
(channel, channel, thread_id, checkpoint_ns),
|
|
469
|
+
)
|
|
470
|
+
stage1_rows = cur.fetchall()
|
|
471
|
+
chain_cids, seed_version = self._walk_stage1(
|
|
472
|
+
cast("list[_DeltaStage1Row]", stage1_rows), checkpoint_id
|
|
473
|
+
)
|
|
474
|
+
seed_versions = [seed_version] if seed_version else []
|
|
475
|
+
with self._cursor() as cur:
|
|
476
|
+
cur.execute(
|
|
477
|
+
SELECT_DELTA_STAGE2_SQL,
|
|
478
|
+
(
|
|
479
|
+
thread_id,
|
|
480
|
+
checkpoint_ns,
|
|
481
|
+
channel,
|
|
482
|
+
chain_cids,
|
|
483
|
+
thread_id,
|
|
484
|
+
checkpoint_ns,
|
|
485
|
+
channel,
|
|
486
|
+
seed_versions,
|
|
487
|
+
),
|
|
488
|
+
)
|
|
489
|
+
stage2_rows = cur.fetchall()
|
|
490
|
+
return self._build_delta_channel_writes_history(
|
|
491
|
+
channel=channel,
|
|
492
|
+
chain_cids=chain_cids,
|
|
493
|
+
seed_version=seed_version,
|
|
494
|
+
stage2_rows=cast("list[_DeltaStage2Row]", stage2_rows),
|
|
495
|
+
)
|
|
496
|
+
|
|
433
497
|
def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
|
|
434
498
|
"""
|
|
435
499
|
Convert a database row into a CheckpointTuple object.
|
|
@@ -4,26 +4,35 @@ import asyncio
|
|
|
4
4
|
from collections import defaultdict
|
|
5
5
|
from collections.abc import AsyncIterator, Iterator, Sequence
|
|
6
6
|
from contextlib import asynccontextmanager
|
|
7
|
-
from typing import Any
|
|
7
|
+
from typing import Any, cast
|
|
8
8
|
|
|
9
9
|
from langchain_core.runnables import RunnableConfig
|
|
10
10
|
from langgraph.checkpoint.base import (
|
|
11
|
+
DELTA_SENTINEL,
|
|
11
12
|
WRITES_IDX_MAP,
|
|
12
13
|
ChannelVersions,
|
|
13
14
|
Checkpoint,
|
|
14
15
|
CheckpointMetadata,
|
|
15
16
|
CheckpointTuple,
|
|
17
|
+
_ChannelWritesHistory,
|
|
16
18
|
get_checkpoint_id,
|
|
17
19
|
get_serializable_checkpoint_metadata,
|
|
18
20
|
)
|
|
19
21
|
from langgraph.checkpoint.serde.base import SerializerProtocol
|
|
22
|
+
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
|
20
23
|
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
|
|
21
24
|
from psycopg.rows import DictRow, dict_row
|
|
22
25
|
from psycopg.types.json import Jsonb
|
|
23
26
|
from psycopg_pool import AsyncConnectionPool
|
|
24
27
|
|
|
25
28
|
from langgraph.checkpoint.postgres import _ainternal
|
|
26
|
-
from langgraph.checkpoint.postgres.base import
|
|
29
|
+
from langgraph.checkpoint.postgres.base import (
|
|
30
|
+
SELECT_DELTA_STAGE1_SQL,
|
|
31
|
+
SELECT_DELTA_STAGE2_SQL,
|
|
32
|
+
BasePostgresSaver,
|
|
33
|
+
_DeltaStage1Row,
|
|
34
|
+
_DeltaStage2Row,
|
|
35
|
+
)
|
|
27
36
|
from langgraph.checkpoint.postgres.shallow import AsyncShallowPostgresSaver
|
|
28
37
|
|
|
29
38
|
Conn = _ainternal.Conn # For backward compatibility
|
|
@@ -261,7 +270,12 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
261
270
|
# others are stored in blobs table
|
|
262
271
|
blob_values = {}
|
|
263
272
|
for k, v in checkpoint["channel_values"].items():
|
|
264
|
-
if v is
|
|
273
|
+
if v is DELTA_SENTINEL:
|
|
274
|
+
copy["channel_values"].pop(k)
|
|
275
|
+
elif isinstance(v, _DeltaSnapshot):
|
|
276
|
+
blob_values[k] = copy["channel_values"].pop(k)
|
|
277
|
+
copy["channel_values"][k] = True
|
|
278
|
+
elif v is None or isinstance(v, (str, int, float, bool)):
|
|
265
279
|
pass
|
|
266
280
|
else:
|
|
267
281
|
blob_values[k] = copy["channel_values"].pop(k)
|
|
@@ -391,6 +405,56 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
391
405
|
async with conn.cursor(binary=True, row_factory=dict_row) as cur:
|
|
392
406
|
yield cur
|
|
393
407
|
|
|
408
|
+
async def _aget_channel_writes_history(
|
|
409
|
+
self, config: RunnableConfig, channel: str
|
|
410
|
+
) -> _ChannelWritesHistory:
|
|
411
|
+
"""Fast-path override of `BaseCheckpointSaver._aget_channel_writes_history`.
|
|
412
|
+
|
|
413
|
+
Two-stage query: stage 1 scans checkpoint metadata to walk the parent
|
|
414
|
+
chain and locate the nearest snapshot; stage 2 fetches only the
|
|
415
|
+
chain-limited writes and single seed blob.
|
|
416
|
+
"""
|
|
417
|
+
thread_id = config["configurable"]["thread_id"]
|
|
418
|
+
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
|
419
|
+
checkpoint_id = get_checkpoint_id(config)
|
|
420
|
+
if checkpoint_id is None:
|
|
421
|
+
target = await self.aget_tuple(config)
|
|
422
|
+
if target is None:
|
|
423
|
+
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
|
|
424
|
+
checkpoint_id = target.config["configurable"]["checkpoint_id"]
|
|
425
|
+
|
|
426
|
+
async with self._cursor() as cur:
|
|
427
|
+
await cur.execute(
|
|
428
|
+
SELECT_DELTA_STAGE1_SQL,
|
|
429
|
+
(channel, channel, thread_id, checkpoint_ns),
|
|
430
|
+
)
|
|
431
|
+
stage1_rows = await cur.fetchall()
|
|
432
|
+
chain_cids, seed_version = self._walk_stage1(
|
|
433
|
+
cast("list[_DeltaStage1Row]", stage1_rows), checkpoint_id
|
|
434
|
+
)
|
|
435
|
+
seed_versions = [seed_version] if seed_version else []
|
|
436
|
+
async with self._cursor() as cur:
|
|
437
|
+
await cur.execute(
|
|
438
|
+
SELECT_DELTA_STAGE2_SQL,
|
|
439
|
+
(
|
|
440
|
+
thread_id,
|
|
441
|
+
checkpoint_ns,
|
|
442
|
+
channel,
|
|
443
|
+
chain_cids,
|
|
444
|
+
thread_id,
|
|
445
|
+
checkpoint_ns,
|
|
446
|
+
channel,
|
|
447
|
+
seed_versions,
|
|
448
|
+
),
|
|
449
|
+
)
|
|
450
|
+
stage2_rows = await cur.fetchall()
|
|
451
|
+
return self._build_delta_channel_writes_history(
|
|
452
|
+
channel=channel,
|
|
453
|
+
chain_cids=chain_cids,
|
|
454
|
+
seed_version=seed_version,
|
|
455
|
+
stage2_rows=cast("list[_DeltaStage2Row]", stage2_rows),
|
|
456
|
+
)
|
|
457
|
+
|
|
394
458
|
async def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
|
|
395
459
|
"""
|
|
396
460
|
Convert a database row into a CheckpointTuple object.
|
|
@@ -4,13 +4,16 @@ import random
|
|
|
4
4
|
import warnings
|
|
5
5
|
from collections.abc import Sequence
|
|
6
6
|
from importlib.metadata import version as get_version
|
|
7
|
-
from typing import Any, cast
|
|
7
|
+
from typing import Any, TypedDict, cast
|
|
8
8
|
|
|
9
9
|
from langchain_core.runnables import RunnableConfig
|
|
10
10
|
from langgraph.checkpoint.base import (
|
|
11
|
+
DELTA_SENTINEL,
|
|
11
12
|
WRITES_IDX_MAP,
|
|
12
13
|
BaseCheckpointSaver,
|
|
13
14
|
ChannelVersions,
|
|
15
|
+
PendingWrite,
|
|
16
|
+
_ChannelWritesHistory,
|
|
14
17
|
get_checkpoint_id,
|
|
15
18
|
)
|
|
16
19
|
from langgraph.checkpoint.serde.types import TASKS
|
|
@@ -153,6 +156,62 @@ INSERT_CHECKPOINT_WRITES_SQL = """
|
|
|
153
156
|
"""
|
|
154
157
|
|
|
155
158
|
|
|
159
|
+
class _DeltaStage2Row(TypedDict, total=False):
|
|
160
|
+
"""One row from `SELECT_DELTA_STAGE2_SQL` (a UNION ALL of writes and blobs)."""
|
|
161
|
+
|
|
162
|
+
_kind: str # "w" or "b"
|
|
163
|
+
checkpoint_id: str | None # "w" rows only
|
|
164
|
+
type: str | None
|
|
165
|
+
blob: bytes | None
|
|
166
|
+
task_id: str | None # "w" rows only
|
|
167
|
+
idx: int | None # "w" rows only
|
|
168
|
+
version: str | None # "b" rows only
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# Two-stage DeltaChannel reconstruction. Stage 1 scans checkpoint
|
|
172
|
+
# metadata (no blob bytes) to walk the parent chain and locate the
|
|
173
|
+
# nearest snapshot marker. Stage 2 fetches only the chain-limited
|
|
174
|
+
# writes and the single seed snapshot blob.
|
|
175
|
+
#
|
|
176
|
+
# Parameter order:
|
|
177
|
+
# stage1: (channel, channel, thread_id, checkpoint_ns)
|
|
178
|
+
# stage2: (thread_id, checkpoint_ns, channel, chain_cids[],
|
|
179
|
+
# thread_id, checkpoint_ns, channel, seed_versions[])
|
|
180
|
+
|
|
181
|
+
SELECT_DELTA_STAGE1_SQL = """
|
|
182
|
+
SELECT checkpoint_id,
|
|
183
|
+
parent_checkpoint_id,
|
|
184
|
+
checkpoint -> 'channel_versions' ->> %s AS ver,
|
|
185
|
+
(checkpoint -> 'channel_values' -> %s) IS NOT NULL AS has_snapshot
|
|
186
|
+
FROM checkpoints
|
|
187
|
+
WHERE thread_id = %s AND checkpoint_ns = %s
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
SELECT_DELTA_STAGE2_SQL = """
|
|
191
|
+
SELECT 'w'::text AS _kind,
|
|
192
|
+
checkpoint_id,
|
|
193
|
+
type, blob, task_id, idx, NULL::text AS version
|
|
194
|
+
FROM checkpoint_writes
|
|
195
|
+
WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s
|
|
196
|
+
AND checkpoint_id = ANY(%s)
|
|
197
|
+
UNION ALL
|
|
198
|
+
SELECT 'b', NULL,
|
|
199
|
+
type, blob, NULL, NULL, version
|
|
200
|
+
FROM checkpoint_blobs
|
|
201
|
+
WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s
|
|
202
|
+
AND version = ANY(%s)
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class _DeltaStage1Row(TypedDict):
|
|
207
|
+
"""One row from `SELECT_DELTA_STAGE1_SQL`."""
|
|
208
|
+
|
|
209
|
+
checkpoint_id: str
|
|
210
|
+
parent_checkpoint_id: str | None
|
|
211
|
+
ver: str | None
|
|
212
|
+
has_snapshot: bool
|
|
213
|
+
|
|
214
|
+
|
|
156
215
|
class BasePostgresSaver(BaseCheckpointSaver[str]):
|
|
157
216
|
SELECT_SQL = SELECT_SQL
|
|
158
217
|
SELECT_PENDING_SENDS_SQL = SELECT_PENDING_SENDS_SQL
|
|
@@ -195,6 +254,88 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
|
|
195
254
|
if t.decode() != "empty"
|
|
196
255
|
}
|
|
197
256
|
|
|
257
|
+
@staticmethod
|
|
258
|
+
def _walk_stage1(
|
|
259
|
+
stage1_rows: Sequence[_DeltaStage1Row],
|
|
260
|
+
target_id: str,
|
|
261
|
+
) -> tuple[list[str], str | None]:
|
|
262
|
+
"""Walk the parent chain from stage 1 metadata rows.
|
|
263
|
+
|
|
264
|
+
Returns (chain_cids, seed_version):
|
|
265
|
+
chain_cids: ancestor checkpoint IDs from target's parent down to
|
|
266
|
+
the seed (or root), in newest-first order.
|
|
267
|
+
seed_version: the channel blob version at the nearest ancestor
|
|
268
|
+
with has_snapshot=True, or None if pure delta.
|
|
269
|
+
"""
|
|
270
|
+
parent_of: dict[str, str | None] = {}
|
|
271
|
+
ver_of: dict[str, str | None] = {}
|
|
272
|
+
snapshot_of: dict[str, bool] = {}
|
|
273
|
+
for r in stage1_rows:
|
|
274
|
+
cid = r["checkpoint_id"]
|
|
275
|
+
parent_of[cid] = r["parent_checkpoint_id"]
|
|
276
|
+
ver_of[cid] = r["ver"]
|
|
277
|
+
snapshot_of[cid] = r["has_snapshot"]
|
|
278
|
+
|
|
279
|
+
chain_cids: list[str] = []
|
|
280
|
+
seed_version: str | None = None
|
|
281
|
+
cur_cid: str | None = parent_of.get(target_id)
|
|
282
|
+
while cur_cid is not None:
|
|
283
|
+
chain_cids.append(cur_cid)
|
|
284
|
+
if snapshot_of.get(cur_cid, False):
|
|
285
|
+
seed_version = ver_of.get(cur_cid)
|
|
286
|
+
break
|
|
287
|
+
cur_cid = parent_of.get(cur_cid)
|
|
288
|
+
return chain_cids, seed_version
|
|
289
|
+
|
|
290
|
+
def _build_delta_channel_writes_history(
|
|
291
|
+
self,
|
|
292
|
+
*,
|
|
293
|
+
channel: str,
|
|
294
|
+
chain_cids: list[str],
|
|
295
|
+
seed_version: str | None,
|
|
296
|
+
stage2_rows: Sequence[_DeltaStage2Row],
|
|
297
|
+
) -> _ChannelWritesHistory:
|
|
298
|
+
"""Reconstruct delta channel history from two-stage query results.
|
|
299
|
+
|
|
300
|
+
chain_cids are in newest-first order (target's parent first).
|
|
301
|
+
stage2_rows contain only writes for chain_cids and the single
|
|
302
|
+
seed blob at seed_version.
|
|
303
|
+
"""
|
|
304
|
+
writes_by_cid: dict[str, list[tuple[str, bytes, str, int]]] = {}
|
|
305
|
+
seed_blob: tuple[str, bytes] | None = None
|
|
306
|
+
|
|
307
|
+
for r in stage2_rows:
|
|
308
|
+
kind = r["_kind"]
|
|
309
|
+
if kind == "w":
|
|
310
|
+
cid = cast(str, r["checkpoint_id"])
|
|
311
|
+
writes_by_cid.setdefault(cid, []).append(
|
|
312
|
+
cast(
|
|
313
|
+
"tuple[str, bytes, str, int]",
|
|
314
|
+
(r["type"], r["blob"], r["task_id"], r["idx"]),
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
else: # kind == "b"
|
|
318
|
+
seed_blob = cast("tuple[str, bytes]", (r["type"], r["blob"]))
|
|
319
|
+
|
|
320
|
+
for ws in writes_by_cid.values():
|
|
321
|
+
ws.sort(key=lambda w: (w[2], w[3]), reverse=True)
|
|
322
|
+
|
|
323
|
+
if not chain_cids:
|
|
324
|
+
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
|
|
325
|
+
|
|
326
|
+
collected: list[PendingWrite] = []
|
|
327
|
+
for cid in chain_cids:
|
|
328
|
+
for type_tag, write_blob, task_id, _idx in writes_by_cid.get(cid, []):
|
|
329
|
+
val = self.serde.loads_typed((type_tag, write_blob))
|
|
330
|
+
collected.append((task_id, channel, val))
|
|
331
|
+
|
|
332
|
+
seed: Any = DELTA_SENTINEL
|
|
333
|
+
if seed_blob is not None and seed_blob[0] != "empty":
|
|
334
|
+
seed = self.serde.loads_typed(seed_blob)
|
|
335
|
+
|
|
336
|
+
collected.reverse()
|
|
337
|
+
return _ChannelWritesHistory(seed=seed, writes=collected)
|
|
338
|
+
|
|
198
339
|
def _dump_blobs(
|
|
199
340
|
self,
|
|
200
341
|
thread_id: str,
|
{langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/pyproject.toml
RENAMED
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "langgraph-checkpoint-postgres"
|
|
7
|
-
version = "3.
|
|
7
|
+
version = "3.1.0a3"
|
|
8
8
|
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
|
|
9
9
|
authors = []
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -12,7 +12,7 @@ readme = "README.md"
|
|
|
12
12
|
license = "MIT"
|
|
13
13
|
license-files = ['LICENSE']
|
|
14
14
|
dependencies = [
|
|
15
|
-
"langgraph-checkpoint>=
|
|
15
|
+
"langgraph-checkpoint>=4.1.0a3,<5.0.0",
|
|
16
16
|
"orjson>=3.11.5",
|
|
17
17
|
"psycopg>=3.2.0",
|
|
18
18
|
"psycopg-pool>=3.2.0",
|
|
@@ -20,7 +20,7 @@ dependencies = [
|
|
|
20
20
|
|
|
21
21
|
[project.urls]
|
|
22
22
|
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres"
|
|
23
|
-
Twitter = "https://x.com/
|
|
23
|
+
Twitter = "https://x.com/langchain_oss"
|
|
24
24
|
Slack = "https://www.langchain.com/join-community"
|
|
25
25
|
Reddit = "https://www.reddit.com/r/LangChain/"
|
|
26
26
|
|
{langgraph_checkpoint_postgres-3.0.5 → langgraph_checkpoint_postgres-3.1.0a3}/tests/test_async.py
RENAMED
|
@@ -361,9 +361,9 @@ async def test_get_checkpoint_no_channel_values(
|
|
|
361
361
|
|
|
362
362
|
load_checkpoint_tuple = saver._load_checkpoint_tuple
|
|
363
363
|
|
|
364
|
-
def patched_load_checkpoint_tuple(value):
|
|
364
|
+
async def patched_load_checkpoint_tuple(value):
|
|
365
365
|
value["checkpoint"].pop("channel_values", None)
|
|
366
|
-
return load_checkpoint_tuple(value)
|
|
366
|
+
return await load_checkpoint_tuple(value)
|
|
367
367
|
|
|
368
368
|
monkeypatch.setattr(
|
|
369
369
|
saver, "_load_checkpoint_tuple", patched_load_checkpoint_tuple
|
|
@@ -371,3 +371,47 @@ async def test_get_checkpoint_no_channel_values(
|
|
|
371
371
|
|
|
372
372
|
checkpoint = await saver.aget_tuple(config)
|
|
373
373
|
assert checkpoint.checkpoint["channel_values"] == {}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
|
|
377
|
+
async def test_delta_channel_chain_reconstruction(saver_name: str) -> None:
|
|
378
|
+
"""AsyncPostgresSaver reconstructs DeltaChannel chain via point-lookup traversal."""
|
|
379
|
+
pytest.importorskip(
|
|
380
|
+
"langgraph.channels.delta", reason="langgraph core not installed"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
from typing import Annotated
|
|
384
|
+
|
|
385
|
+
from langchain_core.messages import AIMessage, HumanMessage
|
|
386
|
+
from langgraph.channels.delta import DeltaChannel
|
|
387
|
+
from langgraph.graph import START, StateGraph
|
|
388
|
+
from langgraph.graph.message import _messages_delta_reducer
|
|
389
|
+
from typing_extensions import TypedDict
|
|
390
|
+
|
|
391
|
+
class State(TypedDict):
|
|
392
|
+
messages: Annotated[list, DeltaChannel(_messages_delta_reducer)]
|
|
393
|
+
|
|
394
|
+
def respond(state: State) -> dict:
|
|
395
|
+
n = len(state["messages"])
|
|
396
|
+
return {"messages": [AIMessage(content=f"reply-{n}", id=f"ai-{n}")]}
|
|
397
|
+
|
|
398
|
+
builder = StateGraph(State)
|
|
399
|
+
builder.add_node("respond", respond)
|
|
400
|
+
builder.add_edge(START, "respond")
|
|
401
|
+
|
|
402
|
+
async with _saver(saver_name) as saver:
|
|
403
|
+
graph = builder.compile(checkpointer=saver)
|
|
404
|
+
config = {"configurable": {"thread_id": "diff-channel-test-1"}}
|
|
405
|
+
|
|
406
|
+
await graph.ainvoke({"messages": [HumanMessage(content="hi", id="h1")]}, config)
|
|
407
|
+
await graph.ainvoke(
|
|
408
|
+
{"messages": [HumanMessage(content="there", id="h2")]}, config
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
state = await graph.aget_state(config)
|
|
412
|
+
msgs = state.values["messages"]
|
|
413
|
+
assert len(msgs) == 4, f"expected 4, got {len(msgs)}: {msgs}"
|
|
414
|
+
assert msgs[0].content == "hi"
|
|
415
|
+
assert msgs[1].content == "reply-1"
|
|
416
|
+
assert msgs[2].content == "there"
|
|
417
|
+
assert msgs[3].content == "reply-3"
|