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:
Huixin615
2026-06-17 22:57:46 +08:00
committed by GitHub
parent 6a4a30fa2b
commit ec16b6650d
5 changed files with 79 additions and 14 deletions
+1 -1
View File
@@ -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)`.
+3 -2
View File
@@ -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
+39
View File
@@ -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"
+3 -5
View File
@@ -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