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, )