mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-06 21:08:43 +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>
246 lines
9.6 KiB
Python
246 lines
9.6 KiB
Python
"""Redis backend and provider factory for the checkpoint history cache."""
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
|
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.runtime.checkpoint_cache.provider import (
|
|
checkpoint_cache_db_hash,
|
|
checkpoint_cache_key_prefix,
|
|
make_checkpoint_cache,
|
|
)
|
|
|
|
|
|
class _FakeRedis:
|
|
"""Minimal async redis stand-in: mget / set / pipeline / scan / unlink."""
|
|
|
|
def __init__(self) -> None:
|
|
self.store: dict[str, bytes] = {}
|
|
self.ttls: dict[str, int | None] = {}
|
|
self.unlinked: list[tuple[str, ...]] = []
|
|
|
|
async def mget(self, keys: list[str]) -> list[bytes | None]:
|
|
return [self.store.get(k) for k in keys]
|
|
|
|
def set(self, key: str, value: bytes, ex: int | None = None) -> None:
|
|
self.store[key] = value
|
|
self.ttls[key] = ex
|
|
|
|
async def scan(self, cursor: int = 0, match: str | None = None, count: int = 500) -> tuple[int, list[str]]:
|
|
import fnmatch
|
|
|
|
keys = sorted(self.store)
|
|
batch = keys[cursor : cursor + count]
|
|
if match is not None:
|
|
batch = [k for k in batch if fnmatch.fnmatchcase(k, match)]
|
|
next_cursor = cursor + count
|
|
return (0 if next_cursor >= len(keys) else next_cursor), batch
|
|
|
|
async def unlink(self, *keys: str) -> int:
|
|
self.unlinked.append(tuple(keys))
|
|
removed = 0
|
|
for key in keys:
|
|
removed += self.store.pop(key, None) is not None
|
|
return removed
|
|
|
|
def pipeline(self, transaction: bool = False) -> "_FakePipeline":
|
|
return _FakePipeline(self)
|
|
|
|
async def aclose(self) -> None:
|
|
pass
|
|
|
|
|
|
class _FakePipeline:
|
|
def __init__(self, client: _FakeRedis) -> None:
|
|
self._client = client
|
|
|
|
def set(self, key: str, value: bytes, ex: int | None = None) -> "_FakePipeline":
|
|
self._client.set(key, value, ex=ex)
|
|
return self
|
|
|
|
async def execute(self) -> None:
|
|
pass
|
|
|
|
|
|
class _FailingRedis(_FakeRedis):
|
|
"""Simulates a redis outage: every operation raises RedisError."""
|
|
|
|
async def mget(self, keys: list[str]) -> list[bytes | None]:
|
|
from redis.exceptions import RedisError
|
|
|
|
raise RedisError("connection refused")
|
|
|
|
async def scan(self, cursor: int = 0, match: str | None = None, count: int = 500) -> tuple[int, list[str]]:
|
|
from redis.exceptions import RedisError
|
|
|
|
raise RedisError("connection refused")
|
|
|
|
def pipeline(self, transaction: bool = False) -> "_FakePipeline":
|
|
from redis.exceptions import RedisError
|
|
|
|
raise RedisError("connection refused")
|
|
|
|
|
|
def _make_cache(monkeypatch: pytest.MonkeyPatch, fake: _FakeRedis, ttl_seconds: int = 60, **kwargs: Any):
|
|
import deerflow.runtime.checkpoint_cache.redis as redis_mod
|
|
|
|
monkeypatch.setattr(redis_mod, "_create_client", lambda *a, **k: fake)
|
|
return redis_mod.RedisCheckpointHistoryCache("redis://unused", serde=JsonPlusSerializer(), ttl_seconds=ttl_seconds, **kwargs)
|
|
|
|
|
|
def _entry(i: int) -> dict:
|
|
# Real message-like payloads to prove serde fidelity beyond plain dicts.
|
|
from langchain_core.messages import AIMessage
|
|
|
|
return {"writes": [("task-1", "messages", AIMessage(content=f"m{i}", id=f"ai-{i}"))], "seed": [AIMessage(content="s", id="ai-s")]}
|
|
|
|
|
|
# AppConfig requires the sandbox section (no default); the rest of the config
|
|
# is optional. Mirrors test_checkpoint_mode.py's construction pattern.
|
|
def _app_config(database: dict) -> AppConfig:
|
|
return AppConfig.model_validate(
|
|
{
|
|
"sandbox": {"use": "deerflow.sandbox.local.provider:LocalSandboxProvider"},
|
|
"database": database,
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_redis_roundtrip_preserves_types(monkeypatch: pytest.MonkeyPatch):
|
|
fake = _FakeRedis()
|
|
cache = _make_cache(monkeypatch, fake)
|
|
await cache.aset_many({"k1": _entry(1), "k2": {"writes": []}})
|
|
hit = await cache.aget_many(["k1", "k2", "k3"])
|
|
assert set(hit) == {"k1", "k2"}
|
|
msg = hit["k1"]["writes"][0][2]
|
|
assert msg.content == "m1" and msg.id == "ai-1" and msg.type == "ai"
|
|
assert "seed" not in hit["k2"]
|
|
assert cache.stats().hits == 2 and cache.stats().misses == 1
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_redis_keys_land_verbatim_and_ttl_set(monkeypatch: pytest.MonkeyPatch):
|
|
fake = _FakeRedis()
|
|
cache = _make_cache(monkeypatch, fake)
|
|
await cache.aset_many({"k1": _entry(1)})
|
|
assert list(fake.store) == ["k1"]
|
|
assert fake.ttls["k1"] == 60
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_redis_outage_degrades_to_all_miss(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture):
|
|
fake = _FailingRedis()
|
|
cache = _make_cache(monkeypatch, fake)
|
|
with caplog.at_level("WARNING"):
|
|
assert await cache.aget_many(["k1", "k2"]) == {}
|
|
assert cache.stats().misses == 2 and cache.stats().hits == 0
|
|
assert "mget failed" in caplog.text
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_redis_outage_skips_write(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture):
|
|
fake = _FailingRedis()
|
|
cache = _make_cache(monkeypatch, fake)
|
|
with caplog.at_level("WARNING"):
|
|
await cache.aset_many({"k1": _entry(1)}) # must not raise
|
|
assert fake.store == {}
|
|
assert "write failed" in caplog.text
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_zero_ttl_disables_expiry_explicitly(monkeypatch: pytest.MonkeyPatch):
|
|
fake = _FakeRedis()
|
|
cache = _make_cache(monkeypatch, fake, ttl_seconds=0)
|
|
await cache.aset_many({"k1": _entry(1)})
|
|
assert cache._ttl is None
|
|
assert fake.ttls["k1"] is None # SET without EX: redis maxmemory policy only
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_adelete_thread_purges_matching_keys_only(monkeypatch: pytest.MonkeyPatch):
|
|
fake = _FakeRedis()
|
|
cache = _make_cache(monkeypatch, fake)
|
|
prefix = "ckpt-hist:v1:db0"
|
|
await cache.aset_many(
|
|
{
|
|
f"{prefix}:t1:aaa": _entry(1),
|
|
f"{prefix}:t1:bbb": _entry(2),
|
|
f"{prefix}:t10:ccc": _entry(3), # 't1' stem must not over-match 't10'
|
|
f"{prefix}:t2:ddd": _entry(4),
|
|
}
|
|
)
|
|
|
|
await cache.adelete_thread(prefix, "t1")
|
|
|
|
assert sorted(fake.store) == [f"{prefix}:t10:ccc", f"{prefix}:t2:ddd"]
|
|
assert fake.unlinked # UNLINK, not DEL: non-blocking on big histories
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_adelete_thread_outage_degrades_without_raising(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture):
|
|
fake = _FailingRedis()
|
|
cache = _make_cache(monkeypatch, fake)
|
|
with caplog.at_level("WARNING"):
|
|
await cache.adelete_thread("p", "t1") # must not raise
|
|
assert "thread purge failed" in caplog.text
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_provider_memory_default():
|
|
from deerflow.runtime.checkpoint_cache.memory import MemoryCheckpointHistoryCache
|
|
|
|
async with make_checkpoint_cache(_app_config({"backend": "sqlite"}), serde=JsonPlusSerializer()) as cache:
|
|
assert isinstance(cache, MemoryCheckpointHistoryCache)
|
|
assert cache.enabled is True
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_provider_zero_max_entries_disables_any_type():
|
|
app_config = _app_config({"backend": "sqlite", "checkpoint_cache": {"type": "redis", "max_entries": 0}})
|
|
from deerflow.runtime.checkpoint_cache.memory import MemoryCheckpointHistoryCache
|
|
|
|
async with make_checkpoint_cache(app_config, serde=JsonPlusSerializer()) as cache:
|
|
assert isinstance(cache, MemoryCheckpointHistoryCache)
|
|
assert cache.enabled is False
|
|
|
|
|
|
def test_db_hash_distinguishes_backends_and_targets():
|
|
from deerflow.config.database_config import DatabaseConfig
|
|
|
|
sqlite_cfg = DatabaseConfig.model_validate({"backend": "sqlite", "sqlite_dir": "/tmp/a"})
|
|
pg_cfg = DatabaseConfig.model_validate({"backend": "postgres", "postgres_url": "postgresql://u:p@h/db"})
|
|
pg_cfg2 = DatabaseConfig.model_validate({"backend": "postgres", "postgres_url": "postgresql://u:p@h/other"})
|
|
assert checkpoint_cache_db_hash(sqlite_cfg) != checkpoint_cache_db_hash(pg_cfg)
|
|
assert checkpoint_cache_db_hash(pg_cfg) != checkpoint_cache_db_hash(pg_cfg2)
|
|
assert len(checkpoint_cache_db_hash(pg_cfg)) == 12
|
|
|
|
|
|
def test_db_hash_stable_across_credential_rotation():
|
|
"""Same database, rotated user/password -> same cache namespace."""
|
|
from deerflow.config.database_config import DatabaseConfig
|
|
|
|
before = DatabaseConfig.model_validate({"backend": "postgres", "postgres_url": "postgresql://alice:secret1@pg.internal:5432/deerflow"})
|
|
rotated = DatabaseConfig.model_validate({"backend": "postgres", "postgres_url": "postgresql://bob:secret2@pg.internal:5432/deerflow"})
|
|
driver_suffix = DatabaseConfig.model_validate({"backend": "postgres", "postgres_url": "postgresql+asyncpg://alice:secret1@pg.internal:5432/deerflow"})
|
|
other_db = DatabaseConfig.model_validate({"backend": "postgres", "postgres_url": "postgresql://alice:secret1@pg.internal:5432/other"})
|
|
assert checkpoint_cache_db_hash(before) == checkpoint_cache_db_hash(rotated)
|
|
assert checkpoint_cache_db_hash(before) == checkpoint_cache_db_hash(driver_suffix)
|
|
assert checkpoint_cache_db_hash(before) != checkpoint_cache_db_hash(other_db)
|
|
|
|
|
|
def test_db_hash_unparseable_url_falls_back_to_raw():
|
|
from deerflow.config.database_config import DatabaseConfig
|
|
|
|
cfg = DatabaseConfig.model_validate({"backend": "postgres", "postgres_url": "not-a-url"})
|
|
assert len(checkpoint_cache_db_hash(cfg)) == 12 # stable, never raises
|
|
|
|
|
|
def test_key_prefix_override_wins():
|
|
app_config = _app_config({"backend": "sqlite", "checkpoint_cache": {"key_prefix": "custom:"}})
|
|
assert checkpoint_cache_key_prefix(app_config) == "custom:"
|
|
default = checkpoint_cache_key_prefix(_app_config({"backend": "sqlite"}))
|
|
assert default.startswith("ckpt-hist:v1:")
|