from __future__ import annotations import json import os import time from contextlib import contextmanager from datetime import datetime, timezone from typing import Any, Iterator from sqlalchemy import select from sqlalchemy.orm import Session from services.shared.core import new_id, utc_now_iso from services.shared.models import EventEnvelope, EventOutboxItem from services.shared.sql_models import EventInboxRow, EventOutboxRow EXCHANGE_NAME = "mvpcc.domain.events" AUDIT_QUEUE = "mvpcc.audit.events" REPORTING_QUEUE = "mvpcc.reporting.events" PREFETCH_COUNT = 20 RETRY_SCHEDULE = (5, 15, 60, 300) def _bool_env(name: str, default: bool) -> bool: raw = os.getenv(name) if raw is None: return default return raw.strip().lower() in {"1", "true", "yes", "on"} def event_bus_enabled() -> bool: return _bool_env("EVENT_BUS_ENABLED", False) def consumer_enabled() -> bool: return _bool_env("EVENT_BUS_CONSUMER_ENABLED", True) def event_bus_url() -> str: return os.getenv("EVENT_BUS_URL", "amqp://guest:guest@localhost:5672/").strip() def event_bus_exchange() -> str: return os.getenv("EVENT_BUS_EXCHANGE", EXCHANGE_NAME).strip() or EXCHANGE_NAME def event_bus_audit_queue() -> str: return os.getenv("EVENT_BUS_AUDIT_QUEUE", AUDIT_QUEUE).strip() or AUDIT_QUEUE def event_bus_reporting_queue() -> str: return os.getenv("EVENT_BUS_REPORTING_QUEUE", REPORTING_QUEUE).strip() or REPORTING_QUEUE def event_bus_dispatch_batch_size() -> int: try: return max(1, int(os.getenv("EVENT_BUS_DISPATCH_BATCH_SIZE", "50"))) except ValueError: return 50 def event_bus_max_attempts() -> int: try: return max(1, int(os.getenv("EVENT_BUS_MAX_ATTEMPTS", "5"))) except ValueError: return 5 def event_bus_poll_interval_seconds() -> float: try: return max(0.25, float(os.getenv("EVENT_BUS_POLL_INTERVAL_SECONDS", "2"))) except ValueError: return 2.0 def _lazy_import_pika(): try: import pika # type: ignore except ImportError as exc: raise RuntimeError("pika is required for EVENT_BUS_ENABLED=1") from exc return pika def _iso_in(seconds: int) -> str: dt = datetime.now(timezone.utc).timestamp() + seconds return datetime.fromtimestamp(dt, tz=timezone.utc).replace(microsecond=0).isoformat() def retry_delay_seconds(attempt_count: int) -> int: if attempt_count <= 1: return RETRY_SCHEDULE[0] if attempt_count == 2: return RETRY_SCHEDULE[1] if attempt_count == 3: return RETRY_SCHEDULE[2] return RETRY_SCHEDULE[3] def routing_key_for(event_type: str) -> str: return event_type.strip() def build_envelope( *, event_type: str, producer: str, entity_type: str, entity_id: str, payload: dict[str, Any], correlation_id: str | None = None, event_id: str | None = None, event_version: int = 1, ) -> EventEnvelope: return EventEnvelope( event_id=event_id or new_id("evt"), event_type=event_type, event_version=event_version, occurred_at=utc_now_iso(), producer=producer, entity_type=entity_type, entity_id=entity_id, correlation_id=correlation_id, routing_key=routing_key_for(event_type), payload=payload, ) def outbox_item_from_row(row: EventOutboxRow) -> EventOutboxItem: envelope = json.loads(row.payload_json or "{}") return EventOutboxItem( event_id=row.event_id, event_type=row.event_type, event_version=row.event_version, producer_service=row.producer_service, entity_type=row.entity_type, entity_id=row.entity_id, correlation_id=row.correlation_id, routing_key=row.routing_key, payload=envelope.get("payload") if isinstance(envelope.get("payload"), dict) else {}, status=row.status, attempt_count=row.attempt_count, last_error=row.last_error, available_at=row.available_at, published_at=row.published_at, created_at=row.created_at, updated_at=row.updated_at, ) def append_outbox_event( session: Session, *, event_type: str, producer_service: str, entity_type: str, entity_id: str, payload: dict[str, Any], correlation_id: str | None = None, ) -> EventOutboxRow: envelope = build_envelope( event_type=event_type, producer=producer_service, entity_type=entity_type, entity_id=entity_id, payload=payload, correlation_id=correlation_id, ) now = utc_now_iso() row = EventOutboxRow( event_id=envelope.event_id, event_type=envelope.event_type, event_version=envelope.event_version, producer_service=envelope.producer, entity_type=envelope.entity_type, entity_id=envelope.entity_id, correlation_id=envelope.correlation_id, routing_key=envelope.routing_key, payload_json=json.dumps(envelope.model_dump(), ensure_ascii=False), status="pending", attempt_count=0, last_error=None, available_at=now, published_at=None, created_at=now, updated_at=now, ) session.add(row) return row def list_publishable_outbox(session: Session, limit: int | None = None) -> list[EventOutboxRow]: now = utc_now_iso() stmt = ( select(EventOutboxRow) .where( EventOutboxRow.available_at <= now, EventOutboxRow.status.in_(["pending", "failed"]), ) .order_by(EventOutboxRow.id.asc()) ) rows = session.execute(stmt).scalars().all() if limit is None: limit = event_bus_dispatch_batch_size() return rows[:limit] def mark_outbox_published(session: Session, row: EventOutboxRow) -> None: now = utc_now_iso() row.status = "published" row.published_at = now row.updated_at = now row.last_error = None def mark_outbox_failed(session: Session, row: EventOutboxRow, error: str) -> None: row.attempt_count += 1 row.status = "failed" row.last_error = error[:2000] row.available_at = _iso_in(retry_delay_seconds(row.attempt_count)) row.updated_at = utc_now_iso() def retry_outbox_event(session: Session, row: EventOutboxRow) -> None: now = utc_now_iso() row.status = "pending" row.last_error = None row.available_at = now row.updated_at = now def inbox_seen(session: Session, consumer_name: str, event_id: str) -> bool: row = session.execute( select(EventInboxRow).where( EventInboxRow.consumer_name == consumer_name, EventInboxRow.event_id == event_id, ) ).scalar_one_or_none() return row is not None def record_inbox( session: Session, *, consumer_name: str, event_id: str, event_type: str, status: str, notes: str | None = None, ) -> EventInboxRow: row = EventInboxRow( consumer_name=consumer_name, event_id=event_id, event_type=event_type, processed_at=utc_now_iso(), status=status, notes=notes, ) session.add(row) return row @contextmanager def rabbitmq_connection() -> Iterator[tuple[Any, Any]]: pika = _lazy_import_pika() params = pika.URLParameters(event_bus_url()) connection = pika.BlockingConnection(params) channel = connection.channel() try: yield connection, channel finally: try: connection.close() except Exception: pass def ensure_rabbitmq_topology(channel: Any) -> None: exchange = event_bus_exchange() audit_queue = event_bus_audit_queue() reporting_queue = event_bus_reporting_queue() channel.exchange_declare(exchange=exchange, exchange_type="topic", durable=True) for queue_name, bindings in ( (audit_queue, ["interaction.*", "agent.state.changed", "call.recording.ready", "ivr.completed"]), ( reporting_queue, ["interaction.created", "interaction.closed", "agent.state.changed", "ivr.completed", "call.recording.ready"], ), ): dlq_name = f"{queue_name}.dlq" channel.queue_declare(queue=dlq_name, durable=True) channel.queue_declare( queue=queue_name, durable=True, arguments={ "x-dead-letter-exchange": "", "x-dead-letter-routing-key": dlq_name, }, ) for binding in bindings: channel.queue_bind(queue=queue_name, exchange=exchange, routing_key=binding) def publish_envelope(channel: Any, envelope: dict[str, Any], headers: dict[str, Any] | None = None) -> None: pika = _lazy_import_pika() body = json.dumps(envelope, ensure_ascii=False).encode("utf-8") properties = pika.BasicProperties( content_type="application/json", delivery_mode=2, headers=headers or {}, ) channel.basic_publish( exchange=event_bus_exchange(), routing_key=envelope["routing_key"], body=body, properties=properties, ) def dispatch_outbox_batch(session: Session, limit: int | None = None) -> dict[str, int]: rows = list_publishable_outbox(session, limit=limit) if not rows: return {"claimed": 0, "published": 0, "failed": 0} published = 0 failed = 0 try: with rabbitmq_connection() as (_, channel): ensure_rabbitmq_topology(channel) for row in rows: try: publish_envelope(channel, json.loads(row.payload_json or "{}")) mark_outbox_published(session, row) published += 1 except Exception as exc: # noqa: BLE001 mark_outbox_failed(session, row, str(exc)) failed += 1 session.commit() except Exception as exc: # noqa: BLE001 for row in rows: mark_outbox_failed(session, row, str(exc)) session.commit() failed = len(rows) published = 0 return {"claimed": len(rows), "published": published, "failed": failed} def consume_one_message( queue_name: str, handler, *, max_attempts: int | None = None, ) -> bool: if max_attempts is None: max_attempts = event_bus_max_attempts() with rabbitmq_connection() as (_, channel): ensure_rabbitmq_topology(channel) channel.basic_qos(prefetch_count=PREFETCH_COUNT) method, properties, body = channel.basic_get(queue=queue_name, auto_ack=False) if not method: return False headers = dict((properties.headers or {})) try: envelope = json.loads(body.decode("utf-8")) handler(envelope) channel.basic_ack(delivery_tag=method.delivery_tag) return True except Exception as exc: # noqa: BLE001 retry_count = int(headers.get("x-retry-count", 0)) if retry_count + 1 < max_attempts: next_headers = dict(headers) next_headers["x-retry-count"] = retry_count + 1 publish_envelope(channel, json.loads(body.decode("utf-8")), headers=next_headers) channel.basic_ack(delivery_tag=method.delivery_tag) else: dlq_name = f"{queue_name}.dlq" pika = _lazy_import_pika() channel.basic_publish( exchange="", routing_key=dlq_name, body=body, properties=pika.BasicProperties( content_type="application/json", delivery_mode=2, headers={**headers, "error": str(exc)[:2000]}, ), ) channel.basic_ack(delivery_tag=method.delivery_tag) return False def poll_forever(loop_fn) -> None: while True: try: loop_fn() except Exception: time.sleep(event_bus_poll_interval_seconds()) else: time.sleep(event_bus_poll_interval_seconds())