failover.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. # -*- coding: utf-8 -*-
  2. """
  3. Failover-Manager für automatisches Satellite-Failover.
  4. Überwacht Satellites und führt automatisches Failover
  5. zu Backup-Satellites durch.
  6. """
  7. import asyncio
  8. from dataclasses import dataclass, field
  9. from datetime import datetime
  10. from enum import IntEnum, auto
  11. from typing import Any, Callable, TYPE_CHECKING
  12. if TYPE_CHECKING:
  13. from trixy_core.satellite.satellite import Satellite
  14. from trixy_core.satellite.failover.backup import BackupRegistry
  15. class FailoverState(IntEnum):
  16. """Zustand des Failover-Prozesses."""
  17. IDLE = auto() # Kein Failover aktiv
  18. DETECTING = auto() # Ausfall wird erkannt
  19. FAILING_OVER = auto() # Failover in Bearbeitung
  20. ACTIVE = auto() # Failover aktiv
  21. RECOVERING = auto() # Wiederherstellung
  22. FAILED = auto() # Failover fehlgeschlagen
  23. @dataclass
  24. class FailoverConfig:
  25. """
  26. Konfiguration für Failover.
  27. Attributes:
  28. detection_timeout: Zeit ohne Heartbeat bis Ausfall erkannt wird
  29. failover_timeout: Maximale Zeit für Failover-Prozess
  30. recovery_timeout: Zeit bis Wiederherstellung versucht wird
  31. max_failover_attempts: Maximale Failover-Versuche
  32. auto_recovery: Automatische Wiederherstellung wenn Original zurück
  33. health_threshold: Health-Score unter dem Failover ausgelöst wird
  34. """
  35. detection_timeout: float = 10.0
  36. failover_timeout: float = 30.0
  37. recovery_timeout: float = 60.0
  38. max_failover_attempts: int = 3
  39. auto_recovery: bool = True
  40. health_threshold: float = 40.0
  41. @dataclass
  42. class FailoverEvent:
  43. """
  44. Ein Failover-Ereignis.
  45. Attributes:
  46. primary_id: ID des ausgefallenen Satellites
  47. backup_id: ID des Backup-Satellites
  48. state: Aktueller Zustand
  49. reason: Grund für Failover
  50. started_at: Startzeitpunkt
  51. completed_at: Abschlusszeitpunkt
  52. attempts: Anzahl Versuche
  53. """
  54. primary_id: str
  55. backup_id: str | None
  56. state: FailoverState
  57. reason: str = ""
  58. started_at: datetime = field(default_factory=datetime.now)
  59. completed_at: datetime | None = None
  60. attempts: int = 0
  61. @property
  62. def duration(self) -> float | None:
  63. """Dauer des Failovers in Sekunden."""
  64. if self.completed_at is None:
  65. return None
  66. return (self.completed_at - self.started_at).total_seconds()
  67. def to_dict(self) -> dict[str, Any]:
  68. """Konvertiert zu Dictionary."""
  69. return {
  70. "primary_id": self.primary_id,
  71. "backup_id": self.backup_id,
  72. "state": self.state.name,
  73. "reason": self.reason,
  74. "started_at": self.started_at.isoformat(),
  75. "completed_at": self.completed_at.isoformat() if self.completed_at else None,
  76. "duration": self.duration,
  77. "attempts": self.attempts,
  78. }
  79. class FailoverManager:
  80. """
  81. Verwaltet Failover-Prozesse für Satellites.
  82. Überwacht die Gesundheit von Satellites und führt
  83. automatisch Failover zu Backup-Satellites durch.
  84. Example:
  85. manager = FailoverManager(
  86. backup_registry=backup_registry,
  87. config=FailoverConfig(
  88. detection_timeout=10.0,
  89. auto_recovery=True
  90. )
  91. )
  92. # Callback für Failover-Events
  93. @manager.on_failover
  94. async def handle_failover(event: FailoverEvent):
  95. print(f"Failover: {event.primary_id} -> {event.backup_id}")
  96. # Überwachung starten
  97. await manager.start_monitoring()
  98. # Manuelles Failover auslösen
  99. event = await manager.trigger_failover(
  100. primary_id="satellite_1",
  101. reason="Manuelle Auslösung"
  102. )
  103. """
  104. def __init__(
  105. self,
  106. backup_registry: "BackupRegistry",
  107. config: FailoverConfig | None = None,
  108. get_satellite: Callable[[str], "Satellite | None"] | None = None,
  109. ) -> None:
  110. """
  111. Initialisiert den Failover-Manager.
  112. Args:
  113. backup_registry: Registry für Backup-Zuweisungen
  114. config: Failover-Konfiguration
  115. get_satellite: Funktion zum Abrufen eines Satellites nach ID
  116. """
  117. self._registry = backup_registry
  118. self._config = config or FailoverConfig()
  119. self._get_satellite = get_satellite
  120. self._active_failovers: dict[str, FailoverEvent] = {}
  121. self._history: list[FailoverEvent] = []
  122. self._callbacks: list[Callable[[FailoverEvent], Any]] = []
  123. self._monitoring_task: asyncio.Task | None = None
  124. self._monitored_satellites: set[str] = set()
  125. @property
  126. def config(self) -> FailoverConfig:
  127. """Failover-Konfiguration."""
  128. return self._config
  129. @property
  130. def active_failovers(self) -> dict[str, FailoverEvent]:
  131. """Aktive Failover-Events."""
  132. return self._active_failovers.copy()
  133. def on_failover(
  134. self,
  135. callback: Callable[[FailoverEvent], Any]
  136. ) -> Callable[[FailoverEvent], Any]:
  137. """
  138. Registriert einen Callback für Failover-Events.
  139. Kann als Decorator verwendet werden.
  140. Args:
  141. callback: Die aufzurufende Funktion
  142. Returns:
  143. Der registrierte Callback
  144. """
  145. self._callbacks.append(callback)
  146. return callback
  147. def add_monitored_satellite(self, satellite_id: str) -> None:
  148. """Fügt einen Satellite zur Überwachung hinzu."""
  149. self._monitored_satellites.add(satellite_id)
  150. def remove_monitored_satellite(self, satellite_id: str) -> None:
  151. """Entfernt einen Satellite aus der Überwachung."""
  152. self._monitored_satellites.discard(satellite_id)
  153. async def trigger_failover(
  154. self,
  155. primary_id: str,
  156. reason: str = "Manual trigger",
  157. preferred_backup: str | None = None,
  158. ) -> FailoverEvent | None:
  159. """
  160. Löst manuell ein Failover aus.
  161. Args:
  162. primary_id: ID des Primär-Satellites
  163. reason: Grund für Failover
  164. preferred_backup: Optionale bevorzugte Backup-ID
  165. Returns:
  166. FailoverEvent oder None wenn kein Backup verfügbar
  167. """
  168. # Prüfe ob bereits aktiv
  169. if primary_id in self._active_failovers:
  170. return self._active_failovers[primary_id]
  171. # Backup finden
  172. backup_id = preferred_backup
  173. if backup_id is None:
  174. backup = self._registry.get_best_backup(primary_id)
  175. backup_id = backup.backup_id if backup else None
  176. if backup_id is None:
  177. # Kein Backup verfügbar
  178. event = FailoverEvent(
  179. primary_id=primary_id,
  180. backup_id=None,
  181. state=FailoverState.FAILED,
  182. reason=f"{reason} - Kein Backup verfügbar",
  183. )
  184. self._history.append(event)
  185. await self._notify(event)
  186. return event
  187. # Failover-Event erstellen
  188. event = FailoverEvent(
  189. primary_id=primary_id,
  190. backup_id=backup_id,
  191. state=FailoverState.FAILING_OVER,
  192. reason=reason,
  193. )
  194. self._active_failovers[primary_id] = event
  195. try:
  196. # Failover durchführen
  197. success = await self._execute_failover(event)
  198. if success:
  199. event.state = FailoverState.ACTIVE
  200. event.completed_at = datetime.now()
  201. else:
  202. event.state = FailoverState.FAILED
  203. event.completed_at = datetime.now()
  204. except Exception as e:
  205. event.state = FailoverState.FAILED
  206. event.reason = f"{event.reason} - Fehler: {str(e)}"
  207. event.completed_at = datetime.now()
  208. self._history.append(event)
  209. await self._notify(event)
  210. return event
  211. async def _execute_failover(self, event: FailoverEvent) -> bool:
  212. """
  213. Führt das eigentliche Failover durch.
  214. Args:
  215. event: Das Failover-Event
  216. Returns:
  217. True wenn erfolgreich
  218. """
  219. if self._get_satellite is None:
  220. return False
  221. backup = self._get_satellite(event.backup_id)
  222. if backup is None or not backup.is_connected:
  223. return False
  224. # Hier würde die eigentliche Failover-Logik implementiert:
  225. # - Aktive Konversation übertragen
  226. # - Audio-Streams umleiten
  227. # - Clients benachrichtigen
  228. # etc.
  229. event.attempts += 1
  230. # Für jetzt: Erfolg wenn Backup verbunden
  231. return True
  232. async def recover(self, primary_id: str) -> bool:
  233. """
  234. Versucht, zum Primär-Satellite zurückzukehren.
  235. Args:
  236. primary_id: ID des Primär-Satellites
  237. Returns:
  238. True wenn erfolgreich
  239. """
  240. if primary_id not in self._active_failovers:
  241. return False
  242. event = self._active_failovers[primary_id]
  243. if event.state != FailoverState.ACTIVE:
  244. return False
  245. # Prüfe ob Primary wieder verfügbar
  246. if self._get_satellite:
  247. primary = self._get_satellite(primary_id)
  248. if primary is None or not primary.is_connected:
  249. return False
  250. # Recovery durchführen
  251. event.state = FailoverState.RECOVERING
  252. try:
  253. # Recovery-Logik hier
  254. # - Konversation zurück übertragen
  255. # - Streams umleiten
  256. # etc.
  257. # Erfolgreich
  258. del self._active_failovers[primary_id]
  259. recovery_event = FailoverEvent(
  260. primary_id=primary_id,
  261. backup_id=event.backup_id,
  262. state=FailoverState.IDLE,
  263. reason="Recovery erfolgreich",
  264. )
  265. recovery_event.completed_at = datetime.now()
  266. self._history.append(recovery_event)
  267. await self._notify(recovery_event)
  268. return True
  269. except Exception:
  270. event.state = FailoverState.ACTIVE
  271. return False
  272. async def _notify(self, event: FailoverEvent) -> None:
  273. """Benachrichtigt alle Callbacks."""
  274. for callback in self._callbacks:
  275. try:
  276. result = callback(event)
  277. if asyncio.iscoroutine(result):
  278. await result
  279. except Exception:
  280. pass
  281. async def start_monitoring(self) -> None:
  282. """Startet die automatische Überwachung."""
  283. if self._monitoring_task is not None:
  284. return
  285. self._monitoring_task = asyncio.create_task(self._monitoring_loop())
  286. async def stop_monitoring(self) -> None:
  287. """Stoppt die automatische Überwachung."""
  288. if self._monitoring_task is not None:
  289. self._monitoring_task.cancel()
  290. try:
  291. await self._monitoring_task
  292. except asyncio.CancelledError:
  293. pass
  294. self._monitoring_task = None
  295. async def _monitoring_loop(self) -> None:
  296. """Überwachungs-Loop."""
  297. while True:
  298. try:
  299. await asyncio.sleep(1.0) # Prüfintervall
  300. for satellite_id in list(self._monitored_satellites):
  301. await self._check_satellite(satellite_id)
  302. # Auto-Recovery prüfen
  303. if self._config.auto_recovery:
  304. for primary_id in list(self._active_failovers.keys()):
  305. await self._check_recovery(primary_id)
  306. except asyncio.CancelledError:
  307. break
  308. except Exception:
  309. pass
  310. async def _check_satellite(self, satellite_id: str) -> None:
  311. """Prüft einen einzelnen Satellite."""
  312. if satellite_id in self._active_failovers:
  313. return
  314. if self._get_satellite is None:
  315. return
  316. satellite = self._get_satellite(satellite_id)
  317. if satellite is None:
  318. return
  319. # Prüfe Verbindungsstatus
  320. if not satellite.is_connected:
  321. await self.trigger_failover(
  322. satellite_id,
  323. reason="Verbindung verloren"
  324. )
  325. return
  326. # Prüfe Heartbeat
  327. if satellite.last_heartbeat:
  328. age = (datetime.now() - satellite.last_heartbeat).total_seconds()
  329. if age > self._config.detection_timeout:
  330. await self.trigger_failover(
  331. satellite_id,
  332. reason=f"Heartbeat-Timeout ({age:.1f}s)"
  333. )
  334. async def _check_recovery(self, primary_id: str) -> None:
  335. """Prüft, ob Recovery möglich ist."""
  336. event = self._active_failovers.get(primary_id)
  337. if event is None or event.state != FailoverState.ACTIVE:
  338. return
  339. if self._get_satellite is None:
  340. return
  341. primary = self._get_satellite(primary_id)
  342. if primary and primary.is_connected:
  343. # Prüfe ob stabil genug für Recovery
  344. if primary.last_heartbeat:
  345. age = (datetime.now() - primary.last_heartbeat).total_seconds()
  346. if age <= self._config.detection_timeout / 2:
  347. await self.recover(primary_id)
  348. def get_history(
  349. self,
  350. satellite_id: str | None = None,
  351. limit: int = 100
  352. ) -> list[FailoverEvent]:
  353. """
  354. Gibt die Failover-Historie zurück.
  355. Args:
  356. satellite_id: Optionaler Filter nach Satellite
  357. limit: Maximale Anzahl Einträge
  358. Returns:
  359. Liste der Failover-Events
  360. """
  361. history = self._history
  362. if satellite_id:
  363. history = [e for e in history if e.primary_id == satellite_id]
  364. return history[-limit:]
  365. def get_statistics(self) -> dict[str, Any]:
  366. """Gibt Statistiken zurück."""
  367. total = len(self._history)
  368. successful = sum(1 for e in self._history if e.state == FailoverState.ACTIVE)
  369. failed = sum(1 for e in self._history if e.state == FailoverState.FAILED)
  370. return {
  371. "total_failovers": total,
  372. "successful": successful,
  373. "failed": failed,
  374. "success_rate": successful / max(1, total),
  375. "active_failovers": len(self._active_failovers),
  376. "monitored_satellites": len(self._monitored_satellites),
  377. }