mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-05 20:38:41 +00:00
fix(store): honor unified database configuration (#3904)
* fix(store): align store backend with database config * fix(store): preserve no-config memory fallback --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
4e6248f013
commit
b476c7a18d
@ -253,6 +253,11 @@ make down # Stop and remove containers
|
|||||||
|
|
||||||
Access: http://localhost:2026
|
Access: http://localhost:2026
|
||||||
|
|
||||||
|
For persistent deployments, configure `database.backend` as `sqlite` or
|
||||||
|
`postgres`. The selected backend is shared by the LangGraph checkpointer,
|
||||||
|
LangGraph Store, and DeerFlow application data. The deprecated `checkpointer`
|
||||||
|
section, when present, overrides the first two for backward compatibility.
|
||||||
|
|
||||||
The unified nginx endpoint is same-origin by default and does not emit browser CORS headers. If you run a split-origin or port-forwarded browser client, set `GATEWAY_CORS_ORIGINS` to comma-separated exact origins such as `http://localhost:3000`; the Gateway then applies the CORS allowlist and matching CSRF origin checks.
|
The unified nginx endpoint is same-origin by default and does not emit browser CORS headers. If you run a split-origin or port-forwarded browser client, set `GATEWAY_CORS_ORIGINS` to comma-separated exact origins such as `http://localhost:3000`; the Gateway then applies the CORS allowlist and matching CSRF origin checks.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
|
|||||||
@ -250,6 +250,12 @@ Setup: Copy `config.example.yaml` to `config.yaml` in the **project root** direc
|
|||||||
|
|
||||||
Infrastructure fields are **restart-required**. The authoritative list lives in `packages/harness/deerflow/config/reload_boundary.py::STARTUP_ONLY_FIELDS` and is mirrored by the standardised `"startup-only:"` prefix on the corresponding `Field(description=...)` in `AppConfig`, so IDE hover on those fields surfaces the reason inline (no need to context-switch into this table). Currently registered: `database`, `checkpointer`, `run_events`, `stream_bridge`, `sandbox`, `log_level`, `channels`, `channel_connections`. Adding a new restart-required field requires updating the registry; drift is pinned by `tests/test_reload_boundary.py`.
|
Infrastructure fields are **restart-required**. The authoritative list lives in `packages/harness/deerflow/config/reload_boundary.py::STARTUP_ONLY_FIELDS` and is mirrored by the standardised `"startup-only:"` prefix on the corresponding `Field(description=...)` in `AppConfig`, so IDE hover on those fields surfaces the reason inline (no need to context-switch into this table). Currently registered: `database`, `checkpointer`, `run_events`, `stream_bridge`, `sandbox`, `log_level`, `channels`, `channel_connections`. Adding a new restart-required field requires updating the registry; drift is pinned by `tests/test_reload_boundary.py`.
|
||||||
|
|
||||||
|
**Persistence backend resolution**: the unified `database` section selects the
|
||||||
|
Gateway's LangGraph checkpointer, LangGraph Store, and DeerFlow SQL repositories.
|
||||||
|
The deprecated `checkpointer` section remains backward compatible and, when
|
||||||
|
present, overrides `database` for the LangGraph checkpointer and Store only;
|
||||||
|
application repositories continue to use `database`.
|
||||||
|
|
||||||
Configuration priority:
|
Configuration priority:
|
||||||
1. Explicit `config_path` argument
|
1. Explicit `config_path` argument
|
||||||
2. `DEER_FLOW_CONFIG_PATH` environment variable
|
2. `DEER_FLOW_CONFIG_PATH` environment variable
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
"""Async Store factory — backend mirrors the configured checkpointer.
|
"""Async Store factory — backend mirrors runtime persistence configuration.
|
||||||
|
|
||||||
The store and checkpointer share the same ``checkpointer`` section in
|
The deprecated ``checkpointer`` section takes precedence when present;
|
||||||
*config.yaml* so they always use the same persistence backend:
|
otherwise Store follows the unified ``database`` section in *config.yaml*:
|
||||||
|
|
||||||
- ``type: memory`` → :class:`langgraph.store.memory.InMemoryStore`
|
- ``memory`` → :class:`langgraph.store.memory.InMemoryStore`
|
||||||
- ``type: sqlite`` → :class:`langgraph.store.sqlite.aio.AsyncSqliteStore`
|
- ``sqlite`` → :class:`langgraph.store.sqlite.aio.AsyncSqliteStore`
|
||||||
- ``type: postgres`` → :class:`langgraph.store.postgres.aio.AsyncPostgresStore`
|
- ``postgres`` → :class:`langgraph.store.postgres.aio.AsyncPostgresStore`
|
||||||
|
|
||||||
Usage (e.g. FastAPI lifespan)::
|
Usage (e.g. FastAPI lifespan)::
|
||||||
|
|
||||||
@ -17,6 +17,7 @@ Usage (e.g. FastAPI lifespan)::
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
@ -24,7 +25,14 @@ from collections.abc import AsyncIterator
|
|||||||
from langgraph.store.base import BaseStore
|
from langgraph.store.base import BaseStore
|
||||||
|
|
||||||
from deerflow.config.app_config import AppConfig, get_app_config
|
from deerflow.config.app_config import AppConfig, get_app_config
|
||||||
from deerflow.runtime.store.provider import POSTGRES_CONN_REQUIRED, POSTGRES_STORE_INSTALL, SQLITE_STORE_INSTALL, ensure_sqlite_parent_dir, resolve_sqlite_conn_str
|
from deerflow.runtime.store.provider import (
|
||||||
|
POSTGRES_CONN_REQUIRED,
|
||||||
|
POSTGRES_STORE_INSTALL,
|
||||||
|
SQLITE_STORE_INSTALL,
|
||||||
|
_resolve_store_config,
|
||||||
|
ensure_sqlite_parent_dir,
|
||||||
|
resolve_sqlite_conn_str,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -54,7 +62,7 @@ async def _async_store(config) -> AsyncIterator[BaseStore]:
|
|||||||
raise ImportError(SQLITE_STORE_INSTALL) from exc
|
raise ImportError(SQLITE_STORE_INSTALL) from exc
|
||||||
|
|
||||||
conn_str = resolve_sqlite_conn_str(config.connection_string or "store.db")
|
conn_str = resolve_sqlite_conn_str(config.connection_string or "store.db")
|
||||||
ensure_sqlite_parent_dir(conn_str)
|
await asyncio.to_thread(ensure_sqlite_parent_dir, conn_str)
|
||||||
|
|
||||||
async with AsyncSqliteStore.from_conn_string(conn_str) as store:
|
async with AsyncSqliteStore.from_conn_string(conn_str) as store:
|
||||||
await store.setup()
|
await store.setup()
|
||||||
@ -87,28 +95,21 @@ async def _async_store(config) -> AsyncIterator[BaseStore]:
|
|||||||
|
|
||||||
@contextlib.asynccontextmanager
|
@contextlib.asynccontextmanager
|
||||||
async def make_store(app_config: AppConfig | None = None) -> AsyncIterator[BaseStore]:
|
async def make_store(app_config: AppConfig | None = None) -> AsyncIterator[BaseStore]:
|
||||||
"""Async context manager that yields a Store whose backend matches the
|
"""Yield a Store selected from legacy or unified persistence config.
|
||||||
configured checkpointer.
|
|
||||||
|
|
||||||
Reads from the same ``checkpointer`` section of *config.yaml* used by
|
The legacy ``checkpointer`` section takes precedence when configured;
|
||||||
:func:`deerflow.runtime.checkpointer.async_provider.make_checkpointer` so
|
otherwise the unified ``database`` section selects the backend, matching
|
||||||
that both singletons always use the same persistence technology::
|
:func:`deerflow.runtime.checkpointer.async_provider.make_checkpointer`::
|
||||||
|
|
||||||
async with make_store(app_config) as store:
|
async with make_store(app_config) as store:
|
||||||
app.state.store = store
|
app.state.store = store
|
||||||
|
|
||||||
Yields an :class:`~langgraph.store.memory.InMemoryStore` when no
|
An :class:`~langgraph.store.memory.InMemoryStore` is returned only when the
|
||||||
``checkpointer`` section is configured (emits a WARNING in that case).
|
resolved backend is explicitly ``memory``.
|
||||||
"""
|
"""
|
||||||
if app_config is None:
|
if app_config is None:
|
||||||
app_config = get_app_config()
|
app_config = get_app_config()
|
||||||
|
|
||||||
if app_config.checkpointer is None:
|
config = _resolve_store_config(app_config)
|
||||||
from langgraph.store.memory import InMemoryStore
|
async with _async_store(config) as store:
|
||||||
|
|
||||||
logger.warning("No 'checkpointer' section in config.yaml — using InMemoryStore for the store. Thread list will be lost on server restart. Configure a sqlite or postgres backend for persistence.")
|
|
||||||
yield InMemoryStore()
|
|
||||||
return
|
|
||||||
|
|
||||||
async with _async_store(app_config.checkpointer) as store:
|
|
||||||
yield store
|
yield store
|
||||||
|
|||||||
@ -3,8 +3,9 @@
|
|||||||
Provides a **sync singleton** and a **sync context manager** for CLI tools
|
Provides a **sync singleton** and a **sync context manager** for CLI tools
|
||||||
and the embedded :class:`~deerflow.client.DeerFlowClient`.
|
and the embedded :class:`~deerflow.client.DeerFlowClient`.
|
||||||
|
|
||||||
The backend mirrors the configured checkpointer so that both always use the
|
The deprecated ``checkpointer`` section takes precedence when present;
|
||||||
same persistence technology. Supported backends: memory, sqlite, postgres.
|
otherwise Store follows the unified ``database`` section. Supported backends:
|
||||||
|
memory, sqlite, postgres.
|
||||||
|
|
||||||
Usage::
|
Usage::
|
||||||
|
|
||||||
@ -27,8 +28,8 @@ from collections.abc import Iterator
|
|||||||
|
|
||||||
from langgraph.store.base import BaseStore
|
from langgraph.store.base import BaseStore
|
||||||
|
|
||||||
from deerflow.config.app_config import get_app_config
|
from deerflow.config.app_config import AppConfig, get_app_config
|
||||||
from deerflow.config.checkpointer_config import ensure_config_loaded
|
from deerflow.config.checkpointer_config import CheckpointerConfig, ensure_config_loaded, get_checkpointer_config
|
||||||
from deerflow.runtime.store._sqlite_utils import ensure_sqlite_parent_dir, resolve_sqlite_conn_str
|
from deerflow.runtime.store._sqlite_utils import ensure_sqlite_parent_dir, resolve_sqlite_conn_str
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -43,6 +44,44 @@ POSTGRES_STORE_INSTALL = (
|
|||||||
)
|
)
|
||||||
POSTGRES_CONN_REQUIRED = "checkpointer.connection_string is required for the postgres backend"
|
POSTGRES_CONN_REQUIRED = "checkpointer.connection_string is required for the postgres backend"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_store_config(app_config: AppConfig) -> CheckpointerConfig:
|
||||||
|
"""Resolve the Store backend from legacy or unified application config.
|
||||||
|
|
||||||
|
The legacy ``checkpointer`` section remains authoritative when present so
|
||||||
|
Store and Checkpointer continue to use the same backend. Otherwise the
|
||||||
|
unified ``database`` section drives the Store as documented.
|
||||||
|
"""
|
||||||
|
if app_config.checkpointer is not None:
|
||||||
|
return app_config.checkpointer
|
||||||
|
|
||||||
|
database = app_config.database
|
||||||
|
if database.backend == "memory":
|
||||||
|
return CheckpointerConfig(type="memory")
|
||||||
|
if database.backend == "sqlite":
|
||||||
|
return CheckpointerConfig(type="sqlite", connection_string=database.checkpointer_sqlite_path)
|
||||||
|
if database.backend == "postgres":
|
||||||
|
if not database.postgres_url:
|
||||||
|
raise ValueError("database.postgres_url is required for the postgres backend")
|
||||||
|
return CheckpointerConfig(type="postgres", connection_string=database.postgres_url)
|
||||||
|
raise ValueError(f"Unknown database backend: {database.backend!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_store_config() -> CheckpointerConfig:
|
||||||
|
"""Load Store config without holding the provider singleton lock."""
|
||||||
|
ensure_config_loaded()
|
||||||
|
|
||||||
|
# Preserve callers that initialise the legacy config singleton directly.
|
||||||
|
legacy_config = get_checkpointer_config()
|
||||||
|
if legacy_config is not None:
|
||||||
|
return legacy_config
|
||||||
|
try:
|
||||||
|
app_config = get_app_config()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return CheckpointerConfig(type="memory")
|
||||||
|
return _resolve_store_config(app_config)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Sync factory
|
# Sync factory
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -108,37 +147,26 @@ _store_lock = threading.Lock()
|
|||||||
def get_store() -> BaseStore:
|
def get_store() -> BaseStore:
|
||||||
"""Return the global sync Store singleton, creating it on first call.
|
"""Return the global sync Store singleton, creating it on first call.
|
||||||
|
|
||||||
Returns an :class:`~langgraph.store.memory.InMemoryStore` when no
|
The legacy ``checkpointer`` section takes precedence when configured;
|
||||||
checkpointer is configured in *config.yaml* (emits a WARNING in that case).
|
otherwise the unified ``database`` section selects the backend.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ImportError: If the required package for the configured backend is not installed.
|
ImportError: If the required package for the configured backend is not installed.
|
||||||
ValueError: If ``connection_string`` is missing for a backend that requires it.
|
ValueError: If the selected backend is missing its required connection value.
|
||||||
"""
|
"""
|
||||||
global _store, _store_ctx
|
global _store, _store_ctx
|
||||||
|
|
||||||
if _store is not None:
|
if _store is not None:
|
||||||
return _store
|
return _store
|
||||||
|
|
||||||
# Config loading can reset both persistence singletons. Keep it outside
|
# Config loading can reset both persistence singletons. Resolve the full
|
||||||
# this provider lock to avoid cross-provider lock-order inversion.
|
# config outside this provider lock to avoid lock-order inversion.
|
||||||
ensure_config_loaded()
|
config = _get_store_config()
|
||||||
|
|
||||||
with _store_lock:
|
with _store_lock:
|
||||||
if _store is not None:
|
if _store is not None:
|
||||||
return _store
|
return _store
|
||||||
|
|
||||||
from deerflow.config.checkpointer_config import get_checkpointer_config
|
|
||||||
|
|
||||||
config = get_checkpointer_config()
|
|
||||||
|
|
||||||
if config is None:
|
|
||||||
from langgraph.store.memory import InMemoryStore
|
|
||||||
|
|
||||||
logger.warning("No 'checkpointer' section in config.yaml — using InMemoryStore for the store. Thread list will be lost on server restart. Configure a sqlite or postgres backend for persistence.")
|
|
||||||
_store = InMemoryStore()
|
|
||||||
return _store
|
|
||||||
|
|
||||||
store_ctx = _sync_store_cm(config)
|
store_ctx = _sync_store_cm(config)
|
||||||
store = store_ctx.__enter__()
|
store = store_ctx.__enter__()
|
||||||
_store_ctx = store_ctx
|
_store_ctx = store_ctx
|
||||||
@ -179,16 +207,9 @@ def store_context() -> Iterator[BaseStore]:
|
|||||||
with store_context() as store:
|
with store_context() as store:
|
||||||
store.put(("threads",), thread_id, {...})
|
store.put(("threads",), thread_id, {...})
|
||||||
|
|
||||||
Yields an :class:`~langgraph.store.memory.InMemoryStore` when no
|
The legacy ``checkpointer`` section takes precedence when configured;
|
||||||
checkpointer is configured in *config.yaml*.
|
otherwise the unified ``database`` section selects the backend.
|
||||||
"""
|
"""
|
||||||
config = get_app_config()
|
config = _resolve_store_config(get_app_config())
|
||||||
if config.checkpointer is None:
|
with _sync_store_cm(config) as store:
|
||||||
from langgraph.store.memory import InMemoryStore
|
|
||||||
|
|
||||||
logger.warning("No 'checkpointer' section in config.yaml — using InMemoryStore for the store. Thread list will be lost on server restart. Configure a sqlite or postgres backend for persistence.")
|
|
||||||
yield InMemoryStore()
|
|
||||||
return
|
|
||||||
|
|
||||||
with _sync_store_cm(config.checkpointer) as store:
|
|
||||||
yield store
|
yield store
|
||||||
|
|||||||
@ -3,8 +3,10 @@
|
|||||||
import sys
|
import sys
|
||||||
import tomllib
|
import tomllib
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from contextlib import nullcontext
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Barrier, Event, Lock
|
from threading import Barrier, Event, Lock
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@ -17,6 +19,7 @@ from deerflow.config.checkpointer_config import (
|
|||||||
load_checkpointer_config_from_dict,
|
load_checkpointer_config_from_dict,
|
||||||
set_checkpointer_config,
|
set_checkpointer_config,
|
||||||
)
|
)
|
||||||
|
from deerflow.config.database_config import DatabaseConfig
|
||||||
from deerflow.runtime.checkpointer import get_checkpointer, reset_checkpointer
|
from deerflow.runtime.checkpointer import get_checkpointer, reset_checkpointer
|
||||||
from deerflow.runtime.checkpointer.provider import POSTGRES_INSTALL
|
from deerflow.runtime.checkpointer.provider import POSTGRES_INSTALL
|
||||||
from deerflow.runtime.store import get_store, reset_store
|
from deerflow.runtime.store import get_store, reset_store
|
||||||
@ -702,6 +705,156 @@ class TestAsyncCheckpointer:
|
|||||||
mock_saver.setup.assert_awaited_once()
|
mock_saver.setup.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStoreDatabaseConfig:
|
||||||
|
def test_sync_store_falls_back_to_memory_when_config_file_is_missing(self):
|
||||||
|
"""The sync Store keeps its no-config fallback for embedded callers."""
|
||||||
|
from langgraph.store.memory import InMemoryStore
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("deerflow.runtime.store.provider.ensure_config_loaded"),
|
||||||
|
patch("deerflow.runtime.store.provider.get_checkpointer_config", return_value=None),
|
||||||
|
patch("deerflow.runtime.store.provider.get_app_config", side_effect=FileNotFoundError),
|
||||||
|
):
|
||||||
|
assert isinstance(get_store(), InMemoryStore)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_async_postgres_store_uses_database_config(self, caplog):
|
||||||
|
"""Unified database postgres config must not fall back to InMemoryStore."""
|
||||||
|
from deerflow.runtime.store.async_provider import make_store
|
||||||
|
|
||||||
|
caplog.set_level("WARNING", logger="deerflow.runtime.store.async_provider")
|
||||||
|
app_config = SimpleNamespace(
|
||||||
|
checkpointer=None,
|
||||||
|
database=DatabaseConfig(backend="postgres", postgres_url="postgresql://localhost/db"),
|
||||||
|
)
|
||||||
|
mock_store = AsyncMock()
|
||||||
|
mock_cm = AsyncMock()
|
||||||
|
mock_cm.__aenter__.return_value = mock_store
|
||||||
|
mock_cm.__aexit__.return_value = False
|
||||||
|
mock_store_cls = MagicMock()
|
||||||
|
mock_store_cls.from_conn_string.return_value = mock_cm
|
||||||
|
mock_module = MagicMock(AsyncPostgresStore=mock_store_cls)
|
||||||
|
|
||||||
|
with patch.dict(sys.modules, {"langgraph.store.postgres.aio": mock_module}):
|
||||||
|
async with make_store(app_config) as store:
|
||||||
|
assert store is mock_store
|
||||||
|
|
||||||
|
mock_store_cls.from_conn_string.assert_called_once_with("postgresql://localhost/db")
|
||||||
|
mock_store.setup.assert_awaited_once()
|
||||||
|
assert "No 'checkpointer' section" not in caplog.text
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_async_sqlite_store_uses_unified_database_path(self, tmp_path):
|
||||||
|
"""Unified database SQLite config must use the shared deerflow.db path."""
|
||||||
|
from deerflow.runtime.store.async_provider import make_store
|
||||||
|
from deerflow.runtime.store.provider import ensure_sqlite_parent_dir
|
||||||
|
|
||||||
|
db_config = DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path))
|
||||||
|
app_config = SimpleNamespace(checkpointer=None, database=db_config)
|
||||||
|
mock_store = AsyncMock()
|
||||||
|
mock_cm = AsyncMock()
|
||||||
|
mock_cm.__aenter__.return_value = mock_store
|
||||||
|
mock_cm.__aexit__.return_value = False
|
||||||
|
mock_store_cls = MagicMock()
|
||||||
|
mock_store_cls.from_conn_string.return_value = mock_cm
|
||||||
|
mock_module = MagicMock(AsyncSqliteStore=mock_store_cls)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.dict(sys.modules, {"langgraph.store.sqlite.aio": mock_module}),
|
||||||
|
patch(
|
||||||
|
"deerflow.runtime.store.async_provider.asyncio.to_thread",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_to_thread,
|
||||||
|
):
|
||||||
|
async with make_store(app_config) as store:
|
||||||
|
assert store is mock_store
|
||||||
|
|
||||||
|
mock_to_thread.assert_awaited_once()
|
||||||
|
called_fn, called_path = mock_to_thread.await_args.args
|
||||||
|
assert called_fn is ensure_sqlite_parent_dir
|
||||||
|
assert called_path == db_config.checkpointer_sqlite_path
|
||||||
|
mock_store_cls.from_conn_string.assert_called_once_with(db_config.checkpointer_sqlite_path)
|
||||||
|
mock_store.setup.assert_awaited_once()
|
||||||
|
|
||||||
|
def test_sync_store_context_uses_database_config(self):
|
||||||
|
"""The one-shot sync Store factory must follow unified database config."""
|
||||||
|
from deerflow.runtime.store.provider import store_context
|
||||||
|
|
||||||
|
app_config = SimpleNamespace(
|
||||||
|
checkpointer=None,
|
||||||
|
database=DatabaseConfig(backend="postgres", postgres_url="postgresql://localhost/db"),
|
||||||
|
)
|
||||||
|
expected_store = object()
|
||||||
|
factory = MagicMock(return_value=nullcontext(expected_store))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("deerflow.runtime.store.provider.get_app_config", return_value=app_config),
|
||||||
|
patch("deerflow.runtime.store.provider._sync_store_cm", factory),
|
||||||
|
store_context() as store,
|
||||||
|
):
|
||||||
|
assert store is expected_store
|
||||||
|
|
||||||
|
resolved = factory.call_args.args[0]
|
||||||
|
assert resolved.type == "postgres"
|
||||||
|
assert resolved.connection_string == "postgresql://localhost/db"
|
||||||
|
|
||||||
|
def test_sync_store_singleton_uses_database_config(self):
|
||||||
|
"""The cached sync Store factory must resolve database config before locking."""
|
||||||
|
app_config = SimpleNamespace(
|
||||||
|
checkpointer=None,
|
||||||
|
database=DatabaseConfig(backend="postgres", postgres_url="postgresql://localhost/db"),
|
||||||
|
)
|
||||||
|
expected_store = object()
|
||||||
|
factory = MagicMock(return_value=nullcontext(expected_store))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("deerflow.runtime.store.provider.ensure_config_loaded"),
|
||||||
|
patch("deerflow.runtime.store.provider.get_app_config", return_value=app_config),
|
||||||
|
patch("deerflow.runtime.store.provider._sync_store_cm", factory),
|
||||||
|
):
|
||||||
|
assert get_store() is expected_store
|
||||||
|
|
||||||
|
resolved = factory.call_args.args[0]
|
||||||
|
assert resolved.type == "postgres"
|
||||||
|
assert resolved.connection_string == "postgresql://localhost/db"
|
||||||
|
|
||||||
|
def test_legacy_checkpointer_config_takes_precedence_for_store(self):
|
||||||
|
"""Backward-compatible checkpointer config must override database for Store."""
|
||||||
|
from deerflow.runtime.store.provider import store_context
|
||||||
|
|
||||||
|
app_config = SimpleNamespace(
|
||||||
|
checkpointer=CheckpointerConfig(type="memory"),
|
||||||
|
database=DatabaseConfig(backend="postgres", postgres_url="postgresql://localhost/db"),
|
||||||
|
)
|
||||||
|
expected_store = object()
|
||||||
|
factory = MagicMock(return_value=nullcontext(expected_store))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("deerflow.runtime.store.provider.get_app_config", return_value=app_config),
|
||||||
|
patch("deerflow.runtime.store.provider._sync_store_cm", factory),
|
||||||
|
store_context() as store,
|
||||||
|
):
|
||||||
|
assert store is expected_store
|
||||||
|
|
||||||
|
resolved = factory.call_args.args[0]
|
||||||
|
assert resolved.type == "memory"
|
||||||
|
assert resolved.connection_string is None
|
||||||
|
|
||||||
|
def test_explicit_memory_database_uses_in_memory_store(self):
|
||||||
|
"""Explicit memory mode remains an intentional non-persistent Store."""
|
||||||
|
from langgraph.store.memory import InMemoryStore
|
||||||
|
|
||||||
|
from deerflow.runtime.store.provider import store_context
|
||||||
|
|
||||||
|
app_config = SimpleNamespace(
|
||||||
|
checkpointer=None,
|
||||||
|
database=DatabaseConfig(backend="memory"),
|
||||||
|
)
|
||||||
|
with patch("deerflow.runtime.store.provider.get_app_config", return_value=app_config):
|
||||||
|
with store_context() as store:
|
||||||
|
assert isinstance(store, InMemoryStore)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# app_config.py integration
|
# app_config.py integration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -274,6 +274,15 @@ def isolated_app(isolated_deer_flow_home: Path, monkeypatch: pytest.MonkeyPatch)
|
|||||||
return create_app()
|
return create_app()
|
||||||
|
|
||||||
|
|
||||||
|
def test_lifespan_uses_sqlite_store_from_database_config(isolated_app):
|
||||||
|
"""Gateway startup must bind LangGraph Store to the unified database backend."""
|
||||||
|
from langgraph.store.sqlite.aio import AsyncSqliteStore
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
with TestClient(isolated_app):
|
||||||
|
assert isinstance(isolated_app.state.store, AsyncSqliteStore)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def isolated_app_with_title(isolated_deer_flow_home: Path, monkeypatch: pytest.MonkeyPatch):
|
def isolated_app_with_title(isolated_deer_flow_home: Path, monkeypatch: pytest.MonkeyPatch):
|
||||||
config_path = isolated_deer_flow_home.parent / "config-title-enabled.yaml"
|
config_path = isolated_deer_flow_home.parent / "config-title-enabled.yaml"
|
||||||
|
|||||||
@ -1299,12 +1299,12 @@ skill_evolution:
|
|||||||
# Checkpointer Configuration (DEPRECATED — use `database` instead)
|
# Checkpointer Configuration (DEPRECATED — use `database` instead)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Legacy standalone checkpointer config. Kept for backward compatibility.
|
# Legacy standalone checkpointer config. Kept for backward compatibility.
|
||||||
# Prefer the unified `database` section below, which drives BOTH the
|
# Prefer the unified `database` section below, which drives the LangGraph
|
||||||
# LangGraph checkpointer AND DeerFlow application data (runs, feedback,
|
# checkpointer, LangGraph Store, and DeerFlow application data (runs,
|
||||||
# events) from a single backend setting.
|
# feedback, events) from a single backend setting.
|
||||||
#
|
#
|
||||||
# If both `checkpointer` and `database` are present, `checkpointer`
|
# If both `checkpointer` and `database` are present, `checkpointer`
|
||||||
# takes precedence for LangGraph state persistence only.
|
# takes precedence for the LangGraph checkpointer and Store only.
|
||||||
#
|
#
|
||||||
# checkpointer:
|
# checkpointer:
|
||||||
# type: sqlite
|
# type: sqlite
|
||||||
@ -1317,8 +1317,8 @@ skill_evolution:
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Database
|
# Database
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Unified storage backend for LangGraph checkpointer and DeerFlow
|
# Unified storage backend for the LangGraph checkpointer, LangGraph Store,
|
||||||
# application data (runs, threads metadata, feedback, etc.).
|
# and DeerFlow application data (runs, threads metadata, feedback, etc.).
|
||||||
#
|
#
|
||||||
# backend: memory -- No persistence, data lost on restart
|
# backend: memory -- No persistence, data lost on restart
|
||||||
# backend: sqlite -- Single-node deployment, files in sqlite_dir
|
# backend: sqlite -- Single-node deployment, files in sqlite_dir
|
||||||
@ -1329,7 +1329,7 @@ skill_evolution:
|
|||||||
# sqlite_dir: .deer-flow/data
|
# sqlite_dir: .deer-flow/data
|
||||||
#
|
#
|
||||||
# SQLite mode uses a single deerflow.db file with WAL journal mode
|
# SQLite mode uses a single deerflow.db file with WAL journal mode
|
||||||
# for both checkpointer and application data.
|
# for the checkpointer, Store, and application data.
|
||||||
#
|
#
|
||||||
# Postgres mode: put your connection URL in .env as DATABASE_URL,
|
# Postgres mode: put your connection URL in .env as DATABASE_URL,
|
||||||
# then reference it here with $DATABASE_URL.
|
# then reference it here with $DATABASE_URL.
|
||||||
@ -1354,7 +1354,8 @@ skill_evolution:
|
|||||||
# (--all-packages propagates the extra into workspace members — see PR #2584)
|
# (--all-packages propagates the extra into workspace members — see PR #2584)
|
||||||
#
|
#
|
||||||
# NOTE: When both `checkpointer` and `database` are configured,
|
# NOTE: When both `checkpointer` and `database` are configured,
|
||||||
# `checkpointer` takes precedence for LangGraph state persistence.
|
# `checkpointer` takes precedence for the LangGraph checkpointer and Store;
|
||||||
|
# `database` still controls DeerFlow application data.
|
||||||
# If you use `database`, you can remove the `checkpointer` section.
|
# If you use `database`, you can remove the `checkpointer` section.
|
||||||
# database:
|
# database:
|
||||||
# backend: sqlite
|
# backend: sqlite
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user