langgraph-checkpoint-sqlite 3.0.3__tar.gz → 3.1.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_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/.gitignore +1 -0
- {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/Makefile +4 -1
- {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/PKG-INFO +8 -3
- {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/README.md +5 -0
- {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/langgraph/checkpoint/sqlite/__init__.py +90 -1
- langgraph_checkpoint_sqlite-3.1.0/langgraph/checkpoint/sqlite/_delta.py +172 -0
- {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/langgraph/checkpoint/sqlite/aio.py +108 -1
- {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/pyproject.toml +3 -3
- langgraph_checkpoint_sqlite-3.1.0/tests/test_conformance_delta.py +35 -0
- langgraph_checkpoint_sqlite-3.1.0/tests/test_delta_channel_migration.py +170 -0
- langgraph_checkpoint_sqlite-3.1.0/tests/test_get_delta_channel_history.py +271 -0
- {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/uv.lock +384 -224
- {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/LICENSE +0 -0
- {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/langgraph/cache/sqlite/__init__.py +0 -0
- {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/langgraph/checkpoint/sqlite/py.typed +0 -0
- {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/langgraph/checkpoint/sqlite/utils.py +0 -0
- {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/langgraph/store/sqlite/__init__.py +0 -0
- {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/langgraph/store/sqlite/aio.py +0 -0
- {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/langgraph/store/sqlite/base.py +0 -0
- {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/tests/__init__.py +0 -0
- {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/tests/test_aiosqlite.py +0 -0
- {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/tests/test_async_store.py +0 -0
- {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/tests/test_sqlite.py +0 -0
- {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/tests/test_store.py +0 -0
- {langgraph_checkpoint_sqlite-3.0.3 → langgraph_checkpoint_sqlite-3.1.0}/tests/test_ttl.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
|
|
@@ -32,6 +32,9 @@ lint lint_diff lint_package lint_tests:
|
|
|
32
32
|
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE)
|
|
33
33
|
[ "$(PYTHON_FILES)" = "" ] || uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
|
34
34
|
|
|
35
|
+
type:
|
|
36
|
+
mkdir -p $(MYPY_CACHE) && uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
|
37
|
+
|
|
35
38
|
format format_diff:
|
|
36
39
|
uv run ruff format $(PYTHON_FILES)
|
|
37
40
|
uv run ruff check --select I --fix $(PYTHON_FILES)
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: langgraph-checkpoint-sqlite
|
|
3
|
-
Version: 3.0
|
|
3
|
+
Version: 3.1.0
|
|
4
4
|
Summary: Library with a SQLite implementation of LangGraph checkpoint saver.
|
|
5
5
|
Project-URL: Source, https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-sqlite
|
|
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
12
|
Requires-Dist: aiosqlite>=0.20
|
|
13
|
-
Requires-Dist: langgraph-checkpoint<5.0.0,>=
|
|
13
|
+
Requires-Dist: langgraph-checkpoint<5.0.0,>=4.1.0
|
|
14
14
|
Requires-Dist: sqlite-vec>=0.1.6
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
|
|
@@ -18,6 +18,11 @@ Description-Content-Type: text/markdown
|
|
|
18
18
|
|
|
19
19
|
Implementation of LangGraph CheckpointSaver that uses SQLite DB (both sync and async, via `aiosqlite`)
|
|
20
20
|
|
|
21
|
+
## Security
|
|
22
|
+
|
|
23
|
+
> [!IMPORTANT]
|
|
24
|
+
> 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.
|
|
25
|
+
|
|
21
26
|
## Usage
|
|
22
27
|
|
|
23
28
|
```python
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
Implementation of LangGraph CheckpointSaver that uses SQLite DB (both sync and async, via `aiosqlite`)
|
|
4
4
|
|
|
5
|
+
## Security
|
|
6
|
+
|
|
7
|
+
> [!IMPORTANT]
|
|
8
|
+
> 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.
|
|
9
|
+
|
|
5
10
|
## Usage
|
|
6
11
|
|
|
7
12
|
```python
|
|
@@ -4,7 +4,7 @@ import json
|
|
|
4
4
|
import random
|
|
5
5
|
import sqlite3
|
|
6
6
|
import threading
|
|
7
|
-
from collections.abc import AsyncIterator, Iterator, Sequence
|
|
7
|
+
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
|
|
8
8
|
from contextlib import closing, contextmanager
|
|
9
9
|
from typing import Any, cast
|
|
10
10
|
|
|
@@ -16,12 +16,19 @@ from langgraph.checkpoint.base import (
|
|
|
16
16
|
Checkpoint,
|
|
17
17
|
CheckpointMetadata,
|
|
18
18
|
CheckpointTuple,
|
|
19
|
+
DeltaChannelHistory,
|
|
19
20
|
SerializerProtocol,
|
|
20
21
|
get_checkpoint_id,
|
|
21
22
|
get_checkpoint_metadata,
|
|
22
23
|
)
|
|
23
24
|
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
|
24
25
|
|
|
26
|
+
from langgraph.checkpoint.sqlite._delta import (
|
|
27
|
+
DELTA_STAGE1_SQL,
|
|
28
|
+
build_delta_channels_writes_history,
|
|
29
|
+
build_delta_stage2_sql,
|
|
30
|
+
step_walk_with_row,
|
|
31
|
+
)
|
|
25
32
|
from langgraph.checkpoint.sqlite.utils import search_where
|
|
26
33
|
|
|
27
34
|
_AIO_ERROR_MSG = (
|
|
@@ -493,6 +500,88 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
|
|
493
500
|
(str(thread_id),),
|
|
494
501
|
)
|
|
495
502
|
|
|
503
|
+
def get_delta_channel_history(
|
|
504
|
+
self, *, config: RunnableConfig, channels: Sequence[str]
|
|
505
|
+
) -> Mapping[str, DeltaChannelHistory]:
|
|
506
|
+
"""Fast-path override of `BaseCheckpointSaver.get_delta_channel_history`.
|
|
507
|
+
|
|
508
|
+
Two-stage query:
|
|
509
|
+
|
|
510
|
+
* Stage 1 (paged): newest-first slice of `checkpoints` returning
|
|
511
|
+
`(checkpoint_id, parent_checkpoint_id, type, checkpoint)` per
|
|
512
|
+
ancestor. Sqlite has no JSONB, so we ship the full serialized
|
|
513
|
+
checkpoint blob and inspect `channel_values` in Python. Pages
|
|
514
|
+
newest-first by `checkpoint_id` with a `< cursor` predicate;
|
|
515
|
+
page size is `DELTA_PAGE_SIZE`. Stops paging when every channel
|
|
516
|
+
has found its seed or the chain is exhausted.
|
|
517
|
+
|
|
518
|
+
* Stage 2 (per-channel UNION ALL): one branch per channel reading
|
|
519
|
+
`writes` filtered to that channel's specific `chain_cids`. No
|
|
520
|
+
separate seed-blob fetch — sqlite stores `channel_values` inline
|
|
521
|
+
in the checkpoint blob, so seeds come back from stage 1.
|
|
522
|
+
"""
|
|
523
|
+
if not channels:
|
|
524
|
+
return {}
|
|
525
|
+
channels = list(channels)
|
|
526
|
+
thread_id = str(config["configurable"]["thread_id"])
|
|
527
|
+
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
|
528
|
+
checkpoint_id = get_checkpoint_id(config)
|
|
529
|
+
if checkpoint_id is None:
|
|
530
|
+
target = self.get_tuple(config)
|
|
531
|
+
if target is None:
|
|
532
|
+
return {ch: {"writes": []} for ch in channels}
|
|
533
|
+
checkpoint_id = target.config["configurable"]["checkpoint_id"]
|
|
534
|
+
|
|
535
|
+
chain_by_ch: dict[str, list[str]] = {ch: [] for ch in channels}
|
|
536
|
+
seed_val_by_ch: dict[str, Any] = {}
|
|
537
|
+
walk_state: dict[str, Any] = {}
|
|
538
|
+
seeded: set[str] = set()
|
|
539
|
+
|
|
540
|
+
with self.cursor(transaction=False) as cur:
|
|
541
|
+
cur.execute(DELTA_STAGE1_SQL, (thread_id, checkpoint_ns, checkpoint_id))
|
|
542
|
+
for row in cur:
|
|
543
|
+
cid, parent_cid, type_tag, blob = row
|
|
544
|
+
if step_walk_with_row(
|
|
545
|
+
cid=cid,
|
|
546
|
+
parent_cid=parent_cid,
|
|
547
|
+
type_tag=type_tag,
|
|
548
|
+
blob=blob,
|
|
549
|
+
target_id=checkpoint_id,
|
|
550
|
+
serde=self.serde,
|
|
551
|
+
chain_by_ch=chain_by_ch,
|
|
552
|
+
seed_val_by_ch=seed_val_by_ch,
|
|
553
|
+
walk_state=walk_state,
|
|
554
|
+
seeded=seeded,
|
|
555
|
+
channels=channels,
|
|
556
|
+
):
|
|
557
|
+
break
|
|
558
|
+
|
|
559
|
+
channels_with_chain = [ch for ch in channels if chain_by_ch[ch]]
|
|
560
|
+
stage2_sql = build_delta_stage2_sql(
|
|
561
|
+
chain_lens=[len(chain_by_ch[ch]) for ch in channels_with_chain],
|
|
562
|
+
)
|
|
563
|
+
if stage2_sql:
|
|
564
|
+
stage2_params: list[Any] = []
|
|
565
|
+
for ch in channels_with_chain:
|
|
566
|
+
stage2_params.extend(
|
|
567
|
+
[thread_id, checkpoint_ns, ch, *chain_by_ch[ch]]
|
|
568
|
+
)
|
|
569
|
+
cur.execute(stage2_sql, stage2_params)
|
|
570
|
+
stage2_rows = cast(
|
|
571
|
+
"list[tuple[str, str, str, int, str, bytes]]", cur.fetchall()
|
|
572
|
+
)
|
|
573
|
+
else:
|
|
574
|
+
stage2_rows = []
|
|
575
|
+
|
|
576
|
+
return build_delta_channels_writes_history(
|
|
577
|
+
channels=channels,
|
|
578
|
+
chain_by_ch=chain_by_ch,
|
|
579
|
+
seed_val_by_ch=seed_val_by_ch,
|
|
580
|
+
seeded=seeded,
|
|
581
|
+
stage2_rows=stage2_rows,
|
|
582
|
+
serde=self.serde,
|
|
583
|
+
)
|
|
584
|
+
|
|
496
585
|
async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
|
|
497
586
|
"""Get a checkpoint tuple from the database asynchronously.
|
|
498
587
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Shared helpers for `get_delta_channel_history` on sqlite savers.
|
|
2
|
+
|
|
3
|
+
Mirrors the two-stage shape of `BasePostgresSaver` (ancestor walk +
|
|
4
|
+
per-channel UNION ALL writes fetch), but adapted for sqlite's
|
|
5
|
+
constraints. The structural differences:
|
|
6
|
+
|
|
7
|
+
* No JSONB — to inspect `channel_values` for a checkpoint we must
|
|
8
|
+
deserialize the full blob. Stage 1 streams the cursor row-by-row and
|
|
9
|
+
deserializes only the rows the merged walk visits, freeing each blob
|
|
10
|
+
before advancing.
|
|
11
|
+
* No separate blob table — `channel_values` lives inline in the
|
|
12
|
+
checkpoint, so seeds come back from stage 1 with no second fetch.
|
|
13
|
+
* Single merged walk (not K independent walks): each visited cid is
|
|
14
|
+
deserialized exactly once, regardless of how many channels are still
|
|
15
|
+
seeking their seed.
|
|
16
|
+
|
|
17
|
+
The streaming design keeps peak in-flight memory at roughly one
|
|
18
|
+
deserialized checkpoint at a time, instead of holding the entire
|
|
19
|
+
ancestor chain's worth of raw blobs as a `fetchall()`-materialized list.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from collections.abc import Mapping, Sequence
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from langgraph.checkpoint.base import DeltaChannelHistory, PendingWrite
|
|
28
|
+
|
|
29
|
+
# Stage 1 streams ancestors of `target_cid` newest-first. The `<=`
|
|
30
|
+
# predicate keeps target itself in the stream so we can read its
|
|
31
|
+
# `parent_checkpoint_id` from the first row without a separate lookup;
|
|
32
|
+
# the caller skips target's own writes/seed (matches the
|
|
33
|
+
# `BaseCheckpointSaver` contract).
|
|
34
|
+
DELTA_STAGE1_SQL = (
|
|
35
|
+
"SELECT checkpoint_id, parent_checkpoint_id, type, checkpoint "
|
|
36
|
+
"FROM checkpoints "
|
|
37
|
+
"WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id <= ? "
|
|
38
|
+
"ORDER BY checkpoint_id DESC"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_delta_stage2_sql(*, chain_lens: Sequence[int]) -> str:
|
|
43
|
+
"""Stage-2 per-channel UNION ALL fetching writes from `writes`.
|
|
44
|
+
|
|
45
|
+
One branch per channel with a non-empty chain. Each branch inlines its
|
|
46
|
+
own `IN (?, ?, ...)` placeholder list because sqlite has no array-bind
|
|
47
|
+
equivalent of postgres's `= ANY(%s)`. Caller passes parameters in
|
|
48
|
+
matching order: `[thread_id, checkpoint_ns, channel, *chain_cids]` per
|
|
49
|
+
branch.
|
|
50
|
+
|
|
51
|
+
Returns an empty string when no channel has a chain (caller skips
|
|
52
|
+
executing in that case). Per-channel UNION ALL avoids the over-fetch
|
|
53
|
+
of a single `channel = ANY(channels)` filter when channels have
|
|
54
|
+
different chain depths — same rationale as postgres.
|
|
55
|
+
"""
|
|
56
|
+
branches: list[str] = []
|
|
57
|
+
for n in chain_lens:
|
|
58
|
+
cid_placeholders = ",".join("?" * n)
|
|
59
|
+
branches.append(
|
|
60
|
+
"SELECT checkpoint_id, channel, task_id, idx, type, value "
|
|
61
|
+
"FROM writes "
|
|
62
|
+
"WHERE thread_id = ? AND checkpoint_ns = ? AND channel = ? "
|
|
63
|
+
f"AND checkpoint_id IN ({cid_placeholders})"
|
|
64
|
+
)
|
|
65
|
+
return " UNION ALL ".join(branches)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def step_walk_with_row(
|
|
69
|
+
*,
|
|
70
|
+
cid: str,
|
|
71
|
+
parent_cid: str | None,
|
|
72
|
+
type_tag: str,
|
|
73
|
+
blob: bytes,
|
|
74
|
+
target_id: str,
|
|
75
|
+
serde: Any,
|
|
76
|
+
chain_by_ch: dict[str, list[str]],
|
|
77
|
+
seed_val_by_ch: dict[str, Any],
|
|
78
|
+
walk_state: dict[str, Any],
|
|
79
|
+
seeded: set[str],
|
|
80
|
+
channels: Sequence[str],
|
|
81
|
+
) -> bool:
|
|
82
|
+
"""Process one streamed stage-1 row in the merged ancestor walk.
|
|
83
|
+
|
|
84
|
+
The cursor returns (cid, parent_cid, type, blob) rows in
|
|
85
|
+
`checkpoint_id` DESC order starting at target. The first row is
|
|
86
|
+
target itself; we read its parent_cid to seed the walk and otherwise
|
|
87
|
+
skip it (target's own writes/seed are not part of the contract).
|
|
88
|
+
|
|
89
|
+
For each subsequent row, if `cid` matches the walk's current
|
|
90
|
+
position, we deserialize the blob, append the cid to every
|
|
91
|
+
not-yet-seeded channel's chain, and check `channel_values` for
|
|
92
|
+
seeds. The deserialized checkpoint is dropped before advancing — no
|
|
93
|
+
cross-row cache, so peak in-flight is one deserialized checkpoint.
|
|
94
|
+
|
|
95
|
+
Off-path rows (different branch on the same thread) advance the
|
|
96
|
+
cursor without doing any work.
|
|
97
|
+
|
|
98
|
+
Returns True when every requested channel is seeded — the caller
|
|
99
|
+
can stop iterating and close the cursor.
|
|
100
|
+
"""
|
|
101
|
+
if "started" not in walk_state:
|
|
102
|
+
if cid == target_id:
|
|
103
|
+
walk_state["started"] = True
|
|
104
|
+
walk_state["cur_cid"] = parent_cid
|
|
105
|
+
walk_state["active"] = {ch for ch in channels if ch not in seeded}
|
|
106
|
+
# Not target yet (or target not present): keep streaming.
|
|
107
|
+
return False
|
|
108
|
+
active: set[str] = walk_state["active"]
|
|
109
|
+
if not active:
|
|
110
|
+
return True
|
|
111
|
+
if cid != walk_state["cur_cid"]:
|
|
112
|
+
# Off-path row from a sibling branch — skip without deserializing.
|
|
113
|
+
return False
|
|
114
|
+
for ch in active:
|
|
115
|
+
chain_by_ch[ch].append(cid)
|
|
116
|
+
ckpt = serde.loads_typed((type_tag, blob))
|
|
117
|
+
channel_values: Mapping[str, Any] = ckpt.get("channel_values") or {}
|
|
118
|
+
for ch in [ch for ch in active if ch in channel_values]:
|
|
119
|
+
seed_val_by_ch[ch] = channel_values[ch]
|
|
120
|
+
seeded.add(ch)
|
|
121
|
+
active.discard(ch)
|
|
122
|
+
del ckpt, channel_values
|
|
123
|
+
walk_state["cur_cid"] = parent_cid
|
|
124
|
+
return not active
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def build_delta_channels_writes_history(
|
|
128
|
+
*,
|
|
129
|
+
channels: Sequence[str],
|
|
130
|
+
chain_by_ch: Mapping[str, list[str]],
|
|
131
|
+
seed_val_by_ch: Mapping[str, Any],
|
|
132
|
+
seeded: set[str],
|
|
133
|
+
stage2_rows: Sequence[tuple[str, str, str, int, str, bytes]],
|
|
134
|
+
serde: Any,
|
|
135
|
+
) -> dict[str, DeltaChannelHistory]:
|
|
136
|
+
"""Demux stage-2 rows per channel; produce per-channel histories.
|
|
137
|
+
|
|
138
|
+
Stage-2 rows are `(checkpoint_id, channel, task_id, idx, type, value)`.
|
|
139
|
+
Final write order is oldest→newest globally and `(task_id, idx)` within
|
|
140
|
+
a checkpoint, matching the contract on `DeltaChannelHistory.writes`.
|
|
141
|
+
|
|
142
|
+
`seed` is omitted when the walk reached a true root with no snapshot
|
|
143
|
+
found (channel never entered `seeded`); consumers treat absence as
|
|
144
|
+
"start empty".
|
|
145
|
+
"""
|
|
146
|
+
writes_by_ch_by_cid: dict[str, dict[str, list[tuple[str, bytes, str, int]]]] = {
|
|
147
|
+
ch: {} for ch in channels
|
|
148
|
+
}
|
|
149
|
+
for cid, ch, task_id, idx, type_tag, value_blob in stage2_rows:
|
|
150
|
+
writes_by_ch_by_cid.setdefault(ch, {}).setdefault(cid, []).append(
|
|
151
|
+
(type_tag, value_blob, task_id, idx)
|
|
152
|
+
)
|
|
153
|
+
for cid_map in writes_by_ch_by_cid.values():
|
|
154
|
+
for ws in cid_map.values():
|
|
155
|
+
ws.sort(key=lambda w: (w[2], w[3]))
|
|
156
|
+
|
|
157
|
+
result: dict[str, DeltaChannelHistory] = {}
|
|
158
|
+
for ch in channels:
|
|
159
|
+
chain_cids = chain_by_ch.get(ch, [])
|
|
160
|
+
cid_writes = writes_by_ch_by_cid.get(ch, {})
|
|
161
|
+
collected: list[PendingWrite] = []
|
|
162
|
+
# Chain is newest-first; iterate oldest-first for the public order.
|
|
163
|
+
for cid in reversed(chain_cids):
|
|
164
|
+
for type_tag, value_blob, task_id, _idx in cid_writes.get(cid, []):
|
|
165
|
+
collected.append(
|
|
166
|
+
(task_id, ch, serde.loads_typed((type_tag, value_blob)))
|
|
167
|
+
)
|
|
168
|
+
entry: DeltaChannelHistory = {"writes": collected}
|
|
169
|
+
if ch in seeded:
|
|
170
|
+
entry["seed"] = seed_val_by_ch[ch]
|
|
171
|
+
result[ch] = entry
|
|
172
|
+
return result
|
|
@@ -4,7 +4,7 @@ import asyncio
|
|
|
4
4
|
import json
|
|
5
5
|
import random
|
|
6
6
|
import threading
|
|
7
|
-
from collections.abc import AsyncIterator, Callable, Iterator, Sequence
|
|
7
|
+
from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence
|
|
8
8
|
from contextlib import asynccontextmanager
|
|
9
9
|
from typing import Any, TypeVar, cast
|
|
10
10
|
|
|
@@ -17,12 +17,19 @@ from langgraph.checkpoint.base import (
|
|
|
17
17
|
Checkpoint,
|
|
18
18
|
CheckpointMetadata,
|
|
19
19
|
CheckpointTuple,
|
|
20
|
+
DeltaChannelHistory,
|
|
20
21
|
SerializerProtocol,
|
|
21
22
|
get_checkpoint_id,
|
|
22
23
|
get_checkpoint_metadata,
|
|
23
24
|
)
|
|
24
25
|
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
|
25
26
|
|
|
27
|
+
from langgraph.checkpoint.sqlite._delta import (
|
|
28
|
+
DELTA_STAGE1_SQL,
|
|
29
|
+
build_delta_channels_writes_history,
|
|
30
|
+
build_delta_stage2_sql,
|
|
31
|
+
step_walk_with_row,
|
|
32
|
+
)
|
|
26
33
|
from langgraph.checkpoint.sqlite.utils import search_where
|
|
27
34
|
|
|
28
35
|
T = TypeVar("T", bound=Callable)
|
|
@@ -272,6 +279,29 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
|
|
272
279
|
self.adelete_thread(thread_id), self.loop
|
|
273
280
|
).result()
|
|
274
281
|
|
|
282
|
+
def get_delta_channel_history(
|
|
283
|
+
self, *, config: RunnableConfig, channels: Sequence[str]
|
|
284
|
+
) -> Mapping[str, DeltaChannelHistory]:
|
|
285
|
+
"""Sync bridge to `aget_delta_channel_history`.
|
|
286
|
+
|
|
287
|
+
Mirrors the same cross-thread guard as `get_tuple` /
|
|
288
|
+
`delete_thread` — calling from the loop thread raises rather than
|
|
289
|
+
deadlocking.
|
|
290
|
+
"""
|
|
291
|
+
try:
|
|
292
|
+
if asyncio.get_running_loop() is self.loop:
|
|
293
|
+
raise asyncio.InvalidStateError(
|
|
294
|
+
"Synchronous calls to AsyncSqliteSaver are only allowed from a "
|
|
295
|
+
"different thread. From the main thread, use the async interface. "
|
|
296
|
+
"For example, use `await checkpointer.aget_delta_channel_history(...)`."
|
|
297
|
+
)
|
|
298
|
+
except RuntimeError:
|
|
299
|
+
pass
|
|
300
|
+
return asyncio.run_coroutine_threadsafe(
|
|
301
|
+
self.aget_delta_channel_history(config=config, channels=channels),
|
|
302
|
+
self.loop,
|
|
303
|
+
).result()
|
|
304
|
+
|
|
275
305
|
async def setup(self) -> None:
|
|
276
306
|
"""Set up the checkpoint database asynchronously.
|
|
277
307
|
|
|
@@ -589,6 +619,83 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
|
|
589
619
|
)
|
|
590
620
|
await self.conn.commit()
|
|
591
621
|
|
|
622
|
+
async def aget_delta_channel_history(
|
|
623
|
+
self, *, config: RunnableConfig, channels: Sequence[str]
|
|
624
|
+
) -> Mapping[str, DeltaChannelHistory]:
|
|
625
|
+
"""Fast-path override of `BaseCheckpointSaver.aget_delta_channel_history`.
|
|
626
|
+
|
|
627
|
+
See `SqliteSaver.get_delta_channel_history` for design notes; this
|
|
628
|
+
is the async equivalent using `aiosqlite` cursors. Stage 1 pages
|
|
629
|
+
the parent chain newest-first and Python-deserializes each
|
|
630
|
+
checkpoint blob to find per-channel snapshots; stage 2 fetches
|
|
631
|
+
only the relevant writes via per-channel UNION ALL.
|
|
632
|
+
"""
|
|
633
|
+
if not channels:
|
|
634
|
+
return {}
|
|
635
|
+
channels = list(channels)
|
|
636
|
+
await self.setup()
|
|
637
|
+
thread_id = str(config["configurable"]["thread_id"])
|
|
638
|
+
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
|
639
|
+
checkpoint_id = get_checkpoint_id(config)
|
|
640
|
+
if checkpoint_id is None:
|
|
641
|
+
target = await self.aget_tuple(config)
|
|
642
|
+
if target is None:
|
|
643
|
+
return {ch: {"writes": []} for ch in channels}
|
|
644
|
+
checkpoint_id = target.config["configurable"]["checkpoint_id"]
|
|
645
|
+
|
|
646
|
+
chain_by_ch: dict[str, list[str]] = {ch: [] for ch in channels}
|
|
647
|
+
seed_val_by_ch: dict[str, Any] = {}
|
|
648
|
+
walk_state: dict[str, Any] = {}
|
|
649
|
+
seeded: set[str] = set()
|
|
650
|
+
|
|
651
|
+
async with self.lock, self.conn.cursor() as cur:
|
|
652
|
+
await cur.execute(
|
|
653
|
+
DELTA_STAGE1_SQL, (thread_id, checkpoint_ns, checkpoint_id)
|
|
654
|
+
)
|
|
655
|
+
async for row in cur:
|
|
656
|
+
cid, parent_cid, type_tag, blob = row
|
|
657
|
+
if step_walk_with_row(
|
|
658
|
+
cid=cid,
|
|
659
|
+
parent_cid=parent_cid,
|
|
660
|
+
type_tag=type_tag,
|
|
661
|
+
blob=blob,
|
|
662
|
+
target_id=checkpoint_id,
|
|
663
|
+
serde=self.serde,
|
|
664
|
+
chain_by_ch=chain_by_ch,
|
|
665
|
+
seed_val_by_ch=seed_val_by_ch,
|
|
666
|
+
walk_state=walk_state,
|
|
667
|
+
seeded=seeded,
|
|
668
|
+
channels=channels,
|
|
669
|
+
):
|
|
670
|
+
break
|
|
671
|
+
|
|
672
|
+
channels_with_chain = [ch for ch in channels if chain_by_ch[ch]]
|
|
673
|
+
stage2_sql = build_delta_stage2_sql(
|
|
674
|
+
chain_lens=[len(chain_by_ch[ch]) for ch in channels_with_chain],
|
|
675
|
+
)
|
|
676
|
+
if stage2_sql:
|
|
677
|
+
stage2_params: list[Any] = []
|
|
678
|
+
for ch in channels_with_chain:
|
|
679
|
+
stage2_params.extend(
|
|
680
|
+
[thread_id, checkpoint_ns, ch, *chain_by_ch[ch]]
|
|
681
|
+
)
|
|
682
|
+
await cur.execute(stage2_sql, stage2_params)
|
|
683
|
+
stage2_rows = cast(
|
|
684
|
+
"list[tuple[str, str, str, int, str, bytes]]",
|
|
685
|
+
await cur.fetchall(),
|
|
686
|
+
)
|
|
687
|
+
else:
|
|
688
|
+
stage2_rows = []
|
|
689
|
+
|
|
690
|
+
return build_delta_channels_writes_history(
|
|
691
|
+
channels=channels,
|
|
692
|
+
chain_by_ch=chain_by_ch,
|
|
693
|
+
seed_val_by_ch=seed_val_by_ch,
|
|
694
|
+
seeded=seeded,
|
|
695
|
+
stage2_rows=stage2_rows,
|
|
696
|
+
serde=self.serde,
|
|
697
|
+
)
|
|
698
|
+
|
|
592
699
|
def get_next_version(self, current: str | None, channel: None) -> str:
|
|
593
700
|
"""Generate the next version ID for a channel.
|
|
594
701
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "langgraph-checkpoint-sqlite"
|
|
7
|
-
version = "3.0
|
|
7
|
+
version = "3.1.0"
|
|
8
8
|
description = "Library with a SQLite implementation of LangGraph checkpoint saver."
|
|
9
9
|
authors = []
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -12,14 +12,14 @@ readme = "README.md"
|
|
|
12
12
|
license = "MIT"
|
|
13
13
|
license-files = ['LICENSE']
|
|
14
14
|
dependencies = [
|
|
15
|
-
"langgraph-checkpoint>=
|
|
15
|
+
"langgraph-checkpoint>=4.1.0,<5.0.0",
|
|
16
16
|
"aiosqlite>=0.20",
|
|
17
17
|
"sqlite-vec>=0.1.6",
|
|
18
18
|
]
|
|
19
19
|
|
|
20
20
|
[project.urls]
|
|
21
21
|
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-sqlite"
|
|
22
|
-
Twitter = "https://x.com/
|
|
22
|
+
Twitter = "https://x.com/langchain_oss"
|
|
23
23
|
Slack = "https://www.langchain.com/join-community"
|
|
24
24
|
Reddit = "https://www.reddit.com/r/LangChain/"
|
|
25
25
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Run delta-channel conformance capabilities against AsyncSqliteSaver."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
pytest.importorskip(
|
|
8
|
+
"langgraph.checkpoint.conformance",
|
|
9
|
+
reason="langgraph-checkpoint-conformance not installed",
|
|
10
|
+
)
|
|
11
|
+
pytest.importorskip("aiosqlite", reason="aiosqlite not installed")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.mark.asyncio
|
|
15
|
+
async def test_delta_channel_conformance():
|
|
16
|
+
from langgraph.checkpoint.conformance import validate
|
|
17
|
+
from langgraph.checkpoint.conformance.initializer import checkpointer_test
|
|
18
|
+
|
|
19
|
+
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
|
20
|
+
|
|
21
|
+
@checkpointer_test(name="AsyncSqliteSaver")
|
|
22
|
+
async def sqlite_saver():
|
|
23
|
+
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
|
24
|
+
yield saver
|
|
25
|
+
|
|
26
|
+
report = await validate(
|
|
27
|
+
sqlite_saver,
|
|
28
|
+
capabilities={
|
|
29
|
+
"delta_channel_history",
|
|
30
|
+
},
|
|
31
|
+
)
|
|
32
|
+
for cap, result in report.results.items():
|
|
33
|
+
if result.passed is False:
|
|
34
|
+
details = "\n".join(result.failures or [])
|
|
35
|
+
pytest.fail(f"Capability {cap} failed:\n{details}")
|