mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-13 16:28:38 +00:00
* feat(checkpoint-cache): delta-mode checkpoint history cache with recursive compose
Read-only, invalidation-free cache for LangGraph delta-channel history
({writes, seed}) at the get_delta_channel_history choke point:
- database.checkpoint_cache config (memory|redis; max_entries 0=disabled;
redis bounded by TTL, Gateway/async only)
- memory LRU backend (copy-on-read, zero-serde hit path) and redis backend
(lazy import, degrades to all-miss on outage)
- CachedHistorySaver: recursive composition from the nearest warm ancestor
(depth budget 8), caching each level; depth-0 cold chains delegate one
inner fast-path walk. Entries keyed by immutable
(db, thread, ns, checkpoint_id, channel) — no invalidation, coherent
across workers
- provider wiring: wraps in delta mode only (async + sync), full mode
untouched; sync path is memory-only
- bench opt-in: DEERFLOW_CHECKPOINT_BENCH_HISTORY_CACHE=1
sqlite bench (500 updates, payload 2KB): write phase 2.28x at f=250,
1.32x at f=10; one delegated walk per thread cold start.
* chore(config): bump config_version to 32 for database.checkpoint_cache
The checkpoint history cache feature added the database.checkpoint_cache
section to config.example.yaml; bump the schema version so existing
deployments get the outdated-config warning and can run make config-upgrade.
* chore(helm): bump config_version to 32 in chart values and README
* fix(checkpoint-cache): purge thread history entries on delete paths
Addresses review on #4638: delete_thread/prune removed source-of-truth
checkpoints but left the thread's materialized history payloads in the
cache (memory: until LRU eviction; redis: until TTL, default 1 day) — a
data-lifecycle gap for tenant offboarding / GDPR-style erasure.
- Cache contract gains thread-scoped adelete_thread/delete_thread
(lifecycle purge, not invalidation; entries remain immutable)
- Memory backend: stem scan over the LRU map; redis: SCAN MATCH + UNLINK,
outage degrades to TTL-bounded retention without raising
- CachedHistorySaver purges on delete_thread/adelete_thread and
prune/aprune (prune rewrites chains, so pre-prune histories must go);
delete_for_runs stays delegation-only (run->thread mapping unavailable,
no in-tree callers), documented in code
- ttl_seconds description documents the residual-retention window
- Tests: thread-scoped purge on both backends, saver-level delete/prune
purge, prefix-safety (t1 vs t10), redis outage degradation, and the
pinned no-purge behavior of delete_for_runs
* fix(checkpoint-cache): stable db identity, prefix-aware sync singleton, explicit zero TTL
Addresses Copilot review on #4638:
- checkpoint_cache_db_hash now hashes the credential-free postgres
identity (host:port/database + schema): credential rotation no longer
changes the cache namespace (cold cache + orphaned keys until TTL).
Unparseable URLs fall back to the raw string.
- The sync-path memory cache singleton is also keyed by its key_prefix:
a namespace change (db identity change or operator override) recreates
the cache instead of leaving stale-prefix entries unreachable and
unpurgeable.
- ttl_seconds=0 is now an explicit, documented opt-out of redis expiry
(SET without EX; redis maxmemory policy only) instead of a silent
'ttl_seconds or None' coercion.
Tests: credential-rotation hash stability, unparseable-URL fallback,
prefix-change singleton recreation, same-prefix singleton reuse, and
zero-TTL wire behavior (ex=None).
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
365 lines
15 KiB
Python
365 lines
15 KiB
Python
"""Behavioral integration tests for CachedHistorySaver on REAL LangGraph execution.
|
|
|
|
Unlike tests/test_cached_history_saver.py (fake saver, hand-built chains), these
|
|
tests drive compiled StateGraphs through pregel in delta mode
|
|
(``DeltaChannel(merge_message_writes, snapshot_frequency=2)``) and verify the
|
|
cache against a differential oracle: the identical scenario executed on a raw
|
|
``InMemorySaver`` in a fresh thread. Digests are (type, content, id) triples of
|
|
the materialized ``messages`` channel, so any history corruption shows up as a
|
|
digest mismatch.
|
|
|
|
Observed pregel call pattern on langgraph 1.2.9 (5-step linear graph, 7
|
|
checkpoints, snapshot cadence 2): one ``aget_delta_channel_history`` per run
|
|
start (empty-thread load), none for snapshot checkpoints, one per materialized
|
|
non-snapshot checkpoint on the raw saver. The cached saver pays the run-start
|
|
walk plus one cold fallback walk; every other materialization composes from a
|
|
parent snapshot seed or a cached parent history, and a second identical read
|
|
pass costs zero inner walks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Annotated, Any, TypedDict
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage
|
|
from langgraph.channels import DeltaChannel
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
from langgraph.graph import StateGraph
|
|
from langgraph.types import Command, interrupt
|
|
|
|
from deerflow.agents.thread_state import merge_message_writes
|
|
from deerflow.runtime.checkpoint_cache.memory import MemoryCheckpointHistoryCache
|
|
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
|
|
from deerflow.runtime.checkpointer.cached_saver import CachedHistorySaver
|
|
|
|
STEPS = 5
|
|
SNAPSHOT_FREQUENCY = 2
|
|
|
|
|
|
class _CountingInMemorySaver(InMemorySaver):
|
|
"""InMemorySaver that counts full delta-history walks.
|
|
|
|
Placed under ``CachedHistorySaver`` it records exactly the walks the cache
|
|
could not serve; used bare it is the uncached oracle's walk counter.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.history_walks = 0
|
|
|
|
def get_delta_channel_history(self, *, config: Any, channels: Any) -> Any:
|
|
self.history_walks += 1
|
|
return super().get_delta_channel_history(config=config, channels=channels)
|
|
|
|
async def aget_delta_channel_history(self, *, config: Any, channels: Any) -> Any:
|
|
self.history_walks += 1
|
|
return await super().aget_delta_channel_history(config=config, channels=channels)
|
|
|
|
|
|
def _state_schema() -> type:
|
|
class State(TypedDict):
|
|
messages: Annotated[
|
|
list[AnyMessage],
|
|
DeltaChannel(merge_message_writes, snapshot_frequency=SNAPSHOT_FREQUENCY),
|
|
]
|
|
|
|
return State
|
|
|
|
|
|
def _make_step_node(n: int) -> Any:
|
|
def node(state: dict) -> dict:
|
|
return {"messages": [AIMessage(content=f"step-{n}", id=f"ai-{n}")]}
|
|
|
|
return node
|
|
|
|
|
|
def _build_graph(saver: Any, steps: int = STEPS) -> Any:
|
|
builder = StateGraph(_state_schema())
|
|
for i in range(steps):
|
|
builder.add_node(f"step{i}", _make_step_node(i))
|
|
builder.set_entry_point("step0")
|
|
for i in range(steps - 1):
|
|
builder.add_edge(f"step{i}", f"step{i + 1}")
|
|
builder.set_finish_point(f"step{steps - 1}")
|
|
return builder.compile(checkpointer=saver)
|
|
|
|
|
|
def _build_interrupt_graph(saver: Any) -> Any:
|
|
"""step0 -> step1 -> pause(interrupt) -> step2 -> step3."""
|
|
|
|
def pause(state: dict) -> dict:
|
|
answer = interrupt({"question": "continue?"})
|
|
return {"messages": [AIMessage(content=f"resumed:{answer}", id="ai-resume")]}
|
|
|
|
builder = StateGraph(_state_schema())
|
|
builder.add_node("step0", _make_step_node(0))
|
|
builder.add_node("step1", _make_step_node(1))
|
|
builder.add_node("pause", pause)
|
|
builder.add_node("step2", _make_step_node(2))
|
|
builder.add_node("step3", _make_step_node(3))
|
|
builder.set_entry_point("step0")
|
|
builder.add_edge("step0", "step1")
|
|
builder.add_edge("step1", "pause")
|
|
builder.add_edge("pause", "step2")
|
|
builder.add_edge("step2", "step3")
|
|
builder.set_finish_point("step3")
|
|
return builder.compile(checkpointer=saver)
|
|
|
|
|
|
def _config() -> dict[str, Any]:
|
|
return {"configurable": {"thread_id": f"cache-itest-{uuid4().hex}"}}
|
|
|
|
|
|
def _input() -> dict[str, Any]:
|
|
return {"messages": [HumanMessage(content="kickoff", id="h-0")]}
|
|
|
|
|
|
def _digest(values: dict[str, Any]) -> list[tuple[str, str, str | None]]:
|
|
return [(m.type, m.content, m.id) for m in values["messages"]]
|
|
|
|
|
|
def _history_digests(snapshots: list[Any]) -> list[list[tuple[str, str, str | None]]]:
|
|
return [_digest(s.values) for s in snapshots]
|
|
|
|
|
|
def _expected_final_digest(steps: int = STEPS) -> list[tuple[str, str, str | None]]:
|
|
return [("human", "kickoff", "h-0"), *[("ai", f"step-{n}", f"ai-{n}") for n in range(steps)]]
|
|
|
|
|
|
def _make_cached_stack(max_entries: int = 128) -> tuple[_CountingInMemorySaver, CachedHistorySaver, Any, CheckpointStateAccessor]:
|
|
inner = _CountingInMemorySaver()
|
|
saver = CachedHistorySaver(inner, MemoryCheckpointHistoryCache(max_entries), key_prefix=f"itest-{uuid4().hex}")
|
|
graph = _build_graph(saver)
|
|
accessor = CheckpointStateAccessor.bind(graph, saver, mode="delta")
|
|
return inner, saver, graph, accessor
|
|
|
|
|
|
def _make_oracle_stack() -> tuple[_CountingInMemorySaver, Any, CheckpointStateAccessor]:
|
|
inner = _CountingInMemorySaver()
|
|
graph = _build_graph(inner)
|
|
accessor = CheckpointStateAccessor.bind(graph, inner, mode="delta")
|
|
return inner, graph, accessor
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_sequential_run_composes_without_inner_walks() -> None:
|
|
"""A cached run must serve warm reads with strictly fewer inner history
|
|
walks than the identical uncached run, composing histories instead."""
|
|
inner, saver, graph, accessor = _make_cached_stack()
|
|
config = _config()
|
|
await graph.ainvoke(_input(), config)
|
|
|
|
final = await accessor.aget(config)
|
|
assert _digest(final.values) == _expected_final_digest()
|
|
|
|
# Cold pass: materialize every checkpoint in the thread.
|
|
cold = await accessor.ahistory(config)
|
|
cold_walks = inner.history_walks
|
|
|
|
# Warm pass: identical reads must be served entirely from the cache.
|
|
warm = await accessor.ahistory(config)
|
|
assert inner.history_walks == cold_walks, "warm re-read triggered an inner walk"
|
|
assert _history_digests(warm) == _history_digests(cold)
|
|
|
|
# Differential oracle: same run through the raw saver.
|
|
oracle_inner, oracle_graph, oracle_accessor = _make_oracle_stack()
|
|
oracle_config = _config()
|
|
await oracle_graph.ainvoke(_input(), oracle_config)
|
|
await oracle_accessor.aget(oracle_config)
|
|
oracle_history = await oracle_accessor.ahistory(oracle_config)
|
|
|
|
assert _history_digests(cold) == _history_digests(oracle_history)
|
|
assert oracle_inner.history_walks > 0
|
|
assert cold_walks < oracle_inner.history_walks
|
|
assert saver.stats()["compose_hits"] > 0
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_cache_disabled_parity() -> None:
|
|
"""A zero-entry cache must behave exactly like the raw inner saver."""
|
|
inner, saver, graph, accessor = _make_cached_stack(max_entries=0)
|
|
config = _config()
|
|
await graph.ainvoke(_input(), config)
|
|
final = await accessor.aget(config)
|
|
history = await accessor.ahistory(config)
|
|
|
|
oracle_inner, oracle_graph, oracle_accessor = _make_oracle_stack()
|
|
oracle_config = _config()
|
|
await oracle_graph.ainvoke(_input(), oracle_config)
|
|
oracle_final = await oracle_accessor.aget(oracle_config)
|
|
oracle_history = await oracle_accessor.ahistory(oracle_config)
|
|
|
|
assert _digest(final.values) == _expected_final_digest()
|
|
assert _digest(final.values) == _digest(oracle_final.values)
|
|
assert _history_digests(history) == _history_digests(oracle_history)
|
|
assert saver.stats()["hits"] == 0
|
|
|
|
|
|
async def _run_branch_scenario(
|
|
graph: Any,
|
|
accessor: CheckpointStateAccessor,
|
|
config: dict[str, Any],
|
|
*,
|
|
fork_next: str,
|
|
as_node: str,
|
|
branch_id: str,
|
|
) -> dict[tuple[str, ...], Any]:
|
|
"""Run to completion, fork at the checkpoint whose next node is
|
|
``fork_next``, then resume the branch to completion (the branch head is
|
|
then the thread's latest checkpoint). Returns the original chain's
|
|
snapshots keyed by their ``next`` tuple for pinned re-reads.
|
|
|
|
``fork_next`` must name a NON-snapshot checkpoint: on langgraph 1.2.9 a
|
|
``aupdate_state`` fork at a snapshot checkpoint silently drops the update
|
|
(verified against a raw InMemorySaver - upstream behavior, not the cache).
|
|
"""
|
|
await graph.ainvoke(_input(), config)
|
|
history = await accessor.ahistory(config)
|
|
by_next = {s.next: s for s in history}
|
|
base = by_next[(fork_next,)]
|
|
branch_config = await accessor.aupdate(
|
|
base.config,
|
|
{"messages": [AIMessage(content=branch_id, id=f"ai-{branch_id}")]},
|
|
as_node=as_node,
|
|
)
|
|
await graph.ainvoke(None, branch_config)
|
|
return by_next
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_branch_divergence_no_cross_contamination() -> None:
|
|
"""A forked branch and the original head must each materialize their own
|
|
distinct history through the SAME cached saver."""
|
|
inner, saver, graph, accessor = _make_cached_stack()
|
|
config = _config()
|
|
by_next = await _run_branch_scenario(graph, accessor, config, fork_next="step2", as_node="step2", branch_id="branch")
|
|
|
|
# Oracle: identical branch scenario on the raw saver.
|
|
oracle_inner, oracle_graph, oracle_accessor = _make_oracle_stack()
|
|
oracle_config = _config()
|
|
oracle_by_next = await _run_branch_scenario(oracle_graph, oracle_accessor, oracle_config, fork_next="step2", as_node="step2", branch_id="branch")
|
|
|
|
# Branch head = thread's latest checkpoint after the forked resume.
|
|
branch_head = await accessor.aget(config)
|
|
oracle_branch_head = await oracle_accessor.aget(oracle_config)
|
|
expected_branch = [
|
|
("human", "kickoff", "h-0"),
|
|
("ai", "step-0", "ai-0"),
|
|
("ai", "step-1", "ai-1"),
|
|
("ai", "branch", "ai-branch"),
|
|
("ai", "step-3", "ai-3"),
|
|
("ai", "step-4", "ai-4"),
|
|
]
|
|
assert _digest(branch_head.values) == expected_branch
|
|
assert _digest(branch_head.values) == _digest(oracle_branch_head.values)
|
|
|
|
# The original chain still materializes its own un-branched history: the
|
|
# snapshot head and a non-snapshot mid checkpoint (cache-exercising read).
|
|
for next_key, expected_len in [((), 6), (("step4",), 5)]:
|
|
reread_original = await accessor.aget(by_next[next_key].config)
|
|
oracle_reread_original = await oracle_accessor.aget(oracle_by_next[next_key].config)
|
|
assert _digest(reread_original.values) == _expected_final_digest()[:expected_len]
|
|
assert _digest(reread_original.values) == _digest(oracle_reread_original.values)
|
|
assert _digest(reread_original.values) != _digest(branch_head.values)
|
|
|
|
|
|
async def _run_interrupt_scenario(graph: Any, accessor: CheckpointStateAccessor, config: dict[str, Any]) -> None:
|
|
result = await graph.ainvoke(_input(), config)
|
|
assert "__interrupt__" in result
|
|
await graph.ainvoke(Command(resume="yes"), config)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_interrupt_resume_appended_head_writes() -> None:
|
|
"""Resume appends writes under the interrupted head checkpoint; the cached
|
|
final state must equal the no-cache reference."""
|
|
inner = _CountingInMemorySaver()
|
|
saver = CachedHistorySaver(inner, MemoryCheckpointHistoryCache(128), key_prefix=f"itest-{uuid4().hex}")
|
|
graph = _build_interrupt_graph(saver)
|
|
accessor = CheckpointStateAccessor.bind(graph, saver, mode="delta")
|
|
config = _config()
|
|
await _run_interrupt_scenario(graph, accessor, config)
|
|
|
|
oracle_inner = _CountingInMemorySaver()
|
|
oracle_graph = _build_interrupt_graph(oracle_inner)
|
|
oracle_accessor = CheckpointStateAccessor.bind(oracle_graph, oracle_inner, mode="delta")
|
|
oracle_config = _config()
|
|
await _run_interrupt_scenario(oracle_graph, oracle_accessor, oracle_config)
|
|
|
|
final = await accessor.aget(config)
|
|
oracle_final = await oracle_accessor.aget(oracle_config)
|
|
expected = [
|
|
("human", "kickoff", "h-0"),
|
|
("ai", "step-0", "ai-0"),
|
|
("ai", "step-1", "ai-1"),
|
|
("ai", "resumed:yes", "ai-resume"),
|
|
("ai", "step-2", "ai-2"),
|
|
("ai", "step-3", "ai-3"),
|
|
]
|
|
assert _digest(final.values) == expected
|
|
assert _digest(final.values) == _digest(oracle_final.values)
|
|
|
|
# Every checkpoint along the resumed thread matches the oracle, including
|
|
# the interrupted head whose pending writes grew at resume time.
|
|
history = await accessor.ahistory(config)
|
|
oracle_history = await oracle_accessor.ahistory(oracle_config)
|
|
assert _history_digests(history) == _history_digests(oracle_history)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_eviction_only_costs_performance() -> None:
|
|
"""A 1-entry LRU thrashes on every read but must stay correct."""
|
|
inner, saver, graph, accessor = _make_cached_stack(max_entries=1)
|
|
config = _config()
|
|
await graph.ainvoke(_input(), config)
|
|
|
|
first_pass = await accessor.ahistory(config)
|
|
second_pass = await accessor.ahistory(config)
|
|
|
|
oracle_inner, oracle_graph, oracle_accessor = _make_oracle_stack()
|
|
oracle_config = _config()
|
|
await oracle_graph.ainvoke(_input(), oracle_config)
|
|
oracle_history = await oracle_accessor.ahistory(oracle_config)
|
|
|
|
assert _history_digests(first_pass) == _history_digests(oracle_history)
|
|
assert _history_digests(second_pass) == _history_digests(oracle_history)
|
|
assert saver.stats()["evictions"] > 0
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_rollback_supersede_does_not_pollute() -> None:
|
|
"""Re-running from an early checkpoint supersedes the head; the original
|
|
head's cached history must remain intact and re-readable."""
|
|
inner, saver, graph, accessor = _make_cached_stack()
|
|
config = _config()
|
|
by_next = await _run_branch_scenario(graph, accessor, config, fork_next="step0", as_node="step0", branch_id="rollback")
|
|
|
|
oracle_inner, oracle_graph, oracle_accessor = _make_oracle_stack()
|
|
oracle_config = _config()
|
|
oracle_by_next = await _run_branch_scenario(oracle_graph, oracle_accessor, oracle_config, fork_next="step0", as_node="step0", branch_id="rollback")
|
|
|
|
# New head equals the reference run's new head.
|
|
new_head = await accessor.aget(config)
|
|
oracle_new_head = await oracle_accessor.aget(oracle_config)
|
|
expected_new_head = [
|
|
("human", "kickoff", "h-0"),
|
|
("ai", "rollback", "ai-rollback"),
|
|
("ai", "step-1", "ai-1"),
|
|
("ai", "step-2", "ai-2"),
|
|
("ai", "step-3", "ai-3"),
|
|
("ai", "step-4", "ai-4"),
|
|
]
|
|
assert _digest(new_head.values) == expected_new_head
|
|
assert _digest(new_head.values) == _digest(oracle_new_head.values)
|
|
|
|
# Re-reading ORIGINAL chain checkpoints (pinned by checkpoint_id) returns
|
|
# their own original histories - their cached entries predate the fork and
|
|
# must be untouched by the superseding branch.
|
|
for next_key, expected_len in [((), 6), (("step4",), 5), (("step2",), 3)]:
|
|
reread = await accessor.aget(by_next[next_key].config)
|
|
oracle_reread = await oracle_accessor.aget(oracle_by_next[next_key].config)
|
|
assert _digest(reread.values) == _expected_final_digest()[:expected_len]
|
|
assert _digest(reread.values) == _digest(oracle_reread.values)
|