"""RabbitMQ producer for asf-core. asf-core publishes two classes of messages: 1. Domain events → ``asf.events`` topic exchange (e.g. artifact.created) 2. Job requests → ``asf.jobs`` direct exchange, routed to the correct logical queue (asf.ai.interactive, asf.indexing, …). Core never consumes messages directly — that is the responsibility of asf-worker. Connections are opened lazily and reused. """ from __future__ import annotations import json from contextlib import asynccontextmanager from typing import AsyncIterator from uuid import UUID import aio_pika import aio_pika.abc import structlog from ..config import get_settings from asf_domain.base import JobType from asf_events.base import ASFEvent, JobRequestEnvelope log = structlog.get_logger(__name__) _CHANNEL_POOL: aio_pika.abc.AbstractChannel | None = None _CONN: aio_pika.abc.AbstractConnection | None = None class EventPublisher: """Wraps a RabbitMQ channel with helpers for ASF message classes.""" def __init__(self, channel: aio_pika.abc.AbstractChannel) -> None: self._channel = channel self._settings = get_settings() async def declare_exchanges(self) -> None: events_ex = await self._channel.declare_exchange( self._settings.rabbitmq_events_exchange, type=aio_pika.ExchangeType.TOPIC, durable=True, ) jobs_ex = await self._channel.declare_exchange( self._settings.rabbitmq_jobs_exchange, type=aio_pika.ExchangeType.DIRECT, durable=True, ) # ---- Domain event queues (TOPIC bindings on asf.events) ---- domain_binds: list[tuple[str, list[str]]] = [ ("asf.organization.created", ["organization.created"]), ("asf.project.created", ["project.created"]), ("asf.project.updated", ["project.updated"]), ("asf.artifact.created", ["artifact.created"]), ("asf.artifact.updated", ["artifact.updated"]), ("asf.audit", ["audit.#"]), ("asf.ai.execution", ["ai.execution.*"]), ] for qname, keys in domain_binds: q = await self._channel.declare_queue(qname, durable=True) for rk in keys: await q.bind(events_ex, routing_key=rk) # Dead-letter queue (no default bind — consumed explicitly by worker topology) await self._channel.declare_queue("asf.deadletter", durable=True) # ---- Job queues (DIRECT bindings on asf.jobs) ---- for qname in self._settings.queues.values(): q = await self._channel.declare_queue(qname, durable=True) await q.bind(jobs_ex, routing_key=qname) # -------- Domain events -------- async def publish_event(self, event: ASFEvent) -> None: body = event.model_dump_json().encode("utf-8") exchange = await self._channel.get_exchange(self._settings.rabbitmq_events_exchange) await exchange.publish( aio_pika.Message( body=body, delivery_mode=aio_pika.DeliveryMode.PERSISTENT, content_type="application/json", message_id=str(event.event_id), timestamp=event.timestamp, headers={ "tenant_id": str(event.tenant_id), "event_type": event.event_type, }, ), routing_key=event.event_type, ) await log.adebug( "event_published", event_type=event.event_type, event_id=str(event.event_id), ) # -------- Job enqueueing -------- async def enqueue_job(self, envelope: JobRequestEnvelope) -> None: qname = self._routing_key_for_job_type(envelope.type) body = envelope.model_dump_json().encode("utf-8") exchange = await self._channel.get_exchange(self._settings.rabbitmq_jobs_exchange) await exchange.publish( aio_pika.Message( body=body, delivery_mode=aio_pika.DeliveryMode.PERSISTENT, content_type="application/json", message_id=str(envelope.job_id), priority=self._priority(envelope.priority), headers={ "tenant_id": str(envelope.tenant_id), "job_type": envelope.type.value, }, ), routing_key=qname, ) await log.adebug( "job_enqueued", job_id=str(envelope.job_id), queue=qname, ) def _routing_key_for_job_type(self, t: JobType) -> str: mapping = { JobType.AI_INTERACTIVE: "ai_interactive", JobType.AI_BACKGROUND: "ai_background", JobType.INDEXING: "indexing", JobType.CODE_ANALYSIS: "code_analysis", JobType.TESTING: "testing", JobType.DEVOPS: "devops", JobType.NOTIFICATION: "notifications", JobType.CUSTOM: "notifications", } key = mapping[t] return self._settings.queues[key] @staticmethod def _priority(p) -> int: return { "low": 1, "normal": 5, "high": 8, "urgent": 10, }.get(getattr(p, "value", str(p)), 5) async def _get_channel() -> aio_pika.abc.AbstractChannel: global _CHANNEL_POOL, _CONN if _CHANNEL_POOL is None or _CHANNEL_POOL.is_closed: s = get_settings() _CONN = await aio_pika.connect_robust(s.rabbitmq_uri) _CHANNEL_POOL = await _CONN.channel() await _CHANNEL_POOL.set_qos(prefetch_count=100) return _CHANNEL_POOL async def get_event_publisher() -> EventPublisher: ch = await _get_channel() pub = EventPublisher(ch) await pub.declare_exchanges() return pub async def close_rabbitmq() -> None: global _CHANNEL_POOL, _CONN try: if _CHANNEL_POOL is not None: await _CHANNEL_POOL.close() except Exception: # noqa: BLE001 - shutdown best effort pass try: if _CONN is not None: await _CONN.close() except Exception: # noqa: BLE001 pass _CHANNEL_POOL = None _CONN = None @asynccontextmanager async def rabbitmq_lifespan() -> AsyncIterator[None]: await get_event_publisher() # prime connection try: yield finally: await close_rabbitmq() # -------- Convenience helpers (used by services) -------- async def publish_domain_event(event: ASFEvent) -> None: pub = await get_event_publisher() await pub.publish_event(event) async def enqueue_job(envelope: JobRequestEnvelope) -> None: pub = await get_event_publisher() await pub.enqueue_job(envelope)