This commit is contained in:
valenti
2026-09-08 07:21:34 -04:00
commit a4b224da8e
169 changed files with 27483 additions and 0 deletions
@@ -0,0 +1,13 @@
from .rabbitmq import (
get_event_publisher,
EventPublisher,
publish_domain_event,
enqueue_job,
)
__all__ = [
"get_event_publisher",
"EventPublisher",
"publish_domain_event",
"enqueue_job",
]
+199
View File
@@ -0,0 +1,199 @@
"""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)