mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-18 13:46:02 +00:00
fix(channel): force reload config on channel restart (#3619)
* fix: force reload config on channel restart * fix: detect config content changes for reload
This commit is contained in:
+1
-1
@@ -230,7 +230,7 @@ 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 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.
|
||||
**Config Caching**: `get_app_config()` caches the parsed config, but automatically reloads it when the resolved config path or file content signature changes. The signature includes file metadata and a content digest, so Gateway and LangGraph reads stay aligned with `config.yaml` edits even on object-store or network mounts where mtime can remain stale.
|
||||
|
||||
**Config Hot-Reload Boundary**: Gateway dependencies route through `get_app_config()` on every request, so per-run fields like `models[*].max_tokens`, `summarization.*`, `title.*`, `memory.*`, `subagents.*`, `tools[*]`, and the agent system prompt pick up `config.yaml` edits on the next message. `AppConfig` is intentionally **not** cached on `app.state` — `lifespan()` keeps a local `startup_config` variable for one-shot bootstrap work and passes it to `langgraph_runtime(app, startup_config)`.
|
||||
|
||||
|
||||
@@ -234,8 +234,9 @@ class ChannelService:
|
||||
def _load_channel_config(self, name: str) -> dict[str, Any] | None:
|
||||
"""Load the latest config for a specific channel from disk.
|
||||
|
||||
Uses ``get_app_config()`` which detects file changes via mtime,
|
||||
so edits to ``config.yaml`` are picked up without a process restart.
|
||||
Uses ``get_app_config()`` which detects file changes via config
|
||||
signature, so edits to ``config.yaml`` are picked up without a process
|
||||
restart.
|
||||
The UI runtime-config overlay applied at startup is re-applied here
|
||||
so a file-driven reload neither drops credentials entered from the
|
||||
browser nor resurrects a channel disconnected from it.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
@@ -400,6 +401,8 @@ class AppConfig(BaseModel):
|
||||
_app_config: AppConfig | None = None
|
||||
_app_config_path: Path | None = None
|
||||
_app_config_mtime: float | None = None
|
||||
_ConfigSignature = tuple[float | None, int | None, str | None]
|
||||
_app_config_signature: _ConfigSignature | None = None
|
||||
_app_config_is_custom = False
|
||||
_current_app_config: ContextVar[AppConfig | None] = ContextVar("deerflow_current_app_config", default=None)
|
||||
_current_app_config_stack: ContextVar[tuple[AppConfig | None, ...]] = ContextVar("deerflow_current_app_config_stack", default=())
|
||||
@@ -413,14 +416,33 @@ def _get_config_mtime(config_path: Path) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def _get_config_signature(config_path: Path) -> _ConfigSignature | None:
|
||||
"""Get cache metadata for a config file, including a content digest."""
|
||||
try:
|
||||
stat_result = config_path.stat()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with config_path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
except OSError:
|
||||
return (stat_result.st_mtime, stat_result.st_size, None)
|
||||
|
||||
return (stat_result.st_mtime, stat_result.st_size, digest.hexdigest())
|
||||
|
||||
|
||||
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
|
||||
global _app_config, _app_config_path, _app_config_mtime, _app_config_signature, _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_signature = _get_config_signature(resolved_path)
|
||||
_app_config_is_custom = False
|
||||
return _app_config
|
||||
|
||||
@@ -429,11 +451,11 @@ def get_app_config() -> AppConfig:
|
||||
"""Get the DeerFlow config instance.
|
||||
|
||||
Returns a cached singleton instance and automatically reloads it when the
|
||||
underlying config file path or modification time changes. Use
|
||||
underlying config file path or content signature changes. Use
|
||||
`reload_app_config()` to force a reload, or `reset_app_config()` to clear
|
||||
the cache.
|
||||
"""
|
||||
global _app_config, _app_config_path, _app_config_mtime
|
||||
global _app_config, _app_config_path, _app_config_mtime, _app_config_signature
|
||||
|
||||
runtime_override = _current_app_config.get()
|
||||
if runtime_override is not None:
|
||||
@@ -444,8 +466,9 @@ def get_app_config() -> AppConfig:
|
||||
|
||||
resolved_path = AppConfig.resolve_config_path()
|
||||
current_mtime = _get_config_mtime(resolved_path)
|
||||
current_signature = _get_config_signature(resolved_path)
|
||||
|
||||
should_reload = _app_config is None or _app_config_path != resolved_path or _app_config_mtime != current_mtime
|
||||
should_reload = _app_config is None or _app_config_path != resolved_path or _app_config_signature != current_signature
|
||||
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(
|
||||
@@ -453,6 +476,8 @@ def get_app_config() -> AppConfig:
|
||||
_app_config_mtime,
|
||||
current_mtime,
|
||||
)
|
||||
elif _app_config_path == resolved_path and _app_config_signature != current_signature:
|
||||
logger.info("Config file content signature changed, reloading AppConfig")
|
||||
_load_and_cache_app_config(str(resolved_path))
|
||||
return _app_config
|
||||
|
||||
@@ -480,10 +505,11 @@ def reset_app_config() -> None:
|
||||
`get_app_config()` to reload from file. Useful for testing
|
||||
or when switching between different configurations.
|
||||
"""
|
||||
global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom
|
||||
global _app_config, _app_config_path, _app_config_mtime, _app_config_signature, _app_config_is_custom
|
||||
_app_config = None
|
||||
_app_config_path = None
|
||||
_app_config_mtime = None
|
||||
_app_config_signature = None
|
||||
_app_config_is_custom = False
|
||||
|
||||
|
||||
@@ -495,10 +521,11 @@ def set_app_config(config: AppConfig) -> None:
|
||||
Args:
|
||||
config: The AppConfig instance to use.
|
||||
"""
|
||||
global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom
|
||||
global _app_config, _app_config_path, _app_config_mtime, _app_config_signature, _app_config_is_custom
|
||||
_app_config = config
|
||||
_app_config_path = None
|
||||
_app_config_mtime = None
|
||||
_app_config_signature = None
|
||||
_app_config_is_custom = True
|
||||
|
||||
|
||||
|
||||
@@ -216,6 +216,45 @@ def test_get_app_config_reloads_when_file_changes(tmp_path, monkeypatch):
|
||||
reset_app_config()
|
||||
|
||||
|
||||
def test_get_app_config_reloads_when_content_digest_changes_without_metadata(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="model-a", supports_thinking=False)
|
||||
|
||||
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
||||
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
||||
_reset_config_singletons()
|
||||
|
||||
try:
|
||||
initial = get_app_config()
|
||||
initial_mtime = app_config_module._app_config_mtime
|
||||
initial_signature = app_config_module._app_config_signature
|
||||
assert initial.models[0].name == "model-a"
|
||||
assert initial_signature is not None
|
||||
|
||||
_write_config(config_path, model_name="model-b", supports_thinking=False)
|
||||
|
||||
real_get_config_signature = app_config_module._get_config_signature
|
||||
|
||||
def stale_metadata_signature(path: Path):
|
||||
current_signature = real_get_config_signature(path)
|
||||
assert current_signature is not None
|
||||
return (initial_signature[0], initial_signature[1], current_signature[2])
|
||||
|
||||
monkeypatch.setattr(app_config_module, "_get_config_mtime", lambda _path: initial_mtime)
|
||||
monkeypatch.setattr(app_config_module, "_get_config_signature", stale_metadata_signature)
|
||||
|
||||
reloaded = get_app_config()
|
||||
assert reloaded.models[0].name == "model-b"
|
||||
assert reloaded is not initial
|
||||
assert app_config_module._app_config_signature is not None
|
||||
assert app_config_module._app_config_signature[:2] == initial_signature[:2]
|
||||
assert app_config_module._app_config_signature[2] != initial_signature[2]
|
||||
finally:
|
||||
_reset_config_singletons()
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@@ -4474,12 +4474,10 @@ class TestChannelService:
|
||||
"""
|
||||
from app.channels.service import ChannelService
|
||||
|
||||
stale_file_config = {"feishu": {"enabled": True, "app_id": "file_id", "app_secret": "file_secret"}}
|
||||
def fail_get_app_config():
|
||||
raise AssertionError("configure_channel must not reload file config")
|
||||
|
||||
def mock_get_app_config():
|
||||
return SimpleNamespace(model_extra={"channels": stale_file_config})
|
||||
|
||||
monkeypatch.setattr("deerflow.config.app_config.get_app_config", mock_get_app_config)
|
||||
monkeypatch.setattr("deerflow.config.app_config.get_app_config", fail_get_app_config)
|
||||
|
||||
service = ChannelService(channels_config={})
|
||||
service._running = True
|
||||
|
||||
Reference in New Issue
Block a user