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>
145 lines
4.6 KiB
Python
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)
|