425 lines
15 KiB
Python
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,
|
|
)
|