arcly_http_messaging/messaging/
runtime.rs1use 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#[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 pub max_retries: u32,
28 pub dedupe_ttl_secs: u64,
31 pub concurrency: usize,
36}
37
38impl ConsumerRuntime {
39 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 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 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 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 tracing::debug!(topic = %msg.topic, "no handler — acking unrouted message");
137 let _ = self.transport.ack(&msg).await;
138 return;
139 };
140
141 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 let _ = self.transport.nack(&msg).await;
162 return;
163 }
164 }
165 }
166
167 let provenance = arcly_http_core::pipeline::Provenance::from_message(&msg, &self.container);
171
172 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; }
224 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 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 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}