feat: container monitoring backend core (PR-1)
- 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
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"""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},
|
||||
)
|
||||
Reference in New Issue
Block a user