langgraph-checkpoint-postgres 3.0.4__tar.gz → 3.1.0a1__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.4 → langgraph_checkpoint_postgres-3.1.0a1}/.gitignore +1 -0
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/Makefile +4 -1
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/PKG-INFO +9 -4
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/README.md +5 -0
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/checkpoint/postgres/__init__.py +50 -2
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/checkpoint/postgres/aio.py +48 -2
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/checkpoint/postgres/base.py +137 -1
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/store/postgres/aio.py +8 -7
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/pyproject.toml +4 -4
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/tests/test_async.py +46 -2
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/tests/test_async_store.py +54 -0
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/uv.lock +421 -278
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/LICENSE +0 -0
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/checkpoint/postgres/_ainternal.py +0 -0
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/checkpoint/postgres/_internal.py +0 -0
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/checkpoint/postgres/py.typed +0 -0
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/checkpoint/postgres/shallow.py +0 -0
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/store/postgres/__init__.py +0 -0
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/store/postgres/base.py +0 -0
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/langgraph/store/postgres/py.typed +0 -0
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/tests/__init__.py +0 -0
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/tests/compose-postgres.yml +0 -0
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/tests/conftest.py +0 -0
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/tests/embed_test_utils.py +0 -0
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/tests/test_store.py +0 -0
- {langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/tests/test_sync.py +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.PHONY: test test_watch lint format
|
|
1
|
+
.PHONY: test test_watch lint type format
|
|
2
2
|
|
|
3
3
|
######################
|
|
4
4
|
# TESTING AND COVERAGE
|
|
@@ -61,6 +61,9 @@ lint lint_diff lint_package lint_tests:
|
|
|
61
61
|
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE)
|
|
62
62
|
[ "$(PYTHON_FILES)" = "" ] || uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
|
63
63
|
|
|
64
|
+
type:
|
|
65
|
+
mkdir -p $(MYPY_CACHE) && uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
|
66
|
+
|
|
64
67
|
format format_diff:
|
|
65
68
|
uv run ruff format $(PYTHON_FILES)
|
|
66
69
|
uv run ruff check --select I --fix $(PYTHON_FILES)
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: langgraph-checkpoint-postgres
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.1.0a1
|
|
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,>=
|
|
13
|
-
Requires-Dist: orjson>=3.
|
|
12
|
+
Requires-Dist: langgraph-checkpoint<5.0.0,>=4.1.0a1
|
|
13
|
+
Requires-Dist: orjson>=3.11.5
|
|
14
14
|
Requires-Dist: psycopg-pool>=3.2.0
|
|
15
15
|
Requires-Dist: psycopg>=3.2.0
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
@@ -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,15 +4,17 @@ 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
|
)
|
|
@@ -23,7 +25,11 @@ from psycopg.types.json import Jsonb
|
|
|
23
25
|
from psycopg_pool import ConnectionPool
|
|
24
26
|
|
|
25
27
|
from langgraph.checkpoint.postgres import _internal
|
|
26
|
-
from langgraph.checkpoint.postgres.base import
|
|
28
|
+
from langgraph.checkpoint.postgres.base import (
|
|
29
|
+
SELECT_DELTA_COMBINED_SQL,
|
|
30
|
+
BasePostgresSaver,
|
|
31
|
+
_DeltaCombinedRow,
|
|
32
|
+
)
|
|
27
33
|
from langgraph.checkpoint.postgres.shallow import ShallowPostgresSaver
|
|
28
34
|
|
|
29
35
|
Conn = _internal.Conn # For backward compatibility
|
|
@@ -430,6 +436,48 @@ class PostgresSaver(BasePostgresSaver):
|
|
|
430
436
|
with conn.cursor(binary=True, row_factory=dict_row) as cur:
|
|
431
437
|
yield cur
|
|
432
438
|
|
|
439
|
+
def _get_channel_writes_history(
|
|
440
|
+
self, config: RunnableConfig, channel: str
|
|
441
|
+
) -> _ChannelWritesHistory:
|
|
442
|
+
"""Fast-path override of `BaseCheckpointSaver._get_channel_writes_history`.
|
|
443
|
+
|
|
444
|
+
One combined UNION ALL query (`SELECT_DELTA_COMBINED_SQL`) fetches rows
|
|
445
|
+
from `checkpoints`, `checkpoint_writes`, and `checkpoint_blobs` in a
|
|
446
|
+
single roundtrip; the ancestor walk runs in Python.
|
|
447
|
+
"""
|
|
448
|
+
thread_id = config["configurable"]["thread_id"]
|
|
449
|
+
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
|
450
|
+
checkpoint_id = get_checkpoint_id(config)
|
|
451
|
+
if checkpoint_id is None:
|
|
452
|
+
# Caller didn't specify a target — resolve to the latest
|
|
453
|
+
# checkpoint on the thread. `get_tuple` without `checkpoint_id`
|
|
454
|
+
# returns the newest; its config carries the resolved id.
|
|
455
|
+
target = self.get_tuple(config)
|
|
456
|
+
if target is None:
|
|
457
|
+
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
|
|
458
|
+
checkpoint_id = target.config["configurable"]["checkpoint_id"]
|
|
459
|
+
with self._cursor() as cur:
|
|
460
|
+
cur.execute(
|
|
461
|
+
SELECT_DELTA_COMBINED_SQL,
|
|
462
|
+
(
|
|
463
|
+
channel,
|
|
464
|
+
thread_id,
|
|
465
|
+
checkpoint_ns,
|
|
466
|
+
thread_id,
|
|
467
|
+
checkpoint_ns,
|
|
468
|
+
channel,
|
|
469
|
+
thread_id,
|
|
470
|
+
checkpoint_ns,
|
|
471
|
+
channel,
|
|
472
|
+
),
|
|
473
|
+
)
|
|
474
|
+
rows = cur.fetchall()
|
|
475
|
+
return self._build_delta_channel_writes_history(
|
|
476
|
+
channel=channel,
|
|
477
|
+
target_id=checkpoint_id,
|
|
478
|
+
rows=cast("list[_DeltaCombinedRow]", rows),
|
|
479
|
+
)
|
|
480
|
+
|
|
433
481
|
def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
|
|
434
482
|
"""
|
|
435
483
|
Convert a database row into a CheckpointTuple object.
|
|
@@ -4,15 +4,17 @@ 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
|
)
|
|
@@ -23,7 +25,11 @@ from psycopg.types.json import Jsonb
|
|
|
23
25
|
from psycopg_pool import AsyncConnectionPool
|
|
24
26
|
|
|
25
27
|
from langgraph.checkpoint.postgres import _ainternal
|
|
26
|
-
from langgraph.checkpoint.postgres.base import
|
|
28
|
+
from langgraph.checkpoint.postgres.base import (
|
|
29
|
+
SELECT_DELTA_COMBINED_SQL,
|
|
30
|
+
BasePostgresSaver,
|
|
31
|
+
_DeltaCombinedRow,
|
|
32
|
+
)
|
|
27
33
|
from langgraph.checkpoint.postgres.shallow import AsyncShallowPostgresSaver
|
|
28
34
|
|
|
29
35
|
Conn = _ainternal.Conn # For backward compatibility
|
|
@@ -391,6 +397,46 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|
|
391
397
|
async with conn.cursor(binary=True, row_factory=dict_row) as cur:
|
|
392
398
|
yield cur
|
|
393
399
|
|
|
400
|
+
async def _aget_channel_writes_history(
|
|
401
|
+
self, config: RunnableConfig, channel: str
|
|
402
|
+
) -> _ChannelWritesHistory:
|
|
403
|
+
"""Fast-path override of `BaseCheckpointSaver._aget_channel_writes_history`.
|
|
404
|
+
|
|
405
|
+
One combined UNION ALL query (`SELECT_DELTA_COMBINED_SQL`) fetches rows
|
|
406
|
+
from `checkpoints`, `checkpoint_writes`, and `checkpoint_blobs` in a
|
|
407
|
+
single roundtrip; rows are assembled by the shared pure helper on
|
|
408
|
+
`BasePostgresSaver`.
|
|
409
|
+
"""
|
|
410
|
+
thread_id = config["configurable"]["thread_id"]
|
|
411
|
+
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
|
412
|
+
checkpoint_id = get_checkpoint_id(config)
|
|
413
|
+
if checkpoint_id is None:
|
|
414
|
+
target = await self.aget_tuple(config)
|
|
415
|
+
if target is None:
|
|
416
|
+
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
|
|
417
|
+
checkpoint_id = target.config["configurable"]["checkpoint_id"]
|
|
418
|
+
async with self._cursor() as cur:
|
|
419
|
+
await cur.execute(
|
|
420
|
+
SELECT_DELTA_COMBINED_SQL,
|
|
421
|
+
(
|
|
422
|
+
channel,
|
|
423
|
+
thread_id,
|
|
424
|
+
checkpoint_ns,
|
|
425
|
+
thread_id,
|
|
426
|
+
checkpoint_ns,
|
|
427
|
+
channel,
|
|
428
|
+
thread_id,
|
|
429
|
+
checkpoint_ns,
|
|
430
|
+
channel,
|
|
431
|
+
),
|
|
432
|
+
)
|
|
433
|
+
rows = await cur.fetchall()
|
|
434
|
+
return self._build_delta_channel_writes_history(
|
|
435
|
+
channel=channel,
|
|
436
|
+
target_id=checkpoint_id,
|
|
437
|
+
rows=cast("list[_DeltaCombinedRow]", rows),
|
|
438
|
+
)
|
|
439
|
+
|
|
394
440
|
async def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
|
|
395
441
|
"""
|
|
396
442
|
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 _DeltaCombinedRow(TypedDict, total=False):
|
|
160
|
+
"""One row from `SELECT_DELTA_COMBINED_SQL` (a UNION ALL of three tables).
|
|
161
|
+
|
|
162
|
+
Every row carries `_kind` ("p" / "w" / "b") plus whichever columns are
|
|
163
|
+
relevant for that kind; irrelevant columns are NULL and typed as `None`.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
_kind: str # always present: "p", "w", or "b"
|
|
167
|
+
# checkpoint row ("p")
|
|
168
|
+
checkpoint_id: str | None
|
|
169
|
+
parent_checkpoint_id: str | None
|
|
170
|
+
ver: str | None
|
|
171
|
+
# write / blob rows ("w", "b")
|
|
172
|
+
type: str | None
|
|
173
|
+
blob: bytes | None
|
|
174
|
+
# write row only ("w")
|
|
175
|
+
task_id: str | None
|
|
176
|
+
idx: int | None
|
|
177
|
+
# blob row only ("b")
|
|
178
|
+
version: str | None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# DeltaChannel reconstruction: one UNION ALL query fetches checkpoints,
|
|
182
|
+
# writes, and blobs for `channel` in one roundtrip; the ancestor walk runs
|
|
183
|
+
# in Python in `_build_delta_channel_writes_history`.
|
|
184
|
+
#
|
|
185
|
+
# Parameter order: (channel, thread_id, checkpoint_ns,
|
|
186
|
+
# thread_id, checkpoint_ns, channel,
|
|
187
|
+
# thread_id, checkpoint_ns, channel)
|
|
188
|
+
SELECT_DELTA_COMBINED_SQL = """
|
|
189
|
+
SELECT 'p'::text AS _kind,
|
|
190
|
+
checkpoint_id,
|
|
191
|
+
parent_checkpoint_id,
|
|
192
|
+
checkpoint -> 'channel_versions' ->> %s AS ver,
|
|
193
|
+
NULL::text AS type,
|
|
194
|
+
NULL::bytea AS blob,
|
|
195
|
+
NULL::text AS task_id,
|
|
196
|
+
NULL::int AS idx,
|
|
197
|
+
NULL::text AS version
|
|
198
|
+
FROM checkpoints
|
|
199
|
+
WHERE thread_id = %s AND checkpoint_ns = %s
|
|
200
|
+
UNION ALL
|
|
201
|
+
SELECT 'w',
|
|
202
|
+
checkpoint_id, NULL, NULL,
|
|
203
|
+
type, blob, task_id, idx, NULL
|
|
204
|
+
FROM checkpoint_writes
|
|
205
|
+
WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s
|
|
206
|
+
UNION ALL
|
|
207
|
+
SELECT 'b',
|
|
208
|
+
NULL, NULL, NULL,
|
|
209
|
+
type, blob, NULL, NULL, version
|
|
210
|
+
FROM checkpoint_blobs
|
|
211
|
+
WHERE thread_id = %s AND checkpoint_ns = %s AND channel = %s
|
|
212
|
+
"""
|
|
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,83 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
|
|
195
254
|
if t.decode() != "empty"
|
|
196
255
|
}
|
|
197
256
|
|
|
257
|
+
def _build_delta_channel_writes_history(
|
|
258
|
+
self,
|
|
259
|
+
*,
|
|
260
|
+
channel: str,
|
|
261
|
+
target_id: str,
|
|
262
|
+
rows: Sequence[_DeltaCombinedRow],
|
|
263
|
+
) -> _ChannelWritesHistory:
|
|
264
|
+
"""Reconstruct one delta channel's history from the combined UNION ALL rows.
|
|
265
|
+
|
|
266
|
+
Pure data transform shared by sync (`PostgresSaver`) and async
|
|
267
|
+
(`AsyncPostgresSaver`); both paths run `SELECT_DELTA_COMBINED_SQL`
|
|
268
|
+
and feed the tagged rows here.
|
|
269
|
+
|
|
270
|
+
Walk is newest → oldest from the target's parent. A non-sentinel
|
|
271
|
+
blob in `checkpoint_blobs` (a pre-delta snapshot) terminates the
|
|
272
|
+
walk and is returned as the seed so replay starts from it.
|
|
273
|
+
|
|
274
|
+
Writes stored at `target_id` itself are pending writes for the next
|
|
275
|
+
step and are excluded — the walk begins at the target's parent.
|
|
276
|
+
"""
|
|
277
|
+
parent_of: dict[str, str | None] = {}
|
|
278
|
+
ver_of: dict[str, str | None] = {}
|
|
279
|
+
writes_by_cid: dict[str, list[tuple[str, bytes, str, int]]] = {}
|
|
280
|
+
blob_by_ver: dict[str, tuple[str, bytes]] = {}
|
|
281
|
+
|
|
282
|
+
for r in rows:
|
|
283
|
+
kind = r["_kind"]
|
|
284
|
+
if kind == "p":
|
|
285
|
+
cid = cast(str, r["checkpoint_id"])
|
|
286
|
+
parent_of[cid] = r["parent_checkpoint_id"]
|
|
287
|
+
ver_of[cid] = r["ver"]
|
|
288
|
+
elif kind == "w":
|
|
289
|
+
cid = cast(str, r["checkpoint_id"])
|
|
290
|
+
writes_by_cid.setdefault(cid, []).append(
|
|
291
|
+
cast(
|
|
292
|
+
"tuple[str, bytes, str, int]",
|
|
293
|
+
(r["type"], r["blob"], r["task_id"], r["idx"]),
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
else: # kind == "b"
|
|
297
|
+
blob_by_ver[cast(str, r["version"])] = cast(
|
|
298
|
+
"tuple[str, bytes]", (r["type"], r["blob"])
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# newest write first per ancestor (task_id DESC, idx DESC)
|
|
302
|
+
for ws in writes_by_cid.values():
|
|
303
|
+
ws.sort(key=lambda w: (w[2], w[3]), reverse=True)
|
|
304
|
+
|
|
305
|
+
ancestors: list[str] = []
|
|
306
|
+
cur_cid: str | None = parent_of.get(target_id)
|
|
307
|
+
while cur_cid is not None:
|
|
308
|
+
ancestors.append(cur_cid)
|
|
309
|
+
cur_cid = parent_of.get(cur_cid)
|
|
310
|
+
if not ancestors:
|
|
311
|
+
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=[])
|
|
312
|
+
|
|
313
|
+
collected: list[PendingWrite] = [] # newest first; reversed at the end
|
|
314
|
+
for cid in ancestors:
|
|
315
|
+
# Collect writes first — they encode the transition FROM this
|
|
316
|
+
# ancestor's state to its child's and must be included even if
|
|
317
|
+
# this ancestor is also the seed checkpoint.
|
|
318
|
+
for type_tag, write_blob, task_id, _idx in writes_by_cid.get(cid, []):
|
|
319
|
+
val = self.serde.loads_typed((type_tag, write_blob))
|
|
320
|
+
collected.append((task_id, channel, val))
|
|
321
|
+
# Then check seed terminator.
|
|
322
|
+
ver = ver_of.get(cid)
|
|
323
|
+
if ver is not None:
|
|
324
|
+
seed_blob = blob_by_ver.get(ver)
|
|
325
|
+
if seed_blob is not None and seed_blob[0] != "empty":
|
|
326
|
+
blob_value = self.serde.loads_typed(seed_blob)
|
|
327
|
+
if blob_value is not DELTA_SENTINEL:
|
|
328
|
+
collected.reverse()
|
|
329
|
+
return _ChannelWritesHistory(seed=blob_value, writes=collected)
|
|
330
|
+
|
|
331
|
+
collected.reverse() # oldest → newest
|
|
332
|
+
return _ChannelWritesHistory(seed=DELTA_SENTINEL, writes=collected)
|
|
333
|
+
|
|
198
334
|
def _dump_blobs(
|
|
199
335
|
self,
|
|
200
336
|
thread_id: str,
|
|
@@ -164,12 +164,11 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
|
|
|
164
164
|
grouped_ops, num_ops = _group_ops(ops)
|
|
165
165
|
results: list[Result] = [None] * num_ops
|
|
166
166
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
await self._execute_batch(grouped_ops, results, conn)
|
|
167
|
+
if self.pipe:
|
|
168
|
+
async with self.pipe:
|
|
169
|
+
await self._execute_batch(grouped_ops, results)
|
|
170
|
+
else:
|
|
171
|
+
await self._execute_batch(grouped_ops, results)
|
|
173
172
|
|
|
174
173
|
return results
|
|
175
174
|
|
|
@@ -410,8 +409,10 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
|
|
|
410
409
|
self,
|
|
411
410
|
grouped_ops: dict,
|
|
412
411
|
results: list[Result],
|
|
413
|
-
conn: AsyncConnection[DictRow],
|
|
412
|
+
conn: AsyncConnection[DictRow] | None = None,
|
|
414
413
|
) -> None:
|
|
414
|
+
# Keep `conn` for compatibility with subclasses overriding this private hook.
|
|
415
|
+
# All database I/O goes through `_cursor()`, which owns connection acquisition.
|
|
415
416
|
async with self._cursor(pipeline=True) as cur:
|
|
416
417
|
if GetOp in grouped_ops:
|
|
417
418
|
await self._batch_get_ops(
|
{langgraph_checkpoint_postgres-3.0.4 → langgraph_checkpoint_postgres-3.1.0a1}/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.0a1"
|
|
8
8
|
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
|
|
9
9
|
authors = []
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -12,15 +12,15 @@ readme = "README.md"
|
|
|
12
12
|
license = "MIT"
|
|
13
13
|
license-files = ['LICENSE']
|
|
14
14
|
dependencies = [
|
|
15
|
-
"langgraph-checkpoint>=
|
|
16
|
-
"orjson>=3.
|
|
15
|
+
"langgraph-checkpoint>=4.1.0a1,<5.0.0",
|
|
16
|
+
"orjson>=3.11.5",
|
|
17
17
|
"psycopg>=3.2.0",
|
|
18
18
|
"psycopg-pool>=3.2.0",
|
|
19
19
|
]
|
|
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.4 → langgraph_checkpoint_postgres-3.1.0a1}/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"
|
|
@@ -20,6 +20,7 @@ from langgraph.store.base import (
|
|
|
20
20
|
)
|
|
21
21
|
from psycopg import AsyncConnection
|
|
22
22
|
|
|
23
|
+
from langgraph.checkpoint.postgres import _ainternal
|
|
23
24
|
from langgraph.store.postgres import AsyncPostgresStore
|
|
24
25
|
from tests.conftest import (
|
|
25
26
|
DEFAULT_URI,
|
|
@@ -346,6 +347,59 @@ async def test_batch_list_namespaces_ops(store: AsyncPostgresStore) -> None:
|
|
|
346
347
|
assert ("test", "namespace2") in results[0]
|
|
347
348
|
|
|
348
349
|
|
|
350
|
+
@asynccontextmanager
|
|
351
|
+
async def _create_pool_store() -> AsyncIterator[AsyncPostgresStore]:
|
|
352
|
+
database = f"test_{uuid.uuid4().hex[:16]}"
|
|
353
|
+
uri_parts = DEFAULT_URI.split("/")
|
|
354
|
+
uri_base = "/".join(uri_parts[:-1])
|
|
355
|
+
query_params = ""
|
|
356
|
+
if "?" in uri_parts[-1]:
|
|
357
|
+
_, query_params = uri_parts[-1].split("?", 1)
|
|
358
|
+
query_params = "?" + query_params
|
|
359
|
+
|
|
360
|
+
conn_string = f"{uri_base}/{database}{query_params}"
|
|
361
|
+
admin_conn_string = DEFAULT_URI
|
|
362
|
+
async with await AsyncConnection.connect(
|
|
363
|
+
admin_conn_string, autocommit=True
|
|
364
|
+
) as conn:
|
|
365
|
+
await conn.execute(f"CREATE DATABASE {database}")
|
|
366
|
+
try:
|
|
367
|
+
async with AsyncPostgresStore.from_conn_string(
|
|
368
|
+
conn_string, pool_config={"min_size": 1, "max_size": 1}
|
|
369
|
+
) as store:
|
|
370
|
+
await store.setup()
|
|
371
|
+
yield store
|
|
372
|
+
finally:
|
|
373
|
+
async with await AsyncConnection.connect(
|
|
374
|
+
admin_conn_string, autocommit=True
|
|
375
|
+
) as conn:
|
|
376
|
+
await conn.execute(f"DROP DATABASE {database}")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
async def test_abatch_uses_single_pool_checkout(monkeypatch) -> None:
|
|
380
|
+
async with _create_pool_store() as store:
|
|
381
|
+
await store.aput(("test",), "key1", {"data": "value1"})
|
|
382
|
+
|
|
383
|
+
original_get_connection = _ainternal.get_connection
|
|
384
|
+
checkout_count = 0
|
|
385
|
+
|
|
386
|
+
@asynccontextmanager
|
|
387
|
+
async def counting_get_connection(conn):
|
|
388
|
+
nonlocal checkout_count
|
|
389
|
+
checkout_count += 1
|
|
390
|
+
async with original_get_connection(conn) as checked_out_conn:
|
|
391
|
+
yield checked_out_conn
|
|
392
|
+
|
|
393
|
+
monkeypatch.setattr(_ainternal, "get_connection", counting_get_connection)
|
|
394
|
+
|
|
395
|
+
results = await store.abatch([GetOp(namespace=("test",), key="key1")])
|
|
396
|
+
|
|
397
|
+
assert len(results) == 1
|
|
398
|
+
assert results[0] is not None
|
|
399
|
+
assert results[0].value == {"data": "value1"}
|
|
400
|
+
assert checkout_count == 1
|
|
401
|
+
|
|
402
|
+
|
|
349
403
|
@asynccontextmanager
|
|
350
404
|
async def _create_vector_store(
|
|
351
405
|
vector_type: str,
|