"""In-process event bus for publish/subscribe. .. note:: This bus is **in-process only** — events are lost on crash, restart, or when multiple replicas are running. For **domain/business events** that must be delivered reliably (e.g. ``contact.created``, ``contact.updated``, ``user.created``), use the :mod:`app.core.outbox` transactional outbox instead:: from app.core.outbox import enqueue_outbox_event await enqueue_outbox_event(db, tenant_id, "contact.created", {...}) The outbox worker (see :mod:`app.core.worker`) polls the ``event_outbox`` table every 5 seconds and publishes events to this in-process bus, so local handlers still receive them — but with durability guarantees. ``publish()`` may still be used for **uncritical local events** that do not require persistence (e.g. cache invalidation signals). """ from __future__ import annotations import asyncio from collections import defaultdict from collections.abc import Callable, Coroutine from typing import Any EventHandler = Callable[[dict[str, Any]], Coroutine[Any, Any, None]] class EventBus: """Simple async event bus for in-process pub/sub.""" def __init__(self) -> None: self._handlers: dict[str, list[EventHandler]] = defaultdict(list) def subscribe(self, event_name: str, handler: EventHandler) -> None: """Subscribe a handler to an event. Idempotent: subscribing the same handler twice is a no-op (ARCH-020) so double activation cannot fire handlers twice. """ if handler not in self._handlers[event_name]: self._handlers[event_name].append(handler) def unsubscribe(self, event_name: str, handler: EventHandler) -> None: """Unsubscribe a handler from an event.""" if event_name in self._handlers: self._handlers[event_name] = [h for h in self._handlers[event_name] if h is not handler] async def publish(self, event_name: str, payload: dict[str, Any]) -> None: """Publish an event to all subscribers.""" handlers = self._handlers.get(event_name, []) # Also notify wildcard subscribers (catch-all '*' handlers) wildcard_handlers = self._handlers.get('*', []) all_handlers = handlers + wildcard_handlers tasks = [asyncio.create_task(h(payload)) for h in all_handlers] if tasks: await asyncio.gather(*tasks, return_exceptions=True) async def publish_with_results( self, event_name: str, payload: dict[str, Any] ) -> list[Exception | None]: """Publish an event and return per-handler results. Unlike :meth:`publish`, this method does **not** swallow exceptions. Each list entry is ``None`` on success or the caught ``Exception`` on failure, so callers (e.g. the outbox processor) can detect handler errors and apply retry logic. """ handlers = self._handlers.get(event_name, []) wildcard_handlers = self._handlers.get('*', []) all_handlers = handlers + wildcard_handlers if not all_handlers: return [] tasks = [asyncio.create_task(h(payload)) for h in all_handlers] results = await asyncio.gather(*tasks, return_exceptions=True) return [ r if isinstance(r, Exception) else None for r in results ] # Global event bus instance _event_bus = EventBus() def get_event_bus() -> EventBus: """Get the global event bus.""" return _event_bus def register_workflow_event_handlers() -> None: """Register workflow event handlers on the global event bus. Subscribes to events that can trigger workflows (user.created, etc.). Should be called during application startup. """ from app.workflows.engine import register_workflow_event_handlers as _register _register()