queue.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580
  1. # -*- coding: utf-8 -*-
  2. """
  3. Dead Letter Queue Implementation.
  4. Speichert und verwaltet fehlgeschlagene Events.
  5. """
  6. from __future__ import annotations
  7. import asyncio
  8. from dataclasses import dataclass, field
  9. from datetime import datetime, timedelta
  10. from enum import Enum, auto
  11. from typing import TYPE_CHECKING, Any, Callable, Coroutine
  12. import uuid
  13. if TYPE_CHECKING:
  14. from trixy_core.events.event_data.base import EventData
  15. class DeadLetterReason(Enum):
  16. """
  17. Grund für Ablage in DLQ.
  18. """
  19. HANDLER_ERROR = auto()
  20. """Fehler im Event-Handler."""
  21. TIMEOUT = auto()
  22. """Timeout bei Verarbeitung."""
  23. VALIDATION_FAILED = auto()
  24. """Validierung fehlgeschlagen."""
  25. MAX_RETRIES = auto()
  26. """Maximale Wiederholungen erreicht."""
  27. REJECTED = auto()
  28. """Explizit abgelehnt."""
  29. UNKNOWN = auto()
  30. """Unbekannter Grund."""
  31. @dataclass
  32. class DeadLetterEntry:
  33. """
  34. Eintrag in der Dead Letter Queue.
  35. """
  36. id: str
  37. """Eindeutige ID des Eintrags."""
  38. event_name: str
  39. """Name des Events."""
  40. event_data: dict[str, Any]
  41. """Serialisierte Event-Daten."""
  42. reason: DeadLetterReason
  43. """Grund für DLQ."""
  44. error_message: str
  45. """Fehlermeldung."""
  46. error_type: str = ""
  47. """Typ des Fehlers."""
  48. created_at: datetime = field(default_factory=datetime.now)
  49. """Zeitpunkt der Erstellung."""
  50. retry_count: int = 0
  51. """Anzahl der Wiederholungsversuche."""
  52. last_retry_at: datetime | None = None
  53. """Zeitpunkt des letzten Retry."""
  54. next_retry_at: datetime | None = None
  55. """Geplanter nächster Retry."""
  56. metadata: dict[str, Any] = field(default_factory=dict)
  57. """Zusätzliche Metadaten."""
  58. @classmethod
  59. def from_event(
  60. cls,
  61. event_name: str,
  62. event_data: "EventData",
  63. reason: DeadLetterReason,
  64. error: Exception | None = None,
  65. ) -> "DeadLetterEntry":
  66. """
  67. Erstellt einen Eintrag aus Event-Daten.
  68. Args:
  69. event_name: Name des Events.
  70. event_data: Die Event-Daten.
  71. reason: Grund für DLQ.
  72. error: Optionale Exception.
  73. Returns:
  74. DeadLetterEntry.
  75. """
  76. # Event-Daten serialisieren
  77. data = {}
  78. for key, value in event_data.__dict__.items():
  79. if not key.startswith("_"):
  80. if isinstance(value, datetime):
  81. data[key] = value.isoformat()
  82. elif hasattr(value, "__dict__"):
  83. data[key] = str(value)
  84. else:
  85. data[key] = value
  86. return cls(
  87. id=str(uuid.uuid4()),
  88. event_name=event_name,
  89. event_data=data,
  90. reason=reason,
  91. error_message=str(error) if error else "",
  92. error_type=type(error).__name__ if error else "",
  93. metadata={
  94. "original_event_id": event_data.event_id,
  95. "original_source": event_data.source,
  96. },
  97. )
  98. def to_dict(self) -> dict[str, Any]:
  99. """Konvertiert in ein Dictionary."""
  100. return {
  101. "id": self.id,
  102. "event_name": self.event_name,
  103. "event_data": self.event_data,
  104. "reason": self.reason.name,
  105. "error_message": self.error_message,
  106. "error_type": self.error_type,
  107. "created_at": self.created_at.isoformat(),
  108. "retry_count": self.retry_count,
  109. "last_retry_at": self.last_retry_at.isoformat() if self.last_retry_at else None,
  110. "next_retry_at": self.next_retry_at.isoformat() if self.next_retry_at else None,
  111. "metadata": self.metadata,
  112. }
  113. @dataclass
  114. class DLQConfig:
  115. """
  116. Konfiguration für die Dead Letter Queue.
  117. """
  118. max_size: int = 10000
  119. """Maximale Anzahl Einträge."""
  120. max_retries: int = 3
  121. """Maximale Wiederholungsversuche."""
  122. retry_delay_seconds: float = 60.0
  123. """Basis-Verzögerung zwischen Retries."""
  124. auto_retry: bool = True
  125. """Automatische Wiederholung aktivieren."""
  126. retention_hours: int = 168 # 1 Woche
  127. """Aufbewahrungsdauer in Stunden."""
  128. on_max_retries: Callable[[DeadLetterEntry], Coroutine[Any, Any, None]] | None = None
  129. """Callback bei erreichten max Retries."""
  130. @dataclass
  131. class DLQStats:
  132. """
  133. Statistiken der Dead Letter Queue.
  134. """
  135. total_entries: int = 0
  136. """Gesamtzahl Einträge."""
  137. pending_retries: int = 0
  138. """Wartende Retries."""
  139. max_retried: int = 0
  140. """Einträge mit max Retries erreicht."""
  141. by_reason: dict[str, int] = field(default_factory=dict)
  142. """Einträge nach Grund."""
  143. by_event: dict[str, int] = field(default_factory=dict)
  144. """Einträge nach Event-Typ."""
  145. oldest_entry: datetime | None = None
  146. """Ältester Eintrag."""
  147. newest_entry: datetime | None = None
  148. """Neuester Eintrag."""
  149. class DeadLetterQueue:
  150. """
  151. Dead Letter Queue für fehlgeschlagene Events.
  152. Speichert Events die nicht verarbeitet werden konnten
  153. und ermöglicht Wiederholungsversuche.
  154. Example:
  155. dlq = DeadLetterQueue(DLQConfig(
  156. max_retries=5,
  157. retry_delay_seconds=300,
  158. ))
  159. # Fehlerhafte Events hinzufügen
  160. try:
  161. await process_event(event_data)
  162. except Exception as e:
  163. await dlq.add("my_event", event_data, e)
  164. # Retry-Handler registrieren
  165. dlq.set_retry_handler(process_event)
  166. # Automatische Retries starten
  167. await dlq.start_retry_worker()
  168. """
  169. def __init__(
  170. self,
  171. config: DLQConfig | None = None,
  172. ) -> None:
  173. """
  174. Initialisiert die DLQ.
  175. Args:
  176. config: Konfiguration.
  177. """
  178. self._config = config or DLQConfig()
  179. self._entries: dict[str, DeadLetterEntry] = {}
  180. self._retry_handler: Callable[[str, dict[str, Any]], Coroutine[Any, Any, None]] | None = None
  181. self._worker_task: asyncio.Task | None = None
  182. self._lock = asyncio.Lock()
  183. @property
  184. def size(self) -> int:
  185. """Anzahl der Einträge."""
  186. return len(self._entries)
  187. def set_retry_handler(
  188. self,
  189. handler: Callable[[str, dict[str, Any]], Coroutine[Any, Any, None]],
  190. ) -> None:
  191. """
  192. Setzt den Handler für Retry-Versuche.
  193. Args:
  194. handler: Handler-Funktion (event_name, event_data).
  195. """
  196. self._retry_handler = handler
  197. async def add(
  198. self,
  199. event_name: str,
  200. event_data: "EventData",
  201. error: Exception | None = None,
  202. reason: DeadLetterReason = DeadLetterReason.HANDLER_ERROR,
  203. ) -> DeadLetterEntry:
  204. """
  205. Fügt ein Event zur DLQ hinzu.
  206. Args:
  207. event_name: Name des Events.
  208. event_data: Die Event-Daten.
  209. error: Optionale Exception.
  210. reason: Grund für DLQ.
  211. Returns:
  212. Der erstellte Eintrag.
  213. """
  214. async with self._lock:
  215. # Cleanup wenn voll
  216. if len(self._entries) >= self._config.max_size:
  217. await self._cleanup()
  218. entry = DeadLetterEntry.from_event(
  219. event_name=event_name,
  220. event_data=event_data,
  221. reason=reason,
  222. error=error,
  223. )
  224. # Nächsten Retry planen
  225. if self._config.auto_retry:
  226. entry.next_retry_at = datetime.now() + timedelta(
  227. seconds=self._config.retry_delay_seconds
  228. )
  229. self._entries[entry.id] = entry
  230. return entry
  231. async def add_entry(self, entry: DeadLetterEntry) -> None:
  232. """
  233. Fügt einen Eintrag direkt hinzu.
  234. Args:
  235. entry: Der Eintrag.
  236. """
  237. async with self._lock:
  238. self._entries[entry.id] = entry
  239. async def get(self, entry_id: str) -> DeadLetterEntry | None:
  240. """
  241. Gibt einen Eintrag zurück.
  242. Args:
  243. entry_id: Die Eintrags-ID.
  244. Returns:
  245. DeadLetterEntry oder None.
  246. """
  247. return self._entries.get(entry_id)
  248. async def remove(self, entry_id: str) -> bool:
  249. """
  250. Entfernt einen Eintrag.
  251. Args:
  252. entry_id: Die Eintrags-ID.
  253. Returns:
  254. True wenn entfernt.
  255. """
  256. async with self._lock:
  257. return self._entries.pop(entry_id, None) is not None
  258. async def get_pending_retries(self) -> list[DeadLetterEntry]:
  259. """
  260. Gibt Einträge zurück, die für Retry bereit sind.
  261. Returns:
  262. Liste der Einträge.
  263. """
  264. now = datetime.now()
  265. pending = []
  266. for entry in self._entries.values():
  267. if (
  268. entry.retry_count < self._config.max_retries
  269. and entry.next_retry_at
  270. and entry.next_retry_at <= now
  271. ):
  272. pending.append(entry)
  273. return pending
  274. async def retry(self, entry_id: str) -> bool:
  275. """
  276. Führt einen Retry für einen Eintrag durch.
  277. Args:
  278. entry_id: Die Eintrags-ID.
  279. Returns:
  280. True wenn erfolgreich.
  281. """
  282. if not self._retry_handler:
  283. raise RuntimeError("Kein Retry-Handler registriert")
  284. entry = await self.get(entry_id)
  285. if not entry:
  286. return False
  287. async with self._lock:
  288. entry.retry_count += 1
  289. entry.last_retry_at = datetime.now()
  290. try:
  291. await self._retry_handler(entry.event_name, entry.event_data)
  292. # Erfolg - Eintrag entfernen
  293. await self.remove(entry_id)
  294. return True
  295. except Exception as e:
  296. async with self._lock:
  297. entry.error_message = str(e)
  298. entry.error_type = type(e).__name__
  299. if entry.retry_count >= self._config.max_retries:
  300. entry.next_retry_at = None
  301. entry.reason = DeadLetterReason.MAX_RETRIES
  302. if self._config.on_max_retries:
  303. await self._config.on_max_retries(entry)
  304. else:
  305. # Nächsten Retry planen
  306. delay = self._config.retry_delay_seconds * (2 ** entry.retry_count)
  307. entry.next_retry_at = datetime.now() + timedelta(seconds=delay)
  308. return False
  309. async def retry_all_pending(self) -> tuple[int, int]:
  310. """
  311. Führt Retry für alle pending Einträge durch.
  312. Returns:
  313. Tupel (erfolgreich, fehlgeschlagen).
  314. """
  315. pending = await self.get_pending_retries()
  316. success = 0
  317. failed = 0
  318. for entry in pending:
  319. if await self.retry(entry.id):
  320. success += 1
  321. else:
  322. failed += 1
  323. return success, failed
  324. async def start_retry_worker(
  325. self,
  326. interval_seconds: float = 60.0,
  327. ) -> None:
  328. """
  329. Startet den Retry-Worker.
  330. Args:
  331. interval_seconds: Prüfintervall in Sekunden.
  332. """
  333. if self._worker_task and not self._worker_task.done():
  334. return
  335. async def worker():
  336. while True:
  337. try:
  338. await self.retry_all_pending()
  339. await self._cleanup_expired()
  340. except Exception:
  341. pass
  342. await asyncio.sleep(interval_seconds)
  343. self._worker_task = asyncio.create_task(worker())
  344. def stop_retry_worker(self) -> None:
  345. """Stoppt den Retry-Worker."""
  346. if self._worker_task:
  347. self._worker_task.cancel()
  348. self._worker_task = None
  349. async def _cleanup(self) -> int:
  350. """
  351. Entfernt älteste Einträge.
  352. Returns:
  353. Anzahl entfernter Einträge.
  354. """
  355. # Älteste 10% entfernen
  356. to_remove = int(self._config.max_size * 0.1)
  357. sorted_entries = sorted(
  358. self._entries.values(),
  359. key=lambda e: e.created_at,
  360. )
  361. removed = 0
  362. for entry in sorted_entries[:to_remove]:
  363. del self._entries[entry.id]
  364. removed += 1
  365. return removed
  366. async def _cleanup_expired(self) -> int:
  367. """
  368. Entfernt abgelaufene Einträge.
  369. Returns:
  370. Anzahl entfernter Einträge.
  371. """
  372. cutoff = datetime.now() - timedelta(hours=self._config.retention_hours)
  373. expired = [
  374. entry_id
  375. for entry_id, entry in self._entries.items()
  376. if entry.created_at < cutoff
  377. ]
  378. for entry_id in expired:
  379. del self._entries[entry_id]
  380. return len(expired)
  381. async def clear(self) -> int:
  382. """
  383. Löscht alle Einträge.
  384. Returns:
  385. Anzahl gelöschter Einträge.
  386. """
  387. async with self._lock:
  388. count = len(self._entries)
  389. self._entries.clear()
  390. return count
  391. def get_stats(self) -> DLQStats:
  392. """
  393. Gibt Statistiken zurück.
  394. Returns:
  395. DLQStats.
  396. """
  397. stats = DLQStats()
  398. stats.total_entries = len(self._entries)
  399. now = datetime.now()
  400. oldest: datetime | None = None
  401. newest: datetime | None = None
  402. for entry in self._entries.values():
  403. # Pending Retries
  404. if entry.next_retry_at and entry.next_retry_at <= now:
  405. stats.pending_retries += 1
  406. # Max Retried
  407. if entry.retry_count >= self._config.max_retries:
  408. stats.max_retried += 1
  409. # Nach Grund
  410. reason_name = entry.reason.name
  411. stats.by_reason[reason_name] = stats.by_reason.get(reason_name, 0) + 1
  412. # Nach Event
  413. stats.by_event[entry.event_name] = stats.by_event.get(entry.event_name, 0) + 1
  414. # Ältester/Neuester
  415. if oldest is None or entry.created_at < oldest:
  416. oldest = entry.created_at
  417. if newest is None or entry.created_at > newest:
  418. newest = entry.created_at
  419. stats.oldest_entry = oldest
  420. stats.newest_entry = newest
  421. return stats
  422. async def get_by_event(self, event_name: str) -> list[DeadLetterEntry]:
  423. """
  424. Gibt Einträge für ein Event zurück.
  425. Args:
  426. event_name: Name des Events.
  427. Returns:
  428. Liste der Einträge.
  429. """
  430. return [
  431. entry
  432. for entry in self._entries.values()
  433. if entry.event_name == event_name
  434. ]
  435. async def get_by_reason(self, reason: DeadLetterReason) -> list[DeadLetterEntry]:
  436. """
  437. Gibt Einträge nach Grund zurück.
  438. Args:
  439. reason: Der Grund.
  440. Returns:
  441. Liste der Einträge.
  442. """
  443. return [
  444. entry
  445. for entry in self._entries.values()
  446. if entry.reason == reason
  447. ]
  448. async def export_all(self) -> list[dict[str, Any]]:
  449. """
  450. Exportiert alle Einträge.
  451. Returns:
  452. Liste von Dictionaries.
  453. """
  454. return [entry.to_dict() for entry in self._entries.values()]