mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 21:50:36 +00:00
* test(runtime): cover the driver suffix in sync and store DSNs * fix(runtime): strip the SQLAlchemy driver suffix from sync and store DSNs With `postgres_url: postgresql+asyncpg://...` and the default empty `postgres_schema`, the sync checkpointer, the sync store and the async store passed the DSN to psycopg's `from_conn_string` unchanged, and libpq cannot parse the `+asyncpg` scheme. `dsn_with_search_path` only drops the suffix when it adds a schema, so a non-empty schema hid the bug. The async checkpointer already calls `normalize_libpq_dsn` first. Call `normalize_libpq_dsn` at these three call sites as well. * refactor(persistence): drop the driver suffix inside dsn_with_search_path dsn_with_search_path dropped a SQLAlchemy `+driver` suffix only when it added a search_path, so every libpq caller had to wrap the DSN in normalize_libpq_dsn to cover an empty schema, and flattening that nesting later would bring the bug back. Normalize at the top of dsn_with_search_path so both paths drop the suffix and check the scheme in one place, and remove the outer call from its five callers. The sync checkpointer and both stores are back to their code on main.
258 lines
9.5 KiB
Python
258 lines
9.5 KiB
Python
"""Async checkpointer factory.
|
|
|
|
Provides an **async context manager** for long-running async servers that need
|
|
proper resource cleanup.
|
|
|
|
Supported backends: memory, sqlite, postgres.
|
|
|
|
Usage (e.g. FastAPI lifespan)::
|
|
|
|
from deerflow.runtime.checkpointer.async_provider import make_checkpointer
|
|
|
|
async with make_checkpointer() as checkpointer:
|
|
app.state.checkpointer = checkpointer # InMemorySaver if not configured
|
|
|
|
For sync usage see :mod:`deerflow.runtime.checkpointer.provider`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import logging
|
|
from collections.abc import AsyncIterator
|
|
|
|
from langgraph.types import Checkpointer
|
|
|
|
from deerflow.config.app_config import AppConfig, get_app_config
|
|
from deerflow.persistence.postgres_schema import create_schema_sql, dsn_with_search_path
|
|
from deerflow.runtime.cancellation import drained_async_context
|
|
from deerflow.runtime.checkpointer.provider import (
|
|
POSTGRES_CONN_REQUIRED,
|
|
POSTGRES_INSTALL,
|
|
SQLITE_INSTALL,
|
|
)
|
|
from deerflow.runtime.store._sqlite_utils import ensure_sqlite_parent_dir, resolve_sqlite_conn_str
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _prepare_sqlite_checkpointer_path(raw: str) -> str:
|
|
conn_str = resolve_sqlite_conn_str(raw)
|
|
ensure_sqlite_parent_dir(conn_str)
|
|
return conn_str
|
|
|
|
|
|
def _prepare_database_sqlite_checkpointer_path(db_config) -> str:
|
|
conn_str = db_config.checkpointer_sqlite_path
|
|
ensure_sqlite_parent_dir(conn_str)
|
|
return conn_str
|
|
|
|
|
|
def _build_postgres_pool(conn_string: str, schema: str = ""):
|
|
"""Build an AsyncConnectionPool with TCP keepalive and connection checking."""
|
|
from psycopg.rows import dict_row
|
|
from psycopg_pool import AsyncConnectionPool
|
|
|
|
kwargs = {
|
|
"autocommit": True,
|
|
"prepare_threshold": 0,
|
|
"row_factory": dict_row,
|
|
"keepalives": 1,
|
|
"keepalives_idle": 60,
|
|
"keepalives_interval": 10,
|
|
"keepalives_count": 6,
|
|
}
|
|
# Inject search_path into the DSN (merging with any libpq options already in
|
|
# the conn string) rather than via kwargs["options"], which psycopg applies
|
|
# *on top of* the conninfo and would silently drop a DSN-supplied option
|
|
# such as statement_timeout. This also strips a SQLAlchemy ``+driver``
|
|
# suffix so libpq can parse the DSN. Matches the sync/DSN paths.
|
|
dsn = dsn_with_search_path(conn_string, schema)
|
|
|
|
return AsyncConnectionPool(
|
|
dsn,
|
|
kwargs=kwargs,
|
|
check=AsyncConnectionPool.check_connection,
|
|
)
|
|
|
|
|
|
async def _ensure_postgres_schema_with_pool(pool, schema: str) -> None:
|
|
"""Create the configured schema before LangGraph creates its tables."""
|
|
statement = create_schema_sql(schema)
|
|
if statement is None:
|
|
return
|
|
# Drain the connection's exit like the pool context around it: caller
|
|
# cancellation must not leave the connection checked out while the pool's
|
|
# own teardown runs (backend-ownership invariant in runtime/AGENTS.md).
|
|
async with drained_async_context(pool.connection()) as conn:
|
|
await conn.execute(statement)
|
|
|
|
|
|
def _ensure_postgres_imports():
|
|
"""Import and return (AsyncPostgresSaver, AsyncConnectionPool), raising ImportError on failure."""
|
|
try:
|
|
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
|
except ImportError as exc:
|
|
raise ImportError(POSTGRES_INSTALL) from exc
|
|
|
|
try:
|
|
from psycopg_pool import AsyncConnectionPool
|
|
except ImportError as exc:
|
|
raise ImportError(POSTGRES_INSTALL) from exc
|
|
|
|
return AsyncPostgresSaver, AsyncConnectionPool
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Async factory
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def _async_checkpointer(config) -> AsyncIterator[Checkpointer]:
|
|
"""Async context manager that constructs and tears down a checkpointer."""
|
|
if config.type == "memory":
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
|
|
yield InMemorySaver()
|
|
return
|
|
|
|
if config.type == "sqlite":
|
|
try:
|
|
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
|
except ImportError as exc:
|
|
raise ImportError(SQLITE_INSTALL) from exc
|
|
|
|
conn_str = await asyncio.to_thread(_prepare_sqlite_checkpointer_path, config.connection_string or "store.db")
|
|
async with drained_async_context(AsyncSqliteSaver.from_conn_string(conn_str)) as saver:
|
|
await saver.setup()
|
|
yield saver
|
|
return
|
|
|
|
if config.type == "postgres":
|
|
if not config.connection_string:
|
|
raise ValueError(POSTGRES_CONN_REQUIRED)
|
|
|
|
AsyncPostgresSaver, _ = _ensure_postgres_imports()
|
|
pool = _build_postgres_pool(config.connection_string, config.postgres_schema)
|
|
async with drained_async_context(pool):
|
|
await _ensure_postgres_schema_with_pool(pool, config.postgres_schema)
|
|
saver = AsyncPostgresSaver(conn=pool)
|
|
await saver.setup()
|
|
yield saver
|
|
return
|
|
|
|
raise ValueError(f"Unknown checkpointer type: {config.type!r}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public async context manager
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def _async_checkpointer_from_database(db_config) -> AsyncIterator[Checkpointer]:
|
|
"""Async context manager that constructs a checkpointer from unified DatabaseConfig."""
|
|
if db_config.backend == "memory":
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
|
|
yield InMemorySaver()
|
|
return
|
|
|
|
if db_config.backend == "sqlite":
|
|
try:
|
|
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
|
except ImportError as exc:
|
|
raise ImportError(SQLITE_INSTALL) from exc
|
|
|
|
conn_str = await asyncio.to_thread(_prepare_database_sqlite_checkpointer_path, db_config)
|
|
async with drained_async_context(AsyncSqliteSaver.from_conn_string(conn_str)) as saver:
|
|
await saver.setup()
|
|
yield saver
|
|
return
|
|
|
|
if db_config.backend == "postgres":
|
|
if not db_config.postgres_url:
|
|
raise ValueError("database.postgres_url is required for the postgres backend")
|
|
|
|
AsyncPostgresSaver, _ = _ensure_postgres_imports()
|
|
pool = _build_postgres_pool(db_config.postgres_url, db_config.postgres_schema)
|
|
async with drained_async_context(pool):
|
|
await _ensure_postgres_schema_with_pool(pool, db_config.postgres_schema)
|
|
saver = AsyncPostgresSaver(conn=pool)
|
|
await saver.setup()
|
|
yield saver
|
|
return
|
|
|
|
raise ValueError(f"Unknown database backend: {db_config.backend!r}")
|
|
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def _select_inner_checkpointer(app_config: AppConfig) -> AsyncIterator[Checkpointer]:
|
|
"""Yield the raw checkpointer selected by *app_config* (no delta-cache wrapping).
|
|
|
|
Priority:
|
|
1. Legacy ``checkpointer:`` config section (backward compatible)
|
|
2. Unified ``database:`` config section
|
|
3. Default InMemorySaver
|
|
"""
|
|
# Legacy: standalone checkpointer config takes precedence
|
|
if app_config.checkpointer is not None:
|
|
async with _async_checkpointer(app_config.checkpointer) as saver:
|
|
yield saver
|
|
return
|
|
|
|
# Unified database config
|
|
db_config = getattr(app_config, "database", None)
|
|
if db_config is not None and db_config.backend != "memory":
|
|
async with _async_checkpointer_from_database(db_config) as saver:
|
|
yield saver
|
|
return
|
|
|
|
# Default: in-memory
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
|
|
yield InMemorySaver()
|
|
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def make_checkpointer(app_config: AppConfig | None = None) -> AsyncIterator[Checkpointer]:
|
|
"""Async context manager that yields a checkpointer for the caller's lifetime.
|
|
Resources are opened on enter and closed on exit -- no global state::
|
|
|
|
async with make_checkpointer(app_config) as checkpointer:
|
|
app.state.checkpointer = checkpointer
|
|
|
|
Yields an ``InMemorySaver`` when no checkpointer is configured in *config.yaml*.
|
|
|
|
Backend selection priority:
|
|
1. Legacy ``checkpointer:`` config section (backward compatible)
|
|
2. Unified ``database:`` config section
|
|
3. Default InMemorySaver
|
|
|
|
When the effective checkpoint channel mode is ``delta`` (the process-frozen
|
|
mode wins, falling back to ``database.checkpoint_channel_mode``), the raw
|
|
saver is wrapped in a :class:`CachedHistorySaver` backed by a history cache
|
|
whose lifetime equals this context manager's.
|
|
"""
|
|
from deerflow.runtime.checkpoint_mode import frozen_checkpoint_channel_mode
|
|
|
|
if app_config is None:
|
|
app_config = get_app_config()
|
|
|
|
async with _select_inner_checkpointer(app_config) as saver:
|
|
db_config = getattr(app_config, "database", None)
|
|
mode = frozen_checkpoint_channel_mode() or (db_config.checkpoint_channel_mode if db_config is not None else "full")
|
|
if mode == "delta":
|
|
from deerflow.runtime.checkpoint_cache.provider import (
|
|
checkpoint_cache_key_prefix,
|
|
make_checkpoint_cache,
|
|
)
|
|
from deerflow.runtime.checkpointer.cached_saver import CachedHistorySaver
|
|
|
|
async with make_checkpoint_cache(app_config, serde=saver.serde) as cache:
|
|
yield CachedHistorySaver(saver, cache, key_prefix=checkpoint_cache_key_prefix(app_config))
|
|
else:
|
|
yield saver
|