Skip to main content

arcly_http_messaging/messaging/
runtime.rs

1//! Consumer runtime: poll → dedupe → dispatch → ack / retry / dead-letter.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::Duration;
6
7use futures::future::BoxFuture;
8
9use crate::messaging::{
10    EventContext, EventError, EventHandlerDescriptor, InboundMessage, MessageTransport,
11};
12use arcly_http_core::core::engine::FrozenDiContainer;
13
14type Handler = fn(EventContext) -> BoxFuture<'static, Result<(), EventError>>;
15
16/// Drives one transport against the link-time handler registry.
17///
18/// Spawn from `ArclyPlugin::on_start`. The dispatch map is frozen at spawn —
19/// the polling loop performs immutable `HashMap` reads only.
20#[non_exhaustive]
21pub struct ConsumerRuntime {
22    pub transport: Arc<dyn MessageTransport>,
23    pub container: Arc<FrozenDiContainer>,
24    pub poll: Duration,
25    pub batch: usize,
26    /// Failed deliveries per message before dead-lettering.
27    pub max_retries: u32,
28    /// TTL for the consume-side dedupe claim (when an `IdempotencyStore`
29    /// is available in the DI container).
30    pub dedupe_ttl_secs: u64,
31    /// Messages processed concurrently per polled batch. `0`/`1` keeps the
32    /// historical strictly-ordered sequential behaviour; higher values trade
33    /// cross-message ordering within a batch for throughput (per-message
34    /// dedupe and retries are unaffected — `attempts` is a sharded map).
35    pub concurrency: usize,
36}
37
38impl ConsumerRuntime {
39    /// Sensible defaults: 300ms poll, batch 32, 3 retries, 1h dedupe TTL,
40    /// sequential processing. Chain setters to adjust.
41    pub fn new(transport: Arc<dyn MessageTransport>, container: Arc<FrozenDiContainer>) -> Self {
42        Self {
43            transport,
44            container,
45            poll: Duration::from_millis(300),
46            batch: 32,
47            max_retries: 3,
48            dedupe_ttl_secs: 3600,
49            concurrency: 1,
50        }
51    }
52    pub fn poll(mut self, v: Duration) -> Self {
53        self.poll = v;
54        self
55    }
56    pub fn batch(mut self, v: usize) -> Self {
57        self.batch = v;
58        self
59    }
60    pub fn max_retries(mut self, v: u32) -> Self {
61        self.max_retries = v;
62        self
63    }
64    pub fn dedupe_ttl_secs(mut self, v: u64) -> Self {
65        self.dedupe_ttl_secs = v;
66        self
67    }
68    pub fn concurrency(mut self, v: usize) -> Self {
69        self.concurrency = v;
70        self
71    }
72
73    pub fn spawn(self) {
74        // Freeze the dispatch map from the inventory registry — once, here.
75        let dispatch: HashMap<&'static str, Handler> =
76            inventory::iter::<&'static EventHandlerDescriptor>
77                .into_iter()
78                .map(|d| (d.topic, d.handler))
79                .collect();
80
81        for d in inventory::iter::<&'static EventHandlerDescriptor> {
82            tracing::info!(
83                topic = d.topic,
84                consumer = d.consumer,
85                "event handler registered"
86            );
87        }
88
89        let runtime = Arc::new(self);
90        tokio::spawn(async move {
91            // Per-key failure counts — sharded so concurrent processing
92            // never contends on a single lock.
93            let attempts: Arc<dashmap::DashMap<String, u32>> = Arc::new(dashmap::DashMap::new());
94            let mut tick = tokio::time::interval(runtime.poll);
95            let limit = runtime.concurrency.max(1);
96
97            loop {
98                tick.tick().await;
99                // Drain-aware (like the outbox relay): once shutdown begins,
100                // stop claiming work — unacked messages redeliver elsewhere.
101                if arcly_http_core::observability::health::is_draining() {
102                    tracing::info!("consumer runtime: drain flag set — stopping");
103                    return;
104                }
105                let batch = match runtime.transport.poll(runtime.batch).await {
106                    Ok(b) => b,
107                    Err(e) => {
108                        tracing::warn!(error = %e, "transport poll failed — retrying next tick");
109                        continue;
110                    }
111                };
112
113                use futures::StreamExt;
114                futures::stream::iter(batch)
115                    .for_each_concurrent(limit, |msg| {
116                        let runtime = Arc::clone(&runtime);
117                        let attempts = Arc::clone(&attempts);
118                        let dispatch = &dispatch;
119                        async move {
120                            runtime.process(dispatch, &attempts, msg).await;
121                        }
122                    })
123                    .await;
124            }
125        });
126    }
127
128    async fn process(
129        &self,
130        dispatch: &HashMap<&'static str, Handler>,
131        attempts: &dashmap::DashMap<String, u32>,
132        msg: InboundMessage,
133    ) {
134        let Some(handler) = dispatch.get(msg.topic.as_str()) else {
135            // No subscriber for this topic — ack so it doesn't loop forever.
136            tracing::debug!(topic = %msg.topic, "no handler — acking unrouted message");
137            let _ = self.transport.ack(&msg).await;
138            return;
139        };
140
141        // Consume-side dedupe (at-least-once → effectively-once per TTL).
142        // Same contract as the HTTP idempotency layer: claim before running,
143        // `complete` only AFTER success, `release` on failure — so retries of
144        // a failed delivery pass through instead of being replay-swallowed.
145        let store = self
146            .container
147            .try_get::<Box<dyn arcly_http_core::web::idempotency::IdempotencyStore>>();
148        let dedupe_key = format!("consume:{}:{}", msg.topic, msg.idempotency_key);
149        if let Some(store) = store {
150            match store.claim(&dedupe_key, self.dedupe_ttl_secs).await {
151                arcly_http_core::web::idempotency::IdempotencyDecision::Fresh => {}
152                arcly_http_core::web::idempotency::IdempotencyDecision::Unavailable => {}
153                arcly_http_core::web::idempotency::IdempotencyDecision::Replay { .. } => {
154                    metrics::counter!("events_deduped_total").increment(1);
155                    let _ = self.transport.ack(&msg).await;
156                    return;
157                }
158                arcly_http_core::web::idempotency::IdempotencyDecision::InFlight => {
159                    // Our own released-then-retried claim or a concurrent
160                    // consumer; requeue and let the next poll settle it.
161                    let _ = self.transport.nack(&msg).await;
162                    return;
163                }
164            }
165        }
166
167        // One shared extraction (pipeline::Provenance): the producer's trace
168        // continues (fresh root when none was carried) and the envelope's
169        // tenant id is validated against the SAME registry as HTTP traffic.
170        let provenance = arcly_http_core::pipeline::Provenance::from_message(&msg, &self.container);
171
172        // A suspended (or unknown) tenant's queued events must stop being
173        // processed, exactly like its HTTP requests — park them out of band
174        // rather than retry-looping or silently processing. Only enforced
175        // when a TenantRegistry is actually configured.
176        if msg.tenant.is_some()
177            && provenance.tenant.is_none()
178            && self
179                .container
180                .try_get::<arcly_http_core::web::tenant::TenantRegistry>()
181                .is_some()
182        {
183            metrics::counter!("events_tenant_rejected_total").increment(1);
184            tracing::warn!(
185                topic = %msg.topic,
186                tenant = msg.tenant.as_deref().unwrap_or(""),
187                "event tenant suspended or unknown — dead-lettering"
188            );
189            if let Some(store) = store {
190                store.release(&dedupe_key).await;
191            }
192            let _ = self
193                .transport
194                .dead_letter(&msg, "tenant suspended or unknown")
195                .await;
196            return;
197        }
198
199        let ctx = EventContext {
200            message: msg.clone(),
201            container: self.container.clone(),
202            trace: provenance.trace,
203            tenant: provenance.tenant,
204        };
205
206        match handler(ctx).await {
207            Ok(()) => {
208                attempts.remove(&msg.idempotency_key);
209                if let Some(store) = store {
210                    store
211                        .complete(&dedupe_key, 200, b"", self.dedupe_ttl_secs)
212                        .await;
213                }
214                metrics::counter!("events_consumed_total", "topic" => msg.topic.clone())
215                    .increment(1);
216                if let Err(e) = self.transport.ack(&msg).await {
217                    tracing::warn!(error = %e, "ack failed — message may redeliver");
218                }
219            }
220            Err(error) => {
221                if let Some(store) = store {
222                    store.release(&dedupe_key).await; // allow the retry through
223                }
224                // Typed fate: a permanent failure skips the retry budget and
225                // parks immediately; transient failures take the bounded
226                // retry path below.
227                let (reason, poison) = match error {
228                    EventError::DeadLetter(m) => {
229                        metrics::counter!("events_poisoned_total").increment(1);
230                        (m, true)
231                    }
232                    EventError::Retry(m) => (m, false),
233                };
234                // Bump-and-read, dropping the shard guard BEFORE any await
235                // below (dead_letter / nack are async).
236                let n = {
237                    let mut entry = attempts.entry(msg.idempotency_key.clone()).or_insert(0);
238                    *entry += 1;
239                    *entry
240                };
241                if poison || n > self.max_retries {
242                    attempts.remove(&msg.idempotency_key);
243                    metrics::counter!("events_dead_lettered_total").increment(1);
244                    tracing::error!(topic = %msg.topic, key = %msg.idempotency_key,
245                        attempts = n, reason = %reason,
246                        "poison message → dead letter");
247                    // PII never parks raw in the DLQ: dead-lettered payloads
248                    // are masked first (the queue copy already served its
249                    // delivery purpose; the DLQ copy is for forensics).
250                    let mut parked = msg.clone();
251                    if let Some(masker) = self
252                        .container
253                        .try_get::<arcly_http_compliance::compliance::Masker>()
254                    {
255                        masker.apply(&mut parked.payload);
256                    }
257                    let _ = self.transport.dead_letter(&parked, &reason).await;
258                } else {
259                    metrics::counter!("events_retried_total").increment(1);
260                    tracing::warn!(topic = %msg.topic, attempt = n, reason = %reason,
261                        "event handler failed — nack for retry");
262                    let _ = self.transport.nack(&msg).await;
263                }
264            }
265        }
266    }
267}