mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-04 20:08:40 +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>
320 lines
14 KiB
Python
320 lines
14 KiB
Python
"""Unified database backend configuration.
|
|
|
|
Controls BOTH the LangGraph checkpointer and the DeerFlow application
|
|
persistence layer (runs, threads metadata, users, etc.). The user
|
|
configures one backend; the system handles physical separation details.
|
|
|
|
SQLite mode: checkpointer and app share a single .db file
|
|
({sqlite_dir}/deerflow.db) with WAL journal mode enabled on every
|
|
connection. WAL allows concurrent readers and a single writer without
|
|
blocking, making a unified file safe for both workloads. Writers
|
|
that contend for the lock wait via the default 5-second sqlite3
|
|
busy timeout rather than failing immediately.
|
|
|
|
Postgres mode: both use the same database URL but maintain independent
|
|
connection pools with different lifecycles.
|
|
|
|
Memory mode: checkpointer uses MemorySaver, app uses in-memory stores.
|
|
No database is initialized.
|
|
|
|
Sensitive values (postgres_url) should use $VAR syntax in config.yaml
|
|
to reference environment variables from .env:
|
|
|
|
database:
|
|
backend: postgres
|
|
postgres_url: $DATABASE_URL
|
|
|
|
The $VAR resolution is handled by AppConfig.resolve_env_variables()
|
|
before this config is instantiated -- DatabaseConfig itself does not
|
|
need to do any environment variable processing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from typing import Any, Literal
|
|
|
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
|
|
from deerflow.config.postgres_schema import POSTGRES_SCHEMA_PATTERN, validate_postgres_schema
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def resolve_checkpoint_graph_cache_max(database_config: Any, field_name: str, default: int) -> int:
|
|
"""Read a graph-cache cap from a database config-ish object.
|
|
|
|
Tolerates stub configs (SimpleNamespace/MagicMock in tests, missing
|
|
section): anything that is not a plain int >= 1 falls back to
|
|
``default``.
|
|
"""
|
|
section = getattr(database_config, "checkpoint_graph_cache", None)
|
|
value = getattr(section, field_name, None)
|
|
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
return default
|
|
return value
|
|
|
|
|
|
CheckpointChannelMode = Literal["full", "delta"]
|
|
|
|
DEFAULT_CHECKPOINT_SNAPSHOT_FREQUENCY = 10
|
|
|
|
|
|
class CheckpointDeltaConfig(BaseModel):
|
|
"""Tuning knobs for ``checkpoint_channel_mode: delta``.
|
|
|
|
Ignored in ``full`` mode. Like the mode itself, these values are
|
|
restart-required and must match across every process sharing one
|
|
checkpoint database: the snapshot cadence is baked into each compiled
|
|
graph's channel table, not stored in the checkpoint, so a mismatched
|
|
process would apply a different cadence to the same threads.
|
|
"""
|
|
|
|
snapshot_frequency: int = Field(
|
|
default=DEFAULT_CHECKPOINT_SNAPSHOT_FREQUENCY,
|
|
ge=1,
|
|
description=(
|
|
"DeltaChannel snapshot cadence: a full messages snapshot is stored "
|
|
"every N per-step writes (higher = smaller checkpoints, slower "
|
|
"materialization). Restart is required, and all processes sharing "
|
|
"one checkpoint database must use the same value."
|
|
),
|
|
)
|
|
|
|
|
|
class CheckpointGraphCacheConfig(BaseModel):
|
|
"""Size cap for the process-local compiled checkpoint graph cache.
|
|
|
|
Unlike the mode and snapshot cadence, this is NOT restart-required:
|
|
a larger/smaller cap only changes when the cache evicts, never graph
|
|
semantics, so a hot-reloaded value takes effect on the next eviction
|
|
check. The cache is keyed by (assistant, mode, cadence, app_config) and
|
|
cleared wholesale at the cap.
|
|
"""
|
|
|
|
accessor_graph_max: int = Field(
|
|
default=64,
|
|
ge=1,
|
|
description=("Max compiled thread-state accessor graphs cached by the gateway (keyed per assistant, channel mode, and snapshot cadence)."),
|
|
)
|
|
|
|
|
|
class CheckpointCacheConfig(BaseModel):
|
|
"""Delta-history cache policy. Performance-only: never frozen, never
|
|
required to match across processes sharing one checkpoint database.
|
|
|
|
Applies only when ``checkpoint_channel_mode`` is ``delta``. ``max_entries``
|
|
bounds the process-local memory backend; ``0`` disables the cache
|
|
entirely. The redis backend is bounded by ``ttl_seconds`` and the server's
|
|
own maxmemory policy.
|
|
"""
|
|
|
|
type: Literal["memory", "redis"] = Field(
|
|
default="memory",
|
|
description=("Checkpoint history cache backend. 'memory' = process-local LRU; 'redis' = shared cache for multi-worker deployments (async/Gateway path only; the sync embedded path rejects it)."),
|
|
)
|
|
max_entries: int = Field(
|
|
default=128,
|
|
ge=0,
|
|
description="LRU capacity of the memory backend. 0 disables the cache.",
|
|
)
|
|
redis_url: str | None = Field(
|
|
default=None,
|
|
description=("Redis URL for type=redis. If omitted, DEER_FLOW_CHECKPOINT_CACHE_REDIS_URL, REDIS_URL, or redis://localhost:6379/0 is used."),
|
|
)
|
|
ttl_seconds: int = Field(
|
|
default=86400,
|
|
ge=0,
|
|
description=(
|
|
"Redis entry TTL; a leak safety net, not a correctness mechanism (entries are immutable). "
|
|
"Thread deletion purges that thread's entries immediately; if the purge fails (redis outage), "
|
|
"residual copies of the thread's history persist until this TTL expires. "
|
|
"0 explicitly disables expiry — orphaned keys then rely on the redis maxmemory policy alone."
|
|
),
|
|
)
|
|
key_prefix: str = Field(
|
|
default="",
|
|
description="Optional override for the redis key prefix; defaults to a hash of the database identity.",
|
|
)
|
|
|
|
|
|
class DatabaseConfig(BaseModel):
|
|
backend: Literal["memory", "sqlite", "postgres"] = Field(
|
|
default="memory",
|
|
description=("Storage backend for both checkpointer and application data. 'memory' for development (no persistence across restarts), 'sqlite' for single-node deployment, 'postgres' for production multi-node deployment."),
|
|
)
|
|
checkpoint_channel_mode: CheckpointChannelMode = Field(
|
|
default="full",
|
|
description=(
|
|
"Checkpoint representation for accumulating channels. "
|
|
"'full' preserves full-value message checkpoints; 'delta' uses "
|
|
"LangGraph DeltaChannel for messages. Restart is required, and all "
|
|
"processes sharing one checkpoint database must use the same value."
|
|
),
|
|
)
|
|
checkpoint_delta: CheckpointDeltaConfig = Field(
|
|
default_factory=CheckpointDeltaConfig,
|
|
description="Delta-mode checkpoint tuning. Only applies when checkpoint_channel_mode is 'delta'.",
|
|
)
|
|
checkpoint_graph_cache: CheckpointGraphCacheConfig = Field(
|
|
default_factory=CheckpointGraphCacheConfig,
|
|
description="Size caps for the compiled checkpoint graph caches. Hot-reloadable; not restart-required.",
|
|
)
|
|
checkpoint_cache: CheckpointCacheConfig = Field(
|
|
default_factory=CheckpointCacheConfig,
|
|
description="Delta-mode checkpoint history cache. Performance-only; safe to differ across workers.",
|
|
)
|
|
sqlite_dir: str = Field(
|
|
default=".deer-flow/data",
|
|
description=("Directory for the SQLite database file. Both checkpointer and application data share {sqlite_dir}/deerflow.db."),
|
|
)
|
|
postgres_url: str = Field(
|
|
default="",
|
|
description=(
|
|
"PostgreSQL connection URL, shared by checkpointer and app. "
|
|
"Use $DATABASE_URL in config.yaml to reference .env. "
|
|
"Example: postgresql://user:pass@host:5432/deerflow "
|
|
"(the +asyncpg driver suffix is added automatically where needed)."
|
|
),
|
|
)
|
|
echo_sql: bool = Field(
|
|
default=False,
|
|
description="Echo all SQL statements to log (debug only).",
|
|
)
|
|
pool_size: int = Field(
|
|
default=5,
|
|
description="Connection pool size for the app ORM engine (postgres only).",
|
|
)
|
|
pool_recycle: int = Field(
|
|
default=300,
|
|
gt=0,
|
|
description="Seconds before app ORM PostgreSQL connections are recycled.",
|
|
)
|
|
command_timeout: float | None = Field(
|
|
default=30,
|
|
gt=0,
|
|
description="Timeout in seconds for app ORM PostgreSQL commands. Set to null to disable the command timeout.",
|
|
)
|
|
postgres_schema: str = Field(
|
|
default="",
|
|
description=(
|
|
"PostgreSQL schema for both app ORM tables and LangGraph "
|
|
"checkpointer/store tables (postgres only). Empty string keeps "
|
|
"the server default search_path (usually 'public'). When set, "
|
|
"the schema is created automatically at startup and applied via "
|
|
"connection-level search_path. Only plain identifiers are "
|
|
f"allowed: {POSTGRES_SCHEMA_PATTERN}."
|
|
),
|
|
)
|
|
|
|
@field_validator("postgres_schema")
|
|
@classmethod
|
|
def _validate_postgres_schema(cls, value: str) -> str:
|
|
return validate_postgres_schema(value)
|
|
|
|
# -- Legacy key migration (not user-configured) --
|
|
|
|
@model_validator(mode="before")
|
|
@classmethod
|
|
def _migrate_legacy_snapshot_frequency(cls, data: Any) -> Any:
|
|
"""Carry the pre-rename top-level ``checkpoint_delta_snapshot_frequency``
|
|
key onto ``checkpoint_delta.snapshot_frequency``.
|
|
|
|
``DatabaseConfig`` ignores unknown keys (pydantic ``extra="ignore"``),
|
|
so without this shim a config.yaml written against the old flat key
|
|
would silently fall back to the new default cadence instead of the
|
|
operator's chosen value. An explicitly set nested key always wins.
|
|
"""
|
|
if not isinstance(data, dict) or "checkpoint_delta_snapshot_frequency" not in data:
|
|
return data
|
|
data = dict(data)
|
|
legacy_value = data.pop("checkpoint_delta_snapshot_frequency")
|
|
nested = data.get("checkpoint_delta")
|
|
if isinstance(nested, dict):
|
|
if "snapshot_frequency" in nested:
|
|
logger.warning(
|
|
"Both database.checkpoint_delta_snapshot_frequency (deprecated) and database.checkpoint_delta.snapshot_frequency are set; the nested key wins.",
|
|
)
|
|
return data
|
|
data["checkpoint_delta"] = {**nested, "snapshot_frequency": legacy_value}
|
|
elif nested is None:
|
|
data["checkpoint_delta"] = {"snapshot_frequency": legacy_value}
|
|
else:
|
|
# Programmatically constructed CheckpointDeltaConfig instance: the
|
|
# explicit object wins over the legacy scalar.
|
|
logger.warning(
|
|
"Ignoring deprecated database.checkpoint_delta_snapshot_frequency because database.checkpoint_delta is already set.",
|
|
)
|
|
return data
|
|
logger.warning(
|
|
"database.checkpoint_delta_snapshot_frequency is deprecated; use database.checkpoint_delta.snapshot_frequency instead. Carried the legacy value (%r) forward.",
|
|
legacy_value,
|
|
)
|
|
return data
|
|
|
|
# -- Derived helpers (not user-configured) --
|
|
|
|
@property
|
|
def _resolved_sqlite_dir(self) -> str:
|
|
"""Resolve sqlite_dir to an absolute path (relative to CWD)."""
|
|
from pathlib import Path
|
|
|
|
return str(Path(self.sqlite_dir).resolve())
|
|
|
|
@property
|
|
def sqlite_path(self) -> str:
|
|
"""Unified SQLite file path shared by checkpointer and app."""
|
|
return os.path.join(self._resolved_sqlite_dir, "deerflow.db")
|
|
|
|
# Backward-compatible aliases
|
|
@property
|
|
def checkpointer_sqlite_path(self) -> str:
|
|
"""SQLite file path for the LangGraph checkpointer (alias for sqlite_path)."""
|
|
return self.sqlite_path
|
|
|
|
@property
|
|
def app_sqlite_path(self) -> str:
|
|
"""SQLite file path for application ORM data (alias for sqlite_path)."""
|
|
return self.sqlite_path
|
|
|
|
@property
|
|
def app_sqlalchemy_url(self) -> str:
|
|
"""SQLAlchemy async URL for the application ORM engine."""
|
|
if self.backend == "sqlite":
|
|
return f"sqlite+aiosqlite:///{self.sqlite_path}"
|
|
if self.backend == "postgres":
|
|
url = self.postgres_url
|
|
if url.startswith("postgresql://"):
|
|
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
elif url.startswith("postgres://"):
|
|
# libpq's short alias: accepted by the psycopg checkpointer, but not a SQLAlchemy dialect.
|
|
url = url.replace("postgres://", "postgresql+asyncpg://", 1)
|
|
return url
|
|
raise ValueError(f"No SQLAlchemy URL for backend={self.backend!r}")
|
|
|
|
@property
|
|
def app_sync_sqlalchemy_url(self) -> str:
|
|
"""SQLAlchemy *synchronous* URL for the application ORM data.
|
|
|
|
Used by the ``agent_storage.backend: db`` store, whose consumers (the
|
|
LangGraph graph factory, the setup/update tools) are synchronous and may
|
|
run on the event loop or in a separate process from the gateway, where an
|
|
async engine cannot be driven. Points at the same database file/server as
|
|
:meth:`app_sqlalchemy_url`; only the driver differs (both drivers —
|
|
stdlib sqlite3 and psycopg — ship with the app, so this adds no
|
|
dependency).
|
|
"""
|
|
if self.backend == "sqlite":
|
|
return f"sqlite:///{self.sqlite_path}"
|
|
if self.backend == "postgres":
|
|
url = self.postgres_url
|
|
if url.startswith("postgresql+asyncpg://"):
|
|
url = url.replace("postgresql+asyncpg://", "postgresql+psycopg://", 1)
|
|
elif url.startswith("postgresql://"):
|
|
url = url.replace("postgresql://", "postgresql+psycopg://", 1)
|
|
elif url.startswith("postgres://"):
|
|
url = url.replace("postgres://", "postgresql+psycopg://", 1)
|
|
return url
|
|
raise ValueError(f"No SQLAlchemy URL for backend={self.backend!r}")
|