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

117 lines
4.4 KiB
Python

"""Shared Redis backend. Entries are immutable, so a multi-worker shared
cache needs no invalidation; the TTL is a leak safety net only.
Thread-scoped purge (``adelete_thread``) exists for data lifecycle: when a
thread's checkpoints are deleted, its cached history payloads are removed
immediately instead of lingering until TTL expiry.
The redis import is lazy (module is importable without the optional
``redis`` extra), mirroring runtime/stream_bridge/redis.py.
"""
from __future__ import annotations
import logging
from typing import Any
from deerflow.runtime.checkpoint_cache.base import CheckpointCacheStats, thread_key_stem
logger = logging.getLogger(__name__)
REDIS_INSTALL = "redis is required for the redis checkpoint cache backend. Install it with: uv sync --extra redis"
_TAG_SEPARATOR = b"\x00"
def _create_client(redis_url: str, *, max_connections: int | None) -> Any:
try:
import redis.asyncio as redis_async
except ImportError as exc:
raise ImportError(REDIS_INSTALL) from exc
kwargs: dict[str, Any] = {"decode_responses": False}
if max_connections is not None:
kwargs["max_connections"] = max_connections
return redis_async.from_url(redis_url, **kwargs)
def _redis_error() -> type[Exception]:
"""Lazy RedisError import, mirroring the lazy client creation above."""
try:
from redis.exceptions import RedisError
except ImportError as exc:
raise ImportError(REDIS_INSTALL) from exc
return RedisError
class RedisCheckpointHistoryCache:
def __init__(
self,
redis_url: str,
*,
serde: Any,
ttl_seconds: int,
max_connections: int | None = None,
) -> None:
self._client = _create_client(redis_url, max_connections=max_connections)
self._serde = serde
# ttl_seconds=0 is an explicit opt-out of expiry (no SETEX) — not the
# default, and leaked/orphaned keys then rely on redis maxmemory only.
self._ttl = ttl_seconds if ttl_seconds > 0 else None
self._hits = 0
self._misses = 0
async def aget_many(self, keys: list[str]) -> dict[str, dict[str, Any]]:
if not keys:
return {}
try:
raws = await self._client.mget(keys)
except _redis_error() as exc:
# Performance-only bypass: a redis outage costs hits, never availability.
logger.warning("checkpoint history cache mget failed; treating as all-miss: %s", exc)
self._misses += len(keys)
return {}
found: dict[str, dict[str, Any]] = {}
for key, raw in zip(keys, raws, strict=True):
if raw is None:
self._misses += 1
continue
self._hits += 1
tag, payload = raw.split(_TAG_SEPARATOR, 1)
found[key] = self._serde.loads_typed((tag.decode(), payload))
return found
async def aset_many(self, entries: dict[str, dict[str, Any]]) -> None:
if not entries:
return
try:
pipe = self._client.pipeline(transaction=False)
for key, entry in entries.items():
tag, data = self._serde.dumps_typed(entry)
pipe.set(key, tag.encode() + _TAG_SEPARATOR + data, ex=self._ttl)
await pipe.execute()
except _redis_error() as exc:
# Writes are optional; the next read simply recomputes the history.
logger.warning("checkpoint history cache write failed; skipping: %s", exc)
async def adelete_thread(self, key_prefix: str, thread_id: str) -> None:
"""SCAN+UNLINK every entry of one thread. Failure degrades to
TTL-bounded residual retention; the source-of-truth delete already
happened, so this never raises."""
stem = thread_key_stem(key_prefix, thread_id)
try:
cursor = 0
while True:
cursor, keys = await self._client.scan(cursor=cursor, match=stem + "*", count=500)
if keys:
await self._client.unlink(*keys)
if cursor == 0:
break
except _redis_error() as exc:
logger.warning("checkpoint history cache thread purge failed; residual entries expire via TTL: %s", exc)
def stats(self) -> CheckpointCacheStats:
return CheckpointCacheStats(hits=self._hits, misses=self._misses)
async def aclose(self) -> None:
await self._client.aclose()