mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-04 03:49:25 +00:00
* feat(persistence): support custom postgres schema * fix(persistence): address CI lint/test failures and review feedback - Map missing psycopg import to actionable POSTGRES_INSTALL guidance in sync/async schema-creation helpers - Accept SQLAlchemy compound DSN schemes (postgresql+asyncpg) when injecting search_path, normalizing to a libpq-consumable DSN - Guard keyword-DSN tests with importorskip so they skip without psycopg - Set database=None in sync checkpointer none-fix test to avoid MagicMock backend resolution - Apply ruff import sort and format * fix(persistence): address pg-schema review feedback - Restrict postgres_schema regex to lowercase-only so the quoted CREATE SCHEMA matches the unquoted search_path (PG case-folds it), fixing the mixed-case bug where tables silently fell back to public. - Replace shlex.join/split with libpq-correct backslash escaping for the options parameter so values containing spaces survive intact. - Add normalize_libpq_dsn() and route the async checkpointer pool through dsn_with_search_path() so a +asyncpg suffix is stripped and existing DSN options (e.g. statement_timeout) are merged instead of overridden. - Extract shared ensure_postgres_schema()/ensure_postgres_schema_async() helpers (mapping missing psycopg to the install hint) used by all four provider sites. - Tests: reject mixed-case schemas, preserve space-containing libpq option, cover normalize_libpq_dsn, and assert pool search_path via DSN. * fix(persistence): align pg-schema test with merged store API The main merge moved the sync Store factory to the single-path _resolve_store_config/_sync_store_cm design, dropping the PR's _sync_store_from_database helper. The integration test still imported the removed symbol, breaking test collection (backend-unit-tests). Resolve the store config from a DatabaseConfig and drive it through _sync_store_cm instead. * fix(persistence): address pg-schema review feedback - reject trailing/leading whitespace in postgres_schema via re.fullmatch (a $-anchored re.match let "deerflow\n" through, silently landing tables in public) - re-escape all whitespace (TAB/CR/LF) when re-joining libpq options so a caller's pre-existing options value round-trips losslessly - re-validate the identifier inside create_schema_sql as defense-in-depth at the SQL-emitting boundary - accept the postgres:// short scheme in the alembic search_path injection - close the sync psycopg connection explicitly (psycopg3 __exit__ does not close()), mirroring the async path - drop the partial checkpointer/store reset on a database config change; database is restart-required and the ORM engine is not rebuilt, so a partial reset would half-migrate the deployment * docs(config): complete the postgres_schema migration checklist Address PR review (P1): the documented `public`->schema migration only moved runs, run_events, threads_meta, feedback, and users. That strands every other DeerFlow-owned table -- the four channel_* tables, both scheduled_* tables, agents, and (critically) alembic_version -- in `public`. On restart bootstrap treats the partially-populated target schema as unversioned, re-baselines it, and replays migrations while the real rows stay invisible in `public`. List the full owned set explicitly, call out alembic_version as required, and keep the "discover the rest" query for version-drift safety. * refactor(checkpointer): drop test-only _sync_checkpointer_from_database Address PR review: the helper was only reached by the env-gated integration test and re-implemented the DatabaseConfig->CheckpointerConfig backend resolution that _resolve_checkpointer_config already owns, so a future backend added there would silently miss this path. Mirror the store side of the same test, which reuses the production path directly: _resolve_checkpointer_config(...) + _sync_checkpointer_cm(...).
277 lines
12 KiB
Python
277 lines
12 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 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.",
|
|
)
|
|
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}")
|