aimdb_core/router.rs
1//! Generic message router for efficient connector dispatch
2//!
3//! Provides O(M) routing complexity instead of O(N×M) filtered streams.
4//! Routes incoming messages directly to fused ingest callbacks based on
5//! topic/key matching.
6//!
7//! This router is protocol-agnostic and can be used by any connector:
8//! - MQTT: Routes topics to records
9//! - Kafka: Routes topics/partitions to records
10//! - HTTP: Routes paths to records
11//! - DDS: Routes topics to records
12//! - Shared Memory: Routes segment names to records
13
14use alloc::{string::String, sync::Arc, vec::Vec};
15
16use crate::connector::IngestFn;
17
18/// A single routing entry
19///
20/// Maps one (resource_id, type) pair to a fused ingest callback.
21/// Multiple routes can exist for the same resource_id (different types).
22///
23/// # Resource ID Examples
24///
25/// - MQTT: "sensors/temperature" (topic)
26/// - Kafka: "events:0" (topic:partition)
27/// - HTTP: "/api/v1/sensors" (path)
28/// - DDS: "TelemetryData" (topic name)
29/// - Shmem: "temperature_buffer" (segment name)
30pub struct Route {
31 /// Resource identifier to match (reference-counted for proper memory management)
32 ///
33 /// Examples: MQTT topic, Kafka topic, HTTP path, DDS topic, shmem segment
34 ///
35 /// Uses `Arc<str>` instead of `&'static str` to avoid memory leaks from `Box::leak()`.
36 /// This adds ~8 bytes overhead per route (Arc control block) but enables proper cleanup.
37 pub resource_id: Arc<str>,
38
39 /// Fused ingest callback: deserialize + produce in one typed closure
40 /// built at registration time (no `Box<dyn Any>` per message).
41 pub ingest: IngestFn,
42}
43
44/// Generic message router for connector dispatch
45///
46/// Routes incoming messages to the matching records' ingest callbacks based on
47/// resource_id. Uses linear search which is efficient for <100 routes.
48///
49/// # Performance
50///
51/// - O(M) complexity where M = number of routes
52/// - May check multiple routes if same resource_id maps to multiple types
53/// - Typical routing time: <1μs for <50 routes
54///
55/// # Protocol Support
56///
57/// This router is protocol-agnostic. Each connector uses it with their own resource_id format:
58/// - **MQTT**: `topic` (e.g., "sensors/temperature")
59/// - **Kafka**: `topic` or `topic:partition` (e.g., "events" or "events:0")
60/// - **HTTP**: `path` (e.g., "/api/v1/sensors")
61/// - **DDS**: `topic_name` (e.g., "TelemetryData")
62/// - **Shmem**: `segment_name` (e.g., "temperature_buffer")
63pub struct Router {
64 /// List of all registered routes
65 routes: Vec<Route>,
66}
67
68impl Router {
69 /// Create a new router with the given routes
70 pub fn new(routes: Vec<Route>) -> Self {
71 Self { routes }
72 }
73
74 /// Route a message to the appropriate record(s)
75 ///
76 /// Synchronous: the ingest callback deserializes and produces in place
77 /// (`Producer::produce` is sync and infallible) — nothing on
78 /// this path awaits.
79 ///
80 /// # Arguments
81 /// * `resource_id` - Resource identifier (topic, path, segment name, etc.)
82 /// * `payload` - Raw message payload bytes
83 /// * `ctx` - Runtime context, threaded to context-aware deserializers
84 ///
85 /// # Returns
86 /// * `Ok(())` - Always returns Ok, even if no routes matched or processing failed.
87 /// Failures are logged (via tracing/defmt) but do not propagate as errors.
88 ///
89 /// # Behavior
90 /// - Checks all routes that match the resource_id (may be multiple)
91 /// - Logs warnings on ingest (deserialization) failures but continues
92 /// - Logs debug message if no routes found for resource_id
93 pub fn route(
94 &self,
95 resource_id: &str,
96 payload: &[u8],
97 ctx: &crate::RuntimeContext,
98 ) -> Result<(), String> {
99 let mut routed = false;
100 let mut matched = false;
101
102 // Linear search through all routes
103 // Note: Multiple routes may match the same resource_id (different types)
104 for route in &self.routes {
105 if route.resource_id.as_ref() == resource_id {
106 matched = true;
107 match (route.ingest)(ctx, payload) {
108 Ok(()) => {
109 routed = true;
110
111 log_debug!("Routed message on '{}' to producer", resource_id);
112 }
113 Err(_e) => {
114 log_warn!("Failed to ingest message on '{}': {}", resource_id, _e);
115
116 #[cfg(feature = "defmt")]
117 defmt::warn!(
118 "Failed to ingest message on '{}': {}",
119 resource_id,
120 _e.as_str()
121 );
122 }
123 }
124 }
125 }
126
127 if !routed {
128 if matched {
129 log_debug!(
130 "Route matched for '{}' but message was not produced (ingest errors)",
131 resource_id
132 );
133
134 #[cfg(feature = "defmt")]
135 defmt::debug!("Route matched for '{}' but not produced", resource_id);
136 } else {
137 log_debug!("No route found for resource: '{}'", resource_id);
138
139 #[cfg(feature = "defmt")]
140 defmt::debug!("No route found for resource: '{}'", resource_id);
141 }
142 }
143
144 Ok(())
145 }
146
147 /// Get list of all resource IDs registered in this router
148 ///
149 /// Useful for subscribing at the protocol level (e.g., MQTT SUBSCRIBE).
150 /// Returns unique resource IDs (deduplicated even if multiple routes per resource).
151 pub fn resource_ids(&self) -> Vec<Arc<str>> {
152 let mut ids: Vec<Arc<str>> = self.routes.iter().map(|r| r.resource_id.clone()).collect();
153
154 // Deduplicate by converting to strings for comparison
155 ids.sort_unstable_by(|a, b| a.as_ref().cmp(b.as_ref()));
156 ids.dedup_by(|a, b| a.as_ref() == b.as_ref());
157
158 ids
159 }
160
161 /// Get the number of routes in this router
162 pub fn route_count(&self) -> usize {
163 self.routes.len()
164 }
165}
166
167/// Builder for constructing routers
168///
169/// Provides a fluent API for adding routes before creating the router.
170pub struct RouterBuilder {
171 routes: Vec<Route>,
172}
173
174impl RouterBuilder {
175 /// Create a new router builder
176 pub fn new() -> Self {
177 Self { routes: Vec::new() }
178 }
179
180 /// Create a router builder from a collection of routes
181 ///
182 /// This is a convenience method for automatic router construction from
183 /// `AimDb::collect_inbound_routes()`. The resource_ids are converted to
184 /// `Arc<str>` for proper memory management.
185 ///
186 /// # Arguments
187 /// * `routes` - Vector of (resource_id, ingest) tuples
188 pub fn from_routes(routes: Vec<(String, IngestFn)>) -> Self {
189 let mut builder = Self::new();
190 for (resource_id, ingest) in routes {
191 // Convert String to Arc<str> - no leaking needed!
192 let resource_id_arc: Arc<str> = Arc::from(resource_id.as_str());
193 builder = builder.add_route(resource_id_arc, ingest);
194 }
195 builder
196 }
197
198 /// Add a route to the router
199 ///
200 /// # Arguments
201 /// * `resource_id` - Resource identifier to match (as `Arc<str>`)
202 /// * `ingest` - Fused ingest callback (deserialize + produce)
203 ///
204 /// # Resource ID Memory Management
205 /// The resource_id is stored as `Arc<str>` for proper reference counting and cleanup.
206 /// You can create an `Arc<str>` from:
207 /// - String literal: `Arc::from("sensors/temperature")`
208 /// - Owned String: `Arc::from(string.as_str())`
209 pub fn add_route(mut self, resource_id: Arc<str>, ingest: IngestFn) -> Self {
210 self.routes.push(Route {
211 resource_id,
212 ingest,
213 });
214 self
215 }
216
217 /// Build the router
218 ///
219 /// Consumes the builder and returns a configured Router.
220 pub fn build(self) -> Router {
221 Router::new(self.routes)
222 }
223
224 /// Get the number of routes that will be created
225 pub fn route_count(&self) -> usize {
226 self.routes.len()
227 }
228}
229
230impl Default for RouterBuilder {
231 fn default() -> Self {
232 Self::new()
233 }
234}
235
236#[cfg(all(test, feature = "std"))]
237mod tests {
238 use super::*;
239 use std::sync::atomic::{AtomicUsize, Ordering};
240 use std::sync::Arc;
241
242 /// A `RuntimeContext` backed by the shared no-op RuntimeOps.
243 fn test_ctx() -> crate::RuntimeContext {
244 crate::RuntimeContext::new(Arc::new(crate::executor::test_support::NoopRuntimeOps))
245 }
246
247 /// Ingest callback that counts successful invocations.
248 fn counting_ingest(call_count: Arc<AtomicUsize>) -> IngestFn {
249 Arc::new(move |_ctx, _payload| {
250 call_count.fetch_add(1, Ordering::SeqCst);
251 Ok(())
252 })
253 }
254
255 #[test]
256 fn test_single_route() {
257 let call_count = Arc::new(AtomicUsize::new(0));
258
259 let routes = vec![Route {
260 resource_id: Arc::from("test/resource"),
261 ingest: counting_ingest(call_count.clone()),
262 }];
263
264 let router = Router::new(routes);
265
266 router
267 .route("test/resource", b"dummy", &test_ctx())
268 .unwrap();
269
270 assert_eq!(call_count.load(Ordering::SeqCst), 1);
271 }
272
273 #[test]
274 fn test_multiple_routes_same_resource() {
275 let call_count1 = Arc::new(AtomicUsize::new(0));
276 let call_count2 = Arc::new(AtomicUsize::new(0));
277
278 let routes = vec![
279 Route {
280 resource_id: Arc::from("shared/resource"),
281 ingest: counting_ingest(call_count1.clone()),
282 },
283 Route {
284 resource_id: Arc::from("shared/resource"),
285 ingest: counting_ingest(call_count2.clone()),
286 },
287 ];
288
289 let router = Router::new(routes);
290
291 router
292 .route("shared/resource", b"dummy", &test_ctx())
293 .unwrap();
294
295 // Both ingest callbacks should be called
296 assert_eq!(call_count1.load(Ordering::SeqCst), 1);
297 assert_eq!(call_count2.load(Ordering::SeqCst), 1);
298 }
299
300 #[test]
301 fn test_unknown_resource() {
302 let call_count = Arc::new(AtomicUsize::new(0));
303
304 let routes = vec![Route {
305 resource_id: Arc::from("test/resource"),
306 ingest: counting_ingest(call_count.clone()),
307 }];
308
309 let router = Router::new(routes);
310
311 // Should not panic on unknown resource
312 router
313 .route("unknown/resource", b"dummy", &test_ctx())
314 .unwrap();
315
316 assert_eq!(call_count.load(Ordering::SeqCst), 0);
317 }
318
319 #[test]
320 fn test_resource_ids_deduplication() {
321 let routes = vec![
322 Route {
323 resource_id: Arc::from("resource1"),
324 ingest: counting_ingest(Arc::new(AtomicUsize::new(0))),
325 },
326 Route {
327 resource_id: Arc::from("resource1"), // Duplicate
328 ingest: counting_ingest(Arc::new(AtomicUsize::new(0))),
329 },
330 Route {
331 resource_id: Arc::from("resource2"),
332 ingest: counting_ingest(Arc::new(AtomicUsize::new(0))),
333 },
334 ];
335
336 let router = Router::new(routes);
337 let ids = router.resource_ids();
338
339 assert_eq!(ids.len(), 2);
340 assert!(ids.iter().any(|id| id.as_ref() == "resource1"));
341 assert!(ids.iter().any(|id| id.as_ref() == "resource2"));
342 }
343
344 #[test]
345 fn test_ingest_receives_payload_and_ctx() {
346 let seen_len = Arc::new(AtomicUsize::new(0));
347 let seen_len_clone = seen_len.clone();
348
349 let ingest: IngestFn = Arc::new(move |_ctx, payload| {
350 seen_len_clone.store(payload.len(), Ordering::SeqCst);
351 Ok(())
352 });
353
354 let routes = vec![Route {
355 resource_id: Arc::from("ctx/resource"),
356 ingest,
357 }];
358
359 let router = Router::new(routes);
360 router.route("ctx/resource", b"dummy", &test_ctx()).unwrap();
361
362 assert_eq!(seen_len.load(Ordering::SeqCst), 5);
363 }
364
365 #[test]
366 fn test_ingest_error_does_not_propagate() {
367 let ingest: IngestFn = Arc::new(|_ctx, _payload| Err("deserialize failed".into()));
368
369 let routes = vec![Route {
370 resource_id: Arc::from("err/resource"),
371 ingest,
372 }];
373
374 let router = Router::new(routes);
375
376 // Ingest failures are logged, not propagated.
377 router.route("err/resource", b"dummy", &test_ctx()).unwrap();
378 }
379}