deer-flow/backend/tests/test_checkpoint_cache_provider.py
Vanzeren c8cf1bf2fb
feat(checkpoint): checkpoint history cache (#4638)
* 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>
2026-08-02 22:25:02 +08:00

97 lines
3.7 KiB
Python

"""Provider wiring: mode-gated wrapping in async and sync checkpointer factories."""
import pytest
from deerflow.config.app_config import AppConfig, set_app_config
from deerflow.runtime.checkpoint_mode import freeze_checkpoint_channel_mode
from deerflow.runtime.checkpointer.async_provider import make_checkpointer
from deerflow.runtime.checkpointer.cached_saver import CachedHistorySaver
from deerflow.runtime.checkpointer.provider import checkpointer_context, reset_checkpointer
# AppConfig requires the sandbox section (no default); the rest of the config
# is optional. Mirrors test_checkpoint_cache_redis.py's construction pattern.
def _app_config(mode: str, cache: dict | None = None) -> AppConfig:
database: dict = {"backend": "memory", "checkpoint_channel_mode": mode}
if cache is not None:
database["checkpoint_cache"] = cache
return AppConfig.model_validate(
{
"sandbox": {"use": "deerflow.sandbox.local.provider:LocalSandboxProvider"},
"database": database,
}
)
@pytest.mark.anyio
async def test_delta_mode_wraps_with_cached_saver():
set_app_config(_app_config("delta"))
freeze_checkpoint_channel_mode("delta")
async with make_checkpointer() as saver:
assert isinstance(saver, CachedHistorySaver)
assert saver.stats()["entries"] == 0
@pytest.mark.anyio
async def test_full_mode_yields_raw_saver():
set_app_config(_app_config("full"))
freeze_checkpoint_channel_mode("full")
async with make_checkpointer() as saver:
assert not isinstance(saver, CachedHistorySaver)
@pytest.mark.anyio
async def test_zero_max_entries_disables_but_still_wraps():
set_app_config(_app_config("delta", {"max_entries": 0}))
freeze_checkpoint_channel_mode("delta")
async with make_checkpointer() as saver:
assert isinstance(saver, CachedHistorySaver)
# Disabled cache -> every history call is a full walk on the inner saver.
assert saver._cache.enabled is False
def test_sync_delta_mode_wraps_memory():
set_app_config(_app_config("delta"))
freeze_checkpoint_channel_mode("delta")
reset_checkpointer()
with checkpointer_context() as saver:
assert isinstance(saver, CachedHistorySaver)
def test_sync_redis_cache_type_is_config_error():
set_app_config(_app_config("delta", {"type": "redis"}))
freeze_checkpoint_channel_mode("delta")
reset_checkpointer()
with pytest.raises(ValueError, match="redis"):
with checkpointer_context():
pass
def test_sync_full_mode_unwrapped():
set_app_config(_app_config("full"))
freeze_checkpoint_channel_mode("full")
reset_checkpointer()
with checkpointer_context() as saver:
assert not isinstance(saver, CachedHistorySaver)
def test_sync_cache_recreated_when_key_prefix_changes():
"""The singleton must not outlive its namespace: a prefix change without
a process restart leaves old-prefix entries unreachable and unpurgeable."""
reset_checkpointer()
set_app_config(_app_config("delta", {"key_prefix": "ns-a"}))
freeze_checkpoint_channel_mode("delta")
with checkpointer_context() as saver:
saver._cache.set_many({"ns-a:t1:x": {"writes": []}})
first_cache = saver._cache
# Same prefix: singleton is reused (warm across wrappers).
with checkpointer_context() as saver:
assert saver._cache is first_cache
assert saver._cache.stats().entries == 1
# Prefix change: fresh cache, stale namespace gone with the old instance.
set_app_config(_app_config("delta", {"key_prefix": "ns-b"}))
with checkpointer_context() as saver:
assert saver._cache is not first_cache
assert saver._cache.stats().entries == 0
reset_checkpointer()