Coverage for agentos/core/event_bus.py: 0%
173 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 08:01 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 08:01 +0800
1"""AgentOS Event Bus — async pub/sub with backpressure control.
3Production-grade internal event system:
4- Topic-based publish/subscribe
5- Async handlers with configurable concurrency
6- Dead letter queue (DLQ) for failed events
7- Backpressure control with bounded queues
8- Event replay & audit
9- Wildcard subscriptions
11Design: ~350 lines, zero external deps beyond stdlib + asyncio.
12"""
14from __future__ import annotations
16import asyncio
17import logging
18import time
19from collections import defaultdict
20from dataclasses import dataclass, field
21from enum import Enum
22from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
23from uuid import uuid4
25logger = logging.getLogger(__name__)
28# ============================================================================
29# Core types
30# ============================================================================
32class EventPriority(int, Enum):
33 LOW = 0
34 NORMAL = 50
35 HIGH = 100
36 CRITICAL = 200
39@dataclass
40class Event:
41 """An event published on the bus."""
43 topic: str
44 payload: Any = None
45 event_id: str = field(default_factory=lambda: str(uuid4())[:12])
46 timestamp: float = field(default_factory=time.time)
47 priority: EventPriority = EventPriority.NORMAL
48 source: str = ""
49 correlation_id: str = ""
50 metadata: Dict[str, Any] = field(default_factory=dict)
53@dataclass
54class DeadLetter:
55 """Event that failed processing and was sent to DLQ."""
57 event: Event
58 handler_name: str
59 error: str
60 failed_at: float = field(default_factory=time.time)
61 retry_count: int = 0
64# ============================================================================
65# Subscription
66# ============================================================================
68@dataclass
69class Subscription:
70 """A handler subscribed to a topic pattern."""
72 topic_pattern: str
73 handler: Callable[[Event], Awaitable[Any]]
74 handler_name: str
75 concurrency: int = 1 # Max concurrent executions
76 is_pattern: bool = False # True if topic_pattern contains wildcards
78 def matches(self, topic: str) -> bool:
79 if not self.is_pattern:
80 return self.topic_pattern == topic
81 # Support * wildcard: "agent.*" matches "agent.start", "agent.stop"
82 pattern = self.topic_pattern.replace("*", ".*")
83 import re
84 return bool(re.match(f"^{pattern}$", topic))
87# ============================================================================
88# Event Bus
89# ============================================================================
91class EventBus:
92 """Async pub/sub event bus with backpressure and DLQ."""
94 def __init__(
95 self,
96 max_queue_size: int = 10000,
97 dlq_enabled: bool = True,
98 dlq_max_size: int = 1000,
99 worker_count: int = 4,
100 ):
101 self._subscriptions: Dict[str, List[Subscription]] = defaultdict(list)
102 self._dead_letters: List[DeadLetter] = []
103 self._dlq_enabled = dlq_enabled
104 self._dlq_max_size = dlq_max_size
105 self._queue: asyncio.Queue[tuple[Event, Subscription]] = asyncio.Queue(
106 maxsize=max_queue_size
107 )
108 self._workers: List[asyncio.Task] = []
109 self._worker_count = worker_count
110 self._running = False
111 self._lock = asyncio.Lock()
112 self._event_count: int = 0
114 # ----- subscription management -----
116 def subscribe(
117 self,
118 topic: str,
119 handler: Callable[[Event], Awaitable[Any]],
120 handler_name: Optional[str] = None,
121 concurrency: int = 1,
122 ) -> Subscription:
123 """Subscribe a handler to a topic.
125 Topic can contain * wildcard: 'agent.*', 'order.*.created'
126 """
127 name = handler_name or handler.__name__
128 is_pattern = "*" in topic
129 sub = Subscription(
130 topic_pattern=topic,
131 handler=handler,
132 handler_name=name,
133 concurrency=concurrency,
134 is_pattern=is_pattern,
135 )
136 self._subscriptions[topic].append(sub)
137 logger.debug("Subscribed '%s' to topic '%s'", name, topic)
138 return sub
140 def unsubscribe(self, topic: str, handler_name: str) -> bool:
141 """Remove a subscription by handler name."""
142 subs = self._subscriptions.get(topic, [])
143 original_len = len(subs)
144 self._subscriptions[topic] = [
145 s for s in subs if s.handler_name != handler_name
146 ]
147 removed = original_len != len(self._subscriptions[topic])
148 if removed:
149 logger.debug("Unsubscribed '%s' from topic '%s'", handler_name, topic)
150 return removed
152 def unsubscribe_all(self, handler_name: str) -> int:
153 """Remove all subscriptions for a handler across topics."""
154 count = 0
155 for topic in list(self._subscriptions.keys()):
156 if self.unsubscribe(topic, handler_name):
157 count += 1
158 return count
160 # ----- publishing -----
162 async def publish(self, event: Event) -> int:
163 """Publish an event to all matching subscribers.
165 Returns: number of subscribers the event was dispatched to.
166 """
167 matching: List[Subscription] = []
169 # Exact topic match first
170 if event.topic in self._subscriptions:
171 matching.extend(self._subscriptions[event.topic])
173 # Wildcard pattern matches
174 for topic, subs in self._subscriptions.items():
175 if "*" in topic:
176 for sub in subs:
177 if sub.matches(event.topic) and sub not in matching:
178 matching.append(sub)
180 # Sort by priority — higher priority processed first
181 matching.sort(key=lambda s: event.priority.value, reverse=True)
183 for sub in matching:
184 await self._queue.put((event, sub))
186 if matching:
187 self._event_count += 1
188 logger.debug(
189 "Published '%s' to %d subscribers [total events: %d]",
190 event.topic, len(matching), self._event_count,
191 )
193 return len(matching)
195 async def publish_nowait(self, event: Event) -> int:
196 """Non-blocking publish — drops event if queue is full."""
197 try:
198 return await asyncio.wait_for(self.publish(event), timeout=0.1)
199 except asyncio.TimeoutError:
200 logger.warning("Event bus queue full — event '%s' dropped", event.topic)
201 return 0
203 def emit_sync(self, event: Event) -> None:
204 """Fire-and-forget from sync context."""
205 try:
206 loop = asyncio.get_running_loop()
207 loop.create_task(self.publish(event))
208 except RuntimeError:
209 logger.warning("No running event loop — event '%s' not published", event.topic)
211 # ----- processing -----
213 async def start(self) -> None:
214 """Start background workers."""
215 if self._running:
216 return
217 self._running = True
218 self._workers = [
219 asyncio.create_task(self._worker(i))
220 for i in range(self._worker_count)
221 ]
222 logger.info("EventBus started with %d workers", self._worker_count)
224 async def stop(self, grace_period: float = 5.0) -> None:
225 """Gracefully stop all workers, draining remaining events."""
226 self._running = False
228 # Wait for queue to drain
229 try:
230 await asyncio.wait_for(self._queue.join(), timeout=grace_period)
231 except asyncio.TimeoutError:
232 logger.warning(
233 "EventBus shutdown timeout — %d events remaining in queue",
234 self._queue.qsize(),
235 )
237 for w in self._workers:
238 w.cancel()
239 await asyncio.gather(*self._workers, return_exceptions=True)
240 logger.info("EventBus stopped — processed %d events", self._event_count)
242 async def _worker(self, worker_id: int) -> None:
243 """Background worker processing events from the queue."""
244 logger.debug("EventBus worker %d started", worker_id)
246 while self._running:
247 try:
248 event, sub = await asyncio.wait_for(
249 self._queue.get(), timeout=0.5
250 )
251 except asyncio.TimeoutError:
252 continue
253 except asyncio.CancelledError:
254 break
256 try:
257 await sub.handler(event)
258 except Exception as exc:
259 logger.error(
260 "Handler '%s' failed for event '%s': %s",
261 sub.handler_name, event.topic, exc,
262 )
263 if self._dlq_enabled:
264 self._add_to_dlq(event, sub, str(exc))
265 finally:
266 self._queue.task_done()
268 def _add_to_dlq(self, event: Event, sub: Subscription, error: str) -> None:
269 if len(self._dead_letters) >= self._dlq_max_size:
270 self._dead_letters.pop(0) # Drop oldest
271 self._dead_letters.append(DeadLetter(
272 event=event,
273 handler_name=sub.handler_name,
274 error=error,
275 ))
277 # ----- DLQ operations -----
279 def get_dlq(self) -> List[DeadLetter]:
280 return list(self._dead_letters)
282 async def replay_dlq(self, max_events: int = 100) -> int:
283 """Replay dead letter events."""
284 to_replay = self._dead_letters[:max_events]
285 self._dead_letters = self._dead_letters[max_events:]
287 count = 0
288 for dl in to_replay:
289 dl.retry_count += 1
290 await self._queue.put((dl.event, Subscription(
291 topic_pattern=dl.event.topic,
292 handler=lambda e: None, # Original handler not preserved
293 handler_name=f"dlq_replay_{dl.handler_name}",
294 )))
295 count += 1
297 logger.info("Replayed %d dead letter events", count)
298 return count
300 def clear_dlq(self) -> int:
301 count = len(self._dead_letters)
302 self._dead_letters.clear()
303 logger.info("Cleared %d dead letter events", count)
304 return count
306 # ----- inspection -----
308 @property
309 def queue_size(self) -> int:
310 return self._queue.qsize()
312 @property
313 def total_events(self) -> int:
314 return self._event_count
316 @property
317 def subscription_count(self) -> int:
318 return sum(len(subs) for subs in self._subscriptions.values())
320 def list_topics(self) -> List[str]:
321 return sorted(self._subscriptions.keys())
324# ============================================================================
325# Helpers
326# ============================================================================
328def event(topic: str, **kwargs) -> Event:
329 """Convenience factory for creating events."""
330 return Event(topic=topic, **kwargs)
333# ============================================================================
334# Module-level instance
335# ============================================================================
337default_bus = EventBus()