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

102 lines
3.7 KiB
Python

"""Cache factory. Mirrors make_stream_bridge: config -> env fallback -> memory."""
from __future__ import annotations
import contextlib
import hashlib
import logging
import os
from collections.abc import AsyncIterator
from typing import Any
from deerflow.config.app_config import AppConfig
from deerflow.runtime.checkpoint_cache.base import CACHE_FORMAT_VERSION, CheckpointHistoryCache
from deerflow.runtime.checkpoint_cache.memory import MemoryCheckpointHistoryCache
logger = logging.getLogger(__name__)
_ENV_REDIS_URL = "DEER_FLOW_CHECKPOINT_CACHE_REDIS_URL"
def _resolve_redis_url(config: Any) -> str:
return config.redis_url or os.getenv(_ENV_REDIS_URL) or os.getenv("REDIS_URL") or "redis://localhost:6379/0"
def _stable_postgres_identity(postgres_url: str) -> str:
"""Credential-free database identity: host/port/database.
Hashing the raw URL would change the cache namespace on every credential
rotation (cold cache + orphaned keys until TTL) even though the database
— and thus every cached checkpoint history — is unchanged. Unparseable
URLs fall back to the raw string (still stable per deployment).
"""
if not postgres_url:
return ""
try:
from sqlalchemy.engine.url import make_url
parsed = make_url(postgres_url)
except Exception: # noqa: BLE001 - identity must never fail config load
return postgres_url
return f"{parsed.host or 'localhost'}:{parsed.port or 5432}/{parsed.database or ''}"
def checkpoint_cache_db_hash(db_config: Any) -> str:
"""Deployment-identity hash so two deployments sharing one Redis never collide."""
backend = getattr(db_config, "backend", "memory")
if backend == "postgres":
identity = f"postgres:{_stable_postgres_identity(getattr(db_config, 'postgres_url', ''))}:{getattr(db_config, 'postgres_schema', '')}"
elif backend == "sqlite":
identity = f"sqlite:{getattr(db_config, 'checkpointer_sqlite_path', '')}"
else:
identity = "memory"
return hashlib.sha256(identity.encode()).hexdigest()[:12]
def checkpoint_cache_key_prefix(app_config: AppConfig) -> str:
cache_config = app_config.database.checkpoint_cache
if cache_config.key_prefix:
return cache_config.key_prefix
return f"ckpt-hist:v{CACHE_FORMAT_VERSION}:{checkpoint_cache_db_hash(app_config.database)}"
@contextlib.asynccontextmanager
async def make_checkpoint_cache(
app_config: AppConfig | None = None,
*,
serde: Any,
) -> AsyncIterator[CheckpointHistoryCache]:
"""Yield a history cache for the caller's lifetime.
``max_entries == 0`` disables the cache uniformly (both types) via a
disabled memory backend, so the wrapper never needs a None check.
"""
config = app_config.database.checkpoint_cache if app_config is not None else None
if config is None or config.type == "memory" or config.max_entries == 0:
max_entries = config.max_entries if config is not None else 128
cache = MemoryCheckpointHistoryCache(max_entries=max_entries)
logger.info("Checkpoint history cache initialised: memory (max_entries=%d)", max_entries)
try:
yield cache
finally:
await cache.aclose()
return
if config.type == "redis":
from deerflow.runtime.checkpoint_cache.redis import RedisCheckpointHistoryCache
cache = RedisCheckpointHistoryCache(
_resolve_redis_url(config),
serde=serde,
ttl_seconds=config.ttl_seconds,
)
logger.info("Checkpoint history cache initialised: redis (ttl_seconds=%d)", config.ttl_seconds)
try:
yield cache
finally:
await cache.aclose()
return
raise ValueError(f"Unknown checkpoint cache type: {config.type!r}")