## Summary
- replace Python lint type-checking from mypy to ty across LangGraph
packages
- remove mypy config/cache wiring and mypy-only references
- regenerate uv locks with ty 0.0.43
## Verification
- git diff --check
- make lint_package && make lint_tests in libs/langgraph
- make lint_package && make lint_tests in libs/checkpoint
- make lint_package && make lint_tests in libs/checkpoint-sqlite
- make lint_package && make lint_tests in libs/checkpoint-postgres
- make lint_package && make lint_tests in libs/prebuilt
- make lint_package && make lint_tests in libs/cli
- make lint in libs/sdk-py
- make lint in libs/checkpoint-conformance
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com>
## Summary
Add a user-facing design doc and `get_delta_channel_keepset` helper for
third-party `BaseCheckpointSaver` authors who need to support graphs
using `DeltaChannel`.
**Deliverables:**
1. ~~**`docs/delta-channel-checkpointer-guide.md`** — comprehensive
guide covering~~:
moved to docs repo
2. **`BaseCheckpointSaver.get_delta_channel_keepset` /
`aget_delta_channel_keepset`** — returns the minimum set of ancestor
`checkpoint_id`s that must survive deletion for a given head's
`DeltaChannel` reconstruction to remain intact. Enables safe `prune`
implementations without silently corrupting delta history.
3. **Docstring warnings** on `prune`, `aprune`, `delete_for_runs`,
`adelete_for_runs`, `copy_thread`, `acopy_thread` explaining the
DeltaChannel pitfall (silent data loss if ancestor writes/snapshots are
deleted).
4. **Three new conformance capabilities** in
`libs/checkpoint-conformance`:
- `delta_channel_history` — validates the `aget_delta_channel_history`
walk contract
- `delta_channel_keepset` — validates the keep-set contract
- `delta_channel_reconstruction` — end-to-end round-trip (aput +
aput_writes + history + reconstruct)
## Test plan
- [x] `make format lint` passes in `libs/checkpoint`,
`libs/checkpoint-conformance`
- [x] All three new conformance capabilities pass against
`InMemorySaver`
- [x] Run conformance against SQLite saver
- [x] Run conformance against Postgres saver
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
## Summary
Adds a sqlite-specific override of
`BaseCheckpointSaver.get_delta_channel_history` (and async). Before this
PR, `SqliteSaver` / `AsyncSqliteSaver` inherited the default impl, which
calls `get_tuple` once per ancestor — N round-trips, full pending-writes
fetch per step regardless of channel relevance.
The override mirrors the postgres two-stage shape (ancestor walk +
per-channel UNION ALL writes fetch) but adapted for sqlite:
- **No JSONB** → stage 1 streams the cursor row-by-row in
`checkpoint_id` DESC order. The merged walk advances one row at a time,
deserializing only on-path checkpoints and dropping each before
advancing — peak in-flight is one deserialized checkpoint, no
`fetchall()` materialization.
- **No separate blob table** → `channel_values` lives inline in the
checkpoint blob, so seeds come back from stage 1 with no second fetch.
- **Single merged walk (not K independent walks)**: each visited cid is
deserialized exactly once, regardless of how many channels are still
seeking their seed.
- **Stage 2** stays per-channel UNION ALL to avoid over-fetching writes
when channels have different chain depths — same rationale as postgres.
`AsyncSqliteSaver.get_delta_channel_history` bridges to its async form
via `run_coroutine_threadsafe`, matching the same cross-thread guard
used by `get_tuple` / `delete_thread`.
## Tests
- New `tests/test_delta_channel_migration.py`: covers the
`BinaryOperatorAggregate -> DeltaChannel` migration path on sqlite (sync
round-trip, sync continuation with post-migration delta folding, async
round-trip). Mirrors
`libs/langgraph/tests/test_delta_channel_migration.py` (which covered
`InMemorySaver`); without these, the override's behavior on
pre-migration threads was unverified — the override has to identify a
plain accumulated `channel_values[ch]` at a pre-migration ancestor as a
valid `seed`, not just `_DeltaSnapshot` sentinels.
- Existing `tests/test_get_delta_channel_history.py` (7 tests) continues
to pass and now exercises the optimized override end-to-end (previously
hit the inherited default impl).
- `make format`, `make lint`, `make test`: clean. 97/97 in the non-flaky
sqlite suite (the one ignored test, `test_async_asearch_refresh_ttl`, is
a known TTL-store timing flake on a separate module unrelated to this
PR).
## Benchmarks
### `get_delta_channel_history` micro-bench (override vs inherited
default impl)
1000-turn synthetic threads with sentinel snapshots + per-step writes;
`bench_sqlite_delta_history.py`. Per-call latency in microseconds.
| Scenario | min | median | mean |
|---|---:|---:|---:|
| S1 single channel, root-only snapshot | **4.60x** | **4.90x** |
**5.13x** |
| S2 mixed cadence (every-50 + root-only), 2 channels | **6.08x** |
**6.37x** | **6.84x** |
| S3 K=8 channels, root-only snapshot | 1.23x | 1.27x | 0.90x |
S2 wins biggest because per-channel UNION ALL avoids over-fetching
writes for the shallow channel. S3 is the worst case for sqlite (8
channels all walking to root, 1000 deserializations either way) — the
override still wins on min/median.
### Long-running thread mem/storage bench (delta vs no-delta)
`bench_sqlite_delta_memory.py`. `delta` mode uses `DeltaChannel` + the
override; `no_delta` uses `Annotated[list, _messages_delta_reducer]`
(full state in every blob). Same workload, file-backed sqlite. Latency
measured untraced (30 iterations); peak heap measured separately under
tracemalloc.
| Scenario | Turns | Storage Δ | Peak heap Δ | Read latency Δ |
|---|---:|---|---|---|
| K=1, freq=50 | 200 | **-96%** (942 KB vs 25.1 MB) | +21% (504 KB vs
418 KB) | **+13%** |
| K=1, freq=50 | 500 | **-98%** (2.9 MB vs 152.3 MB) | +20% (1.2 MB vs
1.0 MB) | **-6%** (delta wins) |
| K=3, freq=50 uniform | 200 | **-98%** (1.7 MB vs 73.5 MB) | +7% (1.3
MB vs 1.2 MB) | **+10%** |
| K=3, freq=50 uniform | 500 | **-99%** (6.0 MB vs 452.5 MB) | +7% (3.3
MB vs 3.0 MB) | **+6%** |
| K=3, freq=mixed | 200 | **-98%** (1.4 MB vs 73.5 MB) | +5% (1.3 MB vs
1.2 MB) | +190% (5.1 ms vs 1.7 ms abs) |
| K=3, freq=mixed | 500 | **-99%** (4.1 MB vs 452.5 MB) | +8% (3.3 MB vs
3.0 MB) | +377% (20.9 ms vs 4.4 ms abs) |
- **Storage**: -96 to -99% on long threads (a 500-turn K=3 thread
shrinks from 452 MB to 6 MB on disk). This is the headline win.
- **Peak heap**: within +5 to +21% of the no-delta path — the streaming
cursor + merged walk + drop-after-deserialize keep peak in-flight at one
checkpoint at a time.
- **Read latency**: equivalent-ish (within ~15%) on uniform-cadence
scenarios; at K=1/500 turns delta even wins by 6%. The mixed-cadence
rows have one channel with `snapshot_frequency=1000` walking to root on
a 500-turn thread — by configuration. Absolute mixed-delta latency is
still 5-21 ms per read.
Bench scripts (not committed; workspace-root convention matches other
`bench_*.py` files):
- `bench_sqlite_delta_history.py`
- `bench_sqlite_delta_memory.py`
## Test plan
- [x] `cd libs/checkpoint-sqlite && make format` clean
- [x] `cd libs/checkpoint-sqlite && make lint` clean
- [x] `cd libs/checkpoint-sqlite && make test` — 97 passed (1 known
flake unrelated)
- [x] `tests/test_get_delta_channel_history.py` — 7/7 (now exercises the
override)
- [x] `tests/test_delta_channel_migration.py` — 3/3 (new)
## Summary
- Promotes the private K-channel batched ancestor-walk to a stable
public `get_delta_channel_history` / `aget_delta_channel_history` API on
`BaseCheckpointSaver` (returns `Mapping[str, DeltaChannelHistory]`, a
TypedDict with `writes` always present and `seed` `NotRequired`)
- Removes `DELTA_SENTINEL` / `_DeltaSentinel` entirely — the saver layer
is now delta-agnostic on both write and read paths
- Reworks `DeltaChannel` snapshot cadence from "every Nth superstep" to
"every N updates to this channel," persisted in
`CheckpointMetadata.delta_updates_since_snapshot`
- Adds Postgres optimizations: paged stage-1 with cursor (1024-row
pages) and per-channel UNION ALL stage-2 (no over-fetch when channels
have different chain depths)
- Default `snapshot_frequency` becomes a positive int (default `1000`);
the previous `None` opt-out is removed
## Public API
```python
class DeltaChannelHistory(TypedDict):
writes: list[PendingWrite] # always present, possibly empty
seed: NotRequired[Any] # absent if walk reached root
def get_delta_channel_history(
self, *, config: RunnableConfig, channels: Sequence[str]
) -> Mapping[str, DeltaChannelHistory]: ...
async def aget_delta_channel_history(
self, *, config: RunnableConfig, channels: Sequence[str]
) -> Mapping[str, DeltaChannelHistory]: ...
```
`config` and `channels` are keyword-only so later additions (e.g.
`page_size`) don't shift the positional API.
The TypedDict-with-`NotRequired[seed]` shape matches the existing
checkpoint-package convention (`CheckpointMetadata` is
`TypedDict(total=False)`) — absence-via-key-omission rather than
introducing a new sentinel. Pregel translates `"seed" not in hist` to
`MISSING` on its side at consume time.
The default impl walks `get_tuple` + `parent_config` correctly but is
slow on long chains; savers that care override (`InMemorySaver`,
`PostgresSaver`).
## Sentinel removal
`DELTA_SENTINEL` and `_DeltaSentinel` are deleted entirely. The saver
layer becomes delta-agnostic:
- `DeltaChannel.checkpoint()` returns `MISSING` for non-snapshot steps;
pregel's `create_checkpoint` skips MISSING so delta channels without a
snapshot simply don't appear in `channel_values`
- `InMemorySaver.put` and Postgres `put` no longer filter sentinels
(they have nothing to filter)
- `_needs_replay` becomes `stored is MISSING`
- `DeltaChannel.from_checkpoint` accepts: `MISSING` → empty,
`_DeltaSnapshot(value)` → snapshot value, plain value → pre-migration
legacy
## Snapshot cadence
`DeltaChannel.snapshot_frequency: int` (default `1000`, positive). The
previous `None` opt-out is gone.
```python
def should_snapshot(ch_name, ch):
if force_delta_snapshot: # durability="exit"
return True
return updates_since_snapshot.get(ch_name, 0) >= ch.snapshot_frequency
```
Per-channel update counters are persisted in
`CheckpointMetadata.delta_updates_since_snapshot` (`NotRequired`,
`total=False`). The counter is incremented by `_put_checkpoint` for any
delta channel in `updated_channels` and reset to `0` by
`create_checkpoint` for channels that fire a snapshot this step.
Version-format-independent — works for `int`, `float`, and `str`
versioning schemes alike.
## Postgres optimization
Two improvements internal to the override:
**Stage-1 paged with cursor** (`LIMIT 1024` internal const, `AND
checkpoint_id < ?` for subsequent pages). The previous unpaged form
scanned every checkpoint in `(thread_id, ns)` and was pathological at
high thread depths.
**Stage-2 per-channel UNION ALL**: one `WHERE channel='X' AND
checkpoint_id = ANY(chain_X)` branch per channel plus one seed-blob
branch per channel with a seed. The previous form filtered by `channel =
ANY(channels) AND checkpoint_id = ANY(union_chain_cids)`, over-fetching
writes when channels had different chain depths (`K ×
max(chain_lengths)` vs the correct `sum(chain_lengths)`).
Both improvements stay internal to `PostgresSaver`/`AsyncPostgresSaver`;
the public contract returns a single `Mapping`.
## Benchmarks
`libs/langgraph/tests/test_delta_channel_benchmark.py`. Run via `python
libs/langgraph/tests/test_delta_channel_benchmark.py`. Postgres against
local pg:5441.
Results below trimmed to the high-signal cells. Sub-millisecond /
sub-100-turn rows omitted as warmup-bound; freq=1 omitted (chain depth =
1, nothing to optimize); peak read-time memory and Postgres storage are
flat between branches and omitted. Deep-thread reads and the
cadence-rework storage win are the load-bearing numbers.
### Postgres reads, 500 turns
| Scenario | main | branch | Δ |
|---|---:|---:|---:|
| Single-channel deep read | 17.7 ms | **6.1 ms** | **-66%** |
| Single-channel, 1000 turns | 35.0 ms | **14.3 ms** | **-59%** |
| K=3 channels, freq=50 uniform | 70.5 ms | **41.4 ms** | **-41%** |
| K=8 channels, freq=50 uniform | 214.2 ms | **139.4 ms** | **-35%** |
| K=8 channels, mixed freq (25/50/100/.../1000) | 295.6 ms | **214.4
ms** | **-27%** |
K-channel batching + paged stage-1 + per-channel UNION ALL stage-2 doing
exactly what they should at depth.
### InMemory reads, 500 turns
| Scenario | main | branch | Δ |
|---|---:|---:|---:|
| Single-channel deep read | 7.9 ms | **3.8 ms** | **-52%** |
| Single-channel, 1000 turns | 15.6 ms | **7.2 ms** | **-54%** |
| K=8 channels, freq=50 uniform | 112.3 ms | 94.6 ms | -16% |
| K=8 channels, mixed freq | 184.9 ms | **134.5 ms** | **-27%** |
### InMemory storage, 500 turns (cadence-rework win)
| Scenario | main | branch | Δ |
|---|---:|---:|---:|
| K=3, freq=50 uniform | 8.7 MB | **3.3 MB** | **-62%** |
| K=3 mixed freq | 3.8 MB | **1.3 MB** | **-66%** |
| K=8, freq=50 uniform | 23.1 MB | **8.7 MB** | **-62%** |
| K=8 mixed freq | 11.5 MB | **4.2 MB** | **-64%** |
Snapshot frequency now counts **channel updates** instead of
**supersteps**. On graphs where supersteps outpace per-channel updates
(e.g., input/end steps that don't write to channels), branch stores ~3×
fewer snapshot blobs.
### Tradeoff worth flagging
InMemory K=3 with mixed frequencies (50/200/1000) at 500 turns: **+64%
read latency** (46.6 → 76.5 ms). The mixed scenario has a channel with
`freq=1000` that goes the entire 500-turn run with no snapshot. On main,
the old superstep-counted cadence happened to fire at step=500 anyway.
New cadence gives users explicit control over walk depth via
`snapshot_frequency`. The K=8 mixed case still wins overall (-27%); this
regression is specific to the K=3 mixed shape.
Default `snapshot_frequency=1000` is the upper bound on walk depth —
it's a tunable knob.
## Tests
- New sqlite smoke test (`test_get_delta_channel_history.py`) exercises
the inherited default `BaseCheckpointSaver` impl via `SqliteSaver` /
`AsyncSqliteSaver` end-to-end with a real `DeltaChannel`-backed graph.
Sqlite uses the default unchanged — this validates the default path
actually works on a real second saver, not just on the optimized
override.
- Module-level `pytest.importorskip("langgraph.channels.delta")` guards
the test for sqlite's standalone CI environment (matches the postgres
pattern).
## Test plan
- [x] `libs/checkpoint`: 150 passed, 16 skipped
- [x] `libs/langgraph` (channels + delta migration): 41/41 (post-merge)
- [x] `libs/langgraph` (full pregel suite): 1784 passing — 6 "failures"
verified via `env -i` clean shell are local LangSmith env vars + `git
describe revision_id` polluting LangChain metadata fixtures; CI is
unaffected
- [x] `libs/checkpoint-postgres`: 40/40 saver tests + 3/3 delta channel
reconstruction tests against local Postgres
- [x] `libs/checkpoint-sqlite`: 105/105 (incl. retry-passed flake
`test_ttl_refresh`, unrelated to this PR)
- [x] Lint clean across all four libs (`ruff format`, `ruff check`,
`mypy`)
- [x] Branch-vs-main benchmarks — see results above
---------
Co-authored-by: Quanzheng Long <long@langchain.dev>
Co-authored-by: Cursor <cursoragent@cursor.com>
The original implementation for `refresh_on_read=True` in `asearch` for
AsyncSqliteStore used a CTE with an UPDATE statement, which is not
well-supported by SQLite in that specific construction, leading to a
syntax error.
This commit changes the approach:
1. `_prepare_batch_search_queries` in `BaseSqliteStore` no longer
constructs a CTE-based UPDATE. Instead, it returns a flag indicating if
TTL refresh is needed for the searched items.
2. `_batch_search_ops` in both `AsyncSqliteStore` and `SqliteStore` now
check this flag. If true, they perform a separate UPDATE statement after
fetching the search results to refresh the TTL of those items.
Additionally, a new test case `test_async_asearch_refresh_ttl` was added
and existing test logic was refined to accurately verify this behavior.
---------
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: William FH <13333726+hinthornw@users.noreply.github.com>
Co-authored-by: Caspar Broekhuizen <caspar@langchain.dev>
**Description:**
Add test for before and limit parameters for the list in SqliteSaver
which was marked as TODO.
---------
Co-authored-by: Caspar Broekhuizen <caspar@langchain.dev>
This PR updates the dependencies in all Python packages using `uv lock
--upgrade`.
This is an automated PR created by the UV Lock Upgrade workflow.
To make tests pass:
* linting fixes
* whitespace fixes in snapshots
---------
Co-authored-by: sydney-runkle <54324534+sydney-runkle@users.noreply.github.com>
Co-authored-by: Sydney Runkle <sydneymarierunkle@gmail.com>
### Description
* Set `ensure_ascii=False` for all `json.dumps` calls in
`get_text_at_path`. Preserves non-ASCII text instead of embedding
`\uXXXX` escapes.
**Before**
```python
store.put(("user_123", "memories"), "1", {"text": "这是中文"})
# embeds {"text": "\\u8fd9\\u662f\\u4e2d\\u6587"}
```
**After**
```python
store.put(("user_123", "memories"), "1", {"text": "这是中文"})
# embeds {"text": "这是中文"}
```
### Tests & Docs
* Add unit test `test_non_ascii` that writes three records (Chinese,
Japanese, Korean) to an `InMemoryStore`, searches with the same strings,
and asserts the correct top hit with a score >= 0.15 for each.
### Issue
Fixes#5946
- Leave it up to each checkpointer implementation to decide whether to merge in configurable/metadata (previously PregelLoop would do some of this always)
- Never copy over internal langgraph keys into checkpoint.metadata (these are redundant/misleading to include)
Prepare langgraph-checkpoint for 0.5
- Given we have no upper bound on langgraph-checkpoint dep need to undo all changes in langgraph-checkpoint that might break previous versions of langgraph
- Instead store sends in a Topic channel, removing the need to fetch sends as writes against the parent checkpoint
- Remove deprecated/unused functions in langgraph-checkpoint (will require bumping min range for langgraph-checkpoint in langgraph lib)
- Implement migration of old pending sends in langgraph-checkpoint-postgres
- Ensure parent config of `checkpoint_during=False` checkpoints always points to checkpoints that were also saved
* Migrate to `uv`
* Format `pyproject.toml` files properly
* Remove upper bounds on dependencies, and bounds on dev dependencies
(we should be using latest)
* Move to hatch for packaing
In the future we should:
* Set up dependabot / automate lockfile updates and tests
* Add tests for min compatible versions (I'll do this right after merge)
* Use dynamic versioning
* Bump `pydantic` to v2.11.4 in the lockfile, we have some tests failing