mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-20 21:04:45 +08:00
fix(config): reload AppConfig when config path or mtime changes (#1239)
* fix(config): reload AppConfig when config path or mtime changes - Track resolved path + mtime; invalidate cache on change - Preserve set_app_config() injection behavior - Add regression tests (test_app_config_reload.py) - Document behavior in README and backend/CLAUDE.md Signed-off-by: Gao Mingfei <g199209@gmail.com> * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Signed-off-by: Gao Mingfei <g199209@gmail.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -160,6 +160,7 @@ make docker-start # Start services (auto-detects sandbox mode from config.yaml
|
|||||||
```
|
```
|
||||||
|
|
||||||
`make docker-start` starts `provisioner` only when `config.yaml` uses provisioner mode (`sandbox.use: deerflow.community.aio_sandbox:AioSandboxProvider` with `provisioner_url`).
|
`make docker-start` starts `provisioner` only when `config.yaml` uses provisioner mode (`sandbox.use: deerflow.community.aio_sandbox:AioSandboxProvider` with `provisioner_url`).
|
||||||
|
Backend processes automatically pick up `config.yaml` changes on the next config access, so model metadata updates do not require a manual restart during development.
|
||||||
|
|
||||||
**Production** (builds images locally, mounts runtime config and data):
|
**Production** (builds images locally, mounts runtime config and data):
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,8 @@ Setup: Copy `config.example.yaml` to `config.yaml` in the **project root** direc
|
|||||||
|
|
||||||
**Config Versioning**: `config.example.yaml` has a `config_version` field. On startup, `AppConfig.from_file()` compares user version vs example version and emits a warning if outdated. Missing `config_version` = version 0. Run `make config-upgrade` to auto-merge missing fields. When changing the config schema, bump `config_version` in `config.example.yaml`.
|
**Config Versioning**: `config.example.yaml` has a `config_version` field. On startup, `AppConfig.from_file()` compares user version vs example version and emits a warning if outdated. Missing `config_version` = version 0. Run `make config-upgrade` to auto-merge missing fields. When changing the config schema, bump `config_version` in `config.example.yaml`.
|
||||||
|
|
||||||
|
**Config Caching**: `get_app_config()` caches the parsed config, but automatically reloads it when the resolved config path changes or the file's mtime increases. This keeps Gateway and LangGraph reads aligned with `config.yaml` edits without requiring a manual process restart.
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -224,17 +224,65 @@ class AppConfig(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
_app_config: AppConfig | None = None
|
_app_config: AppConfig | None = None
|
||||||
|
_app_config_path: Path | None = None
|
||||||
|
_app_config_mtime: float | None = None
|
||||||
|
_app_config_is_custom = False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_config_mtime(config_path: Path) -> float | None:
|
||||||
|
"""Get the modification time of a config file if it exists."""
|
||||||
|
try:
|
||||||
|
return config_path.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig:
|
||||||
|
"""Load config from disk and refresh cache metadata."""
|
||||||
|
global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom
|
||||||
|
|
||||||
|
resolved_path = AppConfig.resolve_config_path(config_path)
|
||||||
|
_app_config = AppConfig.from_file(str(resolved_path))
|
||||||
|
_app_config_path = resolved_path
|
||||||
|
_app_config_mtime = _get_config_mtime(resolved_path)
|
||||||
|
_app_config_is_custom = False
|
||||||
|
return _app_config
|
||||||
|
|
||||||
|
|
||||||
def get_app_config() -> AppConfig:
|
def get_app_config() -> AppConfig:
|
||||||
"""Get the DeerFlow config instance.
|
"""Get the DeerFlow config instance.
|
||||||
|
|
||||||
Returns a cached singleton instance. Use `reload_app_config()` to reload
|
Returns a cached singleton instance and automatically reloads it when the
|
||||||
from file, or `reset_app_config()` to clear the cache.
|
underlying config file path or modification time changes. Use
|
||||||
|
`reload_app_config()` to force a reload, or `reset_app_config()` to clear
|
||||||
|
the cache.
|
||||||
"""
|
"""
|
||||||
global _app_config
|
global _app_config, _app_config_path, _app_config_mtime
|
||||||
if _app_config is None:
|
|
||||||
_app_config = AppConfig.from_file()
|
if _app_config is not None and _app_config_is_custom:
|
||||||
|
return _app_config
|
||||||
|
|
||||||
|
resolved_path = AppConfig.resolve_config_path()
|
||||||
|
current_mtime = _get_config_mtime(resolved_path)
|
||||||
|
|
||||||
|
should_reload = (
|
||||||
|
_app_config is None
|
||||||
|
or _app_config_path != resolved_path
|
||||||
|
or _app_config_mtime != current_mtime
|
||||||
|
)
|
||||||
|
if should_reload:
|
||||||
|
if (
|
||||||
|
_app_config_path == resolved_path
|
||||||
|
and _app_config_mtime is not None
|
||||||
|
and current_mtime is not None
|
||||||
|
and _app_config_mtime != current_mtime
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"Config file has been modified (mtime: %s -> %s), reloading AppConfig",
|
||||||
|
_app_config_mtime,
|
||||||
|
current_mtime,
|
||||||
|
)
|
||||||
|
_load_and_cache_app_config(str(resolved_path))
|
||||||
return _app_config
|
return _app_config
|
||||||
|
|
||||||
|
|
||||||
@@ -251,9 +299,7 @@ def reload_app_config(config_path: str | None = None) -> AppConfig:
|
|||||||
Returns:
|
Returns:
|
||||||
The newly loaded AppConfig instance.
|
The newly loaded AppConfig instance.
|
||||||
"""
|
"""
|
||||||
global _app_config
|
return _load_and_cache_app_config(config_path)
|
||||||
_app_config = AppConfig.from_file(config_path)
|
|
||||||
return _app_config
|
|
||||||
|
|
||||||
|
|
||||||
def reset_app_config() -> None:
|
def reset_app_config() -> None:
|
||||||
@@ -263,8 +309,11 @@ def reset_app_config() -> None:
|
|||||||
`get_app_config()` to reload from file. Useful for testing
|
`get_app_config()` to reload from file. Useful for testing
|
||||||
or when switching between different configurations.
|
or when switching between different configurations.
|
||||||
"""
|
"""
|
||||||
global _app_config
|
global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom
|
||||||
_app_config = None
|
_app_config = None
|
||||||
|
_app_config_path = None
|
||||||
|
_app_config_mtime = None
|
||||||
|
_app_config_is_custom = False
|
||||||
|
|
||||||
|
|
||||||
def set_app_config(config: AppConfig) -> None:
|
def set_app_config(config: AppConfig) -> None:
|
||||||
@@ -275,5 +324,8 @@ def set_app_config(config: AppConfig) -> None:
|
|||||||
Args:
|
Args:
|
||||||
config: The AppConfig instance to use.
|
config: The AppConfig instance to use.
|
||||||
"""
|
"""
|
||||||
global _app_config
|
global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom
|
||||||
_app_config = config
|
_app_config = config
|
||||||
|
_app_config_path = None
|
||||||
|
_app_config_mtime = None
|
||||||
|
_app_config_is_custom = True
|
||||||
|
|||||||
81
backend/tests/test_app_config_reload.py
Normal file
81
backend/tests/test_app_config_reload.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from deerflow.config.app_config import get_app_config, reset_app_config
|
||||||
|
|
||||||
|
|
||||||
|
def _write_config(path: Path, *, model_name: str, supports_thinking: bool) -> None:
|
||||||
|
path.write_text(
|
||||||
|
yaml.safe_dump(
|
||||||
|
{
|
||||||
|
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"name": model_name,
|
||||||
|
"use": "langchain_openai:ChatOpenAI",
|
||||||
|
"model": "gpt-test",
|
||||||
|
"supports_thinking": supports_thinking,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_extensions_config(path: Path) -> None:
|
||||||
|
path.write_text(json.dumps({"mcpServers": {}, "skills": {}}), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_app_config_reloads_when_file_changes(tmp_path, monkeypatch):
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
extensions_path = tmp_path / "extensions_config.json"
|
||||||
|
_write_extensions_config(extensions_path)
|
||||||
|
_write_config(config_path, model_name="first-model", supports_thinking=False)
|
||||||
|
|
||||||
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
||||||
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
||||||
|
reset_app_config()
|
||||||
|
|
||||||
|
try:
|
||||||
|
initial = get_app_config()
|
||||||
|
assert initial.models[0].supports_thinking is False
|
||||||
|
|
||||||
|
_write_config(config_path, model_name="first-model", supports_thinking=True)
|
||||||
|
next_mtime = config_path.stat().st_mtime + 5
|
||||||
|
os.utime(config_path, (next_mtime, next_mtime))
|
||||||
|
|
||||||
|
reloaded = get_app_config()
|
||||||
|
assert reloaded.models[0].supports_thinking is True
|
||||||
|
assert reloaded is not initial
|
||||||
|
finally:
|
||||||
|
reset_app_config()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_app_config_reloads_when_config_path_changes(tmp_path, monkeypatch):
|
||||||
|
config_a = tmp_path / "config-a.yaml"
|
||||||
|
config_b = tmp_path / "config-b.yaml"
|
||||||
|
extensions_path = tmp_path / "extensions_config.json"
|
||||||
|
_write_extensions_config(extensions_path)
|
||||||
|
_write_config(config_a, model_name="model-a", supports_thinking=False)
|
||||||
|
_write_config(config_b, model_name="model-b", supports_thinking=True)
|
||||||
|
|
||||||
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
||||||
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_a))
|
||||||
|
reset_app_config()
|
||||||
|
|
||||||
|
try:
|
||||||
|
first = get_app_config()
|
||||||
|
assert first.models[0].name == "model-a"
|
||||||
|
|
||||||
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_b))
|
||||||
|
second = get_app_config()
|
||||||
|
assert second.models[0].name == "model-b"
|
||||||
|
assert second is not first
|
||||||
|
finally:
|
||||||
|
reset_app_config()
|
||||||
Reference in New Issue
Block a user