-
feat(checkpoint-sqlite): override get_delta_channel_history with streaming walk (#7702)
发布于
2026-05-05 19:30:57 +00:00 | 193 次提交 在此版本后已推送到 mainSummary
Adds a sqlite-specific override of
BaseCheckpointSaver.get_delta_channel_history(and async). Before this
PR,SqliteSaver/AsyncSqliteSaverinherited the default impl, which
callsget_tupleonce 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_idDESC 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_valueslives 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_historybridges to its async form
viarun_coroutine_threadsafe, matching the same cross-thread guard
used byget_tuple/delete_thread.Tests
- New
tests/test_delta_channel_migration.py: covers the
BinaryOperatorAggregate -> DeltaChannelmigration 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 accumulatedchannel_values[ch]at a pre-migration ancestor as a
validseed, not just_DeltaSnapshotsentinels. - 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_historymicro-bench (override vs inheriteddefault 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.deltamode usesDeltaChannel+ the
override;no_deltausesAnnotated[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 withsnapshot_frequency=1000walking 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_*.pyfiles):bench_sqlite_delta_history.pybench_sqlite_delta_memory.py
Test plan
cd libs/checkpoint-sqlite && make formatcleancd libs/checkpoint-sqlite && make lintcleancd libs/checkpoint-sqlite && make test— 97 passed (1 known
flake unrelated)tests/test_get_delta_channel_history.py— 7/7 (now exercises the
override)tests/test_delta_channel_migration.py— 3/3 (new)
下载附件
- No JSONB → stage 1 streams the cursor row-by-row in