correlation.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. # -*- coding: utf-8 -*-
  2. """
  3. Request-Response Korrelation.
  4. Verwaltet die Zuordnung von Anfragen zu Antworten
  5. über eindeutige Korrelations-IDs.
  6. """
  7. from __future__ import annotations
  8. import asyncio
  9. import logging
  10. import uuid
  11. from dataclasses import dataclass, field
  12. from datetime import datetime, timedelta
  13. from enum import Enum, auto
  14. from typing import Any, Awaitable, Callable, Generic, TypeVar
  15. T = TypeVar("T")
  16. class RequestState(Enum):
  17. """Zustand einer ausstehenden Anfrage."""
  18. PENDING = auto() # Wartet auf Antwort
  19. COMPLETED = auto() # Antwort erhalten
  20. TIMEOUT = auto() # Timeout
  21. CANCELLED = auto() # Abgebrochen
  22. ERROR = auto() # Fehler
  23. @dataclass
  24. class CorrelationConfig:
  25. """Konfiguration für Korrelations-Manager."""
  26. default_timeout: float = 30.0 # Standard-Timeout in Sekunden
  27. cleanup_interval: float = 60.0 # Interval für Cleanup alter Requests
  28. max_pending: int = 10000 # Maximale Anzahl ausstehender Requests
  29. id_generator: Callable[[], str] | None = None # Custom ID-Generator
  30. @dataclass
  31. class PendingRequest(Generic[T]):
  32. """
  33. Repräsentiert eine ausstehende Anfrage.
  34. Enthält alle Informationen über den Request und
  35. ermöglicht das Warten auf die Antwort.
  36. """
  37. correlation_id: str
  38. created_at: datetime = field(default_factory=datetime.now)
  39. timeout: float = 30.0
  40. state: RequestState = RequestState.PENDING
  41. result: T | None = None
  42. error: Exception | None = None
  43. metadata: dict[str, Any] = field(default_factory=dict)
  44. _future: asyncio.Future | None = field(default=None, repr=False)
  45. @property
  46. def is_complete(self) -> bool:
  47. """Prüft ob Request abgeschlossen ist."""
  48. return self.state in (
  49. RequestState.COMPLETED,
  50. RequestState.TIMEOUT,
  51. RequestState.CANCELLED,
  52. RequestState.ERROR,
  53. )
  54. @property
  55. def elapsed(self) -> timedelta:
  56. """Vergangene Zeit seit Erstellung."""
  57. return datetime.now() - self.created_at
  58. @property
  59. def elapsed_seconds(self) -> float:
  60. """Vergangene Zeit in Sekunden."""
  61. return self.elapsed.total_seconds()
  62. def is_expired(self) -> bool:
  63. """Prüft ob Request abgelaufen ist."""
  64. return self.elapsed_seconds > self.timeout
  65. class CorrelationManager(Generic[T]):
  66. """
  67. Manager für Request-Response Korrelation.
  68. Verwaltet ausstehende Anfragen und ordnet
  69. eingehende Antworten den richtigen Requests zu.
  70. Beispiel:
  71. manager = CorrelationManager[bytes]()
  72. # Request erstellen
  73. correlation_id = await manager.create_request()
  74. # Nachricht senden mit correlation_id
  75. await send_message(data, correlation_id)
  76. # Auf Antwort warten
  77. try:
  78. response = await manager.wait_for_response(correlation_id)
  79. except TimeoutError:
  80. print("Timeout")
  81. # Antwort empfangen (von anderem Task)
  82. manager.complete(correlation_id, response_data)
  83. """
  84. def __init__(
  85. self,
  86. config: CorrelationConfig | None = None,
  87. logger: logging.Logger | None = None,
  88. ) -> None:
  89. """
  90. Initialisiert den Korrelations-Manager.
  91. Args:
  92. config: Konfiguration
  93. logger: Logger-Instanz
  94. """
  95. self.config = config or CorrelationConfig()
  96. self.logger = logger or logging.getLogger(__name__)
  97. self._pending: dict[str, PendingRequest[T]] = {}
  98. self._lock = asyncio.Lock()
  99. self._cleanup_task: asyncio.Task | None = None
  100. # ID-Generator
  101. self._generate_id = (
  102. self.config.id_generator
  103. or self._default_id_generator
  104. )
  105. @staticmethod
  106. def _default_id_generator() -> str:
  107. """Standard-ID-Generator."""
  108. return str(uuid.uuid4())
  109. @property
  110. def pending_count(self) -> int:
  111. """Anzahl ausstehender Requests."""
  112. return len(self._pending)
  113. async def start_cleanup(self) -> None:
  114. """Startet den Cleanup-Task."""
  115. if self._cleanup_task is None:
  116. self._cleanup_task = asyncio.create_task(self._cleanup_loop())
  117. async def stop_cleanup(self) -> None:
  118. """Stoppt den Cleanup-Task."""
  119. if self._cleanup_task:
  120. self._cleanup_task.cancel()
  121. try:
  122. await self._cleanup_task
  123. except asyncio.CancelledError:
  124. pass
  125. self._cleanup_task = None
  126. async def _cleanup_loop(self) -> None:
  127. """Periodisches Cleanup abgelaufener Requests."""
  128. while True:
  129. try:
  130. await asyncio.sleep(self.config.cleanup_interval)
  131. await self.cleanup_expired()
  132. except asyncio.CancelledError:
  133. break
  134. except Exception as e:
  135. self.logger.error(f"Cleanup-Fehler: {e}")
  136. async def create_request(
  137. self,
  138. timeout: float | None = None,
  139. metadata: dict[str, Any] | None = None,
  140. ) -> str:
  141. """
  142. Erstellt einen neuen ausstehenden Request.
  143. Args:
  144. timeout: Timeout in Sekunden
  145. metadata: Zusätzliche Metadaten
  146. Returns:
  147. Korrelations-ID
  148. Raises:
  149. RuntimeError: Wenn Maximum erreicht
  150. """
  151. async with self._lock:
  152. if len(self._pending) >= self.config.max_pending:
  153. raise RuntimeError(
  154. f"Maximum ausstehende Requests erreicht: "
  155. f"{self.config.max_pending}"
  156. )
  157. correlation_id = self._generate_id()
  158. # Stelle sicher dass ID eindeutig ist
  159. while correlation_id in self._pending:
  160. correlation_id = self._generate_id()
  161. request = PendingRequest[T](
  162. correlation_id=correlation_id,
  163. timeout=timeout or self.config.default_timeout,
  164. metadata=metadata or {},
  165. _future=asyncio.get_event_loop().create_future(),
  166. )
  167. self._pending[correlation_id] = request
  168. return correlation_id
  169. async def wait_for_response(
  170. self,
  171. correlation_id: str,
  172. timeout: float | None = None,
  173. ) -> T:
  174. """
  175. Wartet auf Antwort für einen Request.
  176. Args:
  177. correlation_id: Korrelations-ID
  178. timeout: Optional: Override Timeout
  179. Returns:
  180. Response-Daten
  181. Raises:
  182. KeyError: Request nicht gefunden
  183. TimeoutError: Timeout erreicht
  184. Exception: Bei Fehler im Request
  185. """
  186. request = self._pending.get(correlation_id)
  187. if not request:
  188. raise KeyError(f"Request nicht gefunden: {correlation_id}")
  189. effective_timeout = timeout or request.timeout
  190. try:
  191. result = await asyncio.wait_for(
  192. request._future,
  193. timeout=effective_timeout,
  194. )
  195. return result
  196. except asyncio.TimeoutError:
  197. async with self._lock:
  198. if correlation_id in self._pending:
  199. request = self._pending[correlation_id]
  200. request.state = RequestState.TIMEOUT
  201. if request._future and not request._future.done():
  202. request._future.cancel()
  203. raise TimeoutError(
  204. f"Timeout für Request {correlation_id} "
  205. f"nach {effective_timeout}s"
  206. )
  207. def complete(
  208. self,
  209. correlation_id: str,
  210. result: T,
  211. ) -> bool:
  212. """
  213. Markiert Request als abgeschlossen mit Ergebnis.
  214. Args:
  215. correlation_id: Korrelations-ID
  216. result: Ergebnis/Antwort
  217. Returns:
  218. True wenn Request gefunden und abgeschlossen
  219. """
  220. request = self._pending.get(correlation_id)
  221. if not request:
  222. self.logger.warning(
  223. f"Unbekannte Korrelations-ID: {correlation_id}"
  224. )
  225. return False
  226. if request.is_complete:
  227. self.logger.warning(
  228. f"Request bereits abgeschlossen: {correlation_id}"
  229. )
  230. return False
  231. request.state = RequestState.COMPLETED
  232. request.result = result
  233. if request._future and not request._future.done():
  234. request._future.set_result(result)
  235. return True
  236. def fail(
  237. self,
  238. correlation_id: str,
  239. error: Exception,
  240. ) -> bool:
  241. """
  242. Markiert Request als fehlgeschlagen.
  243. Args:
  244. correlation_id: Korrelations-ID
  245. error: Fehler
  246. Returns:
  247. True wenn Request gefunden
  248. """
  249. request = self._pending.get(correlation_id)
  250. if not request:
  251. return False
  252. if request.is_complete:
  253. return False
  254. request.state = RequestState.ERROR
  255. request.error = error
  256. if request._future and not request._future.done():
  257. request._future.set_exception(error)
  258. return True
  259. def cancel(self, correlation_id: str) -> bool:
  260. """
  261. Bricht einen Request ab.
  262. Args:
  263. correlation_id: Korrelations-ID
  264. Returns:
  265. True wenn abgebrochen
  266. """
  267. request = self._pending.get(correlation_id)
  268. if not request:
  269. return False
  270. if request.is_complete:
  271. return False
  272. request.state = RequestState.CANCELLED
  273. if request._future and not request._future.done():
  274. request._future.cancel()
  275. return True
  276. async def remove(self, correlation_id: str) -> PendingRequest[T] | None:
  277. """
  278. Entfernt einen Request.
  279. Args:
  280. correlation_id: Korrelations-ID
  281. Returns:
  282. Entfernter Request oder None
  283. """
  284. async with self._lock:
  285. return self._pending.pop(correlation_id, None)
  286. def get(self, correlation_id: str) -> PendingRequest[T] | None:
  287. """Holt Request nach ID."""
  288. return self._pending.get(correlation_id)
  289. def has_pending(self, correlation_id: str) -> bool:
  290. """Prüft ob Request existiert."""
  291. return correlation_id in self._pending
  292. async def cleanup_expired(self) -> int:
  293. """
  294. Entfernt abgelaufene Requests.
  295. Returns:
  296. Anzahl entfernter Requests
  297. """
  298. to_remove = []
  299. async with self._lock:
  300. for cid, request in self._pending.items():
  301. if request.is_expired():
  302. to_remove.append(cid)
  303. if request._future and not request._future.done():
  304. request._future.cancel()
  305. for cid in to_remove:
  306. self._pending.pop(cid, None)
  307. if to_remove:
  308. self.logger.debug(f"Cleanup: {len(to_remove)} abgelaufene Requests entfernt")
  309. return len(to_remove)
  310. async def cancel_all(self) -> int:
  311. """
  312. Bricht alle ausstehenden Requests ab.
  313. Returns:
  314. Anzahl abgebrochener Requests
  315. """
  316. count = 0
  317. async with self._lock:
  318. for cid, request in self._pending.items():
  319. if not request.is_complete:
  320. request.state = RequestState.CANCELLED
  321. if request._future and not request._future.done():
  322. request._future.cancel()
  323. count += 1
  324. return count
  325. def get_pending_ids(self) -> list[str]:
  326. """Listet alle ausstehenden Request-IDs."""
  327. return [
  328. cid for cid, req in self._pending.items()
  329. if req.state == RequestState.PENDING
  330. ]
  331. def get_stats(self) -> dict[str, Any]:
  332. """Liefert Statistiken."""
  333. states = {}
  334. for request in self._pending.values():
  335. state_name = request.state.name
  336. states[state_name] = states.get(state_name, 0) + 1
  337. return {
  338. "total_pending": self.pending_count,
  339. "states": states,
  340. "oldest_request_age": max(
  341. (r.elapsed_seconds for r in self._pending.values()),
  342. default=0.0,
  343. ),
  344. }
  345. def __contains__(self, correlation_id: str) -> bool:
  346. return correlation_id in self._pending
  347. def __len__(self) -> int:
  348. return self.pending_count