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 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)`.
|
**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:
|
def _load_channel_config(self, name: str) -> dict[str, Any] | None:
|
||||||
"""Load the latest config for a specific channel from disk.
|
"""Load the latest config for a specific channel from disk.
|
||||||
|
|
||||||
Uses ``get_app_config()`` which detects file changes via mtime,
|
Uses ``get_app_config()`` which detects file changes via config
|
||||||
so edits to ``config.yaml`` are picked up without a process restart.
|
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
|
The UI runtime-config overlay applied at startup is re-applied here
|
||||||
so a file-driven reload neither drops credentials entered from the
|
so a file-driven reload neither drops credentials entered from the
|
||||||
browser nor resurrects a channel disconnected from it.
|
browser nor resurrects a channel disconnected from it.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
@@ -400,6 +401,8 @@ class AppConfig(BaseModel):
|
|||||||
_app_config: AppConfig | None = None
|
_app_config: AppConfig | None = None
|
||||||
_app_config_path: Path | None = None
|
_app_config_path: Path | None = None
|
||||||
_app_config_mtime: float | 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
|
_app_config_is_custom = False
|
||||||
_current_app_config: ContextVar[AppConfig | None] = ContextVar("deerflow_current_app_config", default=None)
|
_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=())
|
_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
|
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:
|
def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig:
|
||||||
"""Load config from disk and refresh cache metadata."""
|
"""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)
|
resolved_path = AppConfig.resolve_config_path(config_path)
|
||||||
_app_config = AppConfig.from_file(str(resolved_path))
|
_app_config = AppConfig.from_file(str(resolved_path))
|
||||||
_app_config_path = resolved_path
|
_app_config_path = resolved_path
|
||||||
_app_config_mtime = _get_config_mtime(resolved_path)
|
_app_config_mtime = _get_config_mtime(resolved_path)
|
||||||
|
_app_config_signature = _get_config_signature(resolved_path)
|
||||||
_app_config_is_custom = False
|
_app_config_is_custom = False
|
||||||
return _app_config
|
return _app_config
|
||||||
|
|
||||||
@@ -429,11 +451,11 @@ def get_app_config() -> AppConfig:
|
|||||||
"""Get the DeerFlow config instance.
|
"""Get the DeerFlow config instance.
|
||||||
|
|
||||||
Returns a cached singleton instance and automatically reloads it when the
|
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
|
`reload_app_config()` to force a reload, or `reset_app_config()` to clear
|
||||||
the cache.
|
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()
|
runtime_override = _current_app_config.get()
|
||||||
if runtime_override is not None:
|
if runtime_override is not None:
|
||||||
@@ -444,8 +466,9 @@ def get_app_config() -> AppConfig:
|
|||||||
|
|
||||||
resolved_path = AppConfig.resolve_config_path()
|
resolved_path = AppConfig.resolve_config_path()
|
||||||
current_mtime = _get_config_mtime(resolved_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 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:
|
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(
|
logger.info(
|
||||||
@@ -453,6 +476,8 @@ def get_app_config() -> AppConfig:
|
|||||||
_app_config_mtime,
|
_app_config_mtime,
|
||||||
current_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))
|
_load_and_cache_app_config(str(resolved_path))
|
||||||
return _app_config
|
return _app_config
|
||||||
|
|
||||||
@@ -480,10 +505,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, _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 = None
|
||||||
_app_config_path = None
|
_app_config_path = None
|
||||||
_app_config_mtime = None
|
_app_config_mtime = None
|
||||||
|
_app_config_signature = None
|
||||||
_app_config_is_custom = False
|
_app_config_is_custom = False
|
||||||
|
|
||||||
|
|
||||||
@@ -495,10 +521,11 @@ def set_app_config(config: AppConfig) -> None:
|
|||||||
Args:
|
Args:
|
||||||
config: The AppConfig instance to use.
|
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 = config
|
||||||
_app_config_path = None
|
_app_config_path = None
|
||||||
_app_config_mtime = None
|
_app_config_mtime = None
|
||||||
|
_app_config_signature = None
|
||||||
_app_config_is_custom = True
|
_app_config_is_custom = True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,45 @@ def test_get_app_config_reloads_when_file_changes(tmp_path, monkeypatch):
|
|||||||
reset_app_config()
|
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):
|
def test_get_app_config_reloads_when_config_path_changes(tmp_path, monkeypatch):
|
||||||
config_a = tmp_path / "config-a.yaml"
|
config_a = tmp_path / "config-a.yaml"
|
||||||
config_b = tmp_path / "config-b.yaml"
|
config_b = tmp_path / "config-b.yaml"
|
||||||
|
|||||||
@@ -4474,12 +4474,10 @@ class TestChannelService:
|
|||||||
"""
|
"""
|
||||||
from app.channels.service import ChannelService
|
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():
|
monkeypatch.setattr("deerflow.config.app_config.get_app_config", fail_get_app_config)
|
||||||
return SimpleNamespace(model_extra={"channels": stale_file_config})
|
|
||||||
|
|
||||||
monkeypatch.setattr("deerflow.config.app_config.get_app_config", mock_get_app_config)
|
|
||||||
|
|
||||||
service = ChannelService(channels_config={})
|
service = ChannelService(channels_config={})
|
||||||
service._running = True
|
service._running = True
|
||||||
|
|||||||
Reference in New Issue
Block a user