Files
hold-slayer/core/event_bus.py
Robert Helewka 4048ce1db6 Stage 4: honest health, explicit error policy, event-bus integrity
Engine mode is now explicit: USE_MOCK_SIP=true is the only way to get
the mock engine; an unconfigured trunk fails startup with guidance
instead of silently degrading. Root-caused why the engine always ran
mock: nested pydantic-settings never read .env (no env_file on the
sub-settings classes) — all 8 now declare it.

/health stops lying: reports engine mode (sippy|mock), a live DB
SELECT 1, trunk registration state with reason, and TTS/STT
availability from their last real request; "healthy" now requires
ready + db + sippy + registered trunk.

Error policy: leaf services (tts/transcription/llm_client) raise and
track availability; call-loop callers catch, publish EventType.ERROR
naming the failed service, and apply an explicit fallback. Persistence
writes get one bounded 3x exponential retry, then an ERROR log — no
more silent data loss.

Event bus: a full subscriber queue drops its oldest event (counted)
instead of silently evicting the subscription; subscribe(replay_last=N)
delivers the advertised history replay, used by /ws/events (25).

Receptionist correctness: a matched TAKE_MESSAGE rule beats the LLM;
voicemail polls for early hangup and stops/transcribes/hangs up in
finally; RecordingSession finally keeps its leg_ids so taps detach.

Dead code removed: models/contact.py + Contact table, dtmf_buffer,
transcribe_stream stub, SMS stub in notification.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:01:45 -04:00

145 lines
4.6 KiB
Python

"""
Event Bus — Async pub/sub for real-time gateway events.
WebSocket connections, MCP server, and internal services
all subscribe to events here. Pure asyncio — no external deps.
"""
import asyncio
import logging
from typing import Optional
from models.events import EventType, GatewayEvent
logger = logging.getLogger(__name__)
class EventBus:
"""
Async pub/sub event bus using asyncio.Queue per subscriber.
Features:
- Non-blocking publish (put_nowait)
- Slow subscribers lose their oldest event, never their subscription
- Event history (last N events, replayable to late joiners)
- Typed event filtering on subscriptions
- Async iteration via EventSubscription
"""
def __init__(self, max_history: int = 1000):
self._subscribers: list[EventSubscription] = []
self._history: list[GatewayEvent] = []
self._max_history = max_history
async def publish(self, event: GatewayEvent) -> None:
"""Publish an event to all subscribers.
A full subscriber queue drops its oldest event (counted on the
subscription) — a slow dashboard must never be silently
unsubscribed while its socket stays open.
"""
self._history.append(event)
if len(self._history) > self._max_history:
self._history = self._history[-self._max_history :]
logger.info(f"📡 Event: {event.type.value} | {event.message or ''}")
for sub in self._subscribers:
if sub.type_filter and event.type not in sub.type_filter:
continue
sub.deliver(event)
def subscribe(
self,
max_size: int = 100,
event_types: Optional[set[EventType]] = None,
replay_last: int = 0,
) -> "EventSubscription":
"""
Create a new subscription.
Args:
max_size: Queue depth; overflow drops the oldest event.
event_types: Optional filter — only receive these event types.
None means receive everything.
replay_last: Seed the queue with up to N most recent
history events (post-filter) before live ones.
Returns:
An async iterator of GatewayEvents.
"""
queue: asyncio.Queue[GatewayEvent] = asyncio.Queue(maxsize=max_size)
sub = EventSubscription(queue, self, event_types)
if replay_last > 0:
replayable = [
e for e in self._history
if not event_types or e.type in event_types
]
for event in replayable[-replay_last:]:
sub.deliver(event)
self._subscribers.append(sub)
return sub
def unsubscribe(self, sub: "EventSubscription") -> None:
"""Remove a subscriber."""
if sub in self._subscribers:
self._subscribers.remove(sub)
@property
def recent_events(self) -> list[GatewayEvent]:
"""Get recent event history."""
return list(self._history)
@property
def subscriber_count(self) -> int:
return len(self._subscribers)
class EventSubscription:
"""An async iterator that yields events from the bus."""
def __init__(
self,
queue: asyncio.Queue[GatewayEvent],
bus: EventBus,
type_filter: Optional[set[EventType]] = None,
):
self._queue = queue
self._bus = bus
self.type_filter = type_filter
self.dropped = 0 # events lost to queue overflow
def deliver(self, event: GatewayEvent) -> None:
"""Enqueue an event, dropping the oldest on overflow."""
try:
self._queue.put_nowait(event)
except asyncio.QueueFull:
try:
self._queue.get_nowait()
self._queue.put_nowait(event)
except (asyncio.QueueEmpty, asyncio.QueueFull):
pass
self.dropped += 1
if self.dropped in (1, 10, 100) or self.dropped % 1000 == 0:
logger.warning(
f"📡 Slow subscriber: {self.dropped} events dropped"
)
def __aiter__(self):
return self
async def __anext__(self) -> GatewayEvent:
try:
return await self._queue.get()
except asyncio.CancelledError:
self._bus.unsubscribe(self)
raise
async def get(self, timeout: Optional[float] = None) -> GatewayEvent:
"""Get next event with optional timeout."""
return await asyncio.wait_for(self._queue.get(), timeout=timeout)
def close(self):
"""Unsubscribe from the event bus."""
self._bus.unsubscribe(self)