Skip to main content

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}