deer-flow/backend/app/channels/runtime_config_store.py
Felix Shi 737d50f625
fix(channels): retry the runtime config file lock instead of failing after ~10s on Windows (#6888)
* fix(channels): retry the runtime config file lock instead of failing after ~10s on Windows

The runtime channel config sidecar lock (#6844) used Windows' blocking msvcrt LK_LOCK mode, which gives up after roughly ten seconds and surfaces cross-worker contention as Permission denied instead of serializing the read-modify-write cycles it exists to serialize, while fcntl.flock(LOCK_EX) waits on POSIX. Retry the non-blocking acquisition (LK_NBLCK + 0.2s) on the Windows fallback, mirroring the extensions manager's lock (#6020), and bind fcntl/msvcrt at module level so the fallback branch is testable.

Tests: new backend/tests/test_channel_runtime_config_store_lock.py (5 tests: retry cadence via fake msvcrt, sidecar byte creation, POSIX flock preservation, direct Windows regression through the sys.modules seam, real cross-thread contention). 3 of them fail on the unmodified base (one with OSError 36 raised by the blocking call itself) and all pass with the fix. Related suites: store peers + connections router 86 passed / 1 skipped; blocking_io + encoding + channels 498 passed / 2 skipped. Ruff format and check clean on both files. Windows 11, Python 3.12 (uv-managed venv).

* fix(channels): share contention retries and diagnose prolonged lock waits

* docs(channels): shorten guidance to satisfy lint budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-25 11:05:04 +08:00

532 lines
23 KiB
Python

"""Persistence for IM channel configuration entered from the web UI ("runtime config").
An admin can connect a provider from the browser instead of editing
``config.yaml``: the credentials it enters, and a later disconnect, are stored
per provider and overlaid on the ``channels.*`` blocks by
:func:`merge_runtime_channel_configs`. Two stores share the
:class:`RuntimeConfigStore` protocol:
- :class:`SqlChannelRuntimeConfigStore` keeps them in the
``channel_runtime_configs`` table of the application database (SQLite or
PostgreSQL), so every Gateway instance reads the same rows. Each provider's
config is stored as ``DEER_FLOW_CREDENTIALS_KEY`` ciphertext
(``deerflow.config.credentials_key``); a disconnect is a plaintext tombstone
with no secret. Every save bumps the provider's ``version``, which the leader
lease supervisor in ``ChannelService`` polls to apply a save made on a peer.
- :class:`ChannelRuntimeConfigStore` is the historical JSON file
``{base_dir}/channels/runtime-config.json`` (plaintext, mode 0600). It stays
for ``database.backend: memory`` (one process, nothing to share) and as the
source of the SQL store's one-time import, which deletes the file afterwards.
:func:`resolve_runtime_config_store` picks one. Every store call may block (a
file or a database round trip), so async callers run it in a worker thread.
"""
from __future__ import annotations
import json
import logging
import os
import tempfile
import threading
import time
from collections.abc import Iterator
from contextlib import contextmanager, suppress
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
from app.channels.credential_staging import cleanup_stale_credential_staging, credential_staging_prefix
from deerflow.utils.file_lock import retry_until_locked
if TYPE_CHECKING:
from deerflow.config.credentials_key import CredentialsCipher
from deerflow.persistence.channel_runtime_configs import SqlChannelRuntimeConfigRepository, StoredChannelRuntimeConfig
try:
import fcntl
except ImportError: # pragma: no cover - Windows fallback
fcntl = None # type: ignore[assignment]
import msvcrt
logger = logging.getLogger(__name__)
RUNTIME_CHANNEL_DISABLED_FLAG = "_runtime_disabled"
# About ten seconds at the shared helper's 0.2-second retry cadence.
_LOCK_WAIT_WARNING_ATTEMPTS = 50
class ChannelRuntimeConfigEncryptionUnavailable(RuntimeError):
"""The database store has no credentials key, so it refuses to persist credentials."""
class RuntimeConfigStore(Protocol):
"""Provider -> runtime config block, as entered from the web UI. Calls may block."""
@property
def can_store_credentials(self) -> bool:
"""False when :meth:`set_provider_config` would refuse (no encryption key for the database)."""
...
def load_all(self) -> dict[str, dict[str, Any]]: ...
def get_provider_config(self, provider: str) -> dict[str, Any] | None: ...
def set_provider_config(self, provider: str, config: dict[str, Any]) -> None: ...
def set_provider_disconnected(self, provider: str) -> None: ...
def versions(self) -> dict[str, int]:
"""``provider -> version`` that changes on every save, for noticing saves made by another instance."""
...
@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 fcntl is not None:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
else: # pragma: no cover - Windows fallback
lock_file.seek(0, os.SEEK_END)
if lock_file.tell() == 0:
lock_file.write(b"\0")
lock_file.flush()
# LK_LOCK gives up after roughly ten seconds. Share the
# contention-only retry policy, retaining an indefinite wait.
lock_file.seek(0)
retries = 0
def wait_for_lock(delay: float) -> None:
nonlocal retries
retries += 1
if retries == _LOCK_WAIT_WARNING_ATTEMPTS:
# Diagnostics must not abort acquisition or flood the log.
with suppress(Exception):
logger.warning("Still waiting for channel runtime config lock %s after %d contention retries", lock_path, retries)
time.sleep(delay)
retry_until_locked(
lambda: msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1),
sleep=wait_for_lock,
)
try:
yield
finally:
if fcntl is not None:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
else: # pragma: no cover - Windows fallback
lock_file.seek(0)
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
class ChannelRuntimeConfigStore:
"""JSON-backed store for channel credentials entered from the UI.
This intentionally mirrors ``JsonChannelStore``: local/private deployments get
durable runtime configuration without needing a public callback URL or a
config.yaml edit. It serves ``database.backend: memory`` (a single process);
a sqlite/postgres database uses :class:`SqlChannelRuntimeConfigStore`.
"""
#: The file is plaintext (mode 0600), as it always was; there is no key to miss.
can_store_credentials = True
def __init__(self, path: str | Path | None = None) -> None:
self._path = Path(path) if path is not None else default_runtime_config_path()
self._path.parent.mkdir(parents=True, exist_ok=True)
cleanup_stale_credential_staging(self._path)
self._data: dict[str, dict[str, Any]] = self._load()
self._lock = threading.Lock()
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, UnicodeDecodeError):
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 {}
@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",
newline="",
dir=self._path.parent,
prefix=credential_staging_prefix(self._path),
suffix=".tmp",
delete=False,
)
try:
try:
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(data, fd, indent=2, ensure_ascii=False)
fd.close()
Path(fd.name).replace(self._path)
try:
self._path.chmod(0o600)
except OSError:
logger.debug("Unable to chmod channel runtime config store at %s", self._path, exc_info=True)
except BaseException:
# Cleanup is best-effort: a failing close or unlink must not take the
# original save error with it, and the unlink is still attempted after
# a failed close.
try:
fd.close()
except OSError:
with suppress(Exception):
logger.warning("Failed to close temporary channel runtime config file %s", fd.name, exc_info=True)
try:
Path(fd.name).unlink(missing_ok=True)
except OSError:
with suppress(Exception):
logger.warning("Failed to remove temporary channel runtime config file %s", fd.name, exc_info=True)
raise
def load_all(self) -> dict[str, dict[str, Any]]:
with self._lock:
return {name: dict(config) for name, config in self._data.items()}
def get_provider_config(self, provider: str) -> dict[str, Any] | None:
with self._lock:
config = self._data.get(provider)
return dict(config) if isinstance(config, dict) else None
def set_provider_config(self, provider: str, config: dict[str, Any]) -> None:
with self._mutation() as data:
data[provider] = dict(config)
self._save(data)
def set_provider_disconnected(self, provider: str) -> None:
with self._mutation() as data:
data[provider] = {
"enabled": False,
RUNTIME_CHANNEL_DISABLED_FLAG: True,
}
self._save(data)
def remove_provider_config(self, provider: str) -> bool:
with self._mutation() as data:
if provider not in data:
return False
del data[provider]
self._save(data)
return True
def versions(self) -> dict[str, int]:
# One process owns the file (a ``memory`` database cannot be shared), so
# every save already went through this process: there is no peer save
# to notice.
return {}
_TOMBSTONE: dict[str, Any] = {"enabled": False, RUNTIME_CHANNEL_DISABLED_FLAG: True}
class SqlChannelRuntimeConfigStore:
"""Database-backed store over ``channel_runtime_configs``, shared by every instance using the database.
``cipher`` is the deployment ``CredentialsCipher``. Without one a
credential save raises :class:`ChannelRuntimeConfigEncryptionUnavailable`
rather than storing plaintext, and stored credentials read as not
configured; disconnect tombstones need no key either way. A row the key
cannot decrypt (lost or replaced key) also reads as not configured, with
one error per row version.
"""
def __init__(self, repo: SqlChannelRuntimeConfigRepository, *, cipher: CredentialsCipher | None, legacy_path: str | Path | None = None) -> None:
self._repo = repo
self._cipher = cipher
self._legacy_path = Path(legacy_path) if legacy_path is not None else None
self._reported: set[tuple[str, int]] = set()
@property
def legacy_path(self) -> Path | None:
"""The ``runtime-config.json`` that :meth:`import_legacy_json` moves into the table."""
return self._legacy_path
@property
def can_store_credentials(self) -> bool:
return self._cipher is not None
def load_all(self) -> dict[str, dict[str, Any]]:
configs: dict[str, dict[str, Any]] = {}
for row in self._repo.list_all():
config = self._decode(row)
if config is not None:
configs[row.provider] = config
return configs
def get_provider_config(self, provider: str) -> dict[str, Any] | None:
row = self._repo.get(provider)
return self._decode(row) if row is not None else None
def set_provider_config(self, provider: str, config: dict[str, Any]) -> None:
self._repo.save(provider, config_ciphertext=self._encrypt(config), disconnected=False, now=time.time())
def set_provider_disconnected(self, provider: str) -> None:
self._repo.save(provider, config_ciphertext=None, disconnected=True, now=time.time())
def versions(self) -> dict[str, int]:
return self._repo.versions()
def import_legacy_json(self) -> int:
"""Move ``runtime-config.json`` into the table once, then delete it; returns how many rows this call inserted.
Every well-formed entry is inserted with ``ON CONFLICT DO NOTHING``: a
provider already in the table keeps its row (it was saved after the
table took over, so it is newer than the file), any other provider is
imported, and two instances importing the same file at once neither
duplicate nor clobber a row. The file is then deleted, not renamed: a
plaintext copy left on disk would keep the credentials the table
encrypts. No file is a no-op; a corrupt file is kept and reported, and
so is a file with credentials while there is no key to encrypt them.
"""
from deerflow.persistence.channel_runtime_configs import CHANNEL_RUNTIME_CONFIG_PROVIDER_LENGTH, StoredChannelRuntimeConfig
path = self._legacy_path
if path is None:
return 0
entries = _read_legacy_file(path)
if entries is None:
return 0
rows: list[StoredChannelRuntimeConfig] = []
skipped = 0
for provider, config in entries.items():
if not isinstance(config, dict) or not provider or len(provider) > CHANNEL_RUNTIME_CONFIG_PROVIDER_LENGTH:
skipped += 1
continue
if runtime_channel_disconnected(config):
rows.append(StoredChannelRuntimeConfig(provider=provider, config_ciphertext=None, disconnected=True, version=1))
continue
if self._cipher is None:
logger.error("Channel runtime config %s holds credentials but no credentials key is available to encrypt them; keeping the file and not importing it", path)
return 0
rows.append(StoredChannelRuntimeConfig(provider=provider, config_ciphertext=self._encrypt(config), disconnected=False, version=1))
if skipped:
logger.warning("Skipping %d malformed entries in channel runtime config %s (not an object, or an empty or oversized provider name)", skipped, path)
inserted = self._repo.insert_missing(rows, now=time.time())
removed = _remove_legacy_file(path)
logger.info(
"Imported %d of %d channel runtime configs from %s into channel_runtime_configs%s",
inserted,
len(rows),
path,
"; deleted the plaintext file" if removed else " (file already removed by a peer)",
)
return inserted
def _encrypt(self, config: dict[str, Any]) -> str:
if self._cipher is None:
raise ChannelRuntimeConfigEncryptionUnavailable("channel credentials cannot be stored: the DEER_FLOW_CREDENTIALS_KEY credentials key is unavailable")
return self._cipher.encrypt_text(json.dumps(config, ensure_ascii=False))
def _decode(self, row: StoredChannelRuntimeConfig) -> dict[str, Any] | None:
if row.disconnected:
return dict(_TOMBSTONE)
if row.config_ciphertext is None:
return None
if self._cipher is None:
self._report(row, "Channel runtime config for %s is stored encrypted but no DEER_FLOW_CREDENTIALS_KEY credentials key is available; treating the provider as not configured")
return None
from deerflow.config.credentials_key import CredentialsDecryptError
try:
value = json.loads(self._cipher.decrypt_text(row.config_ciphertext))
except (CredentialsDecryptError, json.JSONDecodeError):
self._report(
row, "Channel runtime config for %s cannot be decrypted with the configured DEER_FLOW_CREDENTIALS_KEY; treating the provider as not configured until the key that wrote it is restored or its credentials are saved again"
)
return None
return dict(value) if isinstance(value, dict) else None
def _report(self, row: StoredChannelRuntimeConfig, message: str) -> None:
key = (row.provider, row.version)
if key not in self._reported:
self._reported.add(key)
logger.error(message, row.provider)
def _read_legacy_file(path: Path) -> dict[str, Any] | None:
"""Parse ``runtime-config.json``; ``None`` when it is missing, unreadable or not an object (kept in place)."""
# Also runs after migration removed the legacy file: a crash-orphaned
# credential copy must not survive merely because the store now uses SQL.
cleanup_stale_credential_staging(path)
try:
raw = path.read_text(encoding="utf-8")
except FileNotFoundError:
return None
except UnicodeDecodeError:
logger.warning("Corrupt channel runtime config %s; skipping the import (file kept)", path)
return None
except OSError:
logger.warning("Cannot read channel runtime config %s; skipping the import (file kept)", path, exc_info=True)
return None
try:
data = json.loads(raw)
except json.JSONDecodeError:
data = None
if not isinstance(data, dict):
logger.warning("Corrupt channel runtime config %s; skipping the import (file kept)", path)
return None
return data
def _remove_legacy_file(path: Path) -> bool:
"""Delete the imported plaintext file; ``False`` when a peer already did."""
try:
path.unlink()
except FileNotFoundError:
return False
except OSError:
logger.warning("Imported channel runtime config %s but could not delete it; it still holds plaintext credentials, remove it by hand", path, exc_info=True)
return False
return True
def default_runtime_config_path() -> Path:
"""``{base_dir}/channels/runtime-config.json`` (resolves ``base_dir`` through realpath: call off the loop)."""
from deerflow.config.paths import get_paths
return Path(get_paths().base_dir) / "channels" / "runtime-config.json"
def resolve_runtime_config_store(app_config: Any, *, cipher: CredentialsCipher | None, path: str | Path | None = None) -> RuntimeConfigStore:
"""Pick the store for ``database.backend``: the JSON file for ``memory`` (or no config), the table for a database.
The table needs the persistence engine the Gateway lifespan initialises
(its Alembic bootstrap creates the table); without it a configured
database falls back to the JSON file with a warning, so a bare
``ChannelService`` keeps working. ``path`` is the JSON file (default
:func:`default_runtime_config_path`), also the table's import source.
Blocks (path resolution, the JSON file's first read): call off the loop.
"""
database = getattr(app_config, "database", None)
backend = getattr(database, "backend", None)
store_path = Path(path) if path is not None else default_runtime_config_path()
if backend in ("sqlite", "postgres"):
from deerflow.persistence.engine import get_session_factory
if get_session_factory() is not None:
from deerflow.persistence.agents.sql import get_sync_sessionmaker
from deerflow.persistence.channel_runtime_configs import SqlChannelRuntimeConfigRepository
repo = SqlChannelRuntimeConfigRepository(get_sync_sessionmaker(database.app_sync_sqlalchemy_url))
return SqlChannelRuntimeConfigStore(repo, cipher=cipher, legacy_path=store_path)
logger.warning("database.backend=%s but no persistence engine is initialised; IM channel runtime config falls back to %s for this process", backend, store_path)
return ChannelRuntimeConfigStore(store_path)
def _provider_enabled(channel_connections_config: Any, provider: str) -> bool:
provider_config = getattr(channel_connections_config, provider, None)
return bool(getattr(provider_config, "enabled", False))
def runtime_channel_disconnected(runtime_config: dict[str, Any]) -> bool:
return runtime_config.get(RUNTIME_CHANNEL_DISABLED_FLAG) is True and runtime_config.get("enabled") is False
def overlay_runtime_channel_configs(
channels_config: dict[str, Any],
channel_connections_config: Any,
runtime_configs: dict[str, dict[str, Any]],
*,
keep_disconnected: bool = False,
) -> None:
"""Overlay already-loaded runtime configs onto ``channels_config`` in place.
The runtime block wins key by key over the ``config.yaml`` block. A
disconnect removes the provider, or with ``keep_disconnected`` leaves the
tombstone in its place (a disabled block), so a single-provider reload can
tell "disconnected" from "absent".
"""
if channel_connections_config is None or not getattr(channel_connections_config, "enabled", False):
return
for provider, runtime_config in runtime_configs.items():
if not _provider_enabled(channel_connections_config, provider):
continue
if runtime_channel_disconnected(runtime_config):
if keep_disconnected:
channels_config[provider] = dict(runtime_config)
else:
channels_config.pop(provider, None)
continue
existing = channels_config.get(provider)
merged = dict(existing) if isinstance(existing, dict) else {}
merged.update(runtime_config)
channels_config[provider] = merged
def merge_runtime_channel_configs(
channels_config: dict[str, Any],
channel_connections_config: Any,
*,
store: RuntimeConfigStore,
) -> None:
"""Merge persisted runtime provider config into ``channels_config`` in-place (blocks: reads ``store``)."""
if channel_connections_config is None or not getattr(channel_connections_config, "enabled", False):
return
overlay_runtime_channel_configs(channels_config, channel_connections_config, store.load_all())
def apply_runtime_connection_config(
channel_connections_config: Any,
*,
store: RuntimeConfigStore,
) -> Any:
"""Apply persisted connection metadata that lives outside ``channels``.
Telegram uses a bot username for deep links; UI-entered values are stored
with the runtime channel config so local restarts keep the provider
configured. Blocks: reads ``store``.
"""
if channel_connections_config is None or not getattr(channel_connections_config, "enabled", False):
return channel_connections_config
telegram_runtime_config = store.get_provider_config("telegram")
bot_username = ""
if isinstance(telegram_runtime_config, dict):
bot_username = str(telegram_runtime_config.get("bot_username") or "").strip()
if not bot_username or not _provider_enabled(channel_connections_config, "telegram"):
return channel_connections_config
config = channel_connections_config.model_copy(deep=True)
config.telegram.bot_username = bot_username
return config