ForcePilot/backend/package/yuxi/channel/config/reloader.py
Kris 4962d1f6f8 feat(channel/config): 新增完整的配置模块实现
新增了channel目录下的配置相关模块,包含默认配置、配置迁移、配置差异计算、文件加载器、重载计划、重载器以及配置校验功能,完善了配置管理体系
2026-05-21 10:24:03 +08:00

425 lines
15 KiB
Python

import asyncio
import inspect
import logging
from typing import TYPE_CHECKING
from .diff import ConfigDiff, compute_diff, diff_config_paths
from .snapshot import ConfigSnapshot, RevisionCounter
from .reload_plan import (
ReloadMode,
build_gateway_reload_plan,
)
if TYPE_CHECKING:
from yuxi.channel.events.bus import ChannelEventBus
logger = logging.getLogger(__name__)
MISSING_CONFIG_RETRY_DELAY = 0.15
MISSING_CONFIG_MAX_RETRIES = 2
async def _maybe_await(result):
if inspect.isawaitable(result):
return await result
return result
class ConfigWriteNotification:
def __init__(
self,
event_type: str,
config_id: str | None = None,
diff: ConfigDiff | None = None,
after_write_mode: str = "auto",
after_write_reason: str = "",
persisted_hash: str | None = None,
):
self.event_type = event_type
self.config_id = config_id
self.diff = diff
self.revision = RevisionCounter.next()
self.after_write_mode = after_write_mode
self.after_write_reason = after_write_reason
self.persisted_hash = persisted_hash
def __repr__(self) -> str:
return f"ConfigWriteNotification(event={self.event_type}, config_id={self.config_id}, revision={self.revision})"
class ConfigReloader:
def __init__(
self,
poll_interval_seconds: float = 5.0,
debounce_seconds: float = 0.3,
reload_mode: ReloadMode = ReloadMode.HYBRID,
event_bus: "ChannelEventBus | None" = None,
):
self._poll_interval = poll_interval_seconds
self._debounce = debounce_seconds
self._reload_mode = reload_mode
self._event_bus = event_bus
self._prev_snapshot: ConfigSnapshot | None = None
self._prev_source_config: dict | None = None
self._running = False
self._task: asyncio.Task | None = None
self._on_diff: list = []
self._on_reload_plan: list = []
self._write_subscribers: list = []
self._debounce_timer: asyncio.Task | None = None
self._pending = False
self._reloading = False
self._missing_retries = 0
self._pending_in_process: ConfigWriteNotification | None = None
self._last_applied_write_hash: str | None = None
self._get_current_config = None
self._lock = asyncio.Lock()
def on_diff(self, callback):
self._on_diff.append(callback)
return callback
def on_reload_plan(self, callback):
self._on_reload_plan.append(callback)
return callback
def subscribe_to_writes(self, callback):
self._write_subscribers.append(callback)
return callback
async def _publish_event(self, topic: str, *args, **kwargs) -> None:
if self._event_bus is None:
return
try:
await self._event_bus.publish(topic, *args, **kwargs)
except Exception:
logger.exception("EventBus publish failed: topic=%s", topic)
def _notify_write_subscribers(self, notification: ConfigWriteNotification) -> None:
for subscriber in self._write_subscribers:
try:
subscriber(notification)
except Exception:
logger.exception("ConfigReloader: write subscriber failed")
def set_reload_mode(self, mode: ReloadMode) -> None:
self._reload_mode = mode
async def notify_write(
self,
event_type: str,
config_id: str | None = None,
after_write_mode: str = "auto",
after_write_reason: str = "",
persisted_hash: str | None = None,
) -> None:
if persisted_hash and persisted_hash == self._last_applied_write_hash:
logger.debug(
"ConfigReloader: skipping duplicate write notification (hash=%s)",
persisted_hash,
)
return
notification = ConfigWriteNotification(
event_type=event_type,
config_id=config_id,
after_write_mode=after_write_mode,
after_write_reason=after_write_reason,
persisted_hash=persisted_hash,
)
self._pending_in_process = notification
self._last_applied_write_hash = persisted_hash
await self._schedule_after(0)
async def start(self, get_current_config):
self._running = True
snapshot = await get_current_config()
if snapshot is None:
raise RuntimeError("ConfigReloader.start: get_current_config() returned None")
self._get_current_config = get_current_config
self._prev_snapshot = snapshot
self._prev_source_config = _extract_source_config(snapshot)
self._task = asyncio.create_task(self._poll_loop(get_current_config))
logger.info(
"ConfigReloader started (poll_interval=%.1fs, debounce=%.1fs, mode=%s)",
self._poll_interval,
self._debounce,
self._reload_mode.value,
)
async def stop(self):
self._running = False
if self._debounce_timer and not self._debounce_timer.done():
self._debounce_timer.cancel()
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
logger.info("ConfigReloader stopped")
async def _schedule(self) -> None:
await self._schedule_after(self._debounce)
async def _schedule_after(self, wait: float) -> None:
if not self._running:
return
async with self._lock:
if self._debounce_timer and not self._debounce_timer.done():
self._debounce_timer.cancel()
self._debounce_timer = asyncio.create_task(self._delayed_reload(wait))
async def _delayed_reload(self, wait: float) -> None:
await asyncio.sleep(wait)
await self._run_reload()
async def _poll_loop(self, get_current_config):
while self._running:
await asyncio.sleep(self._poll_interval)
try:
next_snapshot = await get_current_config()
except Exception:
logger.exception("ConfigReloader: failed to fetch config")
continue
if next_snapshot is None:
logger.warning("ConfigReloader: get_current_config() returned None, skipping poll cycle")
continue
if next_snapshot.version == self._prev_snapshot.version:
continue
next_snapshot.revision = RevisionCounter.next()
prev_accounts = self._prev_snapshot.accounts or []
next_accounts = next_snapshot.accounts or []
diff = compute_diff(prev_accounts, next_accounts)
routes_changed = next_snapshot.route_bindings_hash != self._prev_snapshot.route_bindings_hash
diff.routes_changed = routes_changed
prev_source = self._prev_source_config or {}
next_source = _extract_source_config(next_snapshot)
config_paths = diff_config_paths(prev_source, next_source)
diff.changed_paths = config_paths
if diff.has_changes:
logger.info("ConfigReloader: detected changes %s", diff.summary)
reload_mode = _resolve_reload_mode(next_source, self._reload_mode)
reload_plan = build_gateway_reload_plan(config_paths, mode=reload_mode)
if not reload_plan.is_noop:
logger.info("ConfigReloader: reload plan %s", reload_plan.summary)
for cb in self._on_reload_plan:
try:
await _maybe_await(cb(reload_plan))
except Exception:
logger.exception("ConfigReloader: on_reload_plan callback failed")
await self._publish_event("config.changed", diff=diff, reload_plan=reload_plan)
notification = ConfigWriteNotification(
event_type="config_changed",
diff=diff,
)
self._notify_write_subscribers(notification)
for cb in self._on_diff:
try:
await _maybe_await(cb(diff))
except Exception:
logger.exception("ConfigReloader: on_diff callback failed")
self._prev_snapshot = next_snapshot
self._prev_source_config = next_source
async def _run_reload(self) -> None:
if not self._running:
return
async with self._lock:
if self._reloading:
self._pending = True
return
self._reloading = True
if self._debounce_timer and not self._debounce_timer.done():
self._debounce_timer.cancel()
self._debounce_timer = None
try:
if self._pending_in_process:
notification = self._pending_in_process
self._pending_in_process = None
diff = await self._compute_write_diff()
if diff is not None and diff.has_changes:
notification.diff = diff
await self._apply_diff(diff)
if self._prev_snapshot:
self._prev_source_config = _extract_source_config(self._prev_snapshot)
self._missing_retries = 0
else:
await self._poll_once()
except Exception:
logger.exception("ConfigReloader: reload failed")
finally:
async with self._lock:
self._reloading = False
pending = self._pending
self._pending = False
if pending:
await self._schedule()
async def _compute_write_diff(self) -> ConfigDiff | None:
if self._get_current_config is None or self._prev_snapshot is None:
return None
try:
next_snapshot = await self._get_current_config()
except Exception:
logger.exception("ConfigReloader: failed to fetch config in _compute_write_diff")
return None
if next_snapshot is None:
for attempt in range(1, MISSING_CONFIG_MAX_RETRIES + 1):
self._missing_retries += 1
logger.warning(
"ConfigReloader: get_current_config() returned None (retry %d/%d), will retry after %.1fs",
attempt,
MISSING_CONFIG_MAX_RETRIES,
MISSING_CONFIG_RETRY_DELAY,
)
await asyncio.sleep(MISSING_CONFIG_RETRY_DELAY)
try:
next_snapshot = await self._get_current_config()
except Exception:
logger.exception("ConfigReloader: retry fetch config failed")
return None
if next_snapshot is not None:
break
else:
logger.warning(
"ConfigReloader: get_current_config() returned None after %d retries, giving up",
MISSING_CONFIG_MAX_RETRIES,
)
return None
if next_snapshot is None:
return None
next_snapshot.revision = RevisionCounter.next()
prev_accounts = self._prev_snapshot.accounts or []
next_accounts = next_snapshot.accounts or []
diff = compute_diff(prev_accounts, next_accounts)
routes_changed = next_snapshot.route_bindings_hash != self._prev_snapshot.route_bindings_hash
diff.routes_changed = routes_changed
prev_source = self._prev_source_config or {}
next_source = _extract_source_config(next_snapshot)
config_paths = diff_config_paths(prev_source, next_source)
diff.changed_paths = config_paths
if diff.has_changes:
logger.info("ConfigReloader: write-triggered diff %s", diff.summary)
self._prev_snapshot = next_snapshot
self._prev_source_config = next_source
return diff
async def _poll_once(self) -> None:
pass
async def _apply_diff(self, diff: ConfigDiff) -> None:
for cb in self._on_diff:
try:
await _maybe_await(cb(diff))
except Exception:
logger.exception("ConfigReloader: on_diff callback failed")
self._notify_write_subscribers(ConfigWriteNotification(event_type="config_reloaded", diff=diff))
await self._publish_event("config.reloaded", diff=diff)
def _extract_source_config(snapshot: ConfigSnapshot) -> dict:
try:
return getattr(snapshot, "source_config", None) or {}
except Exception:
return {}
def _resolve_reload_mode(source_config: dict, fallback: ReloadMode) -> ReloadMode:
try:
gateway = source_config.get("gateway", {})
reload_cfg = gateway.get("reload", {}) if isinstance(gateway, dict) else {}
raw_mode = reload_cfg.get("mode")
if raw_mode in ("off", "restart", "hot", "hybrid"):
return ReloadMode(raw_mode)
except Exception:
pass
return fallback
async def reload_channel_config(
channel_type: str,
account_id: str,
config_update: dict,
) -> None:
logger.info(
"reload_channel_config: %s:%s with keys=%s",
channel_type,
account_id,
list(config_update.keys()),
)
try:
from yuxi.channel.runtime.manager import gateway
from yuxi.channel.protocols import ConfigProtocol
from yuxi.channel.plugins.registry import ChannelPluginRegistry
plugin = ChannelPluginRegistry.get(channel_type)
if plugin is None:
logger.warning("reload_channel_config: plugin not found for %s", channel_type)
return
if isinstance(plugin, ConfigProtocol):
try:
account = await plugin.resolve_account(account_id)
if not plugin.is_configured(account):
logger.warning(
"reload_channel_config: %s:%s not configured, skipping restart",
channel_type,
account_id,
)
return
except Exception:
logger.exception(
"reload_channel_config: resolve_account failed for %s:%s",
channel_type,
account_id,
)
return
snapshot = gateway.get_snapshot(channel_type, account_id)
if snapshot is None or snapshot.state.value == "stopped":
logger.info(
"reload_channel_config: %s:%s is not running, skipping restart",
channel_type,
account_id,
)
return
await gateway.stop_channel(channel_type, account_id)
await gateway.start_channel(channel_type, account_id, gateway.global_config)
logger.info(
"reload_channel_config: %s:%s restarted with updated config",
channel_type,
account_id,
)
except Exception:
logger.exception(
"reload_channel_config: unexpected error for %s:%s",
channel_type,
account_id,
)