Skip to main content

arcly_http_messaging/messaging/
mod.rs

1//! Event consumer mesh — the inbound half of the event architecture.
2//!
3//! The transactional outbox (`data::outbox`) made *producing* events safe;
4//! this module gives *consuming* them the same framework-grade guarantees,
5//! with NestJS-style ergonomics:
6//!
7//! ```ignore
8//! pub struct OrderEventsConsumer;
9//!
10//! #[EventConsumer]
11//! impl OrderEventsConsumer {
12//!     #[EventPattern("order.confirmed")]
13//!     async fn on_confirmed(ctx: EventContext) -> Result<(), String> {
14//!         let evt: OrderConfirmed = ctx.payload()?;
15//!         ctx.inject::<InventoryService>().reserve(evt.order_id).await
16//!     }
17//! }
18//! ```
19//!
20//! ## Guarantees
21//!
22//! - **Frozen dispatch** — `#[EventPattern]` handlers register through
23//!   `inventory` (the same link-time mechanism as `#[Controller]`); the
24//!   topic→handler map is built once at runtime start and never mutated.
25//! - **At-least-once + dedupe** — when an `IdempotencyStore` is in the DI
26//!   container, each `idempotency_key` is consumed exactly once per TTL.
27//! - **Bounded retries → DLQ** — a failing message is `nack`ed and retried
28//!   up to `max_retries`, then dead-lettered with the failure reason; one
29//!   poison message can never wedge a topic.
30//! - **Trace continuity** — the envelope's `traceparent` (stamped by the
31//!   outbox producer) becomes this hop's parent span; async hops chain in
32//!   the trace UI instead of starting orphan roots.
33//!
34//! The transport (Kafka / AMQP / NATS / in-process bridge) is app-provided
35//! via [`MessageTransport`] — the same boundary rule as every other driver.
36
37pub mod event;
38pub mod runtime;
39
40pub use runtime::ConsumerRuntime;
41
42use futures::future::BoxFuture;
43
44use arcly_http_core::core::engine::FrozenDiContainer;
45use arcly_http_core::observability::propagation::TraceContext;
46
47// ─── Envelope ─────────────────────────────────────────────────────────────────
48
49// `InboundMessage` and `BoxError` live in `arcly-http-core` (shared with the
50// request pipeline + audit), re-exported here so existing
51// `messaging::InboundMessage` / `messaging::BoxError` paths keep resolving.
52pub use arcly_http_core::transport::{BoxError, InboundMessage};
53
54// ─── Transport contract ───────────────────────────────────────────────────────
55
56/// App-provided broker adapter (Kafka / AMQP / NATS / in-process bridge).
57pub trait MessageTransport: Send + Sync + 'static {
58    /// Pull up to `max` messages. An empty Vec means "nothing right now".
59    fn poll(&self, max: usize) -> BoxFuture<'_, Result<Vec<InboundMessage>, BoxError>>;
60    /// Acknowledge successful processing (message will not be redelivered).
61    fn ack<'a>(&'a self, msg: &'a InboundMessage) -> BoxFuture<'a, Result<(), BoxError>>;
62    /// Negative-ack: requeue for a later retry.
63    fn nack<'a>(&'a self, msg: &'a InboundMessage) -> BoxFuture<'a, Result<(), BoxError>>;
64    /// Park a poison message out of band, with the final failure reason.
65    fn dead_letter<'a>(
66        &'a self,
67        msg: &'a InboundMessage,
68        reason: &'a str,
69    ) -> BoxFuture<'a, Result<(), BoxError>>;
70}
71
72// ─── Consumer-side context ────────────────────────────────────────────────────
73
74/// What an `#[EventPattern]` handler receives — DI + payload + trace, no
75/// HTTP types anywhere.
76#[non_exhaustive]
77pub struct EventContext {
78    pub message: InboundMessage,
79    pub container: std::sync::Arc<FrozenDiContainer>,
80    /// Continues the producer's trace (or a fresh root when none was carried).
81    pub trace: TraceContext,
82    /// The envelope's tenant, resolved + validated against the same
83    /// `TenantRegistry` as HTTP traffic (suspended tenants never reach
84    /// handlers — their events are dead-lettered upstream).
85    pub tenant: Option<std::sync::Arc<arcly_http_core::web::tenant::TenantConfig>>,
86}
87
88impl EventContext {
89    /// Resolve a singleton service. O(1), no locks. Panics when absent.
90    #[inline]
91    pub fn inject<T: Send + Sync + 'static>(&self) -> &T {
92        self.container.get::<T>()
93    }
94
95    /// Non-panicking variant of [`Self::inject`].
96    #[inline]
97    pub fn try_inject<T: Send + Sync + 'static>(&self) -> Option<&T> {
98        self.container.try_get::<T>()
99    }
100
101    /// Deserialize the payload into a typed event.
102    ///
103    /// A malformed payload is permanent by definition, so the error is
104    /// already [`EventError::DeadLetter`] — `ctx.payload()?` does the right
105    /// thing without manual mapping.
106    pub fn payload<T: serde::de::DeserializeOwned>(&self) -> Result<T, EventError> {
107        // By-ref deserialization — no clone of the (possibly large) payload.
108        serde::Deserialize::deserialize(&self.message.payload)
109            .map_err(|e| EventError::DeadLetter(format!("payload decode failed: {e}")))
110    }
111
112    /// `traceparent` for forwarding to the next hop (HTTP call, next queue).
113    pub fn traceparent(&self) -> String {
114        self.trace.to_traceparent()
115    }
116}
117
118// ─── Typed handler errors ─────────────────────────────────────────────────────
119
120/// What the mesh should do with a failed message. Handlers may keep
121/// returning `Result<(), String>` (strings convert to [`EventError::Retry`])
122/// or return `EventError` directly to decide the message's fate:
123///
124/// ```ignore
125/// #[EventPattern("order.confirmed")]
126/// async fn on_confirmed(ctx: EventContext) -> Result<(), EventError> {
127///     let order: Order = ctx
128///         .payload()
129///         .map_err(EventError::DeadLetter)?; // malformed: retrying won't help
130///     warehouse.reserve(&order).await
131///         .map_err(|e| EventError::Retry(e.to_string()))      // transient
132/// }
133/// ```
134#[derive(Debug)]
135pub enum EventError {
136    /// Transient failure (downstream 5xx, lock contention): nack → bounded
137    /// retries → dead-letter at `max_retries`.
138    Retry(String),
139    /// Permanent failure (malformed payload, violated business invariant):
140    /// dead-letter **immediately** — burning the retry budget on a poison
141    /// message only delays the alert.
142    DeadLetter(String),
143}
144
145impl From<String> for EventError {
146    fn from(s: String) -> Self {
147        Self::Retry(s)
148    }
149}
150impl From<&str> for EventError {
151    fn from(s: &str) -> Self {
152        Self::Retry(s.to_owned())
153    }
154}
155impl std::fmt::Display for EventError {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        match self {
158            Self::Retry(m) => write!(f, "retryable: {m}"),
159            Self::DeadLetter(m) => write!(f, "poison: {m}"),
160        }
161    }
162}
163impl std::error::Error for EventError {}
164
165// ─── Handler registration (filled in by #[EventConsumer]) ────────────────────
166
167/// Static descriptor emitted by the `#[EventPattern]` expansion and collected
168/// at link time — the runtime freezes these into its dispatch map at start.
169pub struct EventHandlerDescriptor {
170    pub topic: &'static str,
171    pub consumer: &'static str,
172    pub handler: fn(EventContext) -> BoxFuture<'static, Result<(), EventError>>,
173}
174
175inventory::collect!(&'static EventHandlerDescriptor);