4a7f24348c
- Add instance_events and health_checks tables with Alembic migration - InstanceEventBus: typed pub/sub singleton with wildcard support - HealthMonitor: async background loop polling containers every 15s - SSE endpoint GET /events/stream with auth and connection limits - Lifecycle hooks in tool_instances.py (create/start/stop/restart/delete) - Structured JSON logging with correlation IDs - 15 new unit tests (EventBus, HealthMonitor, MonitoringModels) Quality gates: pytest 15 new passed, ruff clean
98 lines
3.3 KiB
Python
98 lines
3.3 KiB
Python
"""In-memory typed event bus for instance lifecycle and health events."""
|
|
|
|
import asyncio
|
|
import inspect
|
|
import logging
|
|
import uuid
|
|
from collections.abc import Awaitable, Callable
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
InstanceEventPayload = dict[str, Any]
|
|
EventCallback = Callable[[InstanceEventPayload], Awaitable[None] | None] # noqa: UP044
|
|
|
|
|
|
class InstanceEventBus:
|
|
"""Singleton in-memory event bus with typed pub/sub and exception isolation."""
|
|
|
|
_instance: "InstanceEventBus | None" = None
|
|
_lock: asyncio.Lock = asyncio.Lock()
|
|
|
|
def __init__(self) -> None:
|
|
self._subscribers: dict[str, list[tuple[str, EventCallback]]] = {}
|
|
|
|
def __new__(cls) -> "InstanceEventBus":
|
|
if cls._instance is None:
|
|
cls._instance = super().__new__(cls)
|
|
cls._instance._subscribers = {}
|
|
return cls._instance
|
|
|
|
def _reset_for_testing(self) -> None:
|
|
"""Clear all subscribers. For test use only."""
|
|
self._subscribers.clear()
|
|
|
|
def subscribe(
|
|
self,
|
|
event_type: str,
|
|
callback: EventCallback,
|
|
) -> Callable[[], None]:
|
|
"""Register a callback for an event type.
|
|
|
|
Args:
|
|
event_type: The event type to subscribe to.
|
|
callback: A sync or async callable that receives the payload.
|
|
|
|
Returns:
|
|
An unsubscribe function.
|
|
"""
|
|
if event_type not in self._subscribers:
|
|
self._subscribers[event_type] = []
|
|
callback_id = str(uuid.uuid4())
|
|
self._subscribers[event_type].append((callback_id, callback))
|
|
|
|
def unsubscribe() -> None:
|
|
self.unsubscribe(event_type, callback_id)
|
|
|
|
return unsubscribe
|
|
|
|
def unsubscribe(self, event_type: str, callback_id: str) -> None:
|
|
"""Remove a specific callback by ID."""
|
|
if event_type in self._subscribers:
|
|
self._subscribers[event_type] = [
|
|
(cid, cb)
|
|
for cid, cb in self._subscribers[event_type]
|
|
if cid != callback_id
|
|
]
|
|
if not self._subscribers[event_type]:
|
|
del self._subscribers[event_type]
|
|
|
|
def unsubscribe_all(self, event_type: str) -> None:
|
|
"""Remove all subscribers for an event type."""
|
|
self._subscribers.pop(event_type, None)
|
|
|
|
async def publish(self, event_type: str, payload: InstanceEventPayload) -> None:
|
|
"""Deliver payload to all subscribers of event_type.
|
|
|
|
Also delivers to subscribers registered under the wildcard "*".
|
|
Exceptions from individual subscribers are caught and logged;
|
|
delivery continues to remaining subscribers.
|
|
"""
|
|
callbacks: list[tuple[str, EventCallback]] = []
|
|
callbacks.extend(self._subscribers.get(event_type, []))
|
|
callbacks.extend(self._subscribers.get("*", []))
|
|
|
|
for _callback_id, callback in callbacks:
|
|
try:
|
|
if inspect.iscoroutinefunction(callback):
|
|
await callback(payload)
|
|
else:
|
|
callback(payload)
|
|
except Exception:
|
|
correlation_id = payload.get("correlation_id", "unknown")
|
|
logger.exception(
|
|
"Event subscriber failed for %s",
|
|
event_type,
|
|
extra={"correlation_id": correlation_id},
|
|
)
|