fix(channels): merge runtime channel config saves across Gateway workers (#6844)

* fix(channels): merge runtime channel config saves across Gateway workers

Each Gateway worker builds its own ChannelRuntimeConfigStore, loads
runtime-config.json once, and rewrote the whole file from that copy on
every save. With two workers, a bot saved on one worker was dropped when
another bot was saved on the other, and a disconnected bot came back.

Every save, disconnect and removal now rereads the file while holding the
thread lock and an advisory sidecar file lock shared across processes,
changes only its own provider, and saves. The served snapshot is
refreshed only after a successful save, and an unreadable file still
falls back to this process's copy as before.

* docs(changelog): reference #6844 for the runtime channel config fix

* fix(channels): normalize the runtime config lock path like extensions_config

The sidecar lock was derived from the store path as given, so a worker
configured with a symlinked spelling of runtime-config.json took a
different lock file and no longer excluded peers using the real path.
Resolve a symlinked path, and make any other path absolute, before
deriving the sidecar, as extensions_config_file_lock already does.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Hyeonsang Cho 2026-10-14 11:21:02 +09:00 committed by GitHub
parent 4fc0d8fb31
commit 7a57022125
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 327 additions and 14 deletions

View File

@ -5871,6 +5871,13 @@ This release closes that milestone with **621 merged pull requests**.
With the default `max_result_chars`, one 100-item page held about 34 MB and
blocked the event loop for about 70 ms (170 MB and 300 ms at `limit=500`);
it now holds under 1 MB. The response is unchanged. ([#6781])
- **channels:** Saving or disconnecting a bot from the channel setup page no
longer overwrites bots saved through another Gateway worker. Each worker kept
the `runtime-config.json` contents it loaded at startup and rewrote the whole
file from that copy, so with two workers a Telegram bot saved on one worker
was dropped when a Slack bot was saved on the other, and a disconnected bot
came back. Every save now rereads the file under a lock shared across
processes and changes only its own provider. ([#6844])
### Security
@ -11651,3 +11658,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6781]: https://github.com/bytedance/deer-flow/pull/6781
[#6785]: https://github.com/bytedance/deer-flow/pull/6785
[#6798]: https://github.com/bytedance/deer-flow/pull/6798
[#6844]: https://github.com/bytedance/deer-flow/pull/6844

View File

@ -4469,6 +4469,11 @@
每 2 秒轮询一次)不再为了只返回预览而载入每个条目的完整报告、证据 JSON 和提示词。
默认 `max_result_chars` 下,一页 100 个条目约占用 34 MB,并阻塞事件循环约 70 ms
(`limit=500` 时为 170 MB 和 300 ms);现在不到 1 MB。响应内容不变。([#6781])
- **渠道:** 在渠道设置页保存或断开机器人时,不再覆盖经由其他 Gateway worker
保存的机器人。每个 worker 都保留启动时读入的 `runtime-config.json` 内容,并用这份
副本重写整个文件,因此在两个 worker 下,一个 worker 上保存的 Telegram 机器人会在
另一个 worker 保存 Slack 机器人时丢失,已断开的机器人也会重新出现。现在每次保存
都会在跨进程共享的锁下重新读取文件,只修改自己的 provider。([#6844])
### 安全
@ -9309,3 +9314,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#6746]: https://github.com/bytedance/deer-flow/pull/6746
[#6781]: https://github.com/bytedance/deer-flow/pull/6781
[#6785]: https://github.com/bytedance/deer-flow/pull/6785
[#6844]: https://github.com/bytedance/deer-flow/pull/6844

View File

@ -4,8 +4,11 @@ from __future__ import annotations
import json
import logging
import os
import tempfile
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any
@ -14,6 +17,42 @@ logger = logging.getLogger(__name__)
RUNTIME_CHANNEL_DISABLED_FLAG = "_runtime_disabled"
@contextmanager
def _runtime_config_file_lock(path: Path) -> Iterator[None]:
"""Exclude read-modify-write cycles of this file in other Gateway processes.
The lock is an advisory sidecar next to the store file, so it covers every
worker and embedded client that resolves the same ``DEER_FLOW_HOME``. The
path is normalized first, as in ``extensions_config_file_lock``, so a
symlinked spelling of the file shares the lock of the file it points to.
"""
target_path = path.resolve(strict=False) if path.is_symlink() else path.absolute()
lock_path = target_path.parent / f".{target_path.name}.lock"
with open(lock_path, "a+b") as lock_file:
if os.name == "nt":
import msvcrt
lock_file.seek(0, os.SEEK_END)
if lock_file.tell() == 0:
lock_file.write(b"\0")
lock_file.flush()
lock_file.seek(0)
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
else:
import fcntl
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
if os.name == "nt":
lock_file.seek(0)
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
else:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
class ChannelRuntimeConfigStore:
"""JSON-backed store for channel credentials entered from the UI.
@ -32,18 +71,39 @@ class ChannelRuntimeConfigStore:
self._data: dict[str, dict[str, Any]] = self._load()
self._lock = threading.Lock()
def _load(self) -> dict[str, dict[str, Any]]:
def _load(self, fallback: dict[str, dict[str, Any]] | None = None) -> dict[str, dict[str, Any]]:
if self._path.exists():
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
if fallback is not None:
logger.warning("Corrupt channel runtime config store at %s, rewriting it from this process's copy", self._path)
return {name: dict(config) for name, config in fallback.items()}
logger.warning("Corrupt channel runtime config store at %s, starting fresh", self._path)
return {}
if isinstance(raw, dict):
return {str(name): dict(value) for name, value in raw.items() if isinstance(value, dict)}
return {}
def _save(self) -> None:
@contextmanager
def _mutation(self) -> Iterator[dict[str, dict[str, Any]]]:
"""Yield the file's current providers for one read-modify-write cycle.
Each Gateway worker keeps its own store, loaded when that worker first
needed it. Saving that snapshot would overwrite providers a peer worker
saved since, so every mutation reloads the file while holding the
thread lock and the cross-process file lock, changes one provider, and
saves. An unreadable file falls back to this process's copy rather
than an empty one. The snapshot this process serves is refreshed only
after the cycle completes; a failed save leaves the previous one in
place.
"""
with self._lock, _runtime_config_file_lock(self._path):
data = self._load(fallback=self._data)
yield data
self._data = data
def _save(self, data: dict[str, dict[str, Any]]) -> None:
fd = tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
@ -56,7 +116,7 @@ class ChannelRuntimeConfigStore:
Path(fd.name).chmod(0o600)
except OSError:
logger.debug("Unable to chmod temporary channel runtime config store at %s", fd.name, exc_info=True)
json.dump(self._data, fd, indent=2, ensure_ascii=False)
json.dump(data, fd, indent=2, ensure_ascii=False)
fd.close()
Path(fd.name).replace(self._path)
try:
@ -78,24 +138,24 @@ class ChannelRuntimeConfigStore:
return dict(config) if isinstance(config, dict) else None
def set_provider_config(self, provider: str, config: dict[str, Any]) -> None:
with self._lock:
self._data[provider] = dict(config)
self._save()
with self._mutation() as data:
data[provider] = dict(config)
self._save(data)
def set_provider_disconnected(self, provider: str) -> None:
with self._lock:
self._data[provider] = {
with self._mutation() as data:
data[provider] = {
"enabled": False,
RUNTIME_CHANNEL_DISABLED_FLAG: True,
}
self._save()
self._save(data)
def remove_provider_config(self, provider: str) -> bool:
with self._lock:
if provider not in self._data:
with self._mutation() as data:
if provider not in data:
return False
del self._data[provider]
self._save()
del data[provider]
self._save(data)
return True

View File

@ -617,7 +617,11 @@ from the browser runtime setup flow, which persists them through
`ChannelRuntimeConfigStore` so local/private deployments can configure bots
without editing YAML. The runtime store is a local plaintext JSON fallback with
owner-only file permissions (`0600`); use it only where the DeerFlow data
directory is already trusted as secret storage. WeChat QR login auth state
directory is already trusted as secret storage. Every Gateway worker keeps its own copy
of the file; a save or disconnect rereads it under an advisory sidecar lock
(`.runtime-config.json.lock`) and changes only that provider, so workers sharing
`DEER_FLOW_HOME` do not overwrite each other's bots. Reads still serve the copy
the worker last loaded or wrote. WeChat QR login auth state
follows the same local-runtime model and may persist a QR-derived bot token in
the channel state directory.

View File

@ -836,6 +836,66 @@ def test_configure_provider_runtime_credentials_survive_local_restart(tmp_path):
anyio.run(repo.close)
def test_runtime_config_saved_on_peer_workers_both_survive_restart(tmp_path):
"""Each Gateway worker keeps its own store; a save must not drop a peer's provider."""
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = _enabled_connections_config()
runtime_config_path = tmp_path / "channels" / "runtime-config.json"
# Both workers load the (empty) file before either one saves.
worker_a = _make_app(config, repo, {}, runtime_config_store=ChannelRuntimeConfigStore(runtime_config_path))
worker_b = _make_app(config, repo, {}, runtime_config_store=ChannelRuntimeConfigStore(runtime_config_path))
with TestClient(worker_a) as client:
telegram_response = client.post(
"/api/channels/telegram/runtime-config",
json={"values": {"bot_token": "tg-token", "bot_username": "ui_bot"}},
)
with TestClient(worker_b) as client:
slack_response = client.post(
"/api/channels/slack/runtime-config",
json={"values": {"bot_token": "xoxb-ui", "app_token": "xapp-ui"}},
)
assert telegram_response.status_code == 200
assert slack_response.status_code == 200
assert ChannelRuntimeConfigStore(runtime_config_path).load_all() == {
"telegram": {"enabled": True, "bot_token": "tg-token", "bot_username": "ui_bot"},
"slack": {"enabled": True, "bot_token": "xoxb-ui", "app_token": "xapp-ui"},
}
anyio.run(repo.close)
def test_runtime_disconnect_on_one_worker_is_not_undone_by_a_peer_save(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = _enabled_connections_config()
runtime_config_path = tmp_path / "channels" / "runtime-config.json"
slack_config = {"enabled": True, "bot_token": "xoxb-ui", "app_token": "xapp-ui"}
ChannelRuntimeConfigStore(runtime_config_path).set_provider_config("slack", slack_config)
worker_a = _make_app(config, repo, {"slack": dict(slack_config)}, runtime_config_store=ChannelRuntimeConfigStore(runtime_config_path))
worker_b = _make_app(config, repo, {"slack": dict(slack_config)}, runtime_config_store=ChannelRuntimeConfigStore(runtime_config_path))
with TestClient(worker_a) as client:
disconnect_response = client.delete("/api/channels/slack/runtime-config")
with TestClient(worker_b) as client:
telegram_response = client.post(
"/api/channels/telegram/runtime-config",
json={"values": {"bot_token": "tg-token", "bot_username": "ui_bot"}},
)
assert disconnect_response.status_code == 200
assert telegram_response.status_code == 200
persisted = ChannelRuntimeConfigStore(runtime_config_path).load_all()
assert persisted["slack"] == {"enabled": False, "_runtime_disabled": True}
assert persisted["telegram"]["bot_token"] == "tg-token"
anyio.run(repo.close)
def test_configure_provider_runtime_credentials_preserves_masked_secrets(tmp_path):
import anyio

View File

@ -0,0 +1,175 @@
"""Runtime channel config saves must merge with peer Gateway workers' saves.
Every Gateway worker builds its own ``ChannelRuntimeConfigStore`` over the same
``runtime-config.json``. Two store instances on one path stand in for two
workers here; the advisory file lock is per open file, so it also excludes the
two instances inside one test process.
"""
from __future__ import annotations
import os
import threading
import pytest
from app.channels.runtime_config_store import ChannelRuntimeConfigStore
TELEGRAM = {"enabled": True, "bot_token": "tg-token"}
SLACK = {"enabled": True, "bot_token": "xoxb-ui", "app_token": "xapp-ui"}
def _path(tmp_path):
return tmp_path / "channels" / "runtime-config.json"
def test_save_keeps_a_provider_a_peer_saved_after_this_store_loaded(tmp_path):
worker_a = ChannelRuntimeConfigStore(_path(tmp_path))
worker_b = ChannelRuntimeConfigStore(_path(tmp_path))
worker_a.set_provider_config("telegram", TELEGRAM)
worker_b.set_provider_config("slack", SLACK)
assert ChannelRuntimeConfigStore(_path(tmp_path)).load_all() == {"telegram": TELEGRAM, "slack": SLACK}
# The saving worker's own snapshot also picks up the peer's provider.
assert worker_b.get_provider_config("telegram") == TELEGRAM
def test_disconnect_survives_a_later_peer_save(tmp_path):
ChannelRuntimeConfigStore(_path(tmp_path)).set_provider_config("slack", SLACK)
worker_a = ChannelRuntimeConfigStore(_path(tmp_path))
worker_b = ChannelRuntimeConfigStore(_path(tmp_path))
worker_a.set_provider_disconnected("slack")
worker_b.set_provider_config("telegram", TELEGRAM)
assert ChannelRuntimeConfigStore(_path(tmp_path)).load_all() == {
"slack": {"enabled": False, "_runtime_disabled": True},
"telegram": TELEGRAM,
}
def test_remove_sees_a_provider_a_peer_saved_after_this_store_loaded(tmp_path):
worker_a = ChannelRuntimeConfigStore(_path(tmp_path))
worker_b = ChannelRuntimeConfigStore(_path(tmp_path))
worker_b.set_provider_config("slack", SLACK)
assert worker_a.remove_provider_config("slack") is True
assert ChannelRuntimeConfigStore(_path(tmp_path)).load_all() == {}
def test_save_over_a_corrupt_file_keeps_this_process_copy(tmp_path):
store = ChannelRuntimeConfigStore(_path(tmp_path))
store.set_provider_config("telegram", TELEGRAM)
_path(tmp_path).write_text("{not json", encoding="utf-8")
store.set_provider_config("slack", SLACK)
assert ChannelRuntimeConfigStore(_path(tmp_path)).load_all() == {"telegram": TELEGRAM, "slack": SLACK}
def test_failed_save_leaves_the_served_snapshot_and_file_unchanged(tmp_path, monkeypatch):
store = ChannelRuntimeConfigStore(_path(tmp_path))
store.set_provider_config("telegram", TELEGRAM)
def failing_save(*_args):
raise OSError("disk full")
monkeypatch.setattr(store, "_save", failing_save)
with pytest.raises(OSError, match="disk full"):
store.set_provider_config("slack", SLACK)
assert store.load_all() == {"telegram": TELEGRAM}
assert ChannelRuntimeConfigStore(_path(tmp_path)).load_all() == {"telegram": TELEGRAM}
def test_peer_mutation_waits_for_the_file_lock_before_reading(tmp_path):
"""The reload must sit inside the cross-process lock, not just before the write.
Worker A stalls between reading and saving. Worker B's mutation must not
read the file until A has saved; reading earlier would let A's save
overwrite B's provider, or B's overwrite A's.
"""
worker_a = ChannelRuntimeConfigStore(_path(tmp_path))
worker_b = ChannelRuntimeConfigStore(_path(tmp_path))
a_saving = threading.Event()
release_a = threading.Event()
b_loaded = threading.Event()
real_save_a = worker_a._save
def stalled_save(data):
a_saving.set()
assert release_a.wait(timeout=5)
real_save_a(data)
real_load_b = worker_b._load
def observed_load(*args, **kwargs):
b_loaded.set()
return real_load_b(*args, **kwargs)
worker_a._save = stalled_save
worker_b._load = observed_load
thread_a = threading.Thread(target=worker_a.set_provider_config, args=("telegram", TELEGRAM))
thread_b = threading.Thread(target=worker_b.set_provider_config, args=("slack", SLACK))
thread_a.start()
try:
assert a_saving.wait(timeout=5)
thread_b.start()
assert not b_loaded.wait(timeout=0.5)
finally:
release_a.set()
thread_a.join(timeout=5)
if thread_b.ident is not None:
thread_b.join(timeout=5)
assert b_loaded.is_set()
assert ChannelRuntimeConfigStore(_path(tmp_path)).load_all() == {"telegram": TELEGRAM, "slack": SLACK}
@pytest.mark.skipif(os.name == "nt", reason="creating symlinks needs extra privileges on Windows")
def test_symlinked_store_path_shares_the_lock_of_its_target(tmp_path):
"""A worker configured with a symlinked spelling of the file must still exclude peers."""
target = _path(tmp_path)
target.parent.mkdir(parents=True)
target.write_text("{}", encoding="utf-8")
link = target.parent / "runtime-config-link.json"
link.symlink_to(target)
via_link = ChannelRuntimeConfigStore(link)
via_target = ChannelRuntimeConfigStore(target)
link_saving = threading.Event()
release_link = threading.Event()
target_loaded = threading.Event()
real_save = via_link._save
def stalled_save(data):
link_saving.set()
assert release_link.wait(timeout=5)
real_save(data)
real_load = via_target._load
def observed_load(*args, **kwargs):
target_loaded.set()
return real_load(*args, **kwargs)
via_link._save = stalled_save
via_target._load = observed_load
thread_link = threading.Thread(target=via_link.set_provider_config, args=("telegram", TELEGRAM))
thread_target = threading.Thread(target=via_target.set_provider_config, args=("slack", SLACK))
thread_link.start()
try:
assert link_saving.wait(timeout=5)
thread_target.start()
assert not target_loaded.wait(timeout=0.5)
finally:
release_link.set()
thread_link.join(timeout=5)
if thread_target.ident is not None:
thread_target.join(timeout=5)
assert target_loaded.is_set()