From 4db081d72fbef494fd7058fcca57187f965bead0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 11 Jun 2026 21:12:59 +0000 Subject: [PATCH 01/17] refactor(core)!: fuse inbound deserialize+produce at registration (036 W1 inbound) Replace the per-message Box inbound path (DeserializerKind -> produce_any downcast) with a fused IngestFn built in InboundConnectorBuilder::finish() where T is known: deserialize + produce in one typed closure, no erasure crossing and no boxed future per message (Producer::produce is sync + infallible, design 029). - IngestFn / IngestFactoryFn replace DeserializerFn/ContextDeserializerFn/ DeserializerKind and ProducerTrait/ProducerFactoryFn (all deleted) - InboundConnectorLink carries the ingest factory (non-optional; finish() validates the deserializer before registering, unchanged error strings) - Router::route is now a sync fn taking &RuntimeContext (every production caller already passed Some(&ctx); the context-skip branch is unrepresentable with fused closures, its test removed) - collect_inbound_routes returns Vec<(String, IngestFn)> - pump_source / pump_client inbound / ws dispatch drop one .await - delete dead TypedRecord::create_producer_trait Registrar API (with_deserializer/_raw, link_from) is source-compatible; MQTT/KNX/WS builders pass routes opaquely and compile unchanged. Part of design 036 W1 (data-plane de-Any). Co-Authored-By: Claude Fable 5 --- aimdb-core/src/builder.rs | 17 +- aimdb-core/src/connector.rs | 155 +++----- aimdb-core/src/lib.rs | 1 + aimdb-core/src/router.rs | 300 +++++--------- aimdb-core/src/session/client.rs | 2 +- aimdb-core/src/session/pump.rs | 7 +- aimdb-core/src/typed_api.rs | 373 +++++++++--------- aimdb-core/src/typed_record.rs | 15 - .../src/server/builder.rs | 2 +- .../src/server/dispatch.rs | 7 +- 10 files changed, 352 insertions(+), 527 deletions(-) diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index 7470a46..1e0fbda 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -1224,13 +1224,14 @@ impl AimDb { /// Collects inbound connector routes for automatic router construction (std only) /// /// Iterates all records, filters their inbound_connectors by scheme, - /// and returns routes with producer creation callbacks. + /// and returns routes with fused ingest callbacks (deserialize + produce + /// in one typed closure — no `Box` per message). /// /// # Arguments /// * `scheme` - URL scheme to filter by (e.g., "mqtt", "kafka") /// /// # Returns - /// Vector of tuples: (topic, producer_trait, deserializer) + /// Vector of tuples: (topic, ingest) /// /// The topic is resolved dynamically if a `TopicResolverFn` is configured, /// otherwise the static topic from the URL is used. @@ -1245,11 +1246,7 @@ impl AimDb { pub fn collect_inbound_routes( &self, scheme: &str, - ) -> Vec<( - String, - Box, - crate::connector::DeserializerKind, - )> { + ) -> Vec<(String, crate::connector::IngestFn)> { let mut routes = Vec::new(); for record in &self.inner.storages { @@ -1264,10 +1261,8 @@ impl AimDb { // Resolve topic: dynamic (from resolver) or static (from URL) let topic = link.resolve_topic(); - // Create producer using the stored factory - if let Some(producer) = link.create_producer(self) { - routes.push((topic, producer, link.deserializer.clone())); - } + // Create the fused ingest callback using the stored factory + routes.push((topic, link.create_ingest(self))); } } diff --git a/aimdb-core/src/connector.rs b/aimdb-core/src/connector.rs index a952350..a8bdd46 100644 --- a/aimdb-core/src/connector.rs +++ b/aimdb-core/src/connector.rs @@ -536,43 +536,29 @@ impl ConnectorLink { } } -/// Type alias for type-erased deserializer callbacks +/// Fused inbound ingest callback: deserialize + produce in one typed closure /// -/// Converts raw bytes to a boxed Any that can be downcast to the concrete type. -/// This allows storing deserializers for different types in a unified collection. -pub type DeserializerFn = - Arc Result, String> + Send + Sync>; - -/// Type alias for context-aware type-erased deserializer callbacks +/// Built where the record type `T` is known (`InboundConnectorBuilder::finish`), +/// so no `Box` crosses the connector boundary per message. The closure +/// captures the typed producer and deserializer; callers only see bytes. /// -/// Like `DeserializerFn`, but receives the concrete [`RuntimeContext`](crate::RuntimeContext) -/// for platform-independent timestamps and logging during deserialization. -pub type ContextDeserializerFn = Arc< - dyn Fn(crate::RuntimeContext, &[u8]) -> Result, String> - + Send - + Sync, ->; - -/// Which deserializer variant is registered for an inbound link +/// Synchronous by design: `Producer::produce` is sync and infallible +/// (design 029, pre-resolved write handle), so the only failure is the user +/// deserializer's — reported as the same `String` the deserializer API uses. /// -/// Enforces mutual exclusivity between raw bytes-only deserializers -/// and context-aware deserializers. -#[derive(Clone)] -pub enum DeserializerKind { - /// Plain bytes-only deserializer (from `.with_deserializer_raw()`) - Raw(DeserializerFn), - /// Context-aware deserializer (from `.with_deserializer()`) - Context(ContextDeserializerFn), -} +/// The [`RuntimeContext`](crate::RuntimeContext) is threaded per call (not +/// captured) for context-aware deserializers (design 026). +pub type IngestFn = Arc Result<(), String> + Send + Sync>; -/// Type alias for producer factory callback (alloc feature) +/// Type alias for ingest factory callback (alloc feature) /// -/// Takes the live [`AimDb`] and returns a boxed `ProducerTrait`. This allows +/// Takes the live [`AimDb`] and returns the fused [`IngestFn`]. This allows /// capturing the record type T at link_from() time while storing the factory -/// in a type-erased InboundConnectorLink. +/// in a type-erased InboundConnectorLink. The factory runs once at +/// route-collection time, not per message. /// /// Available in both `std` and `no_std + alloc` environments. -pub type ProducerFactoryFn = Arc Box + Send + Sync>; +pub type IngestFactoryFn = Arc IngestFn + Send + Sync>; /// Topic resolver function for inbound connections (late-binding) /// @@ -592,28 +578,6 @@ pub type ProducerFactoryFn = Arc Box + Send /// Works in both `std` and `no_std + alloc` environments. pub type TopicResolverFn = Arc Option + Send + Sync>; -/// Type-erased producer trait for MQTT router -/// -/// Allows the router to call produce() on different record types without knowing -/// the concrete type at compile time. The value is passed as `Box` and -/// downcast to the correct type inside the implementation. -/// -/// # Implementation Note -/// -/// This trait uses manual futures instead of `#[async_trait]` to enable `no_std` -/// compatibility. The `async_trait` macro generates code that depends on `std`, -/// while manual `Pin>` works in both `std` and `no_std + alloc`. -pub trait ProducerTrait: Send + Sync { - /// Produce a value into the record's buffer - /// - /// The value must be passed as `Box` and will be downcast to the correct type. - /// Returns an error if the downcast fails or if production fails. - fn produce_any<'a>( - &'a self, - value: Box, - ) -> Pin> + Send + 'a>>; -} - /// Type alias for consumer factory callback (alloc feature) /// /// Takes the live [`AimDb`] and returns a boxed `ConsumerTrait`. This allows @@ -664,9 +628,10 @@ pub trait AnyReader: Send { /// Configuration for an inbound connector link (External → AimDB) /// -/// Stores the parsed URL, configuration, deserializer, and a producer creation callback. -/// The callback captures the type T at creation time, allowing type-safe producer creation -/// later without needing PhantomData or type parameters. +/// Stores the parsed URL, configuration, and the fused ingest factory. The +/// factory captures the type T at creation time, allowing type-safe +/// deserialize+produce later without needing PhantomData or type parameters. +#[derive(Clone)] pub struct InboundConnectorLink { /// Parsed connector URL pub url: ConnectorUrl, @@ -674,22 +639,16 @@ pub struct InboundConnectorLink { /// Additional configuration options (protocol-specific) pub config: Vec<(String, String)>, - /// Deserialization callback that converts bytes to typed values + /// Fused ingest factory (alloc feature) /// - /// Either a plain bytes-only deserializer (`Raw`) or a context-aware - /// deserializer (`Context`) that receives `RuntimeContext` for timestamps - /// and logging. - /// - /// Available in both `std` and `no_std` (with `alloc` feature) environments. - pub deserializer: DeserializerKind, - - /// Producer creation callback (alloc feature) - /// - /// Takes the live [`AimDb`] and returns `Box`. - /// Captures the record type T at link_from() call time. + /// Takes the live [`AimDb`] and returns the [`IngestFn`] that + /// deserializes bytes and produces into the record's buffer in one typed + /// closure. Captures the record type T at link_from() call time — + /// `finish()` validates the deserializer is present before registering + /// the link, so the factory is always set. /// /// Available in both `std` and `no_std + alloc` environments. - pub producer_factory: Option, + pub ingest_factory: IngestFactoryFn, /// Optional dynamic topic resolver (late-binding) /// @@ -700,24 +659,12 @@ pub struct InboundConnectorLink { pub topic_resolver: Option, } -impl Clone for InboundConnectorLink { - fn clone(&self) -> Self { - Self { - url: self.url.clone(), - config: self.config.clone(), - deserializer: self.deserializer.clone(), - producer_factory: self.producer_factory.clone(), - topic_resolver: self.topic_resolver.clone(), - } - } -} - impl Debug for InboundConnectorLink { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("InboundConnectorLink") .field("url", &self.url) .field("config", &self.config) - .field("deserializer", &"") + .field("ingest_factory", &"") .field( "topic_resolver", &self.topic_resolver.as_ref().map(|_| ""), @@ -727,33 +674,24 @@ impl Debug for InboundConnectorLink { } impl InboundConnectorLink { - /// Creates a new inbound connector link from a URL and deserializer - pub fn new(url: ConnectorUrl, deserializer: DeserializerKind) -> Self { + /// Creates a new inbound connector link from a URL and ingest factory + pub fn new(url: ConnectorUrl, ingest_factory: IngestFactoryFn) -> Self { Self { url, config: Vec::new(), - deserializer, - producer_factory: None, + ingest_factory, topic_resolver: None, } } - /// Sets the producer factory callback. + /// Creates the fused ingest callback using the stored factory. /// - /// Available in both `std` and `no_std + alloc` environments. - pub fn with_producer_factory(mut self, factory: F) -> Self - where - F: Fn(&AimDb) -> Box + Send + Sync + 'static, - { - self.producer_factory = Some(Arc::new(factory)); - self - } - - /// Creates a producer using the stored factory. + /// Runs once at route-collection time; the returned [`IngestFn`] is the + /// per-message path (deserialize + produce, no erasure crossing). /// /// Available in both `std` and `no_std + alloc` environments. - pub fn create_producer(&self, db: &AimDb) -> Option> { - self.producer_factory.as_ref().map(|f| f(db)) + pub fn create_ingest(&self, db: &AimDb) -> IngestFn { + (self.ingest_factory)(db) } /// Resolves the subscription topic for this link @@ -1160,14 +1098,17 @@ mod tests { assert_eq!(resolver(), None); } + /// Dummy ingest factory for link-construction tests (never invoked). + fn dummy_ingest_factory() -> super::IngestFactoryFn { + Arc::new(|_db| Arc::new(|_ctx: &crate::RuntimeContext, _bytes: &[u8]| Ok(()))) + } + #[test] fn test_inbound_connector_link_resolve_topic_default() { - use super::{ConnectorUrl, DeserializerFn, DeserializerKind, InboundConnectorLink}; + use super::{ConnectorUrl, InboundConnectorLink}; let url = ConnectorUrl::parse("mqtt://sensors/temperature").unwrap(); - let deserializer: DeserializerFn = - Arc::new(|_| Ok(Box::new(()) as Box)); - let link = InboundConnectorLink::new(url, DeserializerKind::Raw(deserializer)); + let link = InboundConnectorLink::new(url, dummy_ingest_factory()); // No resolver configured, should return static topic from URL assert_eq!(link.resolve_topic(), "sensors/temperature"); @@ -1175,12 +1116,10 @@ mod tests { #[test] fn test_inbound_connector_link_resolve_topic_dynamic() { - use super::{ConnectorUrl, DeserializerFn, DeserializerKind, InboundConnectorLink}; + use super::{ConnectorUrl, InboundConnectorLink}; let url = ConnectorUrl::parse("mqtt://sensors/default").unwrap(); - let deserializer: DeserializerFn = - Arc::new(|_| Ok(Box::new(()) as Box)); - let mut link = InboundConnectorLink::new(url, DeserializerKind::Raw(deserializer)); + let mut link = InboundConnectorLink::new(url, dummy_ingest_factory()); // Configure dynamic resolver link.topic_resolver = Some(Arc::new(|| Some("sensors/dynamic/kitchen".into()))); @@ -1191,12 +1130,10 @@ mod tests { #[test] fn test_inbound_connector_link_resolve_topic_fallback() { - use super::{ConnectorUrl, DeserializerFn, DeserializerKind, InboundConnectorLink}; + use super::{ConnectorUrl, InboundConnectorLink}; let url = ConnectorUrl::parse("mqtt://sensors/fallback").unwrap(); - let deserializer: DeserializerFn = - Arc::new(|_| Ok(Box::new(()) as Box)); - let mut link = InboundConnectorLink::new(url, DeserializerKind::Raw(deserializer)); + let mut link = InboundConnectorLink::new(url, dummy_ingest_factory()); // Configure resolver that returns None link.topic_resolver = Some(Arc::new(|| None)); diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index 9d5cbf8..ff39367 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -88,6 +88,7 @@ pub use profiling::{RecordProfilingMetrics, StageMetrics, StageProfilingInfo}; // Connector Infrastructure exports pub use connector::TopicResolverFn; pub use connector::{ConnectorClient, ConnectorLink, ConnectorUrl, SerializeError}; +pub use connector::{IngestFactoryFn, IngestFn}; pub use connector::{TopicProvider, TopicProviderAny, TopicProviderFn, TopicProviderWrapper}; // Router exports for connector implementations diff --git a/aimdb-core/src/router.rs b/aimdb-core/src/router.rs index b1efa37..694a3bc 100644 --- a/aimdb-core/src/router.rs +++ b/aimdb-core/src/router.rs @@ -1,22 +1,23 @@ //! Generic message router for efficient connector dispatch //! //! Provides O(M) routing complexity instead of O(N×M) filtered streams. -//! Routes incoming messages directly to type-specific producers based on topic/key matching. +//! Routes incoming messages directly to fused ingest callbacks based on +//! topic/key matching. //! //! This router is protocol-agnostic and can be used by any connector: -//! - MQTT: Routes topics to producers -//! - Kafka: Routes topics/partitions to producers -//! - HTTP: Routes paths to producers -//! - DDS: Routes topics to producers -//! - Shared Memory: Routes segment names to producers +//! - MQTT: Routes topics to records +//! - Kafka: Routes topics/partitions to records +//! - HTTP: Routes paths to records +//! - DDS: Routes topics to records +//! - Shared Memory: Routes segment names to records -use alloc::{boxed::Box, string::String, sync::Arc, vec::Vec}; +use alloc::{string::String, sync::Arc, vec::Vec}; -use crate::connector::{DeserializerKind, ProducerTrait}; +use crate::connector::IngestFn; /// A single routing entry /// -/// Maps one (resource_id, type) pair to a producer and deserializer. +/// Maps one (resource_id, type) pair to a fused ingest callback. /// Multiple routes can exist for the same resource_id (different types). /// /// # Resource ID Examples @@ -35,17 +36,15 @@ pub struct Route { /// This adds ~8 bytes overhead per route (Arc control block) but enables proper cleanup. pub resource_id: Arc, - /// Type-erased producer for this route - pub producer: Box, - - /// Deserializer for converting bytes → typed value (raw or context-aware) - pub deserializer: DeserializerKind, + /// Fused ingest callback: deserialize + produce in one typed closure + /// built at registration time (no `Box` per message). + pub ingest: IngestFn, } /// Generic message router for connector dispatch /// -/// Routes incoming messages to appropriate producers based on resource_id. -/// Uses linear search which is efficient for <100 routes. +/// Routes incoming messages to the matching records' ingest callbacks based on +/// resource_id. Uses linear search which is efficient for <100 routes. /// /// # Performance /// @@ -72,12 +71,16 @@ impl Router { Self { routes } } - /// Route a message to appropriate producer(s) + /// Route a message to the appropriate record(s) + /// + /// Synchronous: the ingest callback deserializes and produces in place + /// (`Producer::produce` is sync and infallible, design 029) — nothing on + /// this path awaits. /// /// # Arguments /// * `resource_id` - Resource identifier (topic, path, segment name, etc.) /// * `payload` - Raw message payload bytes - /// * `ctx` - Optional runtime context for context-aware deserializers + /// * `ctx` - Runtime context, threaded to context-aware deserializers /// /// # Returns /// * `Ok(())` - Always returns Ok, even if no routes matched or processing failed. @@ -85,15 +88,13 @@ impl Router { /// /// # Behavior /// - Checks all routes that match the resource_id (may be multiple) - /// - For `DeserializerKind::Raw`, calls the deserializer with payload only - /// - For `DeserializerKind::Context`, calls with context + payload (skips if no context) - /// - Logs warnings on deserialization failures but continues + /// - Logs warnings on ingest (deserialization) failures but continues /// - Logs debug message if no routes found for resource_id - pub async fn route( + pub fn route( &self, resource_id: &str, payload: &[u8], - ctx: Option<&crate::RuntimeContext>, + ctx: &crate::RuntimeContext, ) -> Result<(), String> { let mut routed = false; let mut matched = false; @@ -103,59 +104,18 @@ impl Router { for route in &self.routes { if route.resource_id.as_ref() == resource_id { matched = true; - // Deserialize the payload based on deserializer kind - let result = match &route.deserializer { - DeserializerKind::Raw(deser) => (deser)(payload), - DeserializerKind::Context(deser) => match ctx { - Some(ctx) => (deser)(ctx.clone(), payload), - None => { - log_warn!( - "Context deserializer on '{}' but no context provided, skipping", - resource_id - ); - - #[cfg(feature = "defmt")] - defmt::warn!( - "Context deserializer on '{}' but no context provided", - resource_id - ); - - continue; - } - }, - }; - - match result { - Ok(value_any) => { - // Produce into the buffer - match route.producer.produce_any(value_any).await { - Ok(()) => { - routed = true; - - log_debug!("Routed message on '{}' to producer", resource_id); - } - Err(_e) => { - log_error!( - "Failed to produce message on '{}': {}", - resource_id, - _e - ); - - #[cfg(feature = "defmt")] - defmt::error!( - "Failed to produce message on '{}': {}", - resource_id, - _e.as_str() - ); - } - } + match (route.ingest)(ctx, payload) { + Ok(()) => { + routed = true; + + log_debug!("Routed message on '{}' to producer", resource_id); } Err(_e) => { - log_warn!("Failed to deserialize message on '{}': {}", resource_id, _e); + log_warn!("Failed to ingest message on '{}': {}", resource_id, _e); #[cfg(feature = "defmt")] defmt::warn!( - "Failed to deserialize message on '{}': {}", + "Failed to ingest message on '{}': {}", resource_id, _e.as_str() ); @@ -166,7 +126,10 @@ impl Router { if !routed { if matched { - log_debug!("Route matched for '{}' but message was not produced (missing context or errors)", resource_id); + log_debug!( + "Route matched for '{}' but message was not produced (ingest errors)", + resource_id + ); #[cfg(feature = "defmt")] defmt::debug!("Route matched for '{}' but not produced", resource_id); @@ -210,25 +173,11 @@ impl Router { /// ```rust,ignore /// use aimdb_core::router::RouterBuilder; /// +/// // Ingest callbacks are normally built by `InboundConnectorBuilder::finish()` +/// // and collected via `AimDb::collect_inbound_routes()`. /// let router = RouterBuilder::new() -/// .add_route( -/// "sensors/temperature", -/// producer_temp.clone(), -/// Arc::new(|bytes| { -/// serde_json::from_slice::(bytes) -/// .map(|t| Box::new(t) as Box) -/// .map_err(|e| e.to_string()) -/// }) -/// ) -/// .add_route( -/// "sensors/humidity", -/// producer_humidity.clone(), -/// Arc::new(|bytes| { -/// serde_json::from_slice::(bytes) -/// .map(|h| Box::new(h) as Box) -/// .map_err(|e| e.to_string()) -/// }) -/// ) +/// .add_route(Arc::from("sensors/temperature"), temperature_ingest) +/// .add_route(Arc::from("sensors/humidity"), humidity_ingest) /// .build(); /// ``` pub struct RouterBuilder { @@ -248,7 +197,7 @@ impl RouterBuilder { /// `Arc` for proper memory management. /// /// # Arguments - /// * `routes` - Vector of (resource_id, producer, deserializer) tuples + /// * `routes` - Vector of (resource_id, ingest) tuples /// /// # Example /// ```rust,ignore @@ -256,12 +205,12 @@ impl RouterBuilder { /// let router = RouterBuilder::from_routes(routes).build(); /// connector.set_router(router).await?; /// ``` - pub fn from_routes(routes: Vec<(String, Box, DeserializerKind)>) -> Self { + pub fn from_routes(routes: Vec<(String, IngestFn)>) -> Self { let mut builder = Self::new(); - for (resource_id, producer, deserializer) in routes { + for (resource_id, ingest) in routes { // Convert String to Arc - no leaking needed! let resource_id_arc: Arc = Arc::from(resource_id.as_str()); - builder = builder.add_route(resource_id_arc, producer, deserializer); + builder = builder.add_route(resource_id_arc, ingest); } builder } @@ -270,24 +219,17 @@ impl RouterBuilder { /// /// # Arguments /// * `resource_id` - Resource identifier to match (as `Arc`) - /// * `producer` - Producer that implements ProducerTrait - /// * `deserializer` - Deserializer variant (raw or context-aware) + /// * `ingest` - Fused ingest callback (deserialize + produce) /// /// # Resource ID Memory Management /// The resource_id is stored as `Arc` for proper reference counting and cleanup. /// You can create an `Arc` from: /// - String literal: `Arc::from("sensors/temperature")` /// - Owned String: `Arc::from(string.as_str())` - pub fn add_route( - mut self, - resource_id: Arc, - producer: Box, - deserializer: DeserializerKind, - ) -> Self { + pub fn add_route(mut self, resource_id: Arc, ingest: IngestFn) -> Self { self.routes.push(Route { resource_id, - producer, - deserializer, + ingest, }); self } @@ -314,130 +256,100 @@ impl Default for RouterBuilder { #[cfg(all(test, feature = "std"))] mod tests { use super::*; - use crate::connector::ProducerTrait; - use core::future::Future; - use core::pin::Pin; - use std::any::Any; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; - // Mock producer for testing - struct MockProducer { - call_count: Arc, + /// A `RuntimeContext` backed by the shared no-op RuntimeOps. + fn test_ctx() -> crate::RuntimeContext { + crate::RuntimeContext::new(Arc::new(aimdb_executor::test_support::NoopRuntimeOps)) } - impl ProducerTrait for MockProducer { - fn produce_any<'a>( - &'a self, - _value: Box, - ) -> Pin> + Send + 'a>> { - let call_count = self.call_count.clone(); - Box::pin(async move { - call_count.fetch_add(1, Ordering::SeqCst); - Ok(()) - }) - } + /// Ingest callback that counts successful invocations. + fn counting_ingest(call_count: Arc) -> IngestFn { + Arc::new(move |_ctx, _payload| { + call_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) } - #[tokio::test] - async fn test_single_route() { + #[test] + fn test_single_route() { let call_count = Arc::new(AtomicUsize::new(0)); let routes = vec![Route { resource_id: Arc::from("test/resource"), - producer: Box::new(MockProducer { - call_count: call_count.clone(), - }), - deserializer: DeserializerKind::Raw(Arc::new(|_bytes| Ok(Box::new(42i32)))), + ingest: counting_ingest(call_count.clone()), }]; let router = Router::new(routes); - router.route("test/resource", b"dummy", None).await.unwrap(); + router + .route("test/resource", b"dummy", &test_ctx()) + .unwrap(); assert_eq!(call_count.load(Ordering::SeqCst), 1); } - #[tokio::test] - async fn test_multiple_routes_same_resource() { + #[test] + fn test_multiple_routes_same_resource() { let call_count1 = Arc::new(AtomicUsize::new(0)); let call_count2 = Arc::new(AtomicUsize::new(0)); let routes = vec![ Route { resource_id: Arc::from("shared/resource"), - producer: Box::new(MockProducer { - call_count: call_count1.clone(), - }), - deserializer: DeserializerKind::Raw(Arc::new(|_bytes| Ok(Box::new(42i32)))), + ingest: counting_ingest(call_count1.clone()), }, Route { resource_id: Arc::from("shared/resource"), - producer: Box::new(MockProducer { - call_count: call_count2.clone(), - }), - deserializer: DeserializerKind::Raw(Arc::new(|_bytes| { - Ok(Box::new("test".to_string())) - })), + ingest: counting_ingest(call_count2.clone()), }, ]; let router = Router::new(routes); router - .route("shared/resource", b"dummy", None) - .await + .route("shared/resource", b"dummy", &test_ctx()) .unwrap(); - // Both producers should be called + // Both ingest callbacks should be called assert_eq!(call_count1.load(Ordering::SeqCst), 1); assert_eq!(call_count2.load(Ordering::SeqCst), 1); } - #[tokio::test] - async fn test_unknown_resource() { + #[test] + fn test_unknown_resource() { + let call_count = Arc::new(AtomicUsize::new(0)); + let routes = vec![Route { resource_id: Arc::from("test/resource"), - producer: Box::new(MockProducer { - call_count: Arc::new(AtomicUsize::new(0)), - }), - deserializer: DeserializerKind::Raw(Arc::new(|_bytes| Ok(Box::new(42i32)))), + ingest: counting_ingest(call_count.clone()), }]; let router = Router::new(routes); // Should not panic on unknown resource router - .route("unknown/resource", b"dummy", None) - .await + .route("unknown/resource", b"dummy", &test_ctx()) .unwrap(); + + assert_eq!(call_count.load(Ordering::SeqCst), 0); } - #[tokio::test] - async fn test_resource_ids_deduplication() { + #[test] + fn test_resource_ids_deduplication() { let routes = vec![ Route { resource_id: Arc::from("resource1"), - producer: Box::new(MockProducer { - call_count: Arc::new(AtomicUsize::new(0)), - }), - deserializer: DeserializerKind::Raw(Arc::new(|_bytes| Ok(Box::new(42i32)))), + ingest: counting_ingest(Arc::new(AtomicUsize::new(0))), }, Route { resource_id: Arc::from("resource1"), // Duplicate - producer: Box::new(MockProducer { - call_count: Arc::new(AtomicUsize::new(0)), - }), - deserializer: DeserializerKind::Raw(Arc::new(|_bytes| { - Ok(Box::new("test".to_string())) - })), + ingest: counting_ingest(Arc::new(AtomicUsize::new(0))), }, Route { resource_id: Arc::from("resource2"), - producer: Box::new(MockProducer { - call_count: Arc::new(AtomicUsize::new(0)), - }), - deserializer: DeserializerKind::Raw(Arc::new(|_bytes| Ok(Box::new(99i32)))), + ingest: counting_ingest(Arc::new(AtomicUsize::new(0))), }, ]; @@ -449,53 +361,39 @@ mod tests { assert!(ids.iter().any(|id| id.as_ref() == "resource2")); } - #[tokio::test] - async fn test_context_deserializer_with_context() { - let call_count = Arc::new(AtomicUsize::new(0)); - let call_count_clone = call_count.clone(); + #[test] + fn test_ingest_receives_payload_and_ctx() { + let seen_len = Arc::new(AtomicUsize::new(0)); + let seen_len_clone = seen_len.clone(); + + let ingest: IngestFn = Arc::new(move |_ctx, payload| { + seen_len_clone.store(payload.len(), Ordering::SeqCst); + Ok(()) + }); let routes = vec![Route { resource_id: Arc::from("ctx/resource"), - producer: Box::new(MockProducer { - call_count: call_count.clone(), - }), - deserializer: DeserializerKind::Context(Arc::new(move |_ctx, _bytes| { - Ok(Box::new(42i32) as Box) - })), + ingest, }]; let router = Router::new(routes); + router.route("ctx/resource", b"dummy", &test_ctx()).unwrap(); - // Provide a dummy context backed by the shared no-op RuntimeOps. - let ctx = - crate::RuntimeContext::new(Arc::new(aimdb_executor::test_support::NoopRuntimeOps)); - router - .route("ctx/resource", b"dummy", Some(&ctx)) - .await - .unwrap(); - - assert_eq!(call_count_clone.load(Ordering::SeqCst), 1); + assert_eq!(seen_len.load(Ordering::SeqCst), 5); } - #[tokio::test] - async fn test_context_deserializer_without_context_skips() { - let call_count = Arc::new(AtomicUsize::new(0)); + #[test] + fn test_ingest_error_does_not_propagate() { + let ingest: IngestFn = Arc::new(|_ctx, _payload| Err("deserialize failed".into())); let routes = vec![Route { - resource_id: Arc::from("ctx/resource"), - producer: Box::new(MockProducer { - call_count: call_count.clone(), - }), - deserializer: DeserializerKind::Context(Arc::new(|_ctx, _bytes| { - Ok(Box::new(42i32) as Box) - })), + resource_id: Arc::from("err/resource"), + ingest, }]; let router = Router::new(routes); - // No context provided — context deserializer should be skipped - router.route("ctx/resource", b"dummy", None).await.unwrap(); - - assert_eq!(call_count.load(Ordering::SeqCst), 0); + // Ingest failures are logged, not propagated. + router.route("err/resource", b"dummy", &test_ctx()).unwrap(); } } diff --git a/aimdb-core/src/session/client.rs b/aimdb-core/src/session/client.rs index 66bbc59..c755ff5 100644 --- a/aimdb-core/src/session/client.rs +++ b/aimdb-core/src/session/client.rs @@ -580,7 +580,7 @@ pub fn pump_client(db: &AimDb, scheme: &str, handle: &ClientHandle) -> Vec return, }; while let Some(payload) = stream.next().await { - let _ = router.route(id.as_ref(), &payload, Some(&ctx)).await; + let _ = router.route(id.as_ref(), &payload, &ctx); } })); } diff --git a/aimdb-core/src/session/pump.rs b/aimdb-core/src/session/pump.rs index 3bb465b..c0b0958 100644 --- a/aimdb-core/src/session/pump.rs +++ b/aimdb-core/src/session/pump.rs @@ -156,9 +156,10 @@ pub fn pump_source(db: &AimDb, scheme: &str, mut src: impl Source + 'static) -> ); while let Some((topic, payload)) = src.next().await { - // `route` deserializes and fans out to producers; it drops + logs on a - // full producer buffer and never returns a fatal error. - if let Err(_e) = router.route(&topic, &payload, Some(&ctx)).await { + // `route` deserializes and fans out to producers (synchronously — + // the fused ingest path never awaits); it drops + logs on a full + // producer buffer and never returns a fatal error. + if let Err(_e) = router.route(&topic, &payload, &ctx) { log_error!( "pump_source: failed to route message on '{}': {}", topic, diff --git a/aimdb-core/src/typed_api.rs b/aimdb-core/src/typed_api.rs index f4e2ebc..b3c741c 100644 --- a/aimdb-core/src/typed_api.rs +++ b/aimdb-core/src/typed_api.rs @@ -55,7 +55,6 @@ use core::pin::Pin; use alloc::{ boxed::Box, - format, string::{String, ToString}, sync::Arc, vec::Vec, @@ -153,30 +152,6 @@ impl Clone for Producer { } } -// Implement ProducerTrait for type-erased routing -impl crate::connector::ProducerTrait for Producer -where - T: Send + 'static + Debug + Clone, -{ - fn produce_any<'a>( - &'a self, - value: Box, - ) -> Pin> + Send + 'a>> { - Box::pin(async move { - // The only fallibility left is the type-erasure downcast; the - // produce itself is synchronous + infallible after M14. - let value = value.downcast::().map_err(|_| { - format!( - "Failed to downcast value to type {}", - core::any::type_name::() - ) - })?; - self.produce(*value); - Ok(()) - }) - } -} - // ============================================================================ // Consumer - Type-safe value consumption // ============================================================================ @@ -869,6 +844,13 @@ where /// Type alias for typed deserializer callbacks type TypedDeserializerFn = Arc Result + Send + Sync + 'static>; +/// Type alias for typed context-aware deserializer callbacks +/// +/// Stays typed until `finish()` fuses it with the producer — no per-message +/// erasure (design 036 W1). +type TypedContextDeserializerFn = + Arc Result + Send + Sync + 'static>; + /// Builder for configuring inbound connector links (External → AimDB) /// /// `'r` is the borrow of the registrar taken by `link_from()`; `'a` is the @@ -878,7 +860,7 @@ pub struct InboundConnectorBuilder<'r, 'a, T: Send + Sync + 'static + Debug + Cl url: String, config: Vec<(String, String)>, deserializer: Option>, - context_deserializer: Option, + context_deserializer: Option>, topic_resolver: Option, } @@ -936,10 +918,7 @@ where where F: Fn(crate::RuntimeContext, &[u8]) -> Result + Send + Sync + 'static, { - let f = Arc::new(f); - self.context_deserializer = Some(Arc::new(move |ctx, bytes| { - (f)(ctx, bytes).map(|val| Box::new(val) as Box) - })); + self.context_deserializer = Some(Arc::new(f)); self.deserializer = None; // mutually exclusive self } @@ -1002,7 +981,7 @@ where /// The buffer requirement is validated by `build()` (calling `.buffer()` /// after `.link_from()` is fine). pub fn finish(self) -> &'r mut RecordRegistrar<'a, T> { - use crate::connector::{ConnectorUrl, DeserializerKind, InboundConnectorLink}; + use crate::connector::{ConnectorUrl, InboundConnectorLink}; use crate::error::ConfigError; let record_key = self.registrar.record_key.clone(); @@ -1042,24 +1021,27 @@ where return self.registrar; } - // Resolve deserializer variant (mutually exclusive) - let deser_kind = if let Some(ctx_deser) = self.context_deserializer { - DeserializerKind::Context(ctx_deser) - } else if let Some(raw_deser) = self.deserializer { - // Type-erase the raw deserializer - let erased: crate::connector::DeserializerFn = Arc::new(move |bytes: &[u8]| { - raw_deser(bytes).map(|val| Box::new(val) as Box) - }); - DeserializerKind::Raw(erased) - } else { - self.registrar.rec.push_config_error(ConfigError::new( - record_key, - Some(self.url), - "Inbound connector requires a deserializer. \ - Call .with_deserializer() or .with_deserializer_raw()", - )); - return self.registrar; - }; + // Unify the deserializer variants (mutually exclusive) into one typed + // closure — the raw/context split collapses into what it captures + // (only the context variant pays the per-message ctx clone). Stays + // typed: fused with the producer below, no `Box` per message + // (design 036 W1). + type UnifiedDeserializeFn = + Arc Result + Send + Sync>; + let deserialize: UnifiedDeserializeFn = + if let Some(ctx_deser) = self.context_deserializer { + Arc::new(move |ctx, bytes| ctx_deser(ctx.clone(), bytes)) + } else if let Some(raw_deser) = self.deserializer { + Arc::new(move |_ctx, bytes| raw_deser(bytes)) + } else { + self.registrar.rec.push_config_error(ConfigError::new( + record_key, + Some(self.url), + "Inbound connector requires a deserializer. \ + Call .with_deserializer() or .with_deserializer_raw()", + )); + return self.registrar; + }; // Validation: Connector builder must be registered let has_connector = self @@ -1079,32 +1061,39 @@ where return self.registrar; } - // Create inbound connector link - let mut link = InboundConnectorLink::new(url, deser_kind); - link.config = self.config; - - // Wire through the topic resolver - link.topic_resolver = self.topic_resolver; - - // Add producer factory callback that captures type T and record key. - // The factory runs during build() after every record is registered and - // validated, so failures here are aimdb bugs, not user mistakes. - { + // Fused ingest factory that captures type T and record key: resolves + // the typed producer once at route-collection time; per message the + // returned IngestFn runs deserialize + produce with no erasure + // crossing. The factory runs during build() after every record is + // registered and validated, so failures here are aimdb bugs, not + // user mistakes. + let ingest_factory: crate::connector::IngestFactoryFn = { let record_key = self.registrar.record_key.clone(); - link = link.with_producer_factory(move |db: &AimDb| { + Arc::new(move |db: &AimDb| { let typed_rec = db .inner() .get_typed_record_by_key::(&record_key) .unwrap_or_else(|e| { panic!( - "producer factory: record '{record_key}' lookup failed ({e:?}) — \ + "ingest factory: record '{record_key}' lookup failed ({e:?}) — \ this is a bug in aimdb-core" ) }); - Box::new(Producer::::new(typed_rec.writer_handle())) - as Box - }); - } + let producer = Producer::::new(typed_rec.writer_handle()); + let deserialize = deserialize.clone(); + Arc::new(move |ctx: &crate::RuntimeContext, payload: &[u8]| { + producer.produce(deserialize(ctx, payload)?); + Ok(()) + }) as crate::connector::IngestFn + }) + }; + + // Create inbound connector link + let mut link = InboundConnectorLink::new(url, ingest_factory); + link.config = self.config; + + // Wire through the topic resolver + link.topic_resolver = self.topic_resolver; // Add to record self.registrar.rec.add_inbound_connector(link); @@ -1227,13 +1216,11 @@ mod tests { } // ==================================================================== - // Deserializer-kind selection tests + // Inbound link registration tests (fused ingest — design 036 W1) // ==================================================================== #[test] - fn inbound_finish_stores_raw_deserializer_kind() { - use crate::connector::DeserializerKind; - + fn inbound_finish_registers_fused_link() { let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); @@ -1254,118 +1241,8 @@ mod tests { .finish(); assert_eq!(rec.inbound_connectors().len(), 1); - let link = &rec.inbound_connectors()[0]; - - // Variant must be Raw - assert!( - matches!(link.deserializer, DeserializerKind::Raw(_)), - "expected DeserializerKind::Raw, got Context" - ); - - // Verify the type-erased deserializer round-trips correctly - if let DeserializerKind::Raw(ref f) = link.deserializer { - let result = f(&[1, 2, 3]).expect("deserializer should succeed"); - let record = result - .downcast::() - .expect("should downcast to TestRecord"); - assert_eq!(record.value, 3); - } - } - - #[test] - fn inbound_finish_stores_context_deserializer_kind() { - use crate::connector::DeserializerKind; - - let mut rec = crate::typed_record::TypedRecord::::new(); - rec.set_buffer(Box::new(MockBuffer)); - - let builders: Vec> = - vec![Box::new(MockConnectorBuilder { - scheme: "mqtt".to_string(), - })]; - let extensions = crate::extensions::Extensions::new(); - - let mut reg = make_registrar(&mut rec, &builders, &extensions); - - reg.link_from("mqtt://broker/topic") - .with_deserializer(|_ctx: crate::RuntimeContext, bytes: &[u8]| { - Ok(TestRecord { - value: bytes.len() as i32 * 10, - }) - }) - .finish(); - - assert_eq!(rec.inbound_connectors().len(), 1); - - assert!( - matches!( - rec.inbound_connectors()[0].deserializer, - DeserializerKind::Context(_) - ), - "expected DeserializerKind::Context, got Raw" - ); - } - - #[test] - fn inbound_raw_overrides_previous_context_deserializer() { - use crate::connector::DeserializerKind; - - let mut rec = crate::typed_record::TypedRecord::::new(); - rec.set_buffer(Box::new(MockBuffer)); - - let builders: Vec> = - vec![Box::new(MockConnectorBuilder { - scheme: "mqtt".to_string(), - })]; - let extensions = crate::extensions::Extensions::new(); - - let mut reg = make_registrar(&mut rec, &builders, &extensions); - - // Set context first, then override with raw — raw should win - reg.link_from("mqtt://broker/topic") - .with_deserializer(|_ctx: crate::RuntimeContext, _bytes: &[u8]| { - Ok(TestRecord { value: 0 }) - }) - .with_deserializer_raw(|bytes: &[u8]| { - Ok(TestRecord { - value: bytes.len() as i32, - }) - }) - .finish(); - - assert!(matches!( - rec.inbound_connectors()[0].deserializer, - DeserializerKind::Raw(_) - )); - } - - #[test] - fn inbound_context_overrides_previous_raw_deserializer() { - use crate::connector::DeserializerKind; - - let mut rec = crate::typed_record::TypedRecord::::new(); - rec.set_buffer(Box::new(MockBuffer)); - - let builders: Vec> = - vec![Box::new(MockConnectorBuilder { - scheme: "mqtt".to_string(), - })]; - let extensions = crate::extensions::Extensions::new(); - - let mut reg = make_registrar(&mut rec, &builders, &extensions); - - // Set raw first, then override with context — context should win - reg.link_from("mqtt://broker/topic") - .with_deserializer_raw(|_bytes: &[u8]| Ok(TestRecord { value: 0 })) - .with_deserializer(|_ctx: crate::RuntimeContext, _bytes: &[u8]| { - Ok(TestRecord { value: 99 }) - }) - .finish(); - - assert!(matches!( - rec.inbound_connectors()[0].deserializer, - DeserializerKind::Context(_) - )); + assert_eq!(rec.inbound_connectors()[0].resolve_topic(), "broker/topic"); + assert!(drain_errors(&mut rec).is_empty()); } /// Drains the configuration errors a registrar/setter recorded on `rec`. @@ -1866,4 +1743,136 @@ mod tests { assert_eq!(errors[0].record_key, "rec.x"); assert_eq!(errors[0].url.as_deref(), Some("mqtt://broker/x")); } + + // ==================================================================== + // Fused ingest roundtrip tests (design 036 W1) + // ==================================================================== + + use core::sync::atomic::{AtomicI32, AtomicUsize, Ordering}; + + /// Buffer that records the last pushed value and a push count — + /// atomics only, so the test runs under no_std + alloc too. + struct RecordingBuffer { + last: Arc, + count: Arc, + } + + impl crate::buffer::DynBuffer for RecordingBuffer { + fn push(&self, value: TestRecord) { + self.last.store(value.value, Ordering::SeqCst); + self.count.fetch_add(1, Ordering::SeqCst); + } + fn subscribe_boxed(&self) -> Box + Send> { + unimplemented!("not needed for ingest tests") + } + fn as_any(&self) -> &dyn core::any::Any { + self + } + } + + /// End-to-end inbound path: bytes → fused ingest → typed buffer push, + /// with no `Box` in between. + #[tokio::test] + async fn ingest_roundtrip_produces_value() { + let last = Arc::new(AtomicI32::new(-1)); + let count = Arc::new(AtomicUsize::new(0)); + let (buf_last, buf_count) = (last.clone(), count.clone()); + + let mut builder = crate::AimDbBuilder::new() + .runtime(Arc::new(MockRuntime)) + .with_connector(NoopConnectorBuilder); + builder.configure::("rec.in", move |reg| { + reg.buffer_raw(Box::new(RecordingBuffer { + last: buf_last, + count: buf_count, + })); + reg.link_from("mqtt://cmd/in") + .with_deserializer_raw(|bytes: &[u8]| { + if bytes.is_empty() { + return Err("empty payload".to_string()); + } + Ok(TestRecord { + value: bytes.len() as i32, + }) + }) + .finish(); + }); + let (db, _runner) = builder.build().await.expect("build must succeed"); + + let routes = db.collect_inbound_routes("mqtt"); + assert_eq!(routes.len(), 1); + let (topic, ingest) = &routes[0]; + assert_eq!(topic, "cmd/in"); + + let ctx = db.runtime_ctx(); + ingest(&ctx, &[1, 2, 3]).expect("ingest must succeed"); + assert_eq!(count.load(Ordering::SeqCst), 1); + assert_eq!(last.load(Ordering::SeqCst), 3); + + // Bad bytes: the deserializer error propagates, nothing is produced. + let err = ingest(&ctx, &[]).expect_err("empty payload must fail"); + assert_eq!(err, "empty payload"); + assert_eq!(count.load(Ordering::SeqCst), 1); + } + + /// Raw/context mutual exclusion is behavior now (the kind enum is gone): + /// the variant set last wins inside the fused ingest closure. + #[tokio::test] + async fn context_deserializer_set_last_wins() { + let last = Arc::new(AtomicI32::new(-1)); + let count = Arc::new(AtomicUsize::new(0)); + let (buf_last, buf_count) = (last.clone(), count.clone()); + + let mut builder = crate::AimDbBuilder::new() + .runtime(Arc::new(MockRuntime)) + .with_connector(NoopConnectorBuilder); + builder.configure::("rec.in", move |reg| { + reg.buffer_raw(Box::new(RecordingBuffer { + last: buf_last, + count: buf_count, + })); + reg.link_from("mqtt://cmd/in") + .with_deserializer_raw(|_bytes: &[u8]| Ok(TestRecord { value: 0 })) + .with_deserializer(|_ctx: crate::RuntimeContext, _bytes: &[u8]| { + Ok(TestRecord { value: 99 }) + }) + .finish(); + }); + let (db, _runner) = builder.build().await.expect("build must succeed"); + + let routes = db.collect_inbound_routes("mqtt"); + let (_, ingest) = &routes[0]; + ingest(&db.runtime_ctx(), b"x").expect("ingest must succeed"); + assert_eq!(last.load(Ordering::SeqCst), 99); + } + + /// And the reverse: raw set last wins over a prior context deserializer. + #[tokio::test] + async fn raw_deserializer_set_last_wins() { + let last = Arc::new(AtomicI32::new(-1)); + let count = Arc::new(AtomicUsize::new(0)); + let (buf_last, buf_count) = (last.clone(), count.clone()); + + let mut builder = crate::AimDbBuilder::new() + .runtime(Arc::new(MockRuntime)) + .with_connector(NoopConnectorBuilder); + builder.configure::("rec.in", move |reg| { + reg.buffer_raw(Box::new(RecordingBuffer { + last: buf_last, + count: buf_count, + })); + reg.link_from("mqtt://cmd/in") + .with_deserializer(|_ctx: crate::RuntimeContext, _bytes: &[u8]| { + Ok(TestRecord { value: 0 }) + }) + .with_deserializer_raw(|_bytes: &[u8]| Ok(TestRecord { value: 7 })) + .finish(); + }); + let (db, _runner) = builder.build().await.expect("build must succeed"); + + let routes = db.collect_inbound_routes("mqtt"); + let (_, ingest) = &routes[0]; + ingest(&db.runtime_ctx(), b"x").expect("ingest must succeed"); + assert_eq!(last.load(Ordering::SeqCst), 7); + } } diff --git a/aimdb-core/src/typed_record.rs b/aimdb-core/src/typed_record.rs index 9ed98e1..e24bac4 100644 --- a/aimdb-core/src/typed_record.rs +++ b/aimdb-core/src/typed_record.rs @@ -1092,21 +1092,6 @@ impl TypedRecord { Some(RecordValue::new(value, None)) } } - - /// Creates a boxed ProducerTrait for this record type (std only) - /// - /// Returns a type-erased producer that implements ProducerTrait, - /// allowing inbound connectors to produce values without knowing the concrete type. - /// - /// Backed by a pre-resolved `WriteHandle` (design 029) — no db / key lookup - /// is performed on each `produce_any` call. - #[cfg(feature = "std")] - pub fn create_producer_trait(&self) -> Box - where - T: Send + 'static + Debug + Clone, - { - Box::new(crate::typed_api::Producer::::new(self.writer_handle())) - } } impl Default for TypedRecord { diff --git a/aimdb-websocket-connector/src/server/builder.rs b/aimdb-websocket-connector/src/server/builder.rs index ddddfde..3a63116 100644 --- a/aimdb-websocket-connector/src/server/builder.rs +++ b/aimdb-websocket-connector/src/server/builder.rs @@ -343,7 +343,7 @@ impl ConnectorBuilder for WebSocketConnectorBuilder { known_topics: Arc::new(known_topics), auth: self.auth.clone(), late_join: self.late_join, - runtime_ctx: Some(db.runtime_ctx()), + runtime_ctx: db.runtime_ctx(), }); // ── Outbound: the shared `pump_sink` drives records → bus ─────── diff --git a/aimdb-websocket-connector/src/server/dispatch.rs b/aimdb-websocket-connector/src/server/dispatch.rs index ce7617d..08d50d0 100644 --- a/aimdb-websocket-connector/src/server/dispatch.rs +++ b/aimdb-websocket-connector/src/server/dispatch.rs @@ -33,7 +33,7 @@ pub struct WsDispatch { pub(crate) known_topics: Arc>, pub(crate) auth: Arc, pub(crate) late_join: bool, - pub(crate) runtime_ctx: Option, + pub(crate) runtime_ctx: aimdb_core::RuntimeContext, } impl Dispatch for WsDispatch { @@ -85,7 +85,7 @@ struct WsSession { known_topics: Arc>, auth: Arc, late_join: bool, - runtime_ctx: Option, + runtime_ctx: aimdb_core::RuntimeContext, info: Arc, /// Decrements the live-connection count on drop. _conn_guard: ConnectionGuard, @@ -168,8 +168,7 @@ impl Session for WsSession { return Err(RpcError::Denied); } self.router - .route(topic, &payload, self.runtime_ctx.as_ref()) - .await + .route(topic, &payload, &self.runtime_ctx) .map_err(|_| RpcError::Internal) }) } From 4db78b232714203a7152bcf15e2ca1c88a6ad00f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 11 Jun 2026 21:29:15 +0000 Subject: [PATCH 02/17] refactor(core)!: fuse outbound subscribe+serialize+topic at registration (036 W1 outbound) Replace the per-message Box outbound path (subscribe_any -> recv_any -> SerializerFn(&dyn Any) downcast, plus topic_any(&dyn Any)) with a fused SerializedSource built in OutboundConnectorBuilder::finish() where T is known: its readers yield destination + serialized payload directly (subscribe -> recv -> resolve topic -> serialize, all typed). - SerializedSource / SerializedReader / SerializedValue / SourceFactoryFn replace ConsumerTrait/AnyReader/SerializerKind/SerializerFn/ ContextSerializerFn/ConsumerFactoryFn and the erased TopicProviderAny/ TopicProviderWrapper/TopicProviderFn (all deleted; the typed TopicProvider trait is now stored as-is) - OutboundRoute is { topic, source, config }; ConnectorLink carries the source factory (non-optional; finish() validates the serializer before registering, unchanged error strings; the "skip links without serializer" branch in collect_outbound_routes is gone) - RuntimeContext is threaded into SerializedReader::recv per call (026 context serializers), not captured; raw serializers skip the ctx clone - Buffer errors propagate unchanged (BufferLagged => pump continues, other => pump stops); serialize failures are logged and skipped inside the reader, observably identical to the old pump-side continue - pump_sink / pump_client outbound collapse to recv + publish What disappears per message: the Box allocation, two downcasts, the topic_any erasure crossing, and the subscribe_any boxed future. The one remaining Box::pin per recv is the object-safe-async cost that already existed. Registrar API (with_serializer/_raw, with_topic_provider, link_to) is source-compatible; the KNX fake-gateway and session smoke tests pass unmodified. Part of design 036 W1 (data-plane de-Any). Co-Authored-By: Claude Fable 5 --- aimdb-core/src/builder.rs | 77 +-- aimdb-core/src/codec.rs | 2 +- aimdb-core/src/connector.rs | 316 ++++------- aimdb-core/src/lib.rs | 3 +- aimdb-core/src/session/client.rs | 33 +- aimdb-core/src/session/pump.rs | 60 +-- aimdb-core/src/typed_api.rs | 498 +++++++++++------- .../tests/topic_provider_tests.rs | 54 +- .../tests/topic_provider_tests.rs | 54 +- 9 files changed, 497 insertions(+), 600 deletions(-) diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index 1e0fbda..ffb5620 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -36,17 +36,15 @@ use crate::{DbError, DbResult}; /// One outbound route returned by [`AimDb::collect_outbound_routes`] pub struct OutboundRoute { - /// Default topic/destination from the URL path; used when no - /// `topic_provider` overrides it per value. + /// Default topic/destination from the URL path; used when the source + /// yields no per-value destination. pub topic: String, - /// Type-erased consumer for subscribing to record values - pub consumer: Box, - /// User-provided serializer for the record type (raw or context-aware) - pub serializer: crate::connector::SerializerKind, + /// Fused wire-level source: its readers yield destination + serialized + /// payload directly (subscribe → recv → resolve topic → serialize, all + /// typed inside — no `Box` per message, design 036 W1). + pub source: Box, /// Configuration options from the URL query pub config: Vec<(String, String)>, - /// Optional dynamic topic provider - pub topic_provider: Option, } /// Internal database state @@ -1277,31 +1275,6 @@ impl AimDb { routes } - /// Collects outbound routes for a specific protocol scheme - /// - /// Mirrors `collect_inbound_routes()` for symmetry. Iterates all records, - /// filters their outbound_connectors by scheme, and returns routes with - /// consumer creation callbacks. - /// - /// This method is called by connectors during their `build()` phase to - /// collect all configured outbound routes and spawn publisher tasks. - /// - /// # Arguments - /// * `scheme` - URL scheme to filter by (e.g., "mqtt", "kafka") - /// - /// # Returns - /// Vector of tuples: (destination, consumer_trait, serializer, config) - /// - /// The config Vec contains protocol-specific options (e.g., qos, retain). - /// - /// # Example - /// ```rust,ignore - /// // In MqttConnector::build() - /// let routes = db.collect_outbound_routes("mqtt"); - /// for (topic, consumer, serializer, config) in routes { - /// connector.spawn_publisher(topic, consumer, serializer, config)?; - /// } - /// ``` /// Collect `(topic, TypeId)` pairs for all outbound routes matching `scheme`. /// /// Complements [`collect_outbound_routes`](Self::collect_outbound_routes) when @@ -1327,6 +1300,20 @@ impl AimDb { result } + /// Collects outbound routes for a specific protocol scheme + /// + /// Mirrors `collect_inbound_routes()` for symmetry. Iterates all records, + /// filters their outbound_connectors by scheme, and returns + /// [`OutboundRoute`]s carrying fused serialized sources (subscribe → + /// recv → resolve topic → serialize, all typed inside — no + /// `Box` per message). + /// + /// This method is called by connectors during their `build()` phase to + /// collect all configured outbound routes and spawn publisher tasks + /// (usually via `pump_sink`). + /// + /// # Arguments + /// * `scheme` - URL scheme to filter by (e.g., "mqtt", "kafka") pub fn collect_outbound_routes(&self, scheme: &str) -> Vec { let mut routes = Vec::new(); @@ -1339,24 +1326,12 @@ impl AimDb { continue; } - let destination = link.url.resource_id(); - - // Skip links without serializer - let Some(serializer) = link.serializer.clone() else { - log_warn!("Outbound link '{}' has no serializer, skipping", link.url); - continue; - }; - - // Create consumer using the stored factory - if let Some(consumer) = link.create_consumer(self) { - routes.push(OutboundRoute { - topic: destination, - consumer, - serializer, - config: link.config.clone(), - topic_provider: link.topic_provider.clone(), - }); - } + // Create the fused source using the stored factory + routes.push(OutboundRoute { + topic: link.url.resource_id(), + source: link.create_source(self), + config: link.config.clone(), + }); } } diff --git a/aimdb-core/src/codec.rs b/aimdb-core/src/codec.rs index bb9c561..84347ea 100644 --- a/aimdb-core/src/codec.rs +++ b/aimdb-core/src/codec.rs @@ -20,7 +20,7 @@ //! zero-sized [`SerdeJsonCodec`] implementation. A record stores //! `Option>>`; the AimX read/write/subscribe paths and //! `RecordValue::as_json` route through it. This mirrors the connector -//! layer's `SerializerFn` / `DeserializerFn`. +//! layer's fused `SerializedSource` / `IngestFn` callbacks. use serde::{de::DeserializeOwned, Serialize}; diff --git a/aimdb-core/src/connector.rs b/aimdb-core/src/connector.rs index a8bdd46..2d77c11 100644 --- a/aimdb-core/src/connector.rs +++ b/aimdb-core/src/connector.rs @@ -83,46 +83,65 @@ impl std::fmt::Display for SerializeError { #[cfg(feature = "std")] impl std::error::Error for SerializeError {} -/// Type alias for serializer callbacks (reduces type complexity) -/// -/// Requires the `alloc` feature for `Arc` and `Vec` (available in both std and no_std+alloc). -/// Serializers convert record values to bytes for publishing to external systems. -/// -/// # Current Implementation -/// -/// Returns `Vec` which requires heap allocation. This works in: -/// - ✅ `std` environments (full standard library) -/// - ✅ `no_std + alloc` environments (embedded with allocator, e.g., ESP32, STM32 with heap) -/// - ❌ `no_std` without `alloc` (bare-metal MCUs without allocator) -/// -/// # Future Considerations -/// -/// For zero-allocation embedded environments, future versions may support buffer-based -/// serialization using `&mut [u8]` output or static lifetime slices. -pub type SerializerFn = - Arc Result, SerializeError> + Send + Sync>; +/// One serialized record update, produced by a fused [`SerializedReader`] +/// +/// Carries the wire payload plus the destination resolved by the link's +/// [`TopicProvider`] while the typed value was still in hand — the last +/// erasure crossing the old `topic_any(&dyn Any)` path required. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SerializedValue { + /// Dynamic destination resolved by the link's `TopicProvider`; + /// `None` means use the route's default topic (from the URL). + pub dest: Option, + /// Wire payload from the link's serializer. + /// + /// `Vec` requires heap allocation; works on `std` and + /// `no_std + alloc` (not bare-metal without an allocator). + pub payload: Vec, +} + +/// Type alias for the future returned by [`SerializedReader::recv`] +/// +/// Manual boxed future for object safety — same pattern as the rest of this +/// module (`#[async_trait]` would drag in `std`). +pub type RecvSerializedFuture<'a> = + Pin> + Send + 'a>>; + +/// A subscription to one record, fused with destination resolution and +/// serialization at registration time — no `dyn Any` crosses this boundary +/// (design 036 W1). +pub trait SerializedReader: Send { + /// Yield the next successfully serialized value. + /// + /// `ctx` is threaded per call (not captured) for context-aware + /// serializers (design 026). Buffer errors propagate unchanged: + /// `DbError::BufferLagged` means values were skipped but the reader + /// recovered; any other error means the buffer is gone. Serialization + /// failures are logged and skipped inside the reader. + fn recv<'a>(&'a mut self, ctx: &'a crate::RuntimeContext) -> RecvSerializedFuture<'a>; +} -/// Type alias for context-aware type-erased serializer callbacks +/// A record's outbound wire interface, built where the record type `T` is +/// known (`OutboundConnectorBuilder::finish`) and consumed by the pumps as +/// bytes. Replaces the erased `ConsumerTrait` + serializer + topic-provider +/// triple (design 036 W1). +pub trait SerializedSource: Send + Sync { + /// Subscribe to the record's updates. + /// + /// Synchronous and infallible — the buffer handle is pre-resolved at + /// construction (design 029). + fn subscribe(&self) -> Box; +} + +/// Type alias for source factory callback (alloc feature) /// -/// Like `SerializerFn`, but receives the concrete [`RuntimeContext`](crate::RuntimeContext) -/// for platform-independent timestamps and logging during serialization. -pub type ContextSerializerFn = Arc< - dyn Fn(crate::RuntimeContext, &dyn core::any::Any) -> Result, SerializeError> - + Send - + Sync, ->; - -/// Which serializer variant is registered for an outbound link +/// Takes the live [`AimDb`] and returns the fused [`SerializedSource`]. +/// This allows capturing the record type T at link_to() time while storing +/// the factory in a type-erased ConnectorLink. The factory runs once at +/// route-collection time, not per message. /// -/// Enforces mutual exclusivity between raw value-only serializers -/// and context-aware serializers. -#[derive(Clone)] -pub enum SerializerKind { - /// Plain value-only serializer (from `.with_serializer_raw()`) - Raw(SerializerFn), - /// Context-aware serializer (from `.with_serializer()`) - Context(ContextSerializerFn), -} +/// Available in both `std` and `no_std + alloc` environments. +pub type SourceFactoryFn = Arc Box + Send + Sync>; // ============================================================================ // TopicProvider - Dynamic topic/destination routing @@ -137,7 +156,9 @@ pub enum SerializerKind { /// # Type Safety /// /// The trait is generic over `T`, providing compile-time type safety -/// at the implementation site. Type erasure occurs only at storage time. +/// at the implementation site. The provider stays typed end-to-end: it is +/// fused into the link's [`SerializedSource`] at registration time and +/// called with `&T` while the value is in hand (design 036 W1). /// /// # no_std Compatibility /// @@ -164,66 +185,6 @@ pub trait TopicProvider: Send + Sync { fn topic(&self, value: &T) -> Option; } -/// Type-erased topic provider trait (internal) -/// -/// Allows storing providers for different types in a unified collection. -/// The concrete type is recovered via `Any::downcast_ref()` at runtime. -pub trait TopicProviderAny: Send + Sync { - /// Get the topic for a type-erased value - /// - /// Returns `None` if the value type doesn't match or if the provider - /// returns `None` for this value. - fn topic_any(&self, value: &dyn core::any::Any) -> Option; -} - -/// Wrapper struct for type-erasing a `TopicProvider` -/// -/// This wraps a concrete `TopicProvider` implementation and provides -/// the `TopicProviderAny` interface for type-erased storage. -/// -/// Uses `PhantomData T>` instead of `PhantomData` to avoid -/// inheriting Send/Sync bounds from T (the data type isn't stored). -pub struct TopicProviderWrapper -where - T: 'static, - P: TopicProvider, -{ - provider: P, - // Use fn(T) -> T to avoid Send/Sync variance issues with T - _phantom: core::marker::PhantomData T>, -} - -impl TopicProviderWrapper -where - T: 'static, - P: TopicProvider, -{ - /// Create a new wrapper for a topic provider - pub fn new(provider: P) -> Self { - Self { - provider, - _phantom: core::marker::PhantomData, - } - } -} - -impl TopicProviderAny for TopicProviderWrapper -where - T: 'static, - P: TopicProvider + Send + Sync, -{ - fn topic_any(&self, value: &dyn core::any::Any) -> Option { - value - .downcast_ref::() - .and_then(|v| self.provider.topic(v)) - } -} - -/// Type alias for stored topic provider (no_std compatible) -/// -/// Uses `Arc` for shared ownership across async tasks. -pub type TopicProviderFn = Arc; - /// Parsed connector URL with protocol, host, port, and credentials /// /// Supports multiple protocol schemes: @@ -441,8 +402,9 @@ impl ConnectorClient { /// Configuration for a connector link /// -/// Stores the parsed URL and configuration until the record is built. -/// The actual client creation and handler spawning happens during the build phase. +/// Stores the parsed URL, configuration, and the fused source factory until +/// the record is built. The actual client creation and handler spawning +/// happens during the build phase. #[derive(Clone)] pub struct ConnectorLink { /// Parsed connector URL @@ -451,36 +413,17 @@ pub struct ConnectorLink { /// Additional configuration options (protocol-specific) pub config: Vec<(String, String)>, - /// Serialization callback that converts record values to bytes for publishing - /// - /// Either a plain value-only serializer (`Raw`) or a context-aware - /// serializer (`Context`) that receives `RuntimeContext` for timestamps - /// and logging. - /// - /// If `None`, the connector must provide a default serialization mechanism or fail. - /// - /// Available in both `std` and `no_std` (with `alloc` feature) environments. - pub serializer: Option, - - /// Consumer factory callback (alloc feature) + /// Fused source factory (alloc feature) /// - /// Creates `ConsumerTrait` from the live [`AimDb`] to enable type-safe subscription. - /// The factory captures the record type T at link_to() configuration time, - /// allowing the connector to subscribe without knowing T at compile time. - /// - /// Mirrors the producer_factory pattern used for inbound connectors. - /// - /// Available in both `std` and `no_std + alloc` environments. - pub consumer_factory: Option, - - /// Optional dynamic topic provider - /// - /// When set, the provider is called with each value to determine the - /// topic/destination dynamically. If the provider returns `None`, - /// the static topic from the URL is used as fallback. + /// Takes the live [`AimDb`] and returns the [`SerializedSource`] whose + /// readers yield destination + payload directly (subscribe → recv → + /// resolve topic → serialize, all typed inside). Captures the record + /// type T at link_to() configuration time — `finish()` validates the + /// serializer is present before registering the link, so the factory is + /// always set. /// /// Available in both `std` and `no_std + alloc` environments. - pub topic_provider: Option, + pub source_factory: SourceFactoryFn, } impl Debug for ConnectorLink { @@ -488,34 +431,18 @@ impl Debug for ConnectorLink { f.debug_struct("ConnectorLink") .field("url", &self.url) .field("config", &self.config) - .field( - "serializer", - &self.serializer.as_ref().map(|s| match s { - SerializerKind::Raw(_) => "", - SerializerKind::Context(_) => "", - }), - ) - .field( - "consumer_factory", - &self.consumer_factory.as_ref().map(|_| ""), - ) - .field( - "topic_provider", - &self.topic_provider.as_ref().map(|_| ""), - ) + .field("source_factory", &"") .finish() } } impl ConnectorLink { - /// Creates a new connector link from a URL - pub fn new(url: ConnectorUrl) -> Self { + /// Creates a new connector link from a URL and source factory + pub fn new(url: ConnectorUrl, source_factory: SourceFactoryFn) -> Self { Self { url, config: Vec::new(), - serializer: None, - consumer_factory: None, - topic_provider: None, + source_factory, } } @@ -525,14 +452,14 @@ impl ConnectorLink { self } - /// Creates a consumer using the stored factory (alloc feature) + /// Creates the fused serialized source using the stored factory. /// - /// Invokes the consumer factory with the live database to create a - /// ConsumerTrait instance. Returns None if no factory is configured. + /// Runs once at route-collection time; the readers it hands out are the + /// per-message path (no `Box`, design 036 W1). /// /// Available in both `std` and `no_std + alloc` environments. - pub fn create_consumer(&self, db: &AimDb) -> Option> { - self.consumer_factory.as_ref().map(|f| f(db)) + pub fn create_source(&self, db: &AimDb) -> Box { + (self.source_factory)(db) } } @@ -578,54 +505,6 @@ pub type IngestFactoryFn = Arc IngestFn + Send + Sync>; /// Works in both `std` and `no_std + alloc` environments. pub type TopicResolverFn = Arc Option + Send + Sync>; -/// Type alias for consumer factory callback (alloc feature) -/// -/// Takes the live [`AimDb`] and returns a boxed `ConsumerTrait`. This allows -/// capturing the record type T at link_to() time while storing the factory -/// in a type-erased ConnectorLink. -/// -/// Mirrors the ProducerFactoryFn pattern for symmetry between inbound and outbound. -/// -/// Available in both `std` and `no_std + alloc` environments. -pub type ConsumerFactoryFn = Arc Box + Send + Sync>; - -/// Type-erased consumer trait for outbound routing -/// -/// Mirrors ProducerTrait but for consumption. Allows connectors to subscribe -/// to typed values without knowing the concrete type T at compile time. -/// -/// # Implementation Note -/// -/// Like ProducerTrait, this uses manual futures instead of `#[async_trait]` -/// to enable `no_std` compatibility. -pub trait ConsumerTrait: Send + Sync { - /// Subscribe to typed values from this record - /// - /// Returns a type-erased reader that can be polled for `Box` values. - /// The connector will downcast to the expected type after deserialization. - /// Infallible since M14 — subscription is a pre-resolved buffer handle. - fn subscribe_any<'a>(&'a self) -> SubscribeAnyFuture<'a>; -} - -/// Type alias for the future returned by `ConsumerTrait::subscribe_any` -type SubscribeAnyFuture<'a> = Pin> + Send + 'a>>; - -/// Type alias for the future returned by `AnyReader::recv_any` -type RecvAnyFuture<'a> = - Pin>> + Send + 'a>>; - -/// Helper trait for type-erased reading -/// -/// Allows reading values from a buffer without knowing the concrete type at compile time. -/// The value is returned as `Box` and must be downcast by the caller. -pub trait AnyReader: Send { - /// Receive a type-erased value from the buffer - /// - /// Returns `Box` which must be downcast to the concrete type. - /// Returns an error if the buffer is closed or an I/O error occurs. - fn recv_any<'a>(&'a mut self) -> RecvAnyFuture<'a>; -} - /// Configuration for an inbound connector link (External → AimDB) /// /// Stores the parsed URL, configuration, and the fused ingest factory. The @@ -996,34 +875,22 @@ mod tests { } #[test] - fn test_topic_provider_type_erasure() { - use super::{TopicProviderAny, TopicProviderWrapper}; - - let provider: Arc = - Arc::new(TopicProviderWrapper::new(TestTopicProvider)); + fn test_topic_provider_as_trait_object() { + // Providers are stored as Arc> — typed, no + // erasure (design 036 W1). + let provider: Arc> = + Arc::new(TestTopicProvider); let temp = TestTemperature { sensor_id: "kitchen-001".into(), celsius: 22.5, }; assert_eq!( - provider.topic_any(&temp), + provider.topic(&temp), Some("sensors/temp/kitchen-001".into()) ); } - #[test] - fn test_topic_provider_type_mismatch() { - use super::{TopicProviderAny, TopicProviderWrapper}; - - let provider: Arc = - Arc::new(TopicProviderWrapper::new(TestTopicProvider)); - let wrong_type = "not a temperature"; - - // Type mismatch returns None (falls back to default topic) - assert_eq!(provider.topic_any(&wrong_type), None); - } - #[test] fn test_topic_provider_returns_none() { struct OptionalTopicProvider; @@ -1038,27 +905,22 @@ mod tests { } } - use super::{TopicProviderAny, TopicProviderWrapper}; - - let provider: Arc = - Arc::new(TopicProviderWrapper::new(OptionalTopicProvider)); + let provider: Arc> = + Arc::new(OptionalTopicProvider); // Non-empty sensor_id returns dynamic topic let temp_with_id = TestTemperature { sensor_id: "abc".into(), celsius: 20.0, }; - assert_eq!( - provider.topic_any(&temp_with_id), - Some("sensors/abc".into()) - ); + assert_eq!(provider.topic(&temp_with_id), Some("sensors/abc".into())); // Empty sensor_id returns None (fallback) let temp_without_id = TestTemperature { sensor_id: String::new(), celsius: 20.0, }; - assert_eq!(provider.topic_any(&temp_without_id), None); + assert_eq!(provider.topic(&temp_without_id), None); } // ======================================================================== diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index ff39367..3203be8 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -86,10 +86,11 @@ pub use session::{ pub use profiling::{RecordProfilingMetrics, StageMetrics, StageProfilingInfo}; // Connector Infrastructure exports +pub use connector::TopicProvider; pub use connector::TopicResolverFn; pub use connector::{ConnectorClient, ConnectorLink, ConnectorUrl, SerializeError}; pub use connector::{IngestFactoryFn, IngestFn}; -pub use connector::{TopicProvider, TopicProviderAny, TopicProviderFn, TopicProviderWrapper}; +pub use connector::{SerializedReader, SerializedSource, SerializedValue, SourceFactoryFn}; // Router exports for connector implementations pub use router::{Route, Router, RouterBuilder}; diff --git a/aimdb-core/src/session/client.rs b/aimdb-core/src/session/client.rs index c755ff5..0553b56 100644 --- a/aimdb-core/src/session/client.rs +++ b/aimdb-core/src/session/client.rs @@ -25,7 +25,6 @@ use hashbrown::HashMap; use super::{ BoxFut, BoxStream, Connection, Dialer, EnvelopeCodec, Inbound, Outbound, Payload, RpcError, }; -use crate::connector::SerializerKind; use crate::router::RouterBuilder; use crate::AimDb; @@ -526,40 +525,30 @@ pub fn pump_client(db: &AimDb, scheme: &str, handle: &ClientHandle) -> Vec remote `write` ------------------ for crate::OutboundRoute { topic: destination, - consumer, - serializer, - topic_provider, + source, .. } in db.collect_outbound_routes(scheme) { let handle = handle.clone(); let ctx = ctx.clone(); pumps.push(Box::pin(async move { - let mut reader = consumer.subscribe_any().await; + let mut reader = source.subscribe(); loop { - let value = match reader.recv_any().await { - Ok(v) => v, + // The fused reader yields destination + serialized payload + // (serialize failures are logged and skipped inside it). + let msg = match reader.recv(&ctx).await { + Ok(m) => m, // Lagged (ring overflow) — skip the gap, keep mirroring. Err(crate::DbError::BufferLagged { .. }) => continue, // Buffer closed — the record is gone; end this mirror. Err(_) => break, }; // Dynamic destination (topic provider) or the static link target. - let dest = topic_provider - .as_ref() - .and_then(|p| p.topic_any(&*value)) - .unwrap_or_else(|| destination.clone()); - let bytes = match &serializer { - SerializerKind::Raw(ser) => match ser(&*value) { - Ok(b) => b, - Err(_e) => continue, - }, - SerializerKind::Context(ser) => match ser(ctx.clone(), &*value) { - Ok(b) => b, - Err(_e) => continue, - }, - }; - if handle.write(dest, Payload::from(bytes.as_slice())).is_err() { + let dest = msg.dest.unwrap_or_else(|| destination.clone()); + if handle + .write(dest, Payload::from(msg.payload.as_slice())) + .is_err() + { break; // engine stopped — all handles dropped } } diff --git a/aimdb-core/src/session/pump.rs b/aimdb-core/src/session/pump.rs index c0b0958..e6942df 100644 --- a/aimdb-core/src/session/pump.rs +++ b/aimdb-core/src/session/pump.rs @@ -20,7 +20,6 @@ use alloc::vec::Vec; use super::Source; use crate::builder::{AimDb, BoxFuture}; -use crate::connector::SerializerKind; use crate::router::RouterBuilder; use crate::transport::{Connector, ConnectorConfig}; @@ -28,11 +27,12 @@ use crate::transport::{Connector, ConnectorConfig}; /// /// Extracts the consume-and-publish loop a data-plane connector used to write by /// hand. For each route from [`collect_outbound_routes`](AimDb::collect_outbound_routes), -/// the returned future subscribes to the record (type-erased), serializes each -/// value with the route's [`SerializerKind`], resolves the destination via the -/// route's optional topic provider (falling back to the URL-derived default), and -/// publishes through `sink`. Per-route configuration (`qos`/`retain`/…) is built -/// once from the route's URL query via [`ConnectorConfig::from_query`]. +/// the returned future subscribes to the route's fused +/// [`SerializedSource`](crate::connector::SerializedSource) — whose readers +/// yield destination + serialized payload directly (no `Box` per +/// message, design 036 W1) — and publishes through `sink`. Per-route +/// configuration (`qos`/`retain`/…) is built once from the route's URL query +/// via [`ConnectorConfig::from_query`]. /// /// The publisher future terminates when its subscription yields an error (e.g. the /// record buffer closed), matching the legacy hand-rolled loop. @@ -42,10 +42,8 @@ pub fn pump_sink(db: &AimDb, scheme: &str, sink: Arc) -> Vec) -> Vec) -> Vec v, + let msg = match reader.recv(&runtime_ctx).await { + Ok(m) => m, // SPMC-ring overflow: messages were missed, but the reader // recovers (cursor resets to the oldest live value). Skip the // gap and keep pumping — a transient lag must not permanently @@ -82,40 +81,11 @@ pub fn pump_sink(db: &AimDb, scheme: &str, sink: Arc) -> Vec match ser(&*value_any) { - Ok(b) => b, - Err(_e) => { - log_error!( - "pump_sink: failed to serialize for destination '{}': {:?}", - dest, - _e - ); - continue; - } - }, - SerializerKind::Context(ser) => match ser(runtime_ctx.clone(), &*value_any) { - Ok(b) => b, - Err(_e) => { - log_error!( - "pump_sink: failed to serialize for destination '{}': {:?}", - dest, - _e - ); - continue; - } - }, - }; + // Destination: dynamic (resolved by the source) or default (from URL). + let dest = msg.dest.unwrap_or_else(|| default_topic.clone()); // Publish through the connector's pure I/O adapter. - if let Err(_e) = sink.publish(&dest, &cfg, &bytes).await { + if let Err(_e) = sink.publish(&dest, &cfg, &msg.payload).await { log_error!("pump_sink: failed to publish to '{}': {:?}", dest, _e); } else { log_debug!("pump_sink: published to: {}", dest); diff --git a/aimdb-core/src/typed_api.rs b/aimdb-core/src/typed_api.rs index b3c741c..6edc046 100644 --- a/aimdb-core/src/typed_api.rs +++ b/aimdb-core/src/typed_api.rs @@ -51,7 +51,6 @@ use core::fmt::Debug; use core::future::Future; use core::marker::PhantomData; -use core::pin::Pin; use alloc::{ boxed::Box, @@ -62,7 +61,7 @@ use alloc::{ use crate::buffer::{DynBuffer, WriteHandle}; use crate::typed_record::TypedRecord; -use crate::{AimDb, DbResult}; +use crate::AimDb; // ============================================================================ // Producer - Type-safe value production @@ -236,43 +235,77 @@ impl Clone for Consumer { } // ============================================================================ -// Type-erased Consumer Trait Implementation +// Fused outbound source (design 036 W1) // ============================================================================ -/// Adapter that wraps a typed BufferReader and type-erases it +/// Type alias for the unified typed serializer captured by [`FusedSource`] /// -/// This allows the reader to be used through the AnyReader trait without -/// knowing the concrete type T at compile time. -struct TypedAnyReader { - inner: Box + Send>, +/// Raw and context-aware serializers collapse into this shape at `finish()`; +/// the raw variant simply ignores the threaded context. +type FusedSerializeFn = Arc< + dyn Fn(&crate::RuntimeContext, &T) -> Result, crate::connector::SerializeError> + + Send + + Sync, +>; + +/// The [`SerializedSource`](crate::connector::SerializedSource) built by +/// `OutboundConnectorBuilder::finish()` — holds the typed consumer, +/// serializer, and optional topic provider, so every per-message step stays +/// typed (no `Box`, design 036 W1). +struct FusedSource { + consumer: Consumer, + serialize: FusedSerializeFn, + topic: Option>>, } -impl crate::connector::AnyReader for TypedAnyReader { - fn recv_any<'a>( - &'a mut self, - ) -> Pin>> + Send + 'a>> { - Box::pin(async move { - let value = self.inner.recv().await?; - Ok(Box::new(value) as Box) +impl crate::connector::SerializedSource for FusedSource +where + T: Send + Sync + 'static + Debug + Clone, +{ + fn subscribe(&self) -> Box { + Box::new(FusedReader { + inner: self.consumer.subscribe(), + serialize: self.serialize.clone(), + topic: self.topic.clone(), }) } } -/// Implement ConsumerTrait for type-erased routing -/// -/// This allows connectors to subscribe to records without knowing the concrete -/// type T at compile time. The factory pattern captures T during link_to() -/// configuration, and this implementation provides the runtime subscription logic. -impl crate::connector::ConsumerTrait for Consumer -where - T: Send + Sync + 'static + Debug + Clone, -{ - fn subscribe_any<'a>( - &'a self, - ) -> Pin> + Send + 'a>> { +/// One subscription of a [`FusedSource`]: recv → resolve destination → +/// serialize, all on the typed value. +struct FusedReader { + inner: Box + Send>, + serialize: FusedSerializeFn, + topic: Option>>, +} + +impl crate::connector::SerializedReader for FusedReader { + fn recv<'a>( + &'a mut self, + ctx: &'a crate::RuntimeContext, + ) -> crate::connector::RecvSerializedFuture<'a> { Box::pin(async move { - let reader = self.subscribe(); - Box::new(TypedAnyReader:: { inner: reader }) as Box + loop { + // Buffer errors propagate unchanged: `BufferLagged` lets the + // pump skip the gap and keep going; anything else ends it. + let value = self.inner.recv().await?; + // Resolve the destination while the typed value is in hand. + let dest = self.topic.as_ref().and_then(|p| p.topic(&value)); + match (self.serialize)(ctx, &value) { + Ok(payload) => return Ok(crate::connector::SerializedValue { dest, payload }), + Err(_e) => { + // Same skip-and-log the pumps used to do around the + // erased serializer. + log_error!( + "outbound link: failed to serialize {} (dest {:?}): {:?}", + core::any::type_name::(), + dest, + _e + ); + continue; + } + } + } }) } } @@ -285,6 +318,17 @@ where type TypedSerializerFn = Arc Result, crate::connector::SerializeError> + Send + Sync + 'static>; +/// Type alias for typed context-aware serializer callbacks +/// +/// Stays typed until `finish()` fuses it with the consumer — no per-message +/// erasure (design 036 W1). +type TypedContextSerializerFn = Arc< + dyn Fn(crate::RuntimeContext, &T) -> Result, crate::connector::SerializeError> + + Send + + Sync + + 'static, +>; + /// Kind of execution stage, used to address per-stage profiling metrics and to /// remember which stage `RecordRegistrar::with_name` should rename. #[doc(hidden)] @@ -585,8 +629,8 @@ pub struct OutboundConnectorBuilder<'r, 'a, T: Send + Sync + 'static + Debug + C url: String, config: Vec<(String, String)>, serializer: Option>, - context_serializer: Option, - topic_provider: Option, + context_serializer: Option>, + topic_provider: Option>>, } impl<'r, 'a, T> OutboundConnectorBuilder<'r, 'a, T> @@ -636,14 +680,7 @@ where + Sync + 'static, { - let f = Arc::new(f); - self.context_serializer = Some(Arc::new(move |ctx, value_any| { - if let Some(value) = value_any.downcast_ref::() { - (f)(ctx, value) - } else { - Err(crate::connector::SerializeError::TypeMismatch) - } - })); + self.context_serializer = Some(Arc::new(f)); self.serializer = None; // mutually exclusive self } @@ -669,8 +706,9 @@ where /// /// # Type Safety /// - /// The provider is type-checked at compile time against `T`. - /// Type erasure occurs internally for storage. + /// The provider is type-checked at compile time against `T` and stays + /// typed end-to-end: it is fused into the link's serialized source and + /// called with `&T` per value (design 036 W1). /// /// # Example /// @@ -694,10 +732,8 @@ where where P: crate::connector::TopicProvider + 'static, { - // Type-erase the provider via TopicProviderWrapper - self.topic_provider = Some(Arc::new(crate::connector::TopicProviderWrapper::new( - provider, - ))); + // Stays typed: fused into the link's SerializedSource at finish(). + self.topic_provider = Some(Arc::new(provider)); self } @@ -730,23 +766,15 @@ where let url_string = url.to_string(); let scheme = url.scheme().to_string(); - let mut link = ConnectorLink::new(url.clone()); - link.config = self.config.clone(); - - // Resolve serializer variant (mutually exclusive) - let ser_kind = if let Some(ctx_ser) = self.context_serializer { - crate::connector::SerializerKind::Context(ctx_ser) - } else if let Some(raw_ser) = self.serializer.clone() { - // Type-erase the raw serializer - let erased: crate::connector::SerializerFn = - Arc::new(move |any: &dyn core::any::Any| { - if let Some(value) = any.downcast_ref::() { - (raw_ser)(value) - } else { - Err(crate::connector::SerializeError::TypeMismatch) - } - }); - crate::connector::SerializerKind::Raw(erased) + // Unify the serializer variants (mutually exclusive) into one typed + // closure — the raw/context split collapses into what it captures + // (only the context variant pays the per-message ctx clone). Stays + // typed: fused with the consumer below, no `Box` per message + // (design 036 W1). + let serialize: FusedSerializeFn = if let Some(ctx_ser) = self.context_serializer { + Arc::new(move |ctx: &crate::RuntimeContext, value: &T| ctx_ser(ctx.clone(), value)) + } else if let Some(raw_ser) = self.serializer { + Arc::new(move |_ctx: &crate::RuntimeContext, value: &T| raw_ser(value)) } else { self.registrar.rec.push_config_error(ConfigError::new( record_key, @@ -757,11 +785,6 @@ where return self.registrar; }; - link.serializer = Some(ser_kind); - - // Wire through the topic provider - link.topic_provider = self.topic_provider; - // Validation: Check that connector builder is registered let has_connector = self .registrar @@ -793,31 +816,34 @@ where self.registrar.last_stage = Some((StageKind::Link, 0)); } - // Store consumer factory that captures type T and record key - // This allows the connector to subscribe to values without knowing T at compile time. + // Fused source factory that captures type T and record key. // - // Resolves the record at link-startup time (not per-message) and constructs a - // `Consumer` bound to a pre-resolved buffer handle — same pattern as the - // build-time path in `TypedRecord::collect_consumer_futures` (design 029). + // Resolves the record at route-collection time (not per-message) and + // constructs a `Consumer` bound to a pre-resolved buffer handle — + // same pattern as the build-time path in + // `TypedRecord::collect_consumer_futures` (design 029). The serializer + // and topic provider ride along typed, so the readers handed to the + // pumps yield destination + payload with no erasure crossing. // // The factory runs during build() after every record is registered and // validated (including the linked-records-need-a-buffer check), so // failures here are aimdb bugs, not user mistakes. - { + let source_factory: crate::connector::SourceFactoryFn = { let record_key = self.registrar.record_key.clone(); - link.consumer_factory = Some(Arc::new(move |db: &AimDb| { + let topic_provider = self.topic_provider; + Arc::new(move |db: &AimDb| { let typed_rec = db .inner() .get_typed_record_by_key::(&record_key) .unwrap_or_else(|e| { panic!( - "consumer factory: record '{record_key}' lookup failed ({e:?}) — \ + "source factory: record '{record_key}' lookup failed ({e:?}) — \ this is a bug in aimdb-core" ) }); let buffer = typed_rec.buffer_handle().unwrap_or_else(|| { panic!( - "consumer factory: record '{record_key}' has no buffer despite \ + "source factory: record '{record_key}' has no buffer despite \ build()-time validation — this is a bug in aimdb-core" ) }); @@ -826,11 +852,18 @@ where let mut consumer = Consumer::::new(buffer); #[cfg(feature = "profiling")] consumer.set_profiling(link_metrics.clone(), db.profiling_clock().clone()); - Box::new(consumer) as Box - })); - } + Box::new(FusedSource { + consumer, + serialize: serialize.clone(), + topic: topic_provider.clone(), + }) as Box + }) + }; + + let mut link = ConnectorLink::new(url, source_factory); + link.config = self.config; - // Store the connector link - consumers will be created later in build() + // Store the connector link - sources will be created later in build() // after connectors are actually built self.registrar.rec.add_outbound_connector(link); self.registrar @@ -1120,6 +1153,8 @@ pub trait RecordT: Send + Sync + 'static + Debug + Clone { #[cfg(test)] mod tests { use super::*; + use crate::DbResult; + use core::pin::Pin; #[cfg(not(feature = "std"))] use alloc::vec; @@ -1280,13 +1315,11 @@ mod tests { } // ==================================================================== - // Serializer-kind selection tests + // Outbound link registration tests (fused source — design 036 W1) // ==================================================================== #[test] - fn outbound_finish_stores_raw_serializer_kind() { - use crate::connector::SerializerKind; - + fn outbound_finish_registers_fused_link() { let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); @@ -1303,110 +1336,11 @@ mod tests { .finish(); assert_eq!(rec.outbound_connectors().len(), 1); - let link = &rec.outbound_connectors()[0]; - - // Variant must be Raw - let ser = link.serializer.as_ref().expect("serializer should be set"); - assert!( - matches!(ser, SerializerKind::Raw(_)), - "expected SerializerKind::Raw, got Context" + assert_eq!( + rec.outbound_connectors()[0].url.resource_id(), + "broker/topic" ); - - // Verify the type-erased serializer round-trips correctly - if let SerializerKind::Raw(ref f) = ser { - let val = TestRecord { value: 42 }; - let result = f(&val as &dyn core::any::Any).expect("serializer should succeed"); - assert_eq!(result, 42i32.to_le_bytes().to_vec()); - } - } - - #[test] - fn outbound_finish_stores_context_serializer_kind() { - use crate::connector::SerializerKind; - - let mut rec = crate::typed_record::TypedRecord::::new(); - rec.set_buffer(Box::new(MockBuffer)); - - let builders: Vec> = - vec![Box::new(MockConnectorBuilder { - scheme: "mqtt".to_string(), - })]; - let extensions = crate::extensions::Extensions::new(); - - let mut reg = make_registrar(&mut rec, &builders, &extensions); - - reg.link_to("mqtt://broker/topic") - .with_serializer(|_ctx: crate::RuntimeContext, record: &TestRecord| { - Ok(record.value.to_le_bytes().to_vec()) - }) - .finish(); - - assert_eq!(rec.outbound_connectors().len(), 1); - let ser = rec.outbound_connectors()[0] - .serializer - .as_ref() - .expect("serializer should be set"); - - assert!( - matches!(ser, SerializerKind::Context(_)), - "expected SerializerKind::Context, got Raw" - ); - } - - #[test] - fn outbound_raw_overrides_previous_context_serializer() { - use crate::connector::SerializerKind; - - let mut rec = crate::typed_record::TypedRecord::::new(); - rec.set_buffer(Box::new(MockBuffer)); - - let builders: Vec> = - vec![Box::new(MockConnectorBuilder { - scheme: "mqtt".to_string(), - })]; - let extensions = crate::extensions::Extensions::new(); - - let mut reg = make_registrar(&mut rec, &builders, &extensions); - - // Set context first, then override with raw — raw should win - reg.link_to("mqtt://broker/topic") - .with_serializer(|_ctx: crate::RuntimeContext, _record: &TestRecord| Ok(vec![0])) - .with_serializer_raw(|record: &TestRecord| Ok(record.value.to_le_bytes().to_vec())) - .finish(); - - let ser = rec.outbound_connectors()[0] - .serializer - .as_ref() - .expect("serializer should be set"); - assert!(matches!(ser, SerializerKind::Raw(_))); - } - - #[test] - fn outbound_context_overrides_previous_raw_serializer() { - use crate::connector::SerializerKind; - - let mut rec = crate::typed_record::TypedRecord::::new(); - rec.set_buffer(Box::new(MockBuffer)); - - let builders: Vec> = - vec![Box::new(MockConnectorBuilder { - scheme: "mqtt".to_string(), - })]; - let extensions = crate::extensions::Extensions::new(); - - let mut reg = make_registrar(&mut rec, &builders, &extensions); - - // Set raw first, then override with context — context should win - reg.link_to("mqtt://broker/topic") - .with_serializer_raw(|_record: &TestRecord| Ok(vec![0])) - .with_serializer(|_ctx: crate::RuntimeContext, _record: &TestRecord| Ok(vec![99])) - .finish(); - - let ser = rec.outbound_connectors()[0] - .serializer - .as_ref() - .expect("serializer should be set"); - assert!(matches!(ser, SerializerKind::Context(_))); + assert!(drain_errors(&mut rec).is_empty()); } #[test] @@ -1875,4 +1809,194 @@ mod tests { ingest(&db.runtime_ctx(), b"x").expect("ingest must succeed"); assert_eq!(last.load(Ordering::SeqCst), 7); } + + // ==================================================================== + // Fused outbound reader tests (design 036 W1) + // ==================================================================== + + use crate::connector::{SerializedReader as _, SerializedSource as _}; + + /// Buffer reader that replays a fixed script, then reports the buffer + /// closed. + struct ScriptedReader { + script: Vec>, + } + + impl ScriptedReader { + fn closed() -> crate::DbError { + crate::DbError::BufferClosed { + buffer_name: "scripted".to_string(), + } + } + } + + impl crate::buffer::BufferReader for ScriptedReader { + fn recv( + &mut self, + ) -> Pin> + Send + '_>> + { + let next = if self.script.is_empty() { + Err(Self::closed()) + } else { + self.script.remove(0) + }; + Box::pin(async move { next }) + } + fn try_recv(&mut self) -> Result { + unimplemented!("not needed for fused reader tests") + } + } + + fn lagged() -> crate::DbError { + crate::DbError::BufferLagged { + lag_count: 1, + buffer_name: "scripted".to_string(), + } + } + + fn fused_reader( + script: Vec>, + serialize: FusedSerializeFn, + topic: Option>>, + ) -> FusedReader { + FusedReader { + inner: Box::new(ScriptedReader { script }), + serialize, + topic, + } + } + + fn test_ctx() -> crate::RuntimeContext { + crate::RuntimeContext::new(Arc::new(MockRuntime)) + } + + /// Buffer errors propagate through the fused reader unchanged, so the + /// pumps keep their `BufferLagged => continue / Err => break` shape. + #[tokio::test] + async fn fused_reader_propagates_buffer_errors() { + let mut reader = fused_reader( + vec![ + Ok(TestRecord { value: 1 }), + Err(lagged()), + Ok(TestRecord { value: 2 }), + ], + Arc::new(|_ctx, r| Ok(r.value.to_le_bytes().to_vec())), + None, + ); + let ctx = test_ctx(); + + let first = reader.recv(&ctx).await.expect("first value"); + assert_eq!(first.payload, 1i32.to_le_bytes().to_vec()); + assert_eq!(first.dest, None); + + let err = reader.recv(&ctx).await.expect_err("lag must propagate"); + assert!(matches!(err, crate::DbError::BufferLagged { .. })); + + let second = reader.recv(&ctx).await.expect("second value"); + assert_eq!(second.payload, 2i32.to_le_bytes().to_vec()); + + let closed = reader.recv(&ctx).await.expect_err("closed must propagate"); + assert!(matches!(closed, crate::DbError::BufferClosed { .. })); + } + + /// Serialization failures are skipped inside the reader (logged), exactly + /// like the old pump-side `continue`. + #[tokio::test] + async fn fused_reader_skips_serialize_failures() { + let mut reader = fused_reader( + vec![Ok(TestRecord { value: 13 }), Ok(TestRecord { value: 42 })], + Arc::new(|_ctx, r| { + if r.value == 13 { + Err(crate::connector::SerializeError::InvalidData) + } else { + Ok(r.value.to_le_bytes().to_vec()) + } + }), + None, + ); + + // One recv: the failing value is skipped, the next good one returned. + let msg = reader.recv(&test_ctx()).await.expect("value"); + assert_eq!(msg.payload, 42i32.to_le_bytes().to_vec()); + } + + /// The destination is resolved from the typed value while it is in hand. + #[tokio::test] + async fn fused_reader_resolves_dynamic_topic() { + struct PositiveTopic; + impl crate::connector::TopicProvider for PositiveTopic { + fn topic(&self, value: &TestRecord) -> Option { + (value.value > 0).then(|| alloc::format!("dyn/{}", value.value)) + } + } + + let mut reader = fused_reader( + vec![Ok(TestRecord { value: 5 }), Ok(TestRecord { value: 0 })], + Arc::new(|_ctx, r| Ok(r.value.to_le_bytes().to_vec())), + Some(Arc::new(PositiveTopic)), + ); + let ctx = test_ctx(); + + let first = reader.recv(&ctx).await.expect("value"); + assert_eq!(first.dest.as_deref(), Some("dyn/5")); + + let second = reader.recv(&ctx).await.expect("value"); + assert_eq!(second.dest, None); // falls back to the route default + } + + /// End-to-end outbound path: registrar → build → collect → subscribe → + /// recv, pinning the factory wiring (raw and context serializers). + #[tokio::test] + async fn outbound_roundtrip_yields_serialized_values() { + /// Buffer whose readers replay one canned value, then close. + struct CannedBuffer; + impl crate::buffer::DynBuffer for CannedBuffer { + fn push(&self, _value: TestRecord) {} + fn subscribe_boxed(&self) -> Box + Send> { + Box::new(ScriptedReader { + script: vec![Ok(TestRecord { value: 5 })], + }) + } + fn as_any(&self) -> &dyn core::any::Any { + self + } + } + + struct FixedTopic; + impl crate::connector::TopicProvider for FixedTopic { + fn topic(&self, value: &TestRecord) -> Option { + Some(alloc::format!("dyn/{}", value.value)) + } + } + + let mut builder = crate::AimDbBuilder::new() + .runtime(Arc::new(MockRuntime)) + .with_connector(NoopConnectorBuilder); + builder.configure::("rec.out", |reg| { + reg.buffer_raw(Box::new(CannedBuffer)); + // Raw set first, context set last — context must win (the kind + // enum is gone; mutual exclusion is behavior now). + reg.link_to("mqtt://tele/out") + .with_topic_provider(FixedTopic) + .with_serializer_raw(|_r: &TestRecord| Ok(vec![0])) + .with_serializer(|_ctx: crate::RuntimeContext, r: &TestRecord| { + Ok(r.value.to_le_bytes().to_vec()) + }) + .finish(); + }); + let (db, _runner) = builder.build().await.expect("build must succeed"); + + let routes = db.collect_outbound_routes("mqtt"); + assert_eq!(routes.len(), 1); + assert_eq!(routes[0].topic, "tele/out"); + + let mut reader = routes[0].source.subscribe(); + let ctx = db.runtime_ctx(); + let msg = reader.recv(&ctx).await.expect("value"); + assert_eq!(msg.dest.as_deref(), Some("dyn/5")); + assert_eq!(msg.payload, 5i32.to_le_bytes().to_vec()); + + let closed = reader.recv(&ctx).await.expect_err("buffer closed"); + assert!(matches!(closed, crate::DbError::BufferClosed { .. })); + } } diff --git a/aimdb-knx-connector/tests/topic_provider_tests.rs b/aimdb-knx-connector/tests/topic_provider_tests.rs index 718ffc6..5385142 100644 --- a/aimdb-knx-connector/tests/topic_provider_tests.rs +++ b/aimdb-knx-connector/tests/topic_provider_tests.rs @@ -398,55 +398,49 @@ async fn test_hvac_zone_routing() { } // ============================================================================ -// Test TopicProviderAny Type Erasure with KNX Types +// Test TopicProvider as a typed trait object with KNX Types // ============================================================================ #[test] -fn test_knx_topic_provider_type_erasure() { - use aimdb_core::connector::{TopicProviderAny, TopicProviderWrapper}; - use std::any::Any; +fn test_knx_topic_provider_as_trait_object() { + use aimdb_core::connector::TopicProvider; + use std::sync::Arc; - let provider = TopicProviderWrapper::new(RoomBasedGroupAddressProvider::new(1, 0)); + // Providers are stored as Arc> and stay typed + // end-to-end (design 036 W1) — a wrong-type call is unrepresentable. + let provider: Arc> = + Arc::new(RoomBasedGroupAddressProvider::new(1, 0)); - // Correct type let dimmer = DimmerValue::new("living", 128); - let dimmer_any: &dyn Any = &dimmer; - assert_eq!(provider.topic_any(dimmer_any), Some("knx://1/0/1".into())); - - // Wrong type: should return None - let switch = SwitchState::new("zone-1", true); - let switch_any: &dyn Any = &switch; - assert_eq!(provider.topic_any(switch_any), None); + assert_eq!(provider.topic(&dimmer), Some("knx://1/0/1".into())); } // ============================================================================ // Test: Simulate Connector Group Address Resolution Logic // ============================================================================ // -// These tests simulate EXACTLY what the KNX connector does internally: +// These tests simulate EXACTLY what the fused outbound reader does internally +// while it still holds the typed value (design 036 W1): // ```rust -// let group_addr = topic_provider -// .as_ref() -// .and_then(|provider| provider.topic_any(&*value_any)) -// .unwrap_or_else(|| default_group_addr.clone()); +// let dest = topic.as_ref().and_then(|p| p.topic(&value)); +// // ...later, in the pump: +// let dest = msg.dest.unwrap_or_else(|| default_group_addr.clone()); // ``` -/// Simulates the connector's group address resolution for outbound telegrams -fn resolve_group_address_like_connector( +/// Simulates the fused reader's group address resolution for outbound telegrams +fn resolve_group_address_like_connector( default_group_addr: &str, - topic_provider: Option<&dyn aimdb_core::connector::TopicProviderAny>, - value: &dyn std::any::Any, + topic_provider: Option<&dyn aimdb_core::connector::TopicProvider>, + value: &T, ) -> String { topic_provider - .and_then(|provider| provider.topic_any(value)) + .and_then(|provider| provider.topic(value)) .unwrap_or_else(|| default_group_addr.to_string()) } #[test] fn test_connector_group_address_resolution_room_based() { - use aimdb_core::connector::TopicProviderWrapper; - - let provider = TopicProviderWrapper::new(RoomBasedGroupAddressProvider::new(1, 0)); + let provider = RoomBasedGroupAddressProvider::new(1, 0); let default_addr = "knx://1/0/0"; // Test 1: Living room dimmer @@ -476,9 +470,7 @@ fn test_connector_group_address_resolution_room_based() { #[test] fn test_connector_group_address_resolution_hvac_zones() { - use aimdb_core::connector::TopicProviderWrapper; - - let provider = TopicProviderWrapper::new(HvacZoneProvider); + let provider = HvacZoneProvider; let default_addr = "knx://5/0/0"; // Fallback for invalid zones // Test valid zones 1-16 @@ -504,9 +496,7 @@ fn test_connector_group_address_resolution_hvac_zones() { #[test] fn test_connector_group_address_resolution_emergency_switch() { - use aimdb_core::connector::TopicProviderWrapper; - - let provider = TopicProviderWrapper::new(SwitchWithEmergencyProvider); + let provider = SwitchWithEmergencyProvider; let default_addr = "knx://1/1/0"; // Test 1: Emergency switch → broadcast address diff --git a/aimdb-mqtt-connector/tests/topic_provider_tests.rs b/aimdb-mqtt-connector/tests/topic_provider_tests.rs index 3064f71..0e151b1 100644 --- a/aimdb-mqtt-connector/tests/topic_provider_tests.rs +++ b/aimdb-mqtt-connector/tests/topic_provider_tests.rs @@ -355,58 +355,48 @@ async fn test_mixed_static_and_dynamic_topics() { } // ============================================================================ -// Test TopicProviderAny Type Erasure +// Test TopicProvider as a typed trait object // ============================================================================ #[test] -fn test_topic_provider_type_erasure() { - use aimdb_core::connector::{TopicProviderAny, TopicProviderWrapper}; - use std::any::Any; +fn test_topic_provider_as_trait_object() { + use aimdb_core::connector::TopicProvider; + use std::sync::Arc; - let provider = TopicProviderWrapper::new(SensorIdTopicProvider); + // Providers are stored as Arc> and stay typed + // end-to-end (design 036 W1) — a wrong-type call is unrepresentable. + let provider: Arc> = Arc::new(SensorIdTopicProvider); - // Correct type: should return Some let temp = Temperature::new("kitchen", 22.0); - let temp_any: &dyn Any = &temp; - assert_eq!( - provider.topic_any(temp_any), - Some("sensors/temp/kitchen".into()) - ); - - // Wrong type: should return None (type mismatch) - let wrong_type = "not a temperature"; - let wrong_any: &dyn Any = &wrong_type; - assert_eq!(provider.topic_any(wrong_any), None); + assert_eq!(provider.topic(&temp), Some("sensors/temp/kitchen".into())); } // ============================================================================ // Test: Simulate Connector Topic Resolution Logic // ============================================================================ // -// These tests simulate EXACTLY what the MQTT connector does internally: +// These tests simulate EXACTLY what the fused outbound reader does internally +// while it still holds the typed value (design 036 W1): // ```rust -// let topic = topic_provider -// .as_ref() -// .and_then(|provider| provider.topic_any(&*value_any)) -// .unwrap_or_else(|| default_topic.clone()); +// let dest = topic.as_ref().and_then(|p| p.topic(&value)); +// // ...later, in the pump: +// let dest = msg.dest.unwrap_or_else(|| default_topic.clone()); // ``` -/// Simulates the connector's topic resolution for outbound messages +/// Simulates the fused reader's topic resolution for outbound messages fn resolve_topic_like_connector( default_topic: &str, - topic_provider: Option<&dyn aimdb_core::connector::TopicProviderAny>, - value: &dyn std::any::Any, + topic_provider: Option<&dyn aimdb_core::connector::TopicProvider>, + value: &Temperature, ) -> String { topic_provider - .and_then(|provider| provider.topic_any(value)) + .and_then(|provider| provider.topic(value)) .unwrap_or_else(|| default_topic.to_string()) } #[test] fn test_connector_topic_resolution_with_dynamic_provider() { - use aimdb_core::connector::TopicProviderWrapper; - - let provider = TopicProviderWrapper::new(SensorIdTopicProvider); + let provider = SensorIdTopicProvider; let default_topic = "sensors/temp/default"; // Test 1: Dynamic topic is returned when provider returns Some @@ -427,9 +417,7 @@ fn test_connector_topic_resolution_with_dynamic_provider() { #[test] fn test_connector_topic_resolution_fallback_to_default() { - use aimdb_core::connector::TopicProviderWrapper; - - let provider = TopicProviderWrapper::new(FallbackTopicProvider); + let provider = FallbackTopicProvider; let default_topic = "sensors/temp/default"; // Test 1: Provider returns Some → use dynamic topic @@ -455,9 +443,7 @@ fn test_connector_topic_resolution_no_provider() { #[test] fn test_connector_topic_resolution_with_threshold_provider() { - use aimdb_core::connector::TopicProviderWrapper; - - let provider = TopicProviderWrapper::new(ThresholdTopicProvider { threshold: 30.0 }); + let provider = ThresholdTopicProvider { threshold: 30.0 }; let default_topic = "sensors/temp/normal"; // Normal temperature → fallback (provider returns None) From 569b8709e2083b92ba5200950988506a8f38a2e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 11 Jun 2026 21:34:30 +0000 Subject: [PATCH 03/17] refactor(core)!: drop unrepresentable error surface; document join erasure (036 W1 wrap-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - delete SerializeError::TypeMismatch — both constructors died with the W1 fusion (the downcasts are gone); DbError::TypeMismatch is unrelated and stays - delete dead ConnectorClient (held Arc, zero users) and OutboundConnectorLink (zero users) - document on JoinTrigger why the join fan-in deliberately keeps its Box (the erasure is the multi-type join API) - CHANGELOG entries (global + aimdb-core + websocket-connector) - check in design docs 034/035/036 and amend the 036 W1 acceptance grep: DynBuffer::as_any and the session auth ext slots are setup-time hits the original literal grep missed Part of design 036 W1 (data-plane de-Any). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 + aimdb-core/CHANGELOG.md | 9 + aimdb-core/src/connector.rs | 75 ------ aimdb-core/src/lib.rs | 2 +- aimdb-core/src/transform/join.rs | 11 + aimdb-websocket-connector/CHANGELOG.md | 2 + docs/design/034-technical-debt-review.md | 262 +++++++++++++++++++ docs/design/035-review-followups-deferred.md | 165 ++++++++++++ docs/design/036-followup-refactoring.md | 152 +++++++++++ 9 files changed, 604 insertions(+), 76 deletions(-) create mode 100644 docs/design/034-technical-debt-review.md create mode 100644 docs/design/035-review-followups-deferred.md create mode 100644 docs/design/036-followup-refactoring.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f681bef..42f42d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed (breaking) +- **Design 036 W1 — data-plane de-`Any`: per-message type erasure removed from the connector SPI ([design doc §W1](docs/design/036-followup-refactoring.md)).** #131 removed the control-plane erasure; this removes the per-message kind. The typed pipeline is now fused inside closures at registration time, so no `Box` is constructed on any per-message path (connectors, session pumps, AimX client mirroring). Inbound: a sync `IngestFn` (deserialize + produce in one typed closure — the per-message boxed future disappears too; `Router::route` becomes a sync fn with a mandatory `RuntimeContext`) replaces `DeserializerKind` + `ProducerTrait::produce_any`. Outbound: a fused `SerializedSource`/`SerializedReader` yielding `SerializedValue { dest, payload }` replaces `ConsumerTrait::subscribe_any` → `AnyReader::recv_any` → `SerializerKind(&dyn Any)`, and absorbs the third erasure crossing (`TopicProviderAny::topic_any` — the typed `TopicProvider` is now stored directly). `SerializeError::TypeMismatch` and the dead `ConnectorClient`/`OutboundConnectorLink` are deleted. The user-facing registrar API is **source-compatible** (serializer/deserializer/topic-provider registration signatures unchanged — examples, codegen output, and aimdb-pro compile untouched); `JoinTrigger` deliberately keeps its erasure (it *is* the multi-type join API, documented on the type). Behavior pinned by the unmodified KNX fake-gateway and session smoke tests. ([aimdb-core](aimdb-core/CHANGELOG.md)) + - **Design 034 Phase 3 — runtime type parameter `R` removed from the object graph (Issue #131, [review doc §3.2/§3.3](docs/design/034-technical-debt-review.md)).** The runtime is now a *value* (`Arc`, the #130 groundwork) instead of a type parameter; the only generic left on the user-facing object graph is the record type `T`. The full break inventory: - **Types lose `R`:** `AimDb` → `AimDb`, `AimDbBuilder` → `AimDbBuilder` (the `NoRuntime` typestate is gone — a missing runtime is a `build()` error per the #133 contract), `TypedRecord` → `TypedRecord`, `RecordRegistrar<'a, T, R>` → `RecordRegistrar<'a, T>`, `TransformBuilder` → `TransformBuilder`, `JoinBuilder` → `JoinBuilder`, `RecordT` → `RecordT`, and `ConnectorBuilder` → `ConnectorBuilder` (its `build()` takes `&AimDb`). Turbofish updates: `get_typed_record_by_key::` → `::`, `as_typed::` → `::`. - **`RuntimeContext` is a concrete struct** wrapping `Arc`. `ctx.time().now()` returns **`u64` nanoseconds** from an arbitrary monotonic epoch (was `R::Instant`); `sleep` takes a plain `core::time::Duration` (plus `sleep_millis`/`sleep_secs` helpers); `millis()`/`secs()`/`micros()`/`duration_since()`/`duration_as_nanos()` are deleted (durations are concrete, instants are integer math); the panicking `extract_from_any` is deleted. diff --git a/aimdb-core/CHANGELOG.md b/aimdb-core/CHANGELOG.md index bae4dc2..71702ff 100644 --- a/aimdb-core/CHANGELOG.md +++ b/aimdb-core/CHANGELOG.md @@ -13,6 +13,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed (breaking) +- **Design 036 W1 — data-plane de-`Any`: the per-message `Box` is gone from the connector SPI ([design doc §W1](../docs/design/036-followup-refactoring.md)).** Both ends of every erased hop were typed — `T` is known in the registrar where routes are wired, and the connector spine only wants bytes — so the typed pipeline is now built inside closures at registration time (`finish()`) and the SPI exposes only the wire level. The full break inventory: + - **Inbound:** new `IngestFn = Arc Result<(), String>>` + `IngestFactoryFn` replace deserializer + producer: deserialize + produce in one typed closure, **synchronous** (`Producer::produce` is sync + infallible per design 029 — the per-message `Box::pin` disappears along with the `Box`). Deleted: `ProducerTrait`/`produce_any`, `ProducerFactoryFn`, `DeserializerFn`/`ContextDeserializerFn`/`DeserializerKind`, `TypedRecord::create_producer_trait`. `InboundConnectorLink` is `{ url, config, ingest_factory, topic_resolver }` (factory non-optional — `finish()` validates the deserializer before registering, error strings unchanged); `collect_inbound_routes` returns `Vec<(String, IngestFn)>`; `Route` is `{ resource_id, ingest }`. + - **`Router::route` is a sync fn and its context is mandatory:** `route(&self, resource_id, payload, ctx: &RuntimeContext) -> Result<(), String>` (was `async` with `Option<&RuntimeContext>`). Every production caller already passed `Some(&ctx)`; the old "skip context-deserializers when no ctx" branch is unrepresentable now that raw-vs-context is invisible inside the fused closure. + - **Outbound:** new `SerializedSource` (object-safe `subscribe()`, sync — the buffer handle is pre-resolved) → `SerializedReader` whose `recv(&mut self, ctx: &RuntimeContext)` yields `SerializedValue { dest, payload }` — subscribe → recv → resolve topic → serialize all typed inside, including the third erasure crossing the old path had: `topic_any(&dyn Any)` per value. Deleted: `ConsumerTrait`/`subscribe_any`, `AnyReader`/`recv_any`, `ConsumerFactoryFn`, `SerializerFn`/`ContextSerializerFn`/`SerializerKind`, `TopicProviderAny`/`TopicProviderWrapper`/`TopicProviderFn` (the typed `TopicProvider` is stored as `Arc>` directly). `ConnectorLink` is `{ url, config, source_factory }` (non-optional, same validation contract); `OutboundRoute` is `{ topic, source, config }` and the "skip links without serializer" branch in `collect_outbound_routes` is gone. + - **Error contract of the fused reader:** buffer errors propagate unchanged (`BufferLagged` → pumps skip the gap; anything else → publisher stops, identical control flow). Serialization failures are logged and skipped *inside* the reader — observably the same as the old pump-side `continue`, but the log line moves (now `outbound link: failed to serialize (dest …)` instead of `pump_sink: failed to serialize …`). + - **Unrepresentable error surface deleted:** `SerializeError::TypeMismatch` (both constructors died with the downcasts; `DbError::TypeMismatch` is unrelated and stays). Dead code deleted: `ConnectorClient` (held `Arc`, zero users) and `OutboundConnectorLink`. + - **Source-compatible:** the registrar API (`with_serializer`/`with_serializer_raw`/`with_deserializer`/`with_deserializer_raw`/`with_topic_provider`/`with_topic_resolver`, `link_to`/`link_from`) keeps identical signatures — examples, codegen output, and aimdb-pro compile unchanged. The `RuntimeContext` is threaded into `recv`/ingest per call (not captured) for the design-026 context (de)serializers; raw variants skip the per-message ctx clone. + - **Deliberate exception:** `JoinTrigger` keeps its `Box` — a join fans N differently-typed inputs into one channel and user closures branch via `as_input::()`; the erasure is the public API there (documented on the type). Remaining `dyn Any` in core after W1: `ExtensionMap` (TypeId-keyed), `AnyRecord::as_any`/`as_any_mut` + `DynBuffer::as_any` (setup-time), and join. + - **Phase 3 — `R` removed from the object graph (Issue #131, [design doc §3.2/§3.3](../docs/design/034-technical-debt-review.md)).** The runtime travels as `Arc`; records (`T`) are the only generic surface left. `AimDb`, `AimDbBuilder` (no `NoRuntime` typestate), `TypedRecord`, `RecordRegistrar<'a, T>`, `TransformBuilder`, `JoinBuilder`, `RecordT`, and `ConnectorBuilder` are all non-generic over the runtime; `RuntimeContext` is a concrete struct (`time().now()` → `u64` nanos, `sleep(core::time::Duration)` + `sleep_millis`/`sleep_secs`; `millis`/`secs`/`micros`/`duration_since`/`duration_as_nanos`/`extract_from_any` deleted). `source`/`tap`/`transform`/`transform_join` are inherent registrar methods (the `*_raw` variants and `ext_macros.rs` are deleted); connector consumer/producer factories take `&AimDb` (the `Arc` downcast-or-panic dance is gone); context (de)serializers and `Router::route` receive the concrete `RuntimeContext`; `runtime_arc()` → `runtime_ops()` (+ new `runtime_ctx()`), `runtime_any()` and the borrowed `runtime()` accessor deleted (zero callers; `&*db.runtime_ops()` covers the borrowed flavor — [follow-up doc §2.5](../docs/design/035-review-followups-deferred.md)); `on_start` closures receive `RuntimeContext`; the `RuntimeForProfiling` marker is deleted (profiling clocks ride `RuntimeOps::now_nanos`); the session client engine clock is `Arc`. Multi-input join fan-in is one bounded `async-channel` queue in core (capacity 64 on `std` and wasm32 — matching the old tokio/WASM queues — and 16 on embedded `no_std`, up from Embassy's 8). **Close semantics changed on Embassy:** the queue now closes on *all* runtimes once every input forwarder exits, so a `no_std` join handler's `while let Ok(_) = rx.recv().await` loop ends instead of parking forever — treat `Err(QueueClosed)` as end-of-inputs. Input forwarders skip `BufferLagged` (SPMC-ring overflow) and keep forwarding, the same recoverable-lag policy as every other recv loop in core. The `JoinFanInRuntime` GAT family is gone from `aimdb-executor`. - **Generic runtime trait re-exports removed from the crate root (Issue #131 follow-up).** `aimdb_core::{RuntimeAdapter, Runtime, TimeOps, Logger, RuntimeInfo}` are gone — core no longer consumes the generic family (the runtime travels as `Arc`), and keeping the re-exports invited `R:`-bounds back into downstream signatures. Import them from `aimdb_executor` directly where an adapter still implements them; `ExecutorError`/`ExecutorResult` stay re-exported. diff --git a/aimdb-core/src/connector.rs b/aimdb-core/src/connector.rs index 2d77c11..8ef529c 100644 --- a/aimdb-core/src/connector.rs +++ b/aimdb-core/src/connector.rs @@ -51,9 +51,6 @@ pub enum SerializeError { /// Output buffer is too small for the serialized data BufferTooSmall, - /// Type mismatch in serializer (wrong type passed) - TypeMismatch, - /// Invalid data that cannot be serialized InvalidData, } @@ -63,7 +60,6 @@ impl defmt::Format for SerializeError { fn format(&self, f: defmt::Formatter) { match self { Self::BufferTooSmall => defmt::write!(f, "BufferTooSmall"), - Self::TypeMismatch => defmt::write!(f, "TypeMismatch"), Self::InvalidData => defmt::write!(f, "InvalidData"), } } @@ -74,7 +70,6 @@ impl std::fmt::Display for SerializeError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::BufferTooSmall => write!(f, "Output buffer too small"), - Self::TypeMismatch => write!(f, "Type mismatch in serializer"), Self::InvalidData => write!(f, "Invalid data for serialization"), } } @@ -336,70 +331,6 @@ impl fmt::Display for ConnectorUrl { } } -/// Connector client types (type-erased for storage) -/// -/// This enum allows storing different connector client types in a unified way. -/// Actual protocol implementations will downcast to their concrete types. -/// -/// # Design Note -/// -/// This is intentionally minimal - actual client types are defined by -/// user extensions. The core only provides the infrastructure. -/// -/// Works in both `std` and `no_std` (with `alloc`) environments. -#[derive(Clone)] -pub enum ConnectorClient { - /// MQTT client (protocol-specific, user-provided) - Mqtt(Arc), - - /// Kafka producer (protocol-specific, user-provided) - Kafka(Arc), - - /// HTTP client (protocol-specific, user-provided) - Http(Arc), - - /// Generic connector for custom protocols - Generic { - protocol: String, - client: Arc, - }, -} - -impl Debug for ConnectorClient { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ConnectorClient::Mqtt(_) => write!(f, "ConnectorClient::Mqtt(..)"), - ConnectorClient::Kafka(_) => write!(f, "ConnectorClient::Kafka(..)"), - ConnectorClient::Http(_) => write!(f, "ConnectorClient::Http(..)"), - ConnectorClient::Generic { protocol, .. } => { - write!(f, "ConnectorClient::Generic({})", protocol) - } - } - } -} - -impl ConnectorClient { - /// Downcasts to a concrete client type - /// - /// # Example - /// - /// ```rust,ignore - /// use rumqttc::AsyncClient; - /// - /// if let Some(mqtt_client) = connector.downcast_ref::>() { - /// // Use the MQTT client - /// } - /// ``` - pub fn downcast_ref(&self) -> Option<&T> { - match self { - ConnectorClient::Mqtt(arc) => arc.downcast_ref::(), - ConnectorClient::Kafka(arc) => arc.downcast_ref::(), - ConnectorClient::Http(arc) => arc.downcast_ref::(), - ConnectorClient::Generic { client, .. } => client.downcast_ref::(), - } - } -} - /// Configuration for a connector link /// /// Stores the parsed URL, configuration, and the fused source factory until @@ -587,12 +518,6 @@ impl InboundConnectorLink { } } -/// Configuration for an outbound connector link (AimDB → External) -pub struct OutboundConnectorLink { - pub url: ConnectorUrl, - pub config: Vec<(String, String)>, -} - /// Parses a connector URL string into structured components /// /// This is a simple parser that handles the most common URL formats. diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index 3203be8..fb1efa3 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -88,7 +88,7 @@ pub use profiling::{RecordProfilingMetrics, StageMetrics, StageProfilingInfo}; // Connector Infrastructure exports pub use connector::TopicProvider; pub use connector::TopicResolverFn; -pub use connector::{ConnectorClient, ConnectorLink, ConnectorUrl, SerializeError}; +pub use connector::{ConnectorLink, ConnectorUrl, SerializeError}; pub use connector::{IngestFactoryFn, IngestFn}; pub use connector::{SerializedReader, SerializedSource, SerializedValue, SourceFactoryFn}; diff --git a/aimdb-core/src/transform/join.rs b/aimdb-core/src/transform/join.rs index c89639e..9592c9f 100644 --- a/aimdb-core/src/transform/join.rs +++ b/aimdb-core/src/transform/join.rs @@ -48,6 +48,17 @@ fn join_channel() -> ( /// Passed to the event loop inside the closure registered with [`JoinBuilder::on_triggers`]. /// Use [`JoinTrigger::index`] to branch on the source input and /// [`JoinTrigger::as_input`] to downcast the value to the concrete type. +/// +/// # Why this carries `Box` +/// +/// This is the one deliberate exception to the data-plane de-erasure +/// (design 036 W1): a join fans N *differently-typed* inputs into a single +/// trigger channel, and the user's `on_triggers` closure branches on them +/// via [`JoinTrigger::as_input`] — the erasure *is* the public API here, not +/// an implementation accident. Removing it would mean N channels or a +/// generated input enum, i.e. a different feature. The cost (one box + +/// downcast per join input value) is confined to this module and applies +/// only to explicit user-built joins, never to the connector spine. pub enum JoinTrigger { Input { index: usize, diff --git a/aimdb-websocket-connector/CHANGELOG.md b/aimdb-websocket-connector/CHANGELOG.md index 33bfe30..effb7bb 100644 --- a/aimdb-websocket-connector/CHANGELOG.md +++ b/aimdb-websocket-connector/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Internal refactors +- **Adjusted to core's design-036-W1 data-plane de-`Any`.** `WsDispatch`/`WsSession` carry a concrete `RuntimeContext` (was `Option` — it was always `Some`) and the inbound `Router::route` call is synchronous; the inbound route tuples and `pump_sink` routes flow through opaquely. No public API or wire change. + - **WebSocket server + client ported onto the shared session engine (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** Behavior-preserving (wire-identical, gated by a round-trip test): the WS server now runs on `aimdb-core`'s `serve`/`run_session` and the client on `run_client`, so the two hand-rolled WS stacks collapse onto the same engines as AimX. New modules: `codec` (`WsCodec`, the per-connection WS-JSON `EnvelopeCodec` — id↔topic bookkeeping, O(1) fan-out by writing the bus-pre-serialized `Data` frame verbatim, zero-copy `decode_outbound` replacing the old `&'static` topic interner), `transport` (`WsServerConnection`/`WsClientConnection`/`WsDialer` over axum / tokio-tungstenite, including the multi-topic `Subscribe`/`Unsubscribe` split), and `dispatch` (`WsDispatch`/`WsSession` homing the `ClientManager` bus + auth + query/snapshot). The hand-rolled `client/connector.rs` loop is removed; `client_manager`/`session` slim down to a fan-out bus + snapshot/query providers. Public `WebSocketConnectorBuilder` / `WsClientConnectorBuilder` surfaces are unchanged (the client builder now bounds `R: TimeOps` for the engine clock). Added `examples/ws_server.rs`, `tests/ws_roundtrip.rs`, and a dev-dep on `aimdb-tokio-adapter`. - **WS client connector is now spawn-free (Issue #114, Design 030).** All six `tokio::spawn` call sites in the client connector (initial write/read/keepalive/reconnect-watcher plus the watcher's per-reconnect read/write loops) collapsed into one infrastructure future that owns a `FuturesUnordered` driven by `tokio::select! { biased; }`. The reconnect watcher no longer spawns; on a successful reconnect it sends a `NewLoops { write_sink, read_stream, write_rx }` over an mpsc to the outer future, which pushes fresh read- and write-loop futures onto the set. - `WsClientConnectorImpl::connect()` return type changed from `Result` to `Result<(Self, BoxFuture), String>` — the second element is the infrastructure future; the builder prepends it to the outbound publisher futures before returning to `AimDbBuilder`. diff --git a/docs/design/034-technical-debt-review.md b/docs/design/034-technical-debt-review.md new file mode 100644 index 0000000..0b89fdb --- /dev/null +++ b/docs/design/034-technical-debt-review.md @@ -0,0 +1,262 @@ +# 034 — Technical Debt & Architecture Review + +**Status:** Draft +**Scope:** Whole workspace (aimdb-core, adapters, connectors, tools, examples, build infrastructure) +**Goal:** Identify where the codebase carries technical debt or architectural weight that does not pay for itself, and lay out a path to a leaner codebase **without changing functionality**. + +--- + +## 1. Executive summary + +AimDB's core idea is sound and the recent refactor series (028 → 033) has been moving in the right direction: the runtime-neutral session engine, the spawn-free future collection, and the removal of `R` from `Producer`/`Consumer` all reduced real debt. The remaining debt clusters into five themes: + +1. **Platform variance is encoded as `cfg` forks instead of shared abstractions.** `aimdb-core` has ~128 `cfg(feature = "std")` gates; most exist only because types that live in `alloc` (`String`, `Arc`, `Box`, `Vec`) are imported through two different paths, and because `DbError` has *structurally different fields* per target. +2. **`dyn Any` is the system's plumbing.** The registry, connector factories, runtime context, serializers, and the builder's own spawn/start function lists all pass through `Box`/`Arc` with panicking downcasts. The codebase pays for generics (the `R` parameter on everything) **and** for type erasure at the same time. +3. **Per-runtime duplication in connectors.** The KNX/IP tunneling state machine is implemented twice (~1,000 lines each for tokio and Embassy); MQTT exists twice on two unrelated client ecosystems. The session engine in core proves the better pattern (one engine, thin transports) already works in this repo. +4. **Dead and speculative API surface.** `Database`, the deprecated `.link()`, a vestigial `tokio` dependency in core, `ConnectorConfig`'s Kafka/HTTP/shmem story, and a god-trait `AnyRecord` with ~20 methods spanning six concerns. +5. **Workspace weight.** 12 example crates inside the primary workspace, plus three vendored forks (entire Embassy monorepo, mountain-mqtt, knx-pico) wired through `[patch.crates-io]`. + +Rough size of the prize: removing the mechanical debt alone (themes 1 and 4) is on the order of **1,500–2,500 deleted/simplified lines in core and adapters with zero behavior change**. The connector consolidation (theme 3) is worth another ~1,500 lines but is real engineering work. + +--- + +## 2. Method + +Reviewed: all of `aimdb-core` (with full reads of `typed_api.rs`, `builder.rs`, `typed_record.rs`, `error.rs`, `connector.rs`, `record_id.rs`, `context.rs`, `database.rs`, `transport.rs`, the `session/` and `remote/` modules), the three runtime adapters, all six connectors, `aimdb-sync`, `aimdb-client`, `aimdb-codegen`, `aimdb-data-contracts`, the tools, the workspace manifests, Makefile and CI layout. Line counts below are from `wc -l` at the time of review. + +--- + +## 3. Findings + +### 3.1 The std/no_std split is implemented at the wrong level + +**This is the single largest source of noise in the codebase.** + +#### 3.1.1 `DbError` has different fields per target + +Every variant of `DbError` ([error.rs](../../aimdb-core/src/error.rs)) carries `field: String` under `std` and `_field: ()` under `no_std`: + +```rust +RecordKeyNotFound { + #[cfg(feature = "std")] + key: String, + #[cfg(not(feature = "std"))] + _key: (), +}, +``` + +Consequences ripple through the whole crate: + +- **Every construction site needs a dual branch.** `builder.rs` alone has ~8 of these blocks ([builder.rs:185-196](../../aimdb-core/src/builder.rs#L185-L196), [builder.rs:707-737](../../aimdb-core/src/builder.rs#L703-L737), …); `typed_record.rs`, `typed_api.rs` and the adapters repeat the pattern. Helper constructors (`DbError::runtime_error`, `record_key_not_found`, …) exist but are only used on some paths, so both styles coexist. +- **Error context is silently discarded on embedded targets**, where debugging is hardest. The no_std `Display` impl can only print a numeric code. +- **The no_std shape cannot be tested from a std build** — the enum is literally a different type. + +The kicker: `aimdb-core` **always** requires `alloc` (`extern crate alloc` is unconditional, and the `alloc` feature underpins every other feature). `alloc::string::String` is available on every supported target, and `thiserror` 2.x supports no_std. The dual-field design solves a problem the crate does not have. If string formatting cost on MCU is the real concern, the right shape is still *one* shape (e.g. `&'static str` context or a compact context struct) — not two enums in a trench coat. + +#### 3.1.2 Duplicate import dances and duplicated impl blocks + +~25 sites import the same `alloc` types through two paths: + +```rust +#[cfg(not(feature = "std"))] +use alloc::{boxed::Box, sync::Arc}; +#[cfg(feature = "std")] +use std::{boxed::Box, sync::Arc}; +``` + +`std::sync::Arc` *is* `alloc::sync::Arc`. One unconditional `use alloc::...` works everywhere. The extreme case is [context.rs](../../aimdb-core/src/context.rs), where the **entire `RuntimeContext` impl block is written twice** — std and no_std versions that are character-for-character identical except for the `Arc` path. + +#### 3.1.3 Feature-flag combinatorics and a layering violation + +`aimdb-core` has 11 features with implication chains (`std → remote-access → json-serialize`, `std → connector-session`, `std → tokio`). Gate counts inside core: 128 `std`, 62 `tracing`, 41 `profiling`, 28 `remote-access`, 22 `metrics`, 12 `json-serialize`, 11 `connector-session`, 7 `defmt`. The matrix is untestable in full, and several gates interact (e.g. `profiling` needs the `RuntimeForProfiling` marker-trait workaround in [lib.rs:46-64](../../aimdb-core/src/lib.rs#L46-L64)). + +Concrete vestige: the `std` feature still enables a **non-dev `tokio` dependency with `net`, `io-util`, `sync`, `time`** — but after the session-engine refactor (030/033), the only `tokio::` references left in `aimdb-core/src` are inside `#[cfg(test)]` modules, which are covered by the dev-dependency. Core advertises runtime neutrality while unconditionally pulling tokio into every std build. + +The 62 `#[cfg(feature = "tracing")]` blocks wrapping individual `tracing::info!` calls deserve their own mention: one internal `log_debug!`/`log_info!` macro that expands to nothing when the feature is off would delete ~120 lines of attribute noise and make the code readable again. + +### 3.2 `dyn Any` is the load-bearing plumbing + +The design is "generic at the edges, type-erased in the middle" — and the middle is wide: + +| Mechanism | Location | Erasure | +|---|---|---| +| Producer routing | `ProducerTrait::produce_any` | `Box` per message, downcast per message | +| Consumer routing | `ConsumerTrait::subscribe_any`, `AnyReader::recv_any` | `Box` per received value | +| Connector factories | `ConsumerFactoryFn` / `ProducerFactoryFn` | take `Arc`, downcast to `AimDb`, **panic** on mismatch ([typed_api.rs:856-884](../../aimdb-core/src/typed_api.rs#L856-L884)) | +| Runtime context | `source_raw`/`tap_raw` closures receive `Arc`; adapters call `RuntimeContext::extract_from_any` which **panics** on mismatch | erased and recovered inside one crate | +| Serializers | `SerializerFn(&dyn Any)`, downcast_ref to `T` | per publish | +| Builder internals | `spawn_fns: Vec<(StringKey, Box)>`, `start_fns: Vec>`, downcast at `build()` with `expect` ([builder.rs:325-330](../../aimdb-core/src/builder.rs#L325-L330)) | self-inflicted | + +The last row is the clearest case of self-inflicted erasure: `AimDbBuilder` is *already generic over `R`*, and both `on_start()` and `configure()` are only callable after `.runtime()` fixes `R`. The struct could store `Vec>` directly. The stated justification (sharing the field with the `NoRuntime` builder) doesn't hold — `start_fns` on the `NoRuntime` builder is always empty because `on_start` isn't available there, and the field type may mention `R` regardless since the struct is generic. + +Most of the remaining erasure exists because of one root cause, covered next. + +### 3.3 The runtime parameter `R` infects everything — and then gets erased anyway + +`TypedRecord`, `RecordRegistrar<'a, T, R>`, `AimDb`, `TransformDescriptor`, `ConnectorBuilder` — the runtime adapter type threads through every public signature. Yet: + +- Connectors implement `ConnectorBuilder` **for all `R`** (e.g. [tokio_client.rs:102](../../aimdb-knx-connector/src/tokio_client.rs#L102)) — they never use `R`. +- The machinery that actually runs services receives the runtime as `Arc` (`runtime_any()`, `extract_from_any`) — the static type is erased and recovered with panicking downcasts inside the same process. +- Design docs 028/029 (spawn removal, `R`-free `Producer`/`Consumer`) show the project already concluded `R` was over-applied; `Producer`/`Consumer` are now clean. The record registry and registrar are the unfinished half of that migration. + +What still genuinely needs `R`: stage-profiling clocks (`TimeOps`), join fan-in (`JoinFanInRuntime`), and the context handed to user closures. All of these are *capabilities*, and the session engine already demonstrates the alternative — runtime-neutral code using `async-channel`/`futures` primitives with capabilities passed as values, not type parameters. + +### 3.4 Panic-as-validation, inconsistently applied + +Configuration errors split arbitrarily between two failure models: + +- **Panics:** invalid URL, missing serializer/deserializer, unregistered scheme, duplicate producer, transform/source conflicts, missing buffer in a consumer factory, type mismatch in `configure` (`assert!`). +- **`DbResult` from `build()`:** duplicate keys, missing runtime, dependency cycles, record validation. + +"Fail fast at boot" is a defensible philosophy for an embedded-first library — but then `build()` should not also have a `Result` channel for the *same class* of mistakes. Pick one: either builder methods stay infallible and `build()` performs **all** validation and returns every error (preferable — it also fixes the panic-deep-inside-a-factory-closure problem where the panic site is far from the user's mistake), or panics are the documented contract everywhere. + +### 3.5 The lifetime-chained registrar API + +`RecordRegistrar` methods take `&'a mut self` and return `&'a mut Self` where `'a` is the *struct's own* lifetime parameter ([typed_api.rs:382-403](../../aimdb-core/src/typed_api.rs#L382-L403)). This is the classic fluent-API lifetime bug: the first method call mutably borrows the registrar for its entire remaining lifetime, so users *must* write one unbroken chain — the crate's own test admits it: *"the registrar's lifetime only permits one borrow chain at a time"* ([typed_api.rs:1714-1715](../../aimdb-core/src/typed_api.rs#L1714-L1715)). It also forces the `for<'a> FnOnce(&'a mut RecordRegistrar<'a, T, R>)` HRTB in `configure`, and it's why `OutboundConnectorBuilder` needs `registrar: &'a mut RecordRegistrar<'a, …>` (a double-`'a` that is almost always a mistake in Rust). The fix is mechanical: fresh lifetimes per call (`fn source(&mut self, …) -> &mut Self`). + +### 3.6 Protocol details leak into core; config is stringly-typed + +- `with_qos`, `with_retain` — MQTT concepts — are methods on core's generic `OutboundConnectorBuilder`/`InboundConnectorBuilder`, implemented by pushing `("qos", "1")` into a `Vec<(String, String)>`. +- [transport.rs](../../aimdb-core/src/transport.rs)'s `ConnectorConfig` claims protocol-agnosticism with documented interpretations for **Kafka, HTTP, and shmem connectors that do not exist**. Its typed `qos`/`retain` fields are acknowledged in its own doc comment to be unable to represent "unspecified", so the real data flows through `protocol_options: Vec<(String, String)>` anyway. +- A hand-rolled URL parser (`ConnectorUrl::parse`, ~350 lines with tests) lives in core to support this addressing scheme. + +Keep the URL-based wiring (it's the product's UX), but the *typed* MQTT knobs belong in the MQTT connector as an extension trait, and `ConnectorConfig`'s speculative fields should go. + +### 3.7 Connectors duplicate whole protocol state machines per runtime + +| Connector | tokio side | Embassy side | Shared protocol core | +|---|---|---|---| +| KNX | 994 lines (`tokio_client.rs`) | 1,061 lines (`embassy_client.rs`) | **none** — connect/ACK/keepalive/reconnect implemented twice | +| MQTT | 402 lines on `rumqttc` | 494 lines on `mountain-mqtt` | none — two unrelated client ecosystems | +| Serial | 393 lines | — | `framing.rs` (COBS) shared ✔ | + +The KNX case is the worst: KNX/IP tunneling is a wire protocol (frames in `knx-pico`), and the connection lifecycle (ConnectRequest → ConnectResponse, TunnelingRequest/Ack, keepalive, reconnect-with-backoff) is pure logic that doesn't need to know whether bytes come from `tokio::net::UdpSocket` or `embassy_net::udp`. A sans-io state machine plus two ~150-line transport shims would replace ~2,000 lines with ~700. The repo has already proven this pattern works at scale: the session engine (`session/client.rs`, `server.rs`, `pump.rs`) is exactly this shape, and UDS/serial/WebSocket all ride it. + +MQTT is harder (the duplication is hidden inside third-party clients), so it's lower priority — but note that maintaining a *fork* of mountain-mqtt to keep it compatible (see §3.9) is part of the same cost. + +### 3.8 Dead, deprecated, and leftover surface + +| Item | Location | Status | +|---|---|---| +| `Database` wrapper | [database.rs](../../aimdb-core/src/database.rs) (140 lines) | Only referenced by the `TokioDatabase`/`EmbassyDatabase` type aliases, which are themselves used nowhere in the workspace. Delete all three. | +| `.link()` | [typed_api.rs:581-591](../../aimdb-core/src/typed_api.rs#L581-L591) | Deprecated since 0.2.0; workspace is at 1.1.0. Delete. | +| `tokio` dependency of `aimdb-core` | [Cargo.toml](../../aimdb-core/Cargo.toml) | Only used in `#[cfg(test)]`; dev-dependency already covers that. Remove from `[dependencies]` and from the `std` feature. | +| `ConsumerTrait::subscribe_any` returning `DbResult` | [typed_api.rs:294-310](../../aimdb-core/src/typed_api.rs#L294-L310) | Infallible since M14; the `Result` is kept "so connector code stays unchanged". Flatten it. | +| `AnyRecord` god-trait | [typed_record.rs:217-362](../../aimdb-core/src/typed_record.rs#L217-L362) | ~20 methods mixing storage access, validation, connector enumeration, graph reporting, JSON remote access, profiling, and metrics. Split into focused traits (storage + introspection + remote), or at minimum move the `cfg`-gated JSON/profiling methods to sub-traits so the core trait is stable. | +| `aimdb-data-contracts` traits (`Simulatable`, `Settable`, `MigrationChain`, …) | whole crate | Consumed only by the wasm adapter, websocket connector and the weather demo. Fine to keep, but audit which traits have zero implementors outside examples — several look speculative. | +| `aimdb-codegen` docs/templates referencing "the actual 0.5.x API" | [lib.rs](../../aimdb-codegen/src/lib.rs) | Version skew: workspace is 1.1.0. Symptom of a 2,244-line string-template generator (`rust.rs`) that must chase the API by hand. | + +### 3.9 Workspace and dependency structure + +> **Decision (2026-06-09):** reviewed and **accepted as-is**. The monorepo is preferred, and the vendored forks are required (Embassy version compatibility, knx-pico/mountain-mqtt fixes). The observations below stay recorded as accepted trade-offs, not work items; the publish/Makefile complexity they cause is the accepted cost. + +- **Examples in the primary workspace.** 12 example crates are workspace members (weather-mesh-demo alone is 5). Every `cargo metadata`, lockfile update, and dependabot run pays for them, and the root manifest needs the `default-members = ["aimdb-core"]` escape hatch with a comment apologizing for it. Moving `examples/` to its own workspace (path-dependent on the libraries) keeps `cargo build`/`clippy` honest and the lockfile small. +- **Three vendored forks as git submodules**, wired via `[patch.crates-io]`: the **entire Embassy monorepo** (7 crates pinned), mountain-mqtt, and knx-pico. Each fork is a standing maintenance liability (rebases, security updates, contributor onboarding — a fresh clone needs submodule init before anything builds) and the patches cannot ship to crates.io, which is why a 13-step `publish-check`/`publish` flow exists in the 551-line Makefile. Track upstreaming the knx-pico/mountain-mqtt fixes and pinning Embassy to released crates as a explicit goal; every quarter the forks live, the diff grows. +- **551-line Makefile** wrapping cargo. Much of it is the feature-matrix and embedded-target juggling from §3.1.3 — it shrinks naturally as the feature graph shrinks; `make check` aggregating fmt/clippy/test/embedded/wasm/deny is worth keeping. + +### 3.10 Smaller items worth fixing opportunistically + +- **`StringKey::intern` leaks every dynamic key** (`Box::leak`, [record_id.rs:291-306](../../aimdb-core/src/record_id.rs#L291-L306)) to get `Copy`. Guarded only by a debug-build counter (cap 1000). For the stated multi-tenant edge use case ("tenant.{}.sensors") this is an unbounded leak in a long-lived process. A real interner (dedup on intern) would at least make re-interning the same key free; document the contract loudly either way. +- **`OutboundRoute` is a 5-tuple** type alias ([builder.rs:57-63](../../aimdb-core/src/builder.rs#L57-L63)); every connector destructures it positionally. Make it a struct. +- **O(n) scans in the builder**: `configure()` does `records.iter().position(...)` per call and `build()` does `by_key.keys().find(...)` per topo entry. Irrelevant at 10 records, but trivial to fix while touching the file. +- **`ext_macros.rs` generates the adapter extension traits** (`TokioRecordRegistrarExt`, …) via `macro_rules!`, which hides the primary user-facing API from rustdoc/IDE navigation and bakes magic numbers into call sites (`EmbassyBuffer::`). With the registrar lifetime fix (§3.5) and an `R`-slimmed registrar (§3.3), the macro can likely become a plain generic impl. +- **Two JSON wire protocols** (AimX in `session/aimx` + `remote/`, and `aimdb-ws-protocol` for WebSocket/browser) implement overlapping subscribe/write/query semantics with separate codecs and error mapping. Both now ride the same session engine, which is the hard part done — protocol convergence (or at least a shared envelope/error vocabulary) is a candidate for the next protocol-breaking release, not urgent. + +--- + +## 4. The worst design decisions (root-cause list) + +Ranked by how much debt each one generated. These are the decisions to *not make again*, independent of how quickly the symptoms get cleaned up. + +1. **Encoding the std/no_std split as structural `cfg` forks instead of building on `alloc`.** + The decision that `DbError` would have different fields per target — rather than committing to `alloc` types everywhere (which the crate requires anyway) — multiplied every error site by two, made errors untestable cross-target, and threw away diagnostics on exactly the targets that need them most. The duplicated `RuntimeContext` impl and the 25 dual-import headers are the same decision repeated. **~Several hundred lines of pure noise, plus an ongoing tax on every new error site.** + +2. **Making the runtime adapter a generic parameter `R` on the whole object graph — and then escaping it with `dyn Any` wherever it became unworkable.** + You currently pay for both: generic infection in every signature (`TypedRecord`, `RecordRegistrar<'a, T, R>`, HRTB closures) *and* runtime downcasts with panicking failure paths (`extract_from_any`, factory downcasts, `spawn_fn` downcasts). Either discipline alone would have been cheaper. Designs 028/029 already started the walk-back; the registry is the unfinished half. + +3. **Implementing each connector once per runtime instead of separating protocol from I/O.** + Two full KNX/IP lifecycle implementations and two unrelated MQTT stacks. The cost isn't just the ~2,500 duplicated lines — it's that every bug fix and feature lands twice or diverges. The session engine proves the team knows the right pattern; KNX/MQTT predate it and were never folded in. + +4. **Validating configuration by panicking inside builder closures and factories, mixed with a `Result`-returning `build()`.** + Two failure contracts for one class of error, with the worst panics (consumer-factory "requires a buffer") firing at spawn time, far from the line the user got wrong. + +5. **Vendoring an ecosystem (Embassy fork + mountain-mqtt fork + knx-pico fork) as submodules patched over crates.io.** *(accepted trade-off — see §3.9)* + Justified as a temporary compatibility measure, but it now sits between the project and every release: clean clones don't build without submodules, crates.io publishing needs a bespoke procedure, and the forks drift further from upstream every month. + +6. **The `'a`-self-referential fluent registrar.** + A one-character-class lifetime mistake (`&'a mut self` returning `&'a mut Self` with `'a` = the struct's parameter) that hardened into API shape: it dictated the HRTB in `configure`, the double-`'a` connector builders, and the "single chain only" usage rule that tests have to tiptoe around. + +7. **Speculative generality.** + `ConnectorConfig` documenting Kafka/HTTP/shmem semantics for connectors that were never built; data-contract traits with no implementors outside demos; `Database` as a second façade over `AimDb`; MQTT's `qos`/`retain` lifted into core "for protocol-agnosticism". Each item is small; the habit is the debt. + +8. **Keeping 12 example crates inside the primary workspace.** *(accepted trade-off — see §3.9)* + It made `cargo build` of the workspace expensive enough that `default-members` had to be restricted to `aimdb-core` — which means the default build no longer validates what contributors actually change. + +9. **`StringKey::intern` = `Box::leak` to get `Copy` keys.** + Fine for the static-key embedded path; wrong as the *only* path for dynamic keys in long-lived multi-tenant edge processes. The debug-only counter is an admission of the problem, not a fix. + +10. **Two parallel remote-access protocols (AimX and WS-JSON).** + Understandable history (browser clients needed JSON-over-WS before the session engine existed), but the result is two subscribe/write/query vocabularies, two codecs, and two error mappings to keep in sync. + +--- + +## 5. Remediation roadmap (functionality-preserving) + +Ordered so that each phase is independently shippable and nothing changes observable behavior. + +> **Tracking** (breaking changes accepted per decision 2026-06-09 — clean structure beats API stability): +> +> | Issue | Covers | +> |---|---| +> | [#129](https://github.com/aimdb-dev/aimdb/issues/129) | Phase 1 — `DbError` unification (§3.1.1) | +> | [#132](https://github.com/aimdb-dev/aimdb/issues/132) | Phase 1 — alloc imports, logging shim, dead API, tokio dep, `OutboundRoute` (§3.1.2, §3.1.3, §3.8, §3.10) | +> | [#130](https://github.com/aimdb-dev/aimdb/issues/130) | Phase 2 — `RuntimeOps` groundwork, typed builder internals, registrar lifetimes (§3.2, §3.5) | +> | [#133](https://github.com/aimdb-dev/aimdb/issues/133) | Phase 2 — panic-free builder validation (§3.4) | +> | [#134](https://github.com/aimdb-dev/aimdb/issues/134) | Phase 2 — MQTT knobs out of core, `ConnectorConfig` pruning (§3.6) | +> | [#131](https://github.com/aimdb-dev/aimdb/issues/131) | Phase 3 — remove `R` from the object graph; data-plane de-`Any` as stretch (§3.2, §3.3) — **implemented** (the §6 de-`Any` stretch was split out as a follow-up, per decision 2026-06-10) | +> | [#135](https://github.com/aimdb-dev/aimdb/issues/135) | Phase 3 — sans-io KNX state machine (§3.7) — **implemented** (`aimdb-knx-connector/src/tunnel.rs`) | +> +> Unfiled by intent: data-plane de-`Any` (§3.2 / #131 §6 — file after #131 merges), `AnyRecord` split (file after #131), KNX ACK-retransmit `TunnelConfig` knob (spec-conformant single retransmit; today expire-and-log, from #135), `StringKey::intern` (§3.10), MQTT consolidation review, AimX/WS protocol convergence. Phase 4 dropped (§3.9 decision). +> +> **All unfiled items are now consolidated in [036 — Follow-up Refactoring](036-followup-refactoring.md), which supersedes this list as the live tracker.** + +### Phase 1 — Mechanical deletions (low risk, high noise reduction) + +| Action | Est. effect | +|---|---| +| Unify `DbError` on `alloc::string::String` fields; delete all `_field: ()` variants, the no_std `Display` table, and every dual construction branch | −400 to −600 lines, every future error site is one branch | +| Replace all `#[cfg(std)] use std::… / #[cfg(not(std))] use alloc::…` pairs with unconditional `alloc` imports; merge the duplicated `RuntimeContext` impl blocks | −150 lines | +| Internal `log_*!` macros wrapping `tracing`/`defmt`; delete the 62 per-call-site `cfg` gates | −120 lines of attributes | +| Delete `Database`, `TokioDatabase`, `EmbassyDatabase`, deprecated `.link()` | −180 lines | +| Drop `tokio` from `aimdb-core` `[dependencies]` and the `std` feature | dependency graph honesty; faster std builds | +| Flatten `subscribe_any`'s vestigial `DbResult`; struct-ify `OutboundRoute` | small | + +### Phase 2 — Internal de-erasure and API hygiene (no public behavior change) + +- Store `spawn_fns`/`start_fns` as their typed forms inside `AimDbBuilder`; delete the `Box` round-trips and their `expect`s. +- Fix registrar lifetimes (`&mut self -> &mut Self` with fresh lifetimes); simplify `configure`'s closure bound; collapse `ext_macros.rs` into plain generic impls if the lifetime fix permits. +- Move all builder-time validation into `build()` returning `DbResult` (collect *all* errors, not first); keep panics only for internal invariants. Public API shape is unchanged — only failure timing moves from "panic mid-configure" to "error from build()", which is strictly friendlier. +- Move `with_qos`/`with_retain` into an MQTT-connector extension trait over the generic builder; deprecate the core methods for one release. Delete `ConnectorConfig`'s speculative fields, keep `protocol_options` + `timeout_ms`. + +### Phase 3 — Architecture consolidation (real work, do per-connector) + +- **Finish the `R` removal program** (the 029 direction): make `TypedRecord` runtime-free by representing the runtime capabilities it actually needs (clock, join fan-in) as values, mirroring how the session engine already works. This is what finally deletes the `Arc` runtime-context plumbing and the factory downcasts. +- **Sans-io the KNX connector**: extract the tunneling lifecycle (connect, ACK bookkeeping, keepalive timers as "next deadline" outputs, reconnect policy) into a shared state machine; reduce `tokio_client.rs`/`embassy_client.rs` to socket shims. Apply the same review to MQTT afterwards (harder, third-party clients involved). +- Decide the long-term story for AimX vs WS-JSON (shared envelope/error vocabulary at minimum). + +### Phase 4 — Repository structure — **dropped (decision 2026-06-09)** + +The monorepo (including the example crates) and the vendored fork submodules stay as they are — see the decision note in §3.9. Upstreaming individual knx-pico/mountain-mqtt patches remains welcome opportunistically, but is not a tracked goal. + +### Explicitly keep (this is the good architecture) + +- The **session engine** (`session/`) and the M17 connector-spine consolidation — it is the template the rest of the codebase should converge on. +- `WriteHandle`/pre-resolved `Producer`/`Consumer` hot path (029) — correct and fast. +- `RecordKey`/`RecordId` logical-vs-physical identity split and the dependency graph with topological spawn ordering. +- The design-doc discipline in `docs/design/` — it is the reason this review could reconstruct intent at all. + +--- + +## 6. Suggested sequencing + +Phase 1 is a weekend-sized, reviewable-in-one-PR-each cleanup and removes the noise that makes every later diff harder to read — do it first and alone. Phase 2 next; it shrinks the surface Phase 3 has to move. Phase 3 items are independent of each other and can ride the existing milestone cadence (the KNX sans-io extraction is the best first candidate — self-contained, measurable, and it retires the largest single duplication in the repo). diff --git a/docs/design/035-review-followups-deferred.md b/docs/design/035-review-followups-deferred.md new file mode 100644 index 0000000..2e38295 --- /dev/null +++ b/docs/design/035-review-followups-deferred.md @@ -0,0 +1,165 @@ +# 035 — Review Follow-ups: Deferred Items & Embassy Hardware Validation + +**Status:** Draft +**Predecessor:** [034 — Technical Debt & Architecture Review](034-technical-debt-review.md); code review of the #131/#135 refactor (commit `9152b82` + fix round) +**Scope:** The five findings the review fix round deliberately did *not* change, why, and what it takes to resolve each. Includes the hardware validation plan for the one item that was blocked on it (hardware is now available: Nucleo-H563ZI + KNX/IP gateway). + +--- + +## 1. Context + +The review of the de-genericization refactor fixed 15 findings (heartbeat-response +liveness, build-time error collection, ACK tracking, backoff pacing, doc rot, …). +Five findings were left as-is because each needed either a runtime environment we +couldn't exercise, or a maintainer decision. This doc closes the loop: per item, the +current state, the proposed resolution, and the validation it needs. + +## 2. Items + +### 2.1 Embassy select-loop connected/disconnected fork — **restructure applied; §3 bench validation pending** + +**Current state.** `embassy_client.rs::drive_connection` forks the entire select on +connectivity (`if engine.is_connected() { select3(recv, cmd, deadline) } else { select(recv, deadline) }`, +[embassy_client.rs:432](../../aimdb-knx-connector/src/embassy_client.rs)), duplicating the +datagram Ok/Err handling across both branches. The tokio shim expresses the same +policy as an arm guard. Because both shims gate command intake on connectivity, +`TunnelEngine::handle_command`'s disconnected drop path is dead code in production — +yet both shims carry a "Not connected, dropping GroupWrite" warning for it. + +**Risk.** The two branches can drift silently (e.g. a future edit drains commands in +the disconnected branch and starts dropping writes during a backoff window while +tokio still queues them) — exactly the divergence the sans-io extraction (#135) was +meant to end. + +**Proposed fix — option A (minimal, recommended).** Collapse the fork into one +select with a conditional *arm future* instead of a conditional *select shape*: + +```rust +let cmd_arm = match engine.is_connected() { + true => Either::Left(command_channel.receive()), + false => Either::Right(pending()), // commands stay queued in the channel +}; +match select3(socket.recv_from(&mut recv_buf), cmd_arm, deadline).await { … } +``` + +One select, one datagram handler, identical queue-while-disconnected semantics +(commands keep buffering in the static channel, flush on reconnect). The engine's +drop path then stays dead by construction on both runtimes: demote the shim +warnings, and document `handle_command`'s `false` return as a defensive contract +(covered by the engine unit test only). + +*Implementation note:* the landed code expresses the conditional arm as a plain +`async` block over a pre-evaluated `connected` flag instead of a future `Either` +— same semantics, no extra import; the shims' "dropping GroupWrite" warnings are +removed and the contract is documented on `handle_command` itself. + +**Option B (deeper, not recommended now).** Move the policy into the engine: +`handle_command` queues bounded commands during `Backoff`/`Connecting` and flushes +on connect. Rejected for now — it duplicates buffering the channel already provides +and adds per-connection RAM on the MCU for no behavioral gain. + +**Why it was deferred.** The select shape is the hot heart of the MCU connection +task; `embassy_futures::select` polling semantics around a `pending()` arm and +cancellation of `Channel::receive` are exactly the kind of thing host tests don't +prove. §3 is the validation plan. + +### 2.2 `Action::Send` 278-byte inline frame — **keep** + +`Action::Send { frame: heapless::Vec, … }` dominates the action enum's +size; every protocol event memcpys ~290 bytes through the `VecDeque`, +including 10-byte ACKs. This is an explicitly documented design choice +([tunnel.rs:72-75](../../aimdb-knx-connector/src/tunnel.rs)): frames stay +stack-allocated so the per-datagram path never touches the heap, and the queue +holds 0–2 entries in practice. The old tokio client heap-allocated every frame via +`.to_vec()`, so the net delta vs. pre-refactor is favorable at KNX telegram rates +(tens/sec, not thousands). + +**Revisit trigger:** only if MCU profiling shows the memcpy or the queue's ~290-byte +slot footprint in a flame graph. The alternative is known and mechanical: semantic +actions (`SendAck { channel_id, seq }`, `SendHeartbeat { … }`) with byte-building +moved into `drain_actions` at send time. + +### 2.3 Buffer-ext trait triplication (tokio/embassy/wasm) — **keep, with a tripwire** + +`TokioRecordRegistrarExt` / `EmbassyRecordRegistrarExt` / `WasmRecordRegistrarExt` +each declare `.buffer(cfg)` on the *same* receiver `RecordRegistrar<'_, T>` with +near-identical bodies. This is a deliberate outcome of deleting `ext_macros.rs` +(#131): buffer construction is the one genuinely adapter-specific registration step +left, and a per-crate trait is how Rust method resolution scopes it. + +**Known latent hazard:** if one binary ever links two adapters and imports both +traits, `reg.buffer(cfg)` is E0034-ambiguous — and since the registrar is now +runtime-erased, a fully-qualified call could install adapter A's buffer in a +database driven by adapter B with no type-level guard. No in-tree binary does this +today (verified by grep; embassy's impl is additionally cfg-gated off std). + +**Resolution:** leave as-is. If a dual-adapter binary becomes real (e.g. wasm +native-fallback + tokio), introduce a `BufferFactory` hook in `aimdb-executor` +at that point, so each adapter contributes only its constructor expression. Don't +build the abstraction ahead of the consumer. + +### 2.4 defmt `#[global_logger]` stub duplication in host tests — **mitigate with an exported macro** + +The 18-line no-op `#[defmt::global_logger]` + `#[defmt::panic_handler]` block is +copy-pasted into `aimdb-embassy-adapter/tests/session_smoke.rs`, +`aimdb-serial-connector/tests/embassy_smoke.rs`, and (pre-existing) the adapter's +`buffer.rs` test module; serial-connector carries a `defmt` dev-dependency solely +for it. Root cause (verified): coercing `EmbassyAdapter` to `Arc` +instantiates a vtable whose `log` entry calls `defmt::*` unconditionally, so the +`_defmt_*` extern symbols must resolve in every host test binary that touches the +adapter — and `#[global_logger]` must be defined **once per binary**, so a shared +`#[cfg(test)]` item in the lib cannot serve integration-test binaries. + +**Resolution:** a `#[macro_export] macro_rules! host_test_stubs` in +`aimdb-embassy-adapter` (doc(hidden)) that expands to the no-op logger, panic +handler, and the pinned-at-0 time-driver stub. Each test binary invokes the macro +once — per-binary uniqueness is preserved because the *expansion* is per-binary, +while the definition lives in one place. Alternative (bigger): feature-gate the +adapter's `Logger` impl so host builds link a no-op without defmt at all; rejected +because it forks the adapter's log path per target, which is its own drift hazard. + +### 2.5 `AimDb::runtime()` — **deleted** + +`AimDb` exposes three runtime accessors; `runtime() -> &dyn RuntimeOps` +([builder.rs:1120](../../aimdb-core/src/builder.rs)) has **zero callers** across +aimdb and aimdb-pro (all uses go through `runtime_ops()` / `runtime_ctx()`). +Recommendation: delete it **in this release** — the window is already breaking +(#131), and once published the accessor is semver-frozen forever. If a borrowed +flavor is ever wanted back, `&*db.runtime_ops()` already provides it. One-line +removal + changelog entry; kept out of the fix round only because removing public +API is the maintainer's call. + +## 3. Hardware validation plan (item 2.1 + this fix round's KNX changes) + +Hardware: **Nucleo-H563ZI** (the in-tree embassy demo target, +`examples/embassy-knx-connector-demo`, Ethernet) + a KNX/IP gateway on the same +LAN. One session validates both the 2.1 restructure *and* the behavior changes +already in the working tree that host tests can't fully prove (heartbeat-response +timeout, backoff socket pacing, send-failure untracking on embassy-net). + +Build/flash: `cargo build -p embassy-knx-connector-demo --target thumbv8m.main-none-eabihf` +(demo's own target config), defmt logs via RTT. + +| # | Scenario | Steps | Pass criteria | +|---|----------|-------|---------------| +| 1 | Baseline soak | Boot, let the demo connect and exchange telegrams for 30 min | Stable channel; heartbeats every 55 s each answered; zero AckTimeout warnings | +| 2 | Queue-while-disconnected | Power-cycle the gateway; issue 3–5 GroupWrites while it's down (≤ channel depth 32); restore | No "dropping GroupWrite" log; all queued writes appear on the bus after re-handshake, in order | +| 3 | Heartbeat-response timeout | Block UDP 3671 gateway→device (or unplug gateway *after* handshake, keeping link up via a switch) | Within ≤ 65 s (heartbeat cadence + 10 s timeout): defmt shows reset + backoff; on unblock, reconnect with a fresh channel id and traffic resumes | +| 4 | Stale-channel NACK | Restart the gateway quickly so it forgets the channel but answers heartbeats with non-zero status | Engine reconnects on the NACKed CONNECTIONSTATE_RESPONSE instead of staying Connected | +| 5 | Backoff pacing | Point the demo at a non-existent gateway IP | Exactly one CONNECT_REQUEST per 5 s backoff window (sniff with Wireshark); no rebind storm, CPU idle between attempts | +| 6 | Send-failure path | While connected, briefly bring the interface down (`embassy-net` route loss) during writes | "KNX send failed" logged, **no** AckTimeout for those frames; recovery via scenario-3 mechanism | +| 7 | Inbound flood | Burst group writes from ETS/knxd toward the device | Telegrams routed or drop-logged (channel full); protocol loop never stalls; heartbeats stay on schedule | + +Run the matrix twice: once on current working tree (regression baseline for this +fix round), once with the 2.1 restructure applied. Identical defmt traces (modulo +timestamps) for scenarios 1–7 is the acceptance bar for 2.1; tokio needs no re-run +(its arm-guard shape is unchanged and host-tested against the fake gateway). + +## 4. Suggested order + +1. **2.1** restructure + §3 matrix (hardware is available now; one bench session). +2. **2.5** delete `AimDb::runtime()` while the breaking window is open (one line, zero risk). +3. **2.4** `host_test_stubs!` macro next time an embassy host test is added (third copy is the trigger). +4. **2.2 / 2.3** no action; revisit triggers documented above. + +> **Successor:** the still-open items (2.1's §3 validation, 2.4) and the dormant triggers (2.2, 2.3) are carried forward in [036 — Follow-up Refactoring](036-followup-refactoring.md), the single live follow-up list. 2.1's restructure and 2.5 landed in PR #140. diff --git a/docs/design/036-followup-refactoring.md b/docs/design/036-followup-refactoring.md new file mode 100644 index 0000000..1445a96 --- /dev/null +++ b/docs/design/036-followup-refactoring.md @@ -0,0 +1,152 @@ +# 036 — Follow-up Refactoring: Remaining Work from the 034/035 Review Cycle + +**Status:** Draft +**Predecessors:** [034 — Technical Debt & Architecture Review](034-technical-debt-review.md), [035 — Review Follow-ups: Deferred Items](035-review-followups-deferred.md) +**Baseline:** the de-genericized tree on PR [#140](https://github.com/aimdb-dev/aimdb/pull/140) (#131 R-removal + #135 sans-io KNX + the 035 fix round). Everything below assumes that baseline; nothing here should start before #140 merges. +**Scope:** Consolidate every item that 034 left "unfiled by intent" and every 035 item that is still live into one actionable list, with per-item state (verified 2026-06-11), approach, and acceptance criteria. After this doc, 034 §5 and 035 §4 are historical records; this is the single live follow-up list. + +--- + +## 1. Where the review cycle stands + +Done and merged (PRs #136–#139): 034 Phases 1–2 in full — `DbError` unification (#129), alloc imports / logging shim / dead API / tokio dep / `OutboundRoute` (#132), dyn-safe `RuntimeOps` + de-erased builder internals + registrar lifetimes (#130), panic-free builder validation (#133), MQTT knobs out of core (#134). The opportunistic §3.10 items rode along: the `log_*!` shim exists ([log.rs](../../aimdb-core/src/log.rs)), the builder's O(n) scans are gone, `ext_macros.rs` is deleted, and the codegen doc rot was fixed in the #140 fix round. + +In flight (PR #140, this branch): #131 (non-generic `AimDb`/`TypedRecord`/`RuntimeContext`), #135 (sans-io KNX tunnel engine — [tunnel.rs](../../aimdb-knx-connector/src/tunnel.rs), 1,228 shared lines; shims now 581/465 lines vs. ~1,000 each before), plus 035 items 2.1 (select-loop restructure, applied) and 2.5 (`AimDb::runtime()` deleted). + +Remaining: the items below. §2 is work to schedule, §3 is assessment-only, §4 is dormant triggers (no action, restated here so nothing lives only in 035). + +--- + +## 2. Work items + +### W1 — Data-plane de-`Any` (034 §3.2; the #131 §6 stretch goal, split out per decision 2026-06-10) + +**Current state (verified).** #131 removed the *control-plane* erasure (runtime context, factory downcasts, builder internals). The *per-message* erasure remains intact: + +| Path | Mechanism | Cost per message | +|---|---|---| +| Inbound (connector → record) | router deserializes to `Box`, [`ProducerTrait::produce_any`](../../aimdb-core/src/typed_api.rs#L161) downcasts to `T` | 1 heap box + 1 downcast | +| Outbound (record → connector) | [`subscribe_any`](../../aimdb-core/src/typed_api.rs#L295) → `Box`, [`recv_any`](../../aimdb-core/src/typed_api.rs#L276) → `Box`, then [`SerializerFn(&dyn Any)`](../../aimdb-core/src/connector.rs#L102) downcasts back to `T` | 1 heap box + 2 erasure crossings | +| Session pump / AimX client | same `subscribe_any`/`recv_any` pair ([pump.rs:57](../../aimdb-core/src/session/pump.rs#L57), [client.rs:538](../../aimdb-core/src/session/client.rs#L538)) | same | +| Join fan-in | each input value crosses as `Box` ([join.rs:54](../../aimdb-core/src/transform/join.rs#L54)) | 1 box + downcast per input | + +**Approach: fuse the typed ends at registration time.** Both ends of every erased hop are typed — `T` is known in `RecordRegistrar`/`TypedRecord` where the route is wired, and the connector spine only actually wants *bytes* (or JSON). Instead of shipping `T` erased across the boundary and recovering it, build the typed pipeline inside a closure at registration and expose only the wire-level interface: + +- **Outbound:** replace the `(Arc, Serializer)` pair carried by `OutboundRoute` with a `SerializedSource` built where `T` is known: `subscribe()` returns a reader whose `recv()` yields the serialized payload directly (subscribe → recv → serialize all typed inside). The `Serializer::Raw`/`Serializer::Context` split ([connector.rs:122](../../aimdb-core/src/connector.rs#L122)) collapses into what the closure captures. +- **Inbound:** replace deserializer + `produce_any` with an ingest closure `Fn(&[u8], &ConsumeContext) -> Future>` capturing the typed producer and deserializer. +- **Join:** optional in scope. The erasure is confined to one module and wired once; fuse it with the same trick only if it falls out naturally, otherwise leave it and document why. + +**Acceptance criteria.** +- No `Box` is constructed on any per-message path (connectors, session pump, AimX client). +- `grep -rnE "dyn (core::any::)?Any\b" aimdb-core/src` hits only: [`ExtensionMap`](../../aimdb-core/src/extensions.rs#L32) (TypeId-keyed map — the standard pattern), `AnyRecord::as_any`/`as_any_mut` and `DynBuffer::as_any` (one-time typed-handle resolution at setup), the session `PeerInfo`/`SessionCtx` auth `ext` slots (per-*connection*, set once at authenticate/open), and — if left — join internals. (The latter two were missed by the original list because the literal `"dyn Any"` grep doesn't match `dyn core::any::Any`; all verified setup-time during implementation.) +- All six connectors and `aimdb-pro` compile against the new shape with no behavior change; the fake-gateway and session smoke tests pass unmodified. + +**Risk notes.** Object-safe async readers are already solved in [connector.rs](../../aimdb-core/src/connector.rs) (manual boxed-future pattern — keep it). Context-aware serializers (026) need the `ConsumeContext`/`RuntimeContext` threaded as an argument rather than captured. This is a breaking change to the connector SPI; the window is already open (#131/#135 are breaking), so it should land in the **same release** as #140 if at all possible — otherwise it waits for the next breaking window. + +**Size:** L (the largest remaining core item). **File the issue when #140 merges** (per the 034 tracking note). + +### W2 — Split the `AnyRecord` god-trait (034 §3.8) + +**Current state (verified).** [`AnyRecord`](../../aimdb-core/src/typed_record.rs#L210) still has ~25 methods spanning storage/lifecycle (`validate`, `as_any`, `as_any_mut`, `drain_config_errors`, `set_writable_erased`), graph introspection (`outbound_connector_*`, `inbound_connectors`, `consumer_count`, `has_producer`/`has_buffer`/`has_transform`, `record_origin`, `buffer_info`, `transform_input_keys`, `collect_metadata`), cfg-gated JSON remote access (`latest_json`, `subscribe_json`, `set_from_json`), and cfg-gated profiling/metrics resets. + +**Approach.** Split by consumer, wire with supertraits + dyn upcasting (stable since Rust 1.86; the workspace toolchain is 1.95): + +```rust +pub trait AnyRecord: RecordIntrospect + Send + Sync { /* storage + lifecycle only */ } +pub trait RecordIntrospect { /* graph + metadata — consumed by graph.rs, tools */ } +#[cfg(feature = "json-serialize")] +pub trait JsonRecordAccess { /* latest_json / subscribe_json / set_from_json */ } +``` + +The registry keeps storing `Box`; consumers upcast to the capability they need (`&dyn RecordIntrospect`) or query JSON access via an `Option<&dyn JsonRecordAccess>` accessor so the cfg-gate lives in one place. Profiling/metrics resets become default-implemented methods on a small sub-trait rather than cfg-noise on the core trait. + +**Payoff:** the core storage contract stops churning every time remote access or profiling evolves, and each consumer's dependency is visible in its signature. **Acceptance:** `AnyRecord` ≤ ~8 methods; no behavior change; rustdoc for each trait states its consumer. + +**Size:** M. Mechanical but wide. **File together with W1** (it touches the same files; do W2 first or in the same series — W2 shrinks the surface W1 has to move). + +### W3 — Execute the KNX hardware validation matrix (035 §3) + +**Current state.** The 2.1 select-loop restructure and the fix-round behavior changes (heartbeat-response timeout, backoff socket pacing, send-failure untracking) are in PR #140, validated on host against the fake gateway only. Hardware (Nucleo-H563ZI + KNX/IP gateway) is available; the seven-scenario matrix and pass criteria are specified in [035 §3](035-review-followups-deferred.md) and are not duplicated here. + +**This is the gate for closing the 035 loop** — ideally run before #140 merges (one bench session); at minimum before the next release that ships the KNX connector. Outcome feeds W4 (scenario 1's AckTimeout observations size the retransmit knob). + +**Size:** S (one bench session). No issue needed if run as part of #140; otherwise file as a validation task. + +### W4 — KNX ACK-retransmit knob in `TunnelConfig` (from the #135 review) + +**Current state (verified).** When a `TunnelingRequest` is not ACKed within the timeout, the engine expires the pending slot and emits [`Action::AckTimeout`](../../aimdb-knx-connector/src/tunnel.rs#L94) (shims log a warning) — no retransmit, no disconnect. The KNXnet/IP tunneling spec (3.8.4) says: retransmit once after 1 s, then tear the connection down on the second miss. + +**Approach.** `TunnelConfig` gains `ack_retransmits: u8` (default `1` = spec-conformant; `0` = today's expire-and-log for RAM-constrained builds). Design constraint to resolve in the issue: retransmission needs the frame bytes at expiry time, which means either (a) buffering the sent frame in the pending-ACK slot — 278 bytes × `PENDING_ACK_CAPACITY` extra RAM on MCU, interacting directly with the 035 §2.2 inline-frame decision — or (b) storing the semantic content (cEMI payload) and rebuilding the frame at retransmit time, which is the "semantic actions" alternative 035 §2.2 already documents. Decide (a) vs (b) once, in the issue, with the 035 §2.2 trade-off in view; gate the buffer behind `ack_retransmits > 0` either way. On final timeout, follow the spec: emit a disconnect/reconnect action rather than only warning. + +**Validation:** host fake-gateway test that drops the first ACK; hardware scenario 1/3 from the 035 matrix re-run. + +**Size:** S–M. File after #140 merges (touches `tunnel.rs` on the #140 baseline). + +### W5 — `StringKey::intern`: dedup interner + loud contract (034 §3.10) + +**Current state (verified).** [`StringKey::intern`](../../aimdb-core/src/record_id.rs#L284) still `Box::leak`s every call ([record_id.rs:297](../../aimdb-core/src/record_id.rs#L297)); re-interning the same key leaks again; guarded only by a debug-build counter (cap 1000). + +**Approach.** Keep the `&'static str`/`Copy` design (it is what makes `RecordKey` free to pass around and is correct for the static-key embedded path). Add a global dedup table consulted by `intern` (std: `std::sync::Mutex>`; no_std+alloc: `spin::Mutex` — the crate already depends on `spin` for the no_std lock in typed_record). Result: interning the same key twice returns the same allocation, making the leak bounded by the number of *distinct* dynamic keys — which is the actual lifetime contract of a record key in a long-lived process. Document exactly that contract on `intern` ("each distinct dynamic key allocates once for process lifetime; do not derive keys from unbounded input"), and keep the debug counter as a tripwire on *distinct* keys. + +Explicitly rejected: a non-`Copy` `Arc` key variant — it forks `RecordKey` into two shapes, which is the 034 §3.1 mistake again. + +**Size:** S. Independent of everything else; opportunistic. + +### W6 — `host_test_stubs!` macro for the defmt logger duplication (035 §2.4) + +**Current state (verified).** The no-op `#[defmt::global_logger]`/panic-handler/time-driver block exists in three places: [session_smoke.rs](../../aimdb-embassy-adapter/tests/session_smoke.rs), [buffer.rs](../../aimdb-embassy-adapter/src/buffer.rs) (test module), [embassy_smoke.rs](../../aimdb-serial-connector/tests/embassy_smoke.rs) — the third copy that 035 named as the trigger already exists. + +**Approach.** As specified in 035 §2.4: `#[macro_export] #[doc(hidden)] macro_rules! host_test_stubs` in `aimdb-embassy-adapter`, expanded once per test binary; delete the three copies and the serial-connector's standalone `defmt` dev-dependency if nothing else needs it. **Size:** S. Do it the next time any of those test files is touched, or fold into the W1/W2 series. + +### W7 — `aimdb-data-contracts` trait audit (034 §3.8, last unhandled row) + +**Current state (verified).** The crate still exports `SchemaType`, `Simulatable`, `Settable`, `Observable`, `Linkable`, `MigrationStep`, `MigrationChain`, `Streamable`; consumers remain the wasm adapter, the websocket connector, and the weather demo. Which traits have implementors outside examples has never been audited. + +**Approach.** One-time audit: for each trait, list in-tree implementors and external consumers (aimdb-pro included). Traits with zero non-demo implementors get a deprecation note or deletion in the next breaking window — per 034 root-cause 7, speculative surface is the habit to break, not an emergency. **Size:** S (audit) + S (deletions). Output is a short table appended to this doc or the issue. + +--- + +## 3. Assessment-only items (decision docs, not code) + +### A1 — MQTT consolidation review (034 §3.7) + +Still two unrelated client stacks: [tokio_client.rs](../../aimdb-mqtt-connector/src/tokio_client.rs) (396 lines, `rumqttc`) and [embassy_client.rs](../../aimdb-mqtt-connector/src/embassy_client.rs) (491 lines, forked `mountain-mqtt`). Unlike KNX, the duplicated state machine lives inside third-party clients, so the sans-io extraction that worked for #135 does not transfer directly. The review should weigh exactly three options and pick one: **(a)** keep both clients, extract only the aimdb-side glue that demonstrably drifts (topic/route mapping, reconnect policy, payload plumbing) into a shared module; **(b)** adopt one no_std-capable client on both runtimes (viability hinges on the mountain-mqtt fork's health — see the accepted 034 §3.9 fork trade-off); **(c)** sans-io MQTT engine in-tree — almost certainly rejected (re-implementing an MQTT client is not this project's job). Deliverable: a decision section in this doc or a short 03x doc; code only follows if (a) or (b) wins. **Trigger for urgency:** the next bug fixed twice, or the next mountain-mqtt fork rebase that hurts. + +### A2 — AimX / WS-JSON protocol convergence (034 §3.10, root cause 10) + +Both protocols now ride the session engine (the hard part), but two subscribe/write/query vocabularies, two codecs, and two error mappings remain. Scheduled work, not opportunistic: belongs to the **next protocol-breaking release**. First deliverable is a mapping table (AimX message ↔ ws-protocol message ↔ semantics) and a shared envelope/error-vocabulary proposal; only then decide whether full convergence pays. No issue until a protocol-breaking release is actually planned. + +--- + +## 4. Dormant items — trigger-only, no action (restated from 035 so this doc is the single live list) + +| Item | Decision | Re-open trigger | +|---|---|---| +| `Action::Send` 278-byte inline frame (035 §2.2) | Keep — stack-only datagram path, queue depth 0–2 in practice | MCU profiling shows the memcpy or queue slot footprint in a flame graph; or W4 chooses option (b), which partially supersedes this | +| Buffer-ext trait triplication tokio/embassy/wasm (035 §2.3) | Keep — adapter-specific by design; E0034 hazard verified absent in-tree | A real dual-adapter binary appears → introduce `BufferFactory` in `aimdb-executor` then | +| `aimdb-codegen` 2,234-line string-template generator (034 §3.8) | Keep — doc rot fixed in the #140 round; the generator works | The next API change that forces a multi-day template chase → consider generating against a stable facade or trimming targets | +| Vendored forks + monorepo examples (034 §3.9) | **Decided 2026-06-09: accepted as-is** | Not a work item; do not re-propose | + +--- + +## 5. Sequencing and tracking + +1. **Merge PR #140.** Everything in §2 assumes its baseline. +2. **W3** (hardware matrix) — one bench session, ideally pre-merge; closes 035. +3. **W2 → W1** as one series in the still-open breaking window (W2 shrinks what W1 moves). These are the two issues 034 said to file "after #131 merges" — file both when #140 lands. +4. **W4** after #140, validated against W3's baseline traces. +5. **W5, W6, W7** opportunistic — fold into whatever touches the neighborhood. +6. **A1/A2** when their triggers fire; no code before a written decision. + +| Item | Issue | When to file | +|---|---|---| +| W1 data-plane de-`Any` | — | on #140 merge | +| W2 `AnyRecord` split | — | on #140 merge (same series as W1) | +| W3 hardware matrix | — | none if run with #140; else a validation task | +| W4 ACK-retransmit knob | — | on #140 merge | +| W5 `StringKey` interner | — | opportunistic; file if not done by next release | +| W6 `host_test_stubs!` | — | opportunistic | +| W7 data-contracts audit | — | opportunistic | +| A1 / A2 | — | on trigger only | + +Update this table with issue numbers as they are filed; when every row is filed or closed, this doc's status moves to Final and the live list is the issue tracker again. From 915dc7bcbe49e0522e2fe74f6a4f3e196a2d070c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 11 Jun 2026 21:52:40 +0000 Subject: [PATCH 04/17] refactor(tests): clean up use statements and formatting in tests --- aimdb-core/src/connector.rs | 3 +-- aimdb-core/src/typed_api.rs | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/aimdb-core/src/connector.rs b/aimdb-core/src/connector.rs index 8ef529c..90b0f2a 100644 --- a/aimdb-core/src/connector.rs +++ b/aimdb-core/src/connector.rs @@ -803,8 +803,7 @@ mod tests { fn test_topic_provider_as_trait_object() { // Providers are stored as Arc> — typed, no // erasure (design 036 W1). - let provider: Arc> = - Arc::new(TestTopicProvider); + let provider: Arc> = Arc::new(TestTopicProvider); let temp = TestTemperature { sensor_id: "kitchen-001".into(), celsius: 22.5, diff --git a/aimdb-core/src/typed_api.rs b/aimdb-core/src/typed_api.rs index 6edc046..0fc282e 100644 --- a/aimdb-core/src/typed_api.rs +++ b/aimdb-core/src/typed_api.rs @@ -1814,7 +1814,7 @@ mod tests { // Fused outbound reader tests (design 036 W1) // ==================================================================== - use crate::connector::{SerializedReader as _, SerializedSource as _}; + use crate::connector::SerializedReader as _; /// Buffer reader that replays a fixed script, then reports the buffer /// closed. @@ -1833,8 +1833,7 @@ mod tests { impl crate::buffer::BufferReader for ScriptedReader { fn recv( &mut self, - ) -> Pin> + Send + '_>> - { + ) -> Pin> + Send + '_>> { let next = if self.script.is_empty() { Err(Self::closed()) } else { From 89e9eee1376ada03fd54c3f6bce0a6dcb810d97f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 11 Jun 2026 21:54:08 +0000 Subject: [PATCH 05/17] docs(design): mark 036 W1 implemented in PR #141 Co-Authored-By: Claude Fable 5 --- docs/design/036-followup-refactoring.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design/036-followup-refactoring.md b/docs/design/036-followup-refactoring.md index 1445a96..8fd2a21 100644 --- a/docs/design/036-followup-refactoring.md +++ b/docs/design/036-followup-refactoring.md @@ -43,7 +43,7 @@ Remaining: the items below. §2 is work to schedule, §3 is assessment-only, §4 **Risk notes.** Object-safe async readers are already solved in [connector.rs](../../aimdb-core/src/connector.rs) (manual boxed-future pattern — keep it). Context-aware serializers (026) need the `ConsumeContext`/`RuntimeContext` threaded as an argument rather than captured. This is a breaking change to the connector SPI; the window is already open (#131/#135 are breaking), so it should land in the **same release** as #140 if at all possible — otherwise it waits for the next breaking window. -**Size:** L (the largest remaining core item). **File the issue when #140 merges** (per the 034 tracking note). +**Size:** L (the largest remaining core item). **Status:** implemented in PR [#141](https://github.com/aimdb-dev/aimdb/pull/141) (no separate issue — went straight to PR in the post-#140 breaking window; ingest closure decided **sync**, `RuntimeContext` threaded in place of the sketched `ConsumeContext`, join left erased per the optional-scope note). ### W2 — Split the `AnyRecord` god-trait (034 §3.8) @@ -140,7 +140,7 @@ Both protocols now ride the session engine (the hard part), but two subscribe/wr | Item | Issue | When to file | |---|---|---| -| W1 data-plane de-`Any` | — | on #140 merge | +| W1 data-plane de-`Any` | PR [#141](https://github.com/aimdb-dev/aimdb/pull/141) | done — no separate issue, direct PR | | W2 `AnyRecord` split | — | on #140 merge (same series as W1) | | W3 hardware matrix | — | none if run with #140; else a validation task | | W4 ACK-retransmit knob | — | on #140 merge | From 0b94d213072cb1a211636c511d8ea88c293ca0d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 12 Jun 2026 06:12:35 +0000 Subject: [PATCH 06/17] refactor(core)!: split AnyRecord into capability traits (036 W2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AnyRecord carried ~22 methods spanning four concerns; every remote-access or profiling change churned the core storage contract. Split by consumer: - AnyRecord (6 methods): storage/lifecycle only — validate, as_any(_mut), drain_config_errors, set_writable_erased, plus the cfg-gated json_access() accessor so the remote-access gate lives in one place. - RecordIntrospect (supertrait): graph/metadata introspection consumed by the builder's dependency-graph construction, route collection, and list_records. - JsonRecordAccess (cfg remote-access): latest_json / subscribe_json / set_from_json, reached via json_access(); the runtime .with_remote_access() checks stay inside the methods, so behavior is unchanged. Gate is `remote-access` (the 036 sketch said json-serialize). - RecordMetricsReset (supertrait): the cfg-gated no-op-default resets. Supertrait wiring keeps every dyn AnyRecord call site compiling unchanged; only the four JSON call sites switch to the accessor. Registry storage stays Vec>. Drops the dead outbound_connector_urls (cfg std) — zero callers in-tree and in aimdb-pro. Co-Authored-By: Claude Fable 5 --- aimdb-core/src/builder.rs | 11 +- aimdb-core/src/lib.rs | 6 +- aimdb-core/src/remote/stream.rs | 9 +- aimdb-core/src/session/aimx/dispatch.rs | 10 +- aimdb-core/src/typed_record.rs | 178 +++++++++++++++--------- 5 files changed, 143 insertions(+), 71 deletions(-) diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index ffb5620..624fbe8 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -232,7 +232,7 @@ impl AimDbInner { #[cfg(feature = "remote-access")] pub fn try_latest_as_json(&self, record_key: &str) -> Option { let id = self.resolve_str(record_key)?; - self.storages.get(id.index())?.latest_json() + self.storages.get(id.index())?.json_access()?.latest_json() } /// Sets a record value from JSON (remote access API) @@ -260,7 +260,14 @@ impl AimDbInner { .resolve_str(record_key) .ok_or_else(|| DbError::record_key_not_found(record_key.to_string()))?; - self.storages[id.index()].set_from_json(json_value) + self.storages[id.index()] + .json_access() + .ok_or_else(|| { + DbError::runtime_error(alloc::format!( + "Record '{record_key}' does not support JSON remote access" + )) + })? + .set_from_json(json_value) } } diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index fb1efa3..cc48d1e 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -66,7 +66,11 @@ pub use typed_api::{ Consumer, InboundConnectorBuilder, OutboundConnectorBuilder, Producer, RecordRegistrar, RecordT, StageKind, }; -pub use typed_record::{AnyRecord, AnyRecordExt, TypedRecord}; +#[cfg(feature = "remote-access")] +pub use typed_record::JsonRecordAccess; +pub use typed_record::{ + AnyRecord, AnyRecordExt, RecordIntrospect, RecordMetricsReset, TypedRecord, +}; // JSON codec (feature `json-serialize`, no_std + alloc compatible) #[cfg(feature = "json-serialize")] diff --git a/aimdb-core/src/remote/stream.rs b/aimdb-core/src/remote/stream.rs index ae518f7..7bdea5e 100644 --- a/aimdb-core/src/remote/stream.rs +++ b/aimdb-core/src/remote/stream.rs @@ -37,7 +37,14 @@ pub(crate) fn stream_record_updates( let record = inner .storage(id) .ok_or(DbError::InvalidRecordId { id: id.raw() })?; - let reader = record.subscribe_json()?; + let reader = record + .json_access() + .ok_or_else(|| { + DbError::runtime_error(alloc::format!( + "Record '{record_key}' does not support JSON remote access" + )) + })? + .subscribe_json()?; // Pair the reader with an owned copy of the record key so lag/error // logs identify which record fell behind — the previous mpsc-based diff --git a/aimdb-core/src/session/aimx/dispatch.rs b/aimdb-core/src/session/aimx/dispatch.rs index df4aa5d..d60304e 100644 --- a/aimdb-core/src/session/aimx/dispatch.rs +++ b/aimdb-core/src/session/aimx/dispatch.rs @@ -229,7 +229,15 @@ impl AimxSession { let record = self.db.inner().storage(id).ok_or(RpcError::NotFound)?; // `subscribe_json` fails if the record was not configured with // `.with_remote_access()`. - let reader = record.subscribe_json().map_err(map_db_err)?; + let reader = record + .json_access() + .ok_or_else(|| { + map_db_err(DbError::runtime_error(alloc::format!( + "Record '{name}' does not support JSON remote access" + ))) + })? + .subscribe_json() + .map_err(map_db_err)?; self.drain_readers.insert(name.to_string(), reader); } diff --git a/aimdb-core/src/typed_record.rs b/aimdb-core/src/typed_record.rs index e24bac4..08aa860 100644 --- a/aimdb-core/src/typed_record.rs +++ b/aimdb-core/src/typed_record.rs @@ -184,11 +184,22 @@ type ConsumerServiceFn = type ProducerServiceFn = Box) -> BoxFuture<'static, ()> + Send>; -/// Type-erased trait for records +/// Type-erased trait for records — the storage and lifecycle contract. /// /// Allows storage of heterogeneous record types in a single collection /// while maintaining type safety through downcast operations. /// +/// Since the 036 W2 split this trait carries only the storage/lifecycle +/// surface. The other capabilities live in dedicated traits, reachable from +/// any `dyn AnyRecord`: +/// - [`RecordIntrospect`] (supertrait) — graph/metadata introspection +/// - [`RecordMetricsReset`] (supertrait) — profiling/metrics counter resets +/// - [`JsonRecordAccess`] — JSON remote access, via [`AnyRecord::json_access`] +/// +/// Consumers: `AimDbBuilder::build()` (validation, config-error draining, +/// typed downcasts via [`AnyRecordExt`]) and connectors applying the +/// remote-access security policy (`set_writable_erased`). +/// /// # Thread Safety Requirements /// /// This trait requires both `Send` and `Sync` because: @@ -207,7 +218,7 @@ type ProducerServiceFn = /// **Migration:** If your record type `T` is not `Sync`, wrap non-`Sync` fields /// in `Arc>` or `Arc>` to achieve interior mutability with /// thread-safe sharing. -pub trait AnyRecord: Send + Sync { +pub trait AnyRecord: RecordIntrospect + RecordMetricsReset + Send + Sync { /// Validates that the record has correct producer/consumer setup /// /// Rules: Must have exactly one producer and at least one consumer. @@ -219,18 +230,53 @@ pub trait AnyRecord: Send + Sync { /// Returns self as mutable Any for downcasting fn as_any_mut(&mut self) -> &mut dyn Any; + /// Drains the configuration mistakes recorded during registration. + /// + /// Called by `AimDbBuilder::build()`, which fills in the record key and + /// reports every finding via + /// [`DbError::InvalidConfiguration`](crate::DbError::InvalidConfiguration). + fn drain_config_errors(&mut self) -> Vec; + + /// Sets the writable flag for this record (type-erased) + /// + /// Used internally by the builder to apply security policy to records. + fn set_writable_erased(&self, writable: bool); + + /// Returns the record's JSON remote-access surface, if it has one. + /// + /// This accessor is the single place the `remote-access` cfg-gate lives + /// for consumers: they query the capability here instead of cfg-gating + /// every call site. `TypedRecord` always returns `Some`; the runtime + /// "configured with `.with_remote_access()`" checks stay inside the + /// [`JsonRecordAccess`] methods. + #[cfg(feature = "remote-access")] + fn json_access(&self) -> Option<&dyn JsonRecordAccess> { + None + } +} + +/// Graph and metadata introspection for type-erased records. +/// +/// Supertrait of [`AnyRecord`], so every stored record exposes it and a +/// `dyn AnyRecord` can be upcast to `&dyn RecordIntrospect` where only +/// introspection is needed. +/// +/// Consumers: `AimDbBuilder::build()` (link validation and the dependency +/// graph fed to [`crate::graph`]), the builder's inbound/outbound route +/// collection, and `AimDbInner::list_records` (remote introspection +/// metadata). +pub trait RecordIntrospect { /// Returns the number of registered outbound connectors fn outbound_connector_count(&self) -> usize; - /// Returns the outbound connector URLs as strings - #[cfg(feature = "std")] - fn outbound_connector_urls(&self) -> Vec; - /// Gets the outbound connector links /// /// Returns outbound connector configuration list for spawning logic. fn outbound_connectors(&self) -> &[crate::connector::ConnectorLink]; + /// Get the inbound connector links for this record + fn inbound_connectors(&self) -> &[crate::connector::InboundConnectorLink]; + /// Returns the number of registered consumers (tap observers) fn consumer_count(&self) -> usize; @@ -240,13 +286,6 @@ pub trait AnyRecord: Send + Sync { /// Returns whether a buffer is configured fn has_buffer(&self) -> bool; - /// Drains the configuration mistakes recorded during registration. - /// - /// Called by `AimDbBuilder::build()`, which fills in the record key and - /// reports every finding via - /// [`DbError::InvalidConfiguration`](crate::DbError::InvalidConfiguration). - fn drain_config_errors(&mut self) -> Vec; - /// Returns whether a transform is registered for this record fn has_transform(&self) -> bool; @@ -261,11 +300,6 @@ pub trait AnyRecord: Send + Sync { /// Returns the transform input keys (if a transform is registered) fn transform_input_keys(&self) -> Option>; - /// Sets the writable flag for this record (type-erased) - /// - /// Used internally by the builder to apply security policy to records. - fn set_writable_erased(&self, writable: bool); - /// Collects metadata for this record #[cfg(feature = "remote-access")] fn collect_metadata( @@ -274,12 +308,23 @@ pub trait AnyRecord: Send + Sync { key: crate::record_id::StringKey, id: crate::record_id::RecordId, ) -> crate::remote::RecordMetadata; +} - /// Internal: Returns JSON for type-erased remote access +/// Type-erased JSON read/subscribe/write for remote access. +/// +/// Internal to the remote-access protocol — application code reads values +/// via `record.latest()?.as_json()` instead. Obtained from a record through +/// [`AnyRecord::json_access`], which is where the `remote-access` cfg-gate +/// lives for consumers. +/// +/// Consumers: `AimDbInner::try_latest_as_json` / `set_record_from_json` +/// (`record.get` / `record.set`), the AimX session dispatch (`record.subscribe` +/// value drain), and `remote::stream::stream_record_updates`. +#[cfg(feature = "remote-access")] +pub trait JsonRecordAccess { + /// Returns JSON for type-erased remote access /// /// Used internally by remote access protocol. **Users should use `record.latest()?.as_json()`.** - #[doc(hidden)] - #[cfg(feature = "remote-access")] fn latest_json(&self) -> Option; /// Subscribe to record updates as JSON stream @@ -301,16 +346,13 @@ pub trait AnyRecord: Send + Sync { /// /// # Example (internal use) /// ```rust,ignore - /// let type_id = TypeId::of::(); - /// let record: &Box = db.records.get(&type_id)?; - /// let mut json_reader = record.subscribe_json()?; + /// let record: &Box = db.storage(id)?; + /// let mut json_reader = record.json_access().unwrap().subscribe_json()?; /// /// while let Ok(json_val) = json_reader.recv_json().await { /// // Forward to remote client... /// } /// ``` - #[doc(hidden)] - #[cfg(feature = "remote-access")] fn subscribe_json(&self) -> crate::DbResult>; /// Sets a record value from JSON @@ -339,18 +381,24 @@ pub trait AnyRecord: Send + Sync { /// /// # Example (internal use) /// ```rust,ignore - /// let type_id = TypeId::of::(); - /// let record: &Box = db.records.get(&type_id)?; + /// let record: &Box = db.storage(id)?; /// let json_val = serde_json::json!({"log_level": "debug"}); - /// record.set_from_json(json_val)?; // Only works if producer_count == 0 + /// // Only works if producer_count == 0 + /// record.json_access().unwrap().set_from_json(json_val)?; /// ``` - #[doc(hidden)] - #[cfg(feature = "remote-access")] fn set_from_json(&self, json_value: serde_json::Value) -> crate::DbResult<()>; +} - /// Get the inbound connector links for this record - fn inbound_connectors(&self) -> &[crate::connector::InboundConnectorLink]; - +/// Observability counter resets (features `profiling` / `metrics`). +/// +/// Supertrait of [`AnyRecord`] with no-op defaults, so the cfg-gated reset +/// methods stay off the core storage contract while remaining callable on +/// every stored record. +/// +/// Consumers: `AimDb::reset_profiling` / `AimDb::reset_buffer_metrics`, +/// driven by the AimX `control.reset_buffer_metrics` RPC and the MCP +/// buffer-metrics tool. +pub trait RecordMetricsReset { /// Resets this record's stage profiling counters (feature `profiling`). /// /// Default implementation is a no-op; `TypedRecord` overrides it. @@ -1134,16 +1182,31 @@ impl AnyRecord for TypedRecord { self } - fn outbound_connector_count(&self) -> usize { - self.outbound_connectors.len() + fn drain_config_errors(&mut self) -> Vec { + core::mem::take(&mut self.config_errors) } - #[cfg(feature = "std")] - fn outbound_connector_urls(&self) -> Vec { - self.outbound_connectors - .iter() - .map(|link| format!("{}", link.url)) - .collect() + fn set_writable_erased(&self, writable: bool) { + #[cfg(feature = "remote-access")] + { + self.writable + .store(writable, portable_atomic::Ordering::SeqCst); + } + #[cfg(not(feature = "remote-access"))] + { + let _ = writable; // Suppress unused warning + } + } + + #[cfg(feature = "remote-access")] + fn json_access(&self) -> Option<&dyn JsonRecordAccess> { + Some(self) + } +} + +impl RecordIntrospect for TypedRecord { + fn outbound_connector_count(&self) -> usize { + self.outbound_connectors.len() } fn outbound_connectors(&self) -> &[crate::connector::ConnectorLink] { @@ -1162,10 +1225,6 @@ impl AnyRecord for TypedRecord { TypedRecord::has_buffer(self) } - fn drain_config_errors(&mut self) -> Vec { - core::mem::take(&mut self.config_errors) - } - fn has_transform(&self) -> bool { TypedRecord::has_transform(self) } @@ -1182,16 +1241,8 @@ impl AnyRecord for TypedRecord { TypedRecord::transform_input_keys(self) } - fn set_writable_erased(&self, writable: bool) { - #[cfg(feature = "remote-access")] - { - self.writable - .store(writable, portable_atomic::Ordering::SeqCst); - } - #[cfg(not(feature = "remote-access"))] - { - let _ = writable; // Suppress unused warning - } + fn inbound_connectors(&self) -> &[crate::connector::InboundConnectorLink] { + &self.inbound_connectors } #[cfg(feature = "remote-access")] @@ -1247,9 +1298,10 @@ impl AnyRecord for TypedRecord { metadata } +} - #[doc(hidden)] - #[cfg(feature = "remote-access")] +#[cfg(feature = "remote-access")] +impl JsonRecordAccess for TypedRecord { fn latest_json(&self) -> Option { log_debug!( "latest_json called for type: {}", @@ -1266,8 +1318,6 @@ impl AnyRecord for TypedRecord { result } - #[doc(hidden)] - #[cfg(feature = "remote-access")] fn subscribe_json(&self) -> crate::DbResult> { use crate::DbError; @@ -1302,8 +1352,6 @@ impl AnyRecord for TypedRecord { Ok(Box::new(json_reader)) } - #[doc(hidden)] - #[cfg(feature = "remote-access")] fn set_from_json(&self, json_value: serde_json::Value) -> crate::DbResult<()> { use crate::DbError; @@ -1370,11 +1418,9 @@ impl AnyRecord for TypedRecord { Ok(()) } +} - fn inbound_connectors(&self) -> &[crate::connector::InboundConnectorLink] { - &self.inbound_connectors - } - +impl RecordMetricsReset for TypedRecord { #[cfg(feature = "profiling")] fn reset_profiling(&self) { self.profiling.reset_all(); From 8569548fb982a35dca5613b46c481e159cbcce7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 12 Jun 2026 06:14:05 +0000 Subject: [PATCH 07/17] docs(design): mark 036 W2 implemented in PR #142 Co-Authored-By: Claude Fable 5 --- docs/design/036-followup-refactoring.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design/036-followup-refactoring.md b/docs/design/036-followup-refactoring.md index 8fd2a21..8191bf4 100644 --- a/docs/design/036-followup-refactoring.md +++ b/docs/design/036-followup-refactoring.md @@ -62,7 +62,7 @@ The registry keeps storing `Box`; consumers upcast to the capabil **Payoff:** the core storage contract stops churning every time remote access or profiling evolves, and each consumer's dependency is visible in its signature. **Acceptance:** `AnyRecord` ≤ ~8 methods; no behavior change; rustdoc for each trait states its consumer. -**Size:** M. Mechanical but wide. **File together with W1** (it touches the same files; do W2 first or in the same series — W2 shrinks the surface W1 has to move). +**Size:** M. Mechanical but wide. **File together with W1** (it touches the same files; do W2 first or in the same series — W2 shrinks the surface W1 has to move). **Status:** implemented in PR [#142](https://github.com/aimdb-dev/aimdb/pull/142), stacked on #141 (W1 had already landed, so the "W2 first" ordering note was moot). Deviations from the sketch: the JSON trait's gate is `remote-access` — the actual gate on those methods — not `json-serialize`; the resets live on a `RecordMetricsReset` supertrait (default no-ops, so the supertrait list needs no cfg); the dead `outbound_connector_urls` (cfg `std`, zero callers in-tree and in aimdb-pro) was dropped rather than moved. Result: `AnyRecord` has 6 methods. ### W3 — Execute the KNX hardware validation matrix (035 §3) @@ -141,7 +141,7 @@ Both protocols now ride the session engine (the hard part), but two subscribe/wr | Item | Issue | When to file | |---|---|---| | W1 data-plane de-`Any` | PR [#141](https://github.com/aimdb-dev/aimdb/pull/141) | done — no separate issue, direct PR | -| W2 `AnyRecord` split | — | on #140 merge (same series as W1) | +| W2 `AnyRecord` split | PR [#142](https://github.com/aimdb-dev/aimdb/pull/142) | done — stacked on #141, no separate issue | | W3 hardware matrix | — | none if run with #140; else a validation task | | W4 ACK-retransmit knob | — | on #140 merge | | W5 `StringKey` interner | — | opportunistic; file if not done by next release | From 03fd09bb45e49ed001aa444480eee68f135f23f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 12 Jun 2026 06:45:38 +0000 Subject: [PATCH 08/17] refactor(core): dedup StringKey::intern; document the leak contract (036 W5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit intern() leaked a fresh allocation on every call, so re-interning the same key leaked again. A global dedup table (std Mutex / spin Mutex, same pattern as the TypedRecord field locks) now returns the existing 'static allocation for a known key, bounding the leak by the number of *distinct* dynamic keys — the actual lifetime contract of a record key. The contract is documented loudly on intern(), and the debug-build tripwire now counts distinct keys instead of calls. The &'static str / Copy design stays; an Arc key variant remains rejected (forks RecordKey into two shapes — the 034 §3.1 mistake). Co-Authored-By: Claude Fable 5 --- aimdb-core/src/record_id.rs | 84 +++++++++++++++++++++++++++++-------- 1 file changed, 67 insertions(+), 17 deletions(-) diff --git a/aimdb-core/src/record_id.rs b/aimdb-core/src/record_id.rs index 8d2bb6d..71ccbf9 100644 --- a/aimdb-core/src/record_id.rs +++ b/aimdb-core/src/record_id.rs @@ -50,20 +50,43 @@ //! let producer = db.producer::(AppKey::TempIndoor); //! ``` -use alloc::{boxed::Box, string::ToString}; +use alloc::{boxed::Box, collections::BTreeSet, string::ToString}; #[cfg(all(debug_assertions, feature = "std"))] use core::sync::atomic::{AtomicUsize, Ordering}; -/// Counter for interned keys (debug builds only, std only) +/// Counter for distinct interned keys (debug builds only, std only) #[cfg(all(debug_assertions, feature = "std"))] static INTERNED_KEY_COUNT: AtomicUsize = AtomicUsize::new(0); -/// Maximum expected interned keys before warning. +/// Maximum expected distinct interned keys before warning. /// If exceeded, a debug assertion fires to catch potential misuse. #[cfg(all(debug_assertions, feature = "std"))] const MAX_EXPECTED_INTERNED_KEYS: usize = 1000; +#[cfg(feature = "std")] +type Mutex = std::sync::Mutex; +#[cfg(not(feature = "std"))] +type Mutex = spin::Mutex; + +/// Locks the intern table, hiding the std/spin API difference +/// (`std::sync::Mutex::lock` returns a `LockResult`; `spin` returns the guard +/// directly). A poisoned std mutex is unrecoverable here, so `.unwrap()` is +/// the correct response. Same pattern as the `TypedRecord` field mutexes. +#[cfg(feature = "std")] +fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap() +} +#[cfg(not(feature = "std"))] +fn lock(m: &Mutex) -> spin::MutexGuard<'_, T> { + m.lock() +} + +/// Dedup table for interned keys: re-interning an already-known key returns +/// the existing `'static` allocation instead of leaking a new one, bounding +/// the leak by the number of *distinct* dynamic keys. +static INTERNED_KEYS: Mutex> = Mutex::new(BTreeSet::new()); + // Re-export derive macro when feature is enabled #[cfg(feature = "derive")] pub use aimdb_derive::RecordKey; @@ -248,8 +271,9 @@ enum StringKeyInner { Static(&'static str), /// Interned runtime string (leaked into 'static lifetime) /// - /// Memory is intentionally leaked for O(1) cloning and comparison. - /// This is safe because keys are registered once at startup. + /// Memory is intentionally leaked for O(1) cloning and comparison; a + /// dedup table bounds the leak by the number of distinct dynamic keys. + /// See [`StringKey::intern`] for the contract. Interned(&'static str), } @@ -267,34 +291,47 @@ impl StringKey { /// /// Use this for dynamic names (multi-tenant, config-driven, etc.). /// - /// # Memory + /// # Memory contract /// - /// The string is leaked into `'static` lifetime. This is intentional: - /// - Keys are registered once at startup - /// - Enables O(1) Copy/Clone - /// - Typical overhead: <4KB for 100 keys + /// Each **distinct** dynamic key allocates once for the lifetime of the + /// process (the string is leaked into `'static`); re-interning a known + /// key returns the existing allocation. This is what keeps `StringKey` + /// `Copy` with O(1) comparison — and it means every distinct key stays + /// resident forever. Do **not** derive keys from unbounded input (e.g. + /// per-request or per-message IDs). Typical overhead: <4KB for 100 keys. /// /// # Panics (debug builds only) /// - /// In debug builds with `std` feature, panics if more than 1000 keys are - /// interned. This catches accidental misuse (e.g., creating keys in a loop). - /// Production builds have no limit. - #[inline] + /// In debug builds with the `std` feature, panics if more than 1000 + /// *distinct* keys are interned. This catches accidental misuse (e.g., + /// deriving keys from unbounded input). Production builds have no limit. #[must_use] pub fn intern(s: impl AsRef) -> Self { + let s = s.as_ref(); + + // Hold the lock across lookup + leak + insert so a race cannot leak + // two allocations for the same key. + let mut interned = lock(&INTERNED_KEYS); + if let Some(&existing) = interned.get(s) { + return Self(StringKeyInner::Interned(existing)); + } + #[cfg(all(debug_assertions, feature = "std"))] { let count = INTERNED_KEY_COUNT.fetch_add(1, Ordering::Relaxed); debug_assert!( count < MAX_EXPECTED_INTERNED_KEYS, - "StringKey::intern() called {} times. This exceeds the expected limit of {}. \ - Interned keys leak memory and should only be created at startup. \ + "StringKey::intern() created {} distinct keys. This exceeds the expected \ + limit of {}. Each distinct interned key leaks memory for process lifetime; \ + keys should only be created at startup, never derived from unbounded input. \ Use static string literals or enum keys for better performance.", count + 1, MAX_EXPECTED_INTERNED_KEYS ); } - let leaked: &'static str = Box::leak(s.as_ref().to_string().into_boxed_str()); + + let leaked: &'static str = Box::leak(s.to_string().into_boxed_str()); + interned.insert(leaked); Self(StringKeyInner::Interned(leaked)) } @@ -529,6 +566,19 @@ mod tests { assert_eq!(key.as_str(), "sensors.temperature"); } + #[test] + fn test_intern_dedup_returns_same_allocation() { + // Two separately built Strings with the same content must intern to + // the very same 'static allocation (leak bounded by distinct keys). + let a = StringKey::intern(["dedup", ".", "sensor"].concat()); + let b = StringKey::intern(["dedup", ".", "sensor"].concat()); + assert!(core::ptr::eq(a.as_str(), b.as_str())); + + // A different key gets its own allocation. + let c = StringKey::intern(["dedup", ".", "other"].concat()); + assert!(!core::ptr::eq(a.as_str(), c.as_str())); + } + #[test] fn test_string_key_equality() { let static_key: StringKey = "sensors.temp".into(); From 205db9ea00941f6b169b42bac96a8a2fd7865442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 12 Jun 2026 06:45:39 +0000 Subject: [PATCH 09/17] refactor(tests): host_test_stubs! macro for the defmt logger triplication (036 W6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 18-line no-op #[defmt::global_logger] + panic handler + host time driver block existed in three copies (embassy-adapter session_smoke, embassy-adapter buffer.rs tests, serial-connector embassy_smoke). Per 035 §2.4, an exported host_test_stubs! macro in aimdb-embassy-adapter now holds the single definition; each test binary expands it once, preserving the once-per-binary requirement of #[global_logger]/time_driver_impl!. The time driver uses the wake_by_ref variant (buffer.rs's superset behavior: zero-duration sleeps complete; the smokes never sleep). The serial-connector defmt/embassy-time-driver dev-deps stay — the expansion references them at the invocation site; the Cargo.toml comment now says so. Co-Authored-By: Claude Fable 5 --- aimdb-embassy-adapter/src/buffer.rs | 36 ++----------- aimdb-embassy-adapter/src/lib.rs | 53 +++++++++++++++++++ aimdb-embassy-adapter/tests/session_smoke.rs | 32 ++--------- aimdb-serial-connector/Cargo.toml | 14 ++--- aimdb-serial-connector/tests/embassy_smoke.rs | 32 ++--------- 5 files changed, 72 insertions(+), 95 deletions(-) diff --git a/aimdb-embassy-adapter/src/buffer.rs b/aimdb-embassy-adapter/src/buffer.rs index a45e747..e70c848 100644 --- a/aimdb-embassy-adapter/src/buffer.rs +++ b/aimdb-embassy-adapter/src/buffer.rs @@ -572,41 +572,13 @@ mod tests { // ── Host-test scaffolding ──────────────────────────────────────────── // The crate links `defmt` (workspace dep) and embassy-time's // `defmt-timestamp-uptime`, but on the host neither a defmt logger nor a - // time driver exists. Provide no-op stubs so the test binary links. Run via - // the `test` Make target, or directly: + // time driver exists. `host_test_stubs!` provides no-op stubs so the test + // binary links (expanded here for the lib test binary; integration tests + // expand it themselves). Run via the `test` Make target, or directly: // cargo test -p aimdb-embassy-adapter \ // --no-default-features --features "alloc,embassy-sync,embassy-time" // (`embassy-runtime` would pull the cortex-m executor, which can't host-build.) - #[defmt::global_logger] - struct TestLogger; - - unsafe impl defmt::Logger for TestLogger { - fn acquire() {} - unsafe fn flush() {} - unsafe fn release() {} - unsafe fn write(_bytes: &[u8]) {} - } - - #[defmt::panic_handler] - fn defmt_panic() -> ! { - core::panic!("defmt panic in host test") - } - - // Trivial time driver so `_embassy_time_now` resolves on the host. The - // clock is pinned at 0; `schedule_wake` wakes immediately so an - // already-expired `Timer` (e.g. `sleep(Duration::ZERO)` in the RuntimeOps - // contract test) completes on its next poll. Non-zero sleeps would spin - // forever — host tests must not use them. - struct TestTimeDriver; - impl embassy_time_driver::Driver for TestTimeDriver { - fn now(&self) -> u64 { - 0 - } - fn schedule_wake(&self, _at: u64, waker: &core::task::Waker) { - waker.wake_by_ref(); - } - } - embassy_time_driver::time_driver_impl!(static TEST_TIME_DRIVER: TestTimeDriver = TestTimeDriver); + crate::host_test_stubs!(); // Note: Embassy tests typically run on actual embedded targets or with embassy-executor // For now, these are basic compilation tests. Integration tests would need embassy-executor. diff --git a/aimdb-embassy-adapter/src/lib.rs b/aimdb-embassy-adapter/src/lib.rs index 57c996f..1abf8ae 100644 --- a/aimdb-embassy-adapter/src/lib.rs +++ b/aimdb-embassy-adapter/src/lib.rs @@ -80,6 +80,59 @@ pub mod send_wrapper; #[cfg(all(not(feature = "std"), feature = "connectors"))] pub mod connectors; +/// Link stubs for **host** test binaries that touch the Embassy adapter +/// (035 §2.4): a no-op `#[defmt::global_logger]` + `#[defmt::panic_handler]` +/// and a pinned-at-0 embassy-time driver. +/// +/// Coercing `EmbassyAdapter` to `Arc` instantiates a vtable +/// whose `log` entry calls `defmt::*` unconditionally, so the `_defmt_*` +/// extern symbols must resolve in every host test binary that links the +/// adapter — and `#[global_logger]`/the time driver must be defined **once +/// per binary**, so a shared `#[cfg(test)]` item cannot serve integration +/// tests. This macro keeps the definition in one place; each test binary +/// expands it exactly once at top level (or once in the lib's test module). +/// +/// The invoking crate needs `defmt` and `embassy-time-driver` resolvable +/// (dev-dependencies are enough). +/// +/// The time driver pins the clock at 0 and `schedule_wake` wakes immediately, +/// so an already-expired timer (e.g. `sleep(Duration::ZERO)`) completes on its +/// next poll. Non-zero sleeps would spin forever — host tests must not use +/// them. +#[macro_export] +#[doc(hidden)] +macro_rules! host_test_stubs { + () => { + #[defmt::global_logger] + struct HostTestLogger; + + unsafe impl defmt::Logger for HostTestLogger { + fn acquire() {} + unsafe fn flush() {} + unsafe fn release() {} + unsafe fn write(_bytes: &[u8]) {} + } + + #[defmt::panic_handler] + fn defmt_panic() -> ! { + core::panic!("defmt panic in host test") + } + + struct HostTestTimeDriver; + impl embassy_time_driver::Driver for HostTestTimeDriver { + fn now(&self) -> u64 { + 0 + } + fn schedule_wake(&self, _at: u64, waker: &core::task::Waker) { + waker.wake_by_ref(); + } + } + embassy_time_driver::time_driver_impl!( + static HOST_TEST_TIME_DRIVER: HostTestTimeDriver = HostTestTimeDriver + ); + }; +} + // Error handling exports #[cfg(not(feature = "std"))] pub use error::EmbassyErrorSupport; diff --git a/aimdb-embassy-adapter/tests/session_smoke.rs b/aimdb-embassy-adapter/tests/session_smoke.rs index 3da31fa..44a3cfb 100644 --- a/aimdb-embassy-adapter/tests/session_smoke.rs +++ b/aimdb-embassy-adapter/tests/session_smoke.rs @@ -24,34 +24,10 @@ use aimdb_core::session::{ }; use aimdb_embassy_adapter::EmbassyAdapter; -// No-op defmt logger so the binary links: the engine holds the adapter as -// `Arc` (issue #131), whose vtable references the `log` path -// (`defmt` on Embassy) even though this smoke never logs. -#[defmt::global_logger] -struct TestLogger; - -unsafe impl defmt::Logger for TestLogger { - fn acquire() {} - unsafe fn flush() {} - unsafe fn release() {} - unsafe fn write(_bytes: &[u8]) {} -} - -#[defmt::panic_handler] -fn defmt_panic() -> ! { - core::panic!("defmt panic in host test") -} - -// Trivial host time driver so `embassy_time` links (the happy path never awaits -// `clock.sleep`, so `now`/`schedule_wake` are never actually exercised). -struct TestTimeDriver; -impl embassy_time_driver::Driver for TestTimeDriver { - fn now(&self) -> u64 { - 0 - } - fn schedule_wake(&self, _at: u64, _waker: &core::task::Waker) {} -} -embassy_time_driver::time_driver_impl!(static TEST_TIME_DRIVER: TestTimeDriver = TestTimeDriver); +// No-op defmt logger + host time driver so the binary links: the engine holds +// the adapter as `Arc` (issue #131), whose vtable references +// the `log` path (`defmt` on Embassy) even though this smoke never logs. +aimdb_embassy_adapter::host_test_stubs!(); /// Minimal echo wire: a `Request` is `[id:8][params]`; the loopback returns those /// bytes verbatim, which `decode_outbound` reads back as `Reply { id, Ok(params) }`. diff --git a/aimdb-serial-connector/Cargo.toml b/aimdb-serial-connector/Cargo.toml index 371e642..cce83b9 100644 --- a/aimdb-serial-connector/Cargo.toml +++ b/aimdb-serial-connector/Cargo.toml @@ -83,14 +83,14 @@ tracing = { version = "0.1", optional = true } defmt = { workspace = true, optional = true } [dev-dependencies] -# Trivial host time driver so `embassy-time` links in the embassy smoke test. -# (`aimdb-embassy-adapter` itself is the regular `embassy-runtime` optional dep — -# keeping it out of dev-deps avoids a std/no_std unification clash with the tokio -# tests, where it would otherwise compile against a std `aimdb-core`.) +# The embassy smoke test expands `aimdb_embassy_adapter::host_test_stubs!()` +# (no-op defmt logger + host time driver, 035 §2.4); the expansion references +# `defmt` and `embassy-time-driver` at the invocation site, so both must stay +# resolvable in the test binary. (`aimdb-embassy-adapter` itself is the regular +# `embassy-runtime` optional dep — keeping it out of dev-deps avoids a +# std/no_std unification clash with the tokio tests, where it would otherwise +# compile against a std `aimdb-core`.) embassy-time-driver = { path = "../_external/embassy/embassy-time-driver" } -# No-op defmt logger stub for the embassy smoke test binary: the engine holds -# the adapter as `Arc` (issue #131), whose vtable references -# the defmt-backed `log` path. defmt = { workspace = true } tokio = { version = "1", features = [ "rt-multi-thread", diff --git a/aimdb-serial-connector/tests/embassy_smoke.rs b/aimdb-serial-connector/tests/embassy_smoke.rs index f066cd6..ebfdceb 100644 --- a/aimdb-serial-connector/tests/embassy_smoke.rs +++ b/aimdb-serial-connector/tests/embassy_smoke.rs @@ -31,34 +31,10 @@ use aimdb_embassy_adapter::connectors::{EmbassyConnection, OneShotDialer}; use aimdb_embassy_adapter::EmbassyAdapter; use aimdb_serial_connector::embassy_transport::CobsFramer; -// Trivial host time driver so `embassy_time` links (the happy path never awaits -// `clock.sleep`, so the driver is never actually exercised). -// No-op defmt logger so the binary links: the engine holds the adapter as -// `Arc` (issue #131), whose vtable references the `log` path -// (`defmt` on Embassy) even though this smoke never logs. -#[defmt::global_logger] -struct TestLogger; - -unsafe impl defmt::Logger for TestLogger { - fn acquire() {} - unsafe fn flush() {} - unsafe fn release() {} - unsafe fn write(_bytes: &[u8]) {} -} - -#[defmt::panic_handler] -fn defmt_panic() -> ! { - core::panic!("defmt panic in host test") -} - -struct TestTimeDriver; -impl embassy_time_driver::Driver for TestTimeDriver { - fn now(&self) -> u64 { - 0 - } - fn schedule_wake(&self, _at: u64, _waker: &core::task::Waker) {} -} -embassy_time_driver::time_driver_impl!(static TEST_TIME_DRIVER: TestTimeDriver = TestTimeDriver); +// No-op defmt logger + host time driver so the binary links: the engine holds +// the adapter as `Arc` (issue #131), whose vtable references +// the `log` path (`defmt` on Embassy) even though this smoke never logs. +aimdb_embassy_adapter::host_test_stubs!(); /// Minimal echo wire: a `Request` is `[id:8][params]`; the loopback returns those /// bytes verbatim, which `decode_outbound` reads back as `Reply { id, Ok(params) }`. From 46ad119fd7fa14c623d2d289b23b8cf302b99001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 12 Jun 2026 06:46:34 +0000 Subject: [PATCH 10/17] docs(design): mark 036 W5+W6 implemented in PR #143 Co-Authored-By: Claude Fable 5 --- docs/design/036-followup-refactoring.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/design/036-followup-refactoring.md b/docs/design/036-followup-refactoring.md index 8191bf4..55d17d1 100644 --- a/docs/design/036-followup-refactoring.md +++ b/docs/design/036-followup-refactoring.md @@ -90,13 +90,13 @@ The registry keeps storing `Box`; consumers upcast to the capabil Explicitly rejected: a non-`Copy` `Arc` key variant — it forks `RecordKey` into two shapes, which is the 034 §3.1 mistake again. -**Size:** S. Independent of everything else; opportunistic. +**Size:** S. Independent of everything else; opportunistic. **Status:** implemented in PR [#143](https://github.com/aimdb-dev/aimdb/pull/143) (with W6, stacked on #142). As specified: dedup table behind the std/spin mutex pattern from typed_record, contract documented on `intern`, debug tripwire now counts distinct keys. ### W6 — `host_test_stubs!` macro for the defmt logger duplication (035 §2.4) **Current state (verified).** The no-op `#[defmt::global_logger]`/panic-handler/time-driver block exists in three places: [session_smoke.rs](../../aimdb-embassy-adapter/tests/session_smoke.rs), [buffer.rs](../../aimdb-embassy-adapter/src/buffer.rs) (test module), [embassy_smoke.rs](../../aimdb-serial-connector/tests/embassy_smoke.rs) — the third copy that 035 named as the trigger already exists. -**Approach.** As specified in 035 §2.4: `#[macro_export] #[doc(hidden)] macro_rules! host_test_stubs` in `aimdb-embassy-adapter`, expanded once per test binary; delete the three copies and the serial-connector's standalone `defmt` dev-dependency if nothing else needs it. **Size:** S. Do it the next time any of those test files is touched, or fold into the W1/W2 series. +**Approach.** As specified in 035 §2.4: `#[macro_export] #[doc(hidden)] macro_rules! host_test_stubs` in `aimdb-embassy-adapter`, expanded once per test binary; delete the three copies and the serial-connector's standalone `defmt` dev-dependency if nothing else needs it. **Size:** S. Do it the next time any of those test files is touched, or fold into the W1/W2 series. **Status:** implemented in PR [#143](https://github.com/aimdb-dev/aimdb/pull/143) (with W5, stacked on #142). The time driver adopts the `wake_by_ref` superset variant from buffer.rs; the serial-connector `defmt`/`embassy-time-driver` dev-deps stay because the macro expansion references them at the invocation site — the "if nothing else needs it" condition does not hold. ### W7 — `aimdb-data-contracts` trait audit (034 §3.8, last unhandled row) @@ -144,8 +144,8 @@ Both protocols now ride the session engine (the hard part), but two subscribe/wr | W2 `AnyRecord` split | PR [#142](https://github.com/aimdb-dev/aimdb/pull/142) | done — stacked on #141, no separate issue | | W3 hardware matrix | — | none if run with #140; else a validation task | | W4 ACK-retransmit knob | — | on #140 merge | -| W5 `StringKey` interner | — | opportunistic; file if not done by next release | -| W6 `host_test_stubs!` | — | opportunistic | +| W5 `StringKey` interner | PR [#143](https://github.com/aimdb-dev/aimdb/pull/143) | done — with W6, stacked on #142 | +| W6 `host_test_stubs!` | PR [#143](https://github.com/aimdb-dev/aimdb/pull/143) | done — with W5, stacked on #142 | | W7 data-contracts audit | — | opportunistic | | A1 / A2 | — | on trigger only | From 7f4781c35b1e7037bad80566cfeb9e0ad9f57a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 12 Jun 2026 06:59:18 +0000 Subject: [PATCH 11/17] =?UTF-8?q?docs(design):=20036=20status=20round=20?= =?UTF-8?q?=E2=80=94=20W3=20prep=20done,=20W4=20deferred=20on=20W3=20data,?= =?UTF-8?q?=20W7=20skipped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - W3: Nucleo firmware build verified (thumbv8m.main-none-eabihf); single run on a main build is the bar now that #140 is merged. Bench session is the remaining work and the gate for closing 035. - W4: deferred pending W3 scenario-1 AckTimeout evidence (decision 2026-06-12). Design pre-decided for the trigger: buffer the sent frame (option a) — GroupWrite already carries a 254-byte APDU buffer, so the semantic-content variant saves ~350 B total, no real RAM argument. - W7: skipped entirely (decision 2026-06-12); no audit will be run. Co-Authored-By: Claude Fable 5 --- docs/design/036-followup-refactoring.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/design/036-followup-refactoring.md b/docs/design/036-followup-refactoring.md index 55d17d1..41c728c 100644 --- a/docs/design/036-followup-refactoring.md +++ b/docs/design/036-followup-refactoring.md @@ -72,6 +72,8 @@ The registry keeps storing `Box`; consumers upcast to the capabil **Size:** S (one bench session). No issue needed if run as part of #140; otherwise file as a validation task. +**Prep done 2026-06-12:** the demo firmware builds clean (`cargo build -p embassy-knx-connector-demo --target thumbv8m.main-none-eabihf`); flash from the host via the demo's `flash.sh` (probe-rs, STM32H563ZITx), defmt over RTT; set `KNX_GATEWAY_IP` in the demo's `main.rs` first. Since #140 merged, 035's "run the matrix twice" guidance is moot — one run on a current `main` build is the bar (the open 036 PR stack does not touch the KNX connector, so `main` is the right baseline). The bench session itself is the remaining work. + ### W4 — KNX ACK-retransmit knob in `TunnelConfig` (from the #135 review) **Current state (verified).** When a `TunnelingRequest` is not ACKed within the timeout, the engine expires the pending slot and emits [`Action::AckTimeout`](../../aimdb-knx-connector/src/tunnel.rs#L94) (shims log a warning) — no retransmit, no disconnect. The KNXnet/IP tunneling spec (3.8.4) says: retransmit once after 1 s, then tear the connection down on the second miss. @@ -82,6 +84,8 @@ The registry keeps storing `Box`; consumers upcast to the capabil **Size:** S–M. File after #140 merges (touches `tunnel.rs` on the #140 baseline). +**Status: deferred pending W3 bench data (decision 2026-06-12).** Scenario 1's AckTimeout observations were always meant to size this knob, no field failure motivates it (both previous implementations behaved identically), and scenario 1's pass bar is zero AckTimeout warnings — so the bench decides. Implement only if W3 shows AckTimeouts on healthy hardware or a lossy-link deployment appears. The (a)-vs-(b) design question is pre-decided for when the trigger fires: `GroupWrite.data` already carries a full 254-byte APDU buffer (`MAX_APDU`), so storing semantic content (b) saves only ~22 bytes per slot vs buffering the sent 278-byte frame (a) — ~350 B total at `PENDING_ACK_CAPACITY` 16. The RAM argument for (b) evaporates; **choose (a)** unless 035 §2.2's semantic-actions trigger has fired independently. + ### W5 — `StringKey::intern`: dedup interner + loud contract (034 §3.10) **Current state (verified).** [`StringKey::intern`](../../aimdb-core/src/record_id.rs#L284) still `Box::leak`s every call ([record_id.rs:297](../../aimdb-core/src/record_id.rs#L297)); re-interning the same key leaks again; guarded only by a debug-build counter (cap 1000). @@ -104,6 +108,8 @@ Explicitly rejected: a non-`Copy` `Arc` key variant — it forks `RecordKey **Approach.** One-time audit: for each trait, list in-tree implementors and external consumers (aimdb-pro included). Traits with zero non-demo implementors get a deprecation note or deletion in the next breaking window — per 034 root-cause 7, speculative surface is the habit to break, not an emergency. **Size:** S (audit) + S (deletions). Output is a short table appended to this doc or the issue. +**Status: skipped entirely (decision 2026-06-12).** No audit will be run; the crate keeps its current surface. Re-open only if a data-contracts trait actively blocks other work. + --- ## 3. Assessment-only items (decision docs, not code) @@ -142,11 +148,11 @@ Both protocols now ride the session engine (the hard part), but two subscribe/wr |---|---|---| | W1 data-plane de-`Any` | PR [#141](https://github.com/aimdb-dev/aimdb/pull/141) | done — no separate issue, direct PR | | W2 `AnyRecord` split | PR [#142](https://github.com/aimdb-dev/aimdb/pull/142) | done — stacked on #141, no separate issue | -| W3 hardware matrix | — | none if run with #140; else a validation task | -| W4 ACK-retransmit knob | — | on #140 merge | +| W3 hardware matrix | — | prep done 2026-06-12 (firmware build verified); bench session pending — the gate for closing 035 | +| W4 ACK-retransmit knob | — | deferred 2026-06-12 — implement only on W3 AckTimeout evidence; design pre-decided (option a) | | W5 `StringKey` interner | PR [#143](https://github.com/aimdb-dev/aimdb/pull/143) | done — with W6, stacked on #142 | | W6 `host_test_stubs!` | PR [#143](https://github.com/aimdb-dev/aimdb/pull/143) | done — with W5, stacked on #142 | -| W7 data-contracts audit | — | opportunistic | +| W7 data-contracts audit | — | skipped entirely (decision 2026-06-12) | | A1 / A2 | — | on trigger only | Update this table with issue numbers as they are filed; when every row is filed or closed, this doc's status moves to Final and the live list is the issue tracker again. From b778564bf9150e6576a166fe89f1a72b20ab325e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 12 Jun 2026 07:39:36 +0000 Subject: [PATCH 12/17] =?UTF-8?q?feat(knx):=20ACK-retransmit=20knob=20?= =?UTF-8?q?=E2=80=94=20retransmit=20once,=20disconnect=20on=20second=20mis?= =?UTF-8?q?s=20(036=20W4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KNXnet/IP 3.8.4 says: repeat an unACKed TUNNELING_REQUEST once after the timeout, then tear the connection down on the second miss. The engine previously expired and warned only — hardware bench evidence (W3, 2026-06-12) showed ten button-press writes silently lost during a link outage's heartbeat-detection window (~65 s). TunnelConfig gains ack_retransmits (default 1 = spec behavior; 0 = the old expire-and-warn for the previous semantics). The pending-ACK slot buffers the sent frame (option (a) per the 036 W4 decision record: GroupWrite already carries a 254-byte APDU, so rebuilding from semantic content saves ~350 B total — not worth the rebuild path). On expiry with retries left the identical frame is re-sent (same sequence counter) and the timer re-armed; on final expiry AckTimeout is reported and the engine disconnects so queued commands flush after the re-handshake instead of vanishing into a dead tunnel. Eviction on map overflow stays warn-only (overflow is not confirmed loss). Behavior change at default config: a tunnel with a persistently unanswered write now reconnects within ~2× ack_timeout_ms instead of staying connected. Retransmit delay is ack_timeout_ms (3 s, the pre-engine constant); strict spec timing is one config knob away (ack_timeout_ms = 1_000). Tests: engine units (identical-frame retransmit, ack-after-retransmit, disconnect on second miss, legacy mode pinned at ack_retransmits=0) + fake-gateway test dropping the first ACK and asserting the byte-identical repeat and tunnel survival. Co-Authored-By: Claude Fable 5 --- aimdb-knx-connector/CHANGELOG.md | 4 + aimdb-knx-connector/src/tokio_client.rs | 83 +++++++++++ aimdb-knx-connector/src/tunnel.rs | 189 ++++++++++++++++++++++-- 3 files changed, 261 insertions(+), 15 deletions(-) diff --git a/aimdb-knx-connector/CHANGELOG.md b/aimdb-knx-connector/CHANGELOG.md index c02afcc..2bdd28f 100644 --- a/aimdb-knx-connector/CHANGELOG.md +++ b/aimdb-knx-connector/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Spec-conformant TUNNELING_REQUEST retransmission — `TunnelConfig::ack_retransmits` (036 W4, default `1`).** When a tracked outbound telegram's ACK does not arrive within `ack_timeout_ms`, the engine now retransmits the byte-identical frame (same sequence counter, buffered in the pending-ACK slot per KNXnet/IP 3.8.4) and, when the repeat also goes unanswered, reports `Action::AckTimeout` **and tears the connection down** — so subsequent commands queue for the re-handshake instead of being sent into a dead tunnel. Hardware-bench evidence motivating this: ten button-press writes issued during a link outage's heartbeat-detection window (up to ~65 s) were silently lost with only warnings; with retransmission the loss window shrinks to ~2× `ack_timeout_ms`. `ack_retransmits: 0` restores the previous expire-and-warn behavior (no retransmit, no disconnect, no frame buffering — though the 16-slot frame capacity, ~4.5 KiB, is statically reserved either way on `heapless`). The retransmit delay is `ack_timeout_ms` (default 3 s, the constant both pre-engine implementations used); set it to `1_000` for strict spec timing. Covered by engine unit tests and a fake-gateway test that drops the first ACK and asserts the identical repeat. + ### Fixed - **Heartbeat-response liveness — a dead send path or expired gateway channel now reconnects (review follow-up to #135).** The engine tracks each CONNECTIONSTATE_REQUEST and drops the connection when the gateway's CONNECTIONSTATE_RESPONSE doesn't arrive within the new `TunnelConfig::heartbeat_response_timeout_ms` (default 10 s, the KNX spec timeout) or reports a non-zero status (e.g. the gateway expired the channel during an outage). This restores the old tokio client's recovery from silently-failing sends — the recv path of an unconnected UDP socket never errors, so without it a route flap left the tunnel `Connected` forever with a stale channel id — and adds genuine liveness detection on both runtimes. diff --git a/aimdb-knx-connector/src/tokio_client.rs b/aimdb-knx-connector/src/tokio_client.rs index aaff6a7..193bd60 100644 --- a/aimdb-knx-connector/src/tokio_client.rs +++ b/aimdb-knx-connector/src/tokio_client.rs @@ -419,6 +419,89 @@ mod tests { frame } + /// TUNNELING_ACK from the gateway: header + connection header. + fn gateway_ack(channel_id: u8, seq: u8) -> Vec { + vec![ + 0x06, 0x10, 0x04, 0x21, 0x00, 0x0A, // header, total len 10 + 0x04, channel_id, seq, 0x00, // connection header, status OK + ] + } + + /// W4: the gateway drops the first ACK; the client retransmits the + /// byte-identical TUNNELING_REQUEST (same sequence counter, KNXnet/IP + /// 3.8.4) after the ACK timeout, and the tunnel survives once the repeat + /// is ACKed. Real-time test: waits out the 3 s default ACK timeout. + #[tokio::test] + async fn dropped_ack_triggers_identical_retransmit() { + let gateway = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let gateway_port = gateway.local_addr().unwrap().port(); + + let (command_tx, mut telegram_rx, connection_future) = + KnxConnectorImpl::build_internal(&format!("knx://127.0.0.1:{}", gateway_port), 8) + .await + .unwrap(); + let task = tokio::spawn(connection_future); + + let mut buf = [0u8; 1024]; + let (len, client_addr) = timeout(RECV_TIMEOUT, gateway.recv_from(&mut buf)) + .await + .expect("no CONNECT_REQUEST") + .unwrap(); + assert_eq!(service_type_of(&buf[..len]), 0x0205); + gateway + .send_to(&connect_response(7, 0), client_addr) + .await + .unwrap(); + + // Outbound write; deliberately do NOT ACK the first request. + let mut data = heapless::Vec::new(); + data.push(0x01).unwrap(); + command_tx + .send(GroupWrite { + group_addr: "1/0/8".parse().unwrap(), + data, + }) + .await + .unwrap(); + let (len, _) = timeout(RECV_TIMEOUT, gateway.recv_from(&mut buf)) + .await + .expect("no TUNNELING_REQUEST") + .unwrap(); + let first = buf[..len].to_vec(); + assert_eq!(service_type_of(&first), 0x0420); + + // The retransmit arrives after the ACK timeout, byte-identical. + let (len, _) = timeout(Duration::from_secs(8), gateway.recv_from(&mut buf)) + .await + .expect("no retransmit after dropped ACK") + .unwrap(); + assert_eq!(&buf[..len], &first[..]); + + // ACK the repeat: the tunnel stays up — an inbound telegram still + // round-trips on the same channel (a disconnect would have produced + // a CONNECT_REQUEST here instead of an ACK). + gateway + .send_to(&gateway_ack(7, 0), client_addr) + .await + .unwrap(); + gateway + .send_to(&inbound_group_write(7, 42), client_addr) + .await + .unwrap(); + let (len, _) = timeout(RECV_TIMEOUT, gateway.recv_from(&mut buf)) + .await + .expect("no TUNNELING_ACK for inbound telegram") + .unwrap(); + assert_eq!(service_type_of(&buf[..len]), 0x0421); + let (topic, _) = timeout(RECV_TIMEOUT, telegram_rx.recv()) + .await + .expect("no telegram routed") + .unwrap(); + assert_eq!(topic, "1/0/7"); + + task.abort(); + } + /// Full roundtrip against a scripted fake gateway on localhost UDP: /// handshake, inbound telegram → `KnxSource` channel, outbound command → /// TUNNELING_REQUEST on the wire (then ACKed). diff --git a/aimdb-knx-connector/src/tunnel.rs b/aimdb-knx-connector/src/tunnel.rs index d4115b1..00d8ffe 100644 --- a/aimdb-knx-connector/src/tunnel.rs +++ b/aimdb-knx-connector/src/tunnel.rs @@ -89,9 +89,12 @@ pub enum Action { /// The engine has entered backoff and will emit the next CONNECT_REQUEST /// as a `Send` action once the backoff deadline passes. ResetSocket, - /// An outbound telegram was never acknowledged within the ACK timeout. - /// Log-only: the engine keeps the connection (matching both previous - /// implementations, which expired and warned without retransmitting). + /// An outbound telegram was never acknowledged: every send attempt + /// (1 + [`TunnelConfig::ack_retransmits`]) expired unanswered. Log-only + /// for the transport. With retransmits enabled the engine also tears the + /// connection down afterwards (KNXnet/IP 3.8.4); with + /// `ack_retransmits == 0` it keeps the connection, matching both + /// pre-retransmit implementations. AckTimeout { seq: u8 }, } @@ -124,10 +127,20 @@ pub struct TunnelConfig { pub local_endpoint: LocalEndpoint, /// CONNECT_RESPONSE wait before giving up and backing off. pub connect_timeout_ms: Millis, - /// Pending ACK lifetime before it is reported via [`Action::AckTimeout`]. + /// Pending ACK lifetime before a send attempt is considered unanswered. pub ack_timeout_ms: Millis, /// Cadence of the pending-ACK expiry sweep. pub ack_sweep_ms: Millis, + /// TUNNELING_REQUEST retransmissions after an ACK timeout before the + /// telegram is reported lost via [`Action::AckTimeout`]. + /// + /// KNXnet/IP 3.8.4 repeats the identical frame once and tears the + /// connection down when the repeat also goes unanswered — `1` (the + /// default) is that behavior, with the retransmit delay being + /// [`ack_timeout_ms`](Self::ack_timeout_ms) (set that to `1_000` for + /// strict spec timing). `0` restores the pre-retransmit behavior: expire + /// and warn only, no disconnect, and no frame bytes are buffered. + pub ack_retransmits: u8, /// CONNECTIONSTATE_REQUEST keepalive cadence. pub heartbeat_ms: Millis, /// CONNECTIONSTATE_RESPONSE wait before the connection is considered @@ -147,6 +160,7 @@ impl Default for TunnelConfig { connect_timeout_ms: 5_000, ack_timeout_ms: 3_000, ack_sweep_ms: 500, + ack_retransmits: 1, heartbeat_ms: 55_000, heartbeat_response_timeout_ms: 10_000, reconnect_backoff_ms: 5_000, @@ -154,9 +168,22 @@ impl Default for TunnelConfig { } } -/// Pending outbound ACKs tracked per connection (seq → sent-at). +/// Pending outbound ACKs tracked per connection (seq → [`PendingAck`]). const PENDING_ACK_CAPACITY: usize = 16; +/// A tracked outbound TUNNELING_REQUEST awaiting its TUNNELING_ACK. +#[derive(Debug)] +struct PendingAck { + sent_at: Millis, + /// Send attempts still allowed after the next expiry. + retries_left: u8, + /// The sent frame, buffered for byte-identical retransmission (KNXnet/IP + /// 3.8.4 repeats the same frame, same sequence counter). Left empty when + /// `ack_retransmits == 0`: the slot capacity is statically reserved + /// either way (heapless), but no bytes are copied. + frame: Frame, +} + /// Per-connection state; only exists while the handshake has succeeded, so /// "connected" needs no separate flag and the keepalive cannot fire while /// disconnected. @@ -164,7 +191,7 @@ const PENDING_ACK_CAPACITY: usize = 16; struct ChannelState { channel_id: u8, outbound_seq: u8, - pending_acks: heapless::FnvIndexMap, + pending_acks: heapless::FnvIndexMap, next_heartbeat: Millis, /// Set when a CONNECTIONSTATE_REQUEST goes out; cleared by the gateway's /// OK response. A pending entry older than @@ -335,15 +362,25 @@ impl TunnelEngine { let seq = state.next_outbound_seq(); let cemi = build_group_write_cemi(cmd.group_addr, &cmd.data); let frame = build_tunneling_request(state.channel_id, seq, &cemi); - if state.pending_acks.insert(seq, now).is_err() { + let pending = PendingAck { + sent_at: now, + retries_left: self.cfg.ack_retransmits, + frame: if self.cfg.ack_retransmits > 0 { + frame.clone() + } else { + Frame::new() + }, + }; + if let Err((_, pending)) = state.pending_acks.insert(seq, pending) { // Map full (burst deeper than PENDING_ACK_CAPACITY): evict the // oldest entry and report it now, so no unacknowledged telegram - // is ever silently untracked. + // is ever silently untracked. Eviction is overflow, not confirmed + // loss, so it never tears the connection down. if let Some((&oldest, _)) = state.pending_acks.iter().next() { state.pending_acks.remove(&oldest); self.actions.push_back(Action::AckTimeout { seq: oldest }); } - let _ = state.pending_acks.insert(seq, now); + let _ = state.pending_acks.insert(seq, pending); } self.actions.push_back(Action::Send { frame, @@ -378,6 +415,9 @@ impl TunnelEngine { /// Fire any deadlines that have passed. Call at the top of every loop /// iteration, before draining actions. pub fn poll(&mut self, now: Millis) { + // Set inside the `Connected` arm (where `self.phase` is borrowed) and + // applied after the match — same pattern as `handle_datagram`. + let mut drop_connection = false; match &mut self.phase { Phase::Backoff { until } => { if now >= *until { @@ -419,19 +459,44 @@ impl TunnelEngine { } if now >= state.next_ack_sweep { let mut expired: heapless::Vec = heapless::Vec::new(); - for (&seq, &sent_at) in state.pending_acks.iter() { - if now.saturating_sub(sent_at) > self.cfg.ack_timeout_ms { + for (&seq, pending) in state.pending_acks.iter() { + if now.saturating_sub(pending.sent_at) > self.cfg.ack_timeout_ms { let _ = expired.push(seq); } } for seq in &expired { - state.pending_acks.remove(seq); - self.actions.push_back(Action::AckTimeout { seq: *seq }); + let Some(pending) = state.pending_acks.get_mut(seq) else { + continue; + }; + if pending.retries_left > 0 { + // KNXnet/IP 3.8.4: repeat the identical frame + // (same sequence counter) and re-arm the timeout. + pending.retries_left -= 1; + pending.sent_at = now; + self.actions.push_back(Action::Send { + frame: pending.frame.clone(), + await_ack: Some(*seq), + }); + } else { + state.pending_acks.remove(seq); + self.actions.push_back(Action::AckTimeout { seq: *seq }); + // Spec: tear the connection down once the repeat + // also went unanswered, so queued commands stop + // being sent into a dead tunnel. + // `ack_retransmits == 0` keeps the legacy + // warn-and-continue behavior. + if self.cfg.ack_retransmits > 0 { + drop_connection = true; + } + } } state.next_ack_sweep = now + self.cfg.ack_sweep_ms; } } } + if drop_connection { + self.handle_socket_error(now); + } } /// Earliest deadline the transport must wake the engine for. @@ -711,16 +776,27 @@ fn parse_telegram(cemi_data: &[u8]) -> Option<(GroupAddress, Vec)> { mod tests { use super::*; + /// Legacy-mode config: no retransmits, expire-and-warn only. Most tests + /// pin this pre-retransmit contract; the retransmit tests use + /// [`RETRANSMIT_CFG`]. const CFG: TunnelConfig = TunnelConfig { local_endpoint: LocalEndpoint::Nat, connect_timeout_ms: 5_000, ack_timeout_ms: 3_000, ack_sweep_ms: 500, + ack_retransmits: 0, heartbeat_ms: 55_000, heartbeat_response_timeout_ms: 10_000, reconnect_backoff_ms: 5_000, }; + /// [`CFG`] with the spec-conformant retransmit knob (the shipping + /// default) enabled. + const RETRANSMIT_CFG: TunnelConfig = TunnelConfig { + ack_retransmits: 1, + ..CFG + }; + fn drain(engine: &mut TunnelEngine) -> Vec { let mut actions = Vec::new(); while let Some(a) = engine.next_action() { @@ -757,7 +833,12 @@ mod tests { /// Drive a fresh engine to Connected; returns it with actions drained. fn connected_engine(now: Millis) -> TunnelEngine { - let mut engine = TunnelEngine::new(CFG.clone(), now); + connected_engine_with(CFG.clone(), now) + } + + /// [`connected_engine`] with an explicit config. + fn connected_engine_with(cfg: TunnelConfig, now: Millis) -> TunnelEngine { + let mut engine = TunnelEngine::new(cfg, now); engine.poll(now); let actions = drain(&mut engine); assert_eq!(actions.len(), 1); @@ -1024,10 +1105,88 @@ mod tests { engine.poll(1_000); assert!(drain(&mut engine).is_empty()); - // First sweep past sent_at + ack_timeout expires it. + // First sweep past sent_at + ack_timeout expires it. Legacy mode + // (ack_retransmits == 0): warn only, the connection is kept. engine.poll(10 + CFG.ack_timeout_ms + CFG.ack_sweep_ms); let actions = drain(&mut engine); assert_eq!(actions, vec![Action::AckTimeout { seq: 0 }]); + assert!(engine.is_connected()); + } + + #[test] + fn ack_timeout_retransmits_identical_frame_then_disconnects() { + let addr: GroupAddress = "1/0/8".parse().unwrap(); + let mut engine = connected_engine_with(RETRANSMIT_CFG.clone(), 0); + let mut data = heapless::Vec::new(); + data.push(0x01).unwrap(); + engine.handle_command( + GroupWrite { + group_addr: addr, + data, + }, + 10, + ); + let actions = drain(&mut engine); + let [Action::Send { + frame: original, + await_ack: Some(0), + }] = &actions[..] + else { + panic!("expected tracked TUNNELING_REQUEST send, got {actions:?}"); + }; + let original = original.clone(); + + // First expiry: the identical frame goes out again (same sequence + // counter, KNXnet/IP 3.8.4), no AckTimeout, connection kept. + engine.poll(10 + CFG.ack_timeout_ms + CFG.ack_sweep_ms); + let actions = drain(&mut engine); + assert_eq!( + actions, + vec![Action::Send { + frame: original, + await_ack: Some(0), + }] + ); + assert!(engine.is_connected()); + + // Second expiry: reported lost, and the connection is torn down so + // subsequent commands queue instead of vanishing into a dead tunnel. + let t = 10 + 2 * (CFG.ack_timeout_ms + CFG.ack_sweep_ms); + engine.poll(t); + let actions = drain(&mut engine); + assert_eq!( + actions, + vec![Action::AckTimeout { seq: 0 }, Action::ResetSocket] + ); + assert!(!engine.is_connected()); + assert_eq!(engine.next_deadline(), t + CFG.reconnect_backoff_ms); + } + + #[test] + fn retransmitted_request_acked_keeps_connection() { + let addr: GroupAddress = "1/0/8".parse().unwrap(); + let mut engine = connected_engine_with(RETRANSMIT_CFG.clone(), 0); + let mut data = heapless::Vec::new(); + data.push(0x01).unwrap(); + engine.handle_command( + GroupWrite { + group_addr: addr, + data, + }, + 10, + ); + drain(&mut engine); + + // Let the first send expire and the retransmit go out… + engine.poll(10 + CFG.ack_timeout_ms + CFG.ack_sweep_ms); + assert_eq!(drain(&mut engine).len(), 1); + + // …then the (late) ACK lands: pending cleared, no AckTimeout at any + // later sweep, connection kept. + engine.handle_datagram(&gateway_ack(7, 0), 4_000); + engine.poll(20_000); + assert!(drain(&mut engine).is_empty()); + assert!(engine.is_connected()); } #[test] From d9e53c5bd3da303e59951927586e080aee513230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 12 Jun 2026 07:40:58 +0000 Subject: [PATCH 13/17] docs(design): mark 036 W4 implemented in PR #144; record W3 bench findings Co-Authored-By: Claude Fable 5 --- docs/design/036-followup-refactoring.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/design/036-followup-refactoring.md b/docs/design/036-followup-refactoring.md index 41c728c..13d2ff6 100644 --- a/docs/design/036-followup-refactoring.md +++ b/docs/design/036-followup-refactoring.md @@ -72,7 +72,9 @@ The registry keeps storing `Box`; consumers upcast to the capabil **Size:** S (one bench session). No issue needed if run as part of #140; otherwise file as a validation task. -**Prep done 2026-06-12:** the demo firmware builds clean (`cargo build -p embassy-knx-connector-demo --target thumbv8m.main-none-eabihf`); flash from the host via the demo's `flash.sh` (probe-rs, STM32H563ZITx), defmt over RTT; set `KNX_GATEWAY_IP` in the demo's `main.rs` first. Since #140 merged, 035's "run the matrix twice" guidance is moot — one run on a current `main` build is the bar (the open 036 PR stack does not touch the KNX connector, so `main` is the right baseline). The bench session itself is the remaining work. +**Prep done 2026-06-12:** the demo firmware builds clean (`cargo build -p embassy-knx-connector-demo --target thumbv8m.main-none-eabihf`); flash from the host via the demo's `flash.sh` (probe-rs, STM32H563ZITx), defmt over RTT; set `KNX_GATEWAY_IP` in the demo's `main.rs` first. Since #140 merged, 035's "run the matrix twice" guidance is moot — one run is the bar. (The session actually ran on the 036 stack-tip build, which is fine — the stack's core changes ride under the connector and got hardware exposure for free.) + +**Bench findings so far (2026-06-12, partial):** baseline boot → DHCP → tunnel connect → inbound telegrams ✅; outbound button-press round trip with zero AckTimeouts on a healthy link ✅ (scenario 1 preview); reconnect after a gateway outage via the heartbeat path ✅ (link-bounce variant of scenario 3). The scenario-2 variant (writes during the undetected-outage window) showed **ten writes silently lost** — warn-only AckTimeouts, no retransmit, no re-queue — which fired W4's trigger; see W4. Remaining: the 30-min soak (1), scenario 2 re-run on the #144 build, stale-channel NACK (4), backoff pacing with Wireshark (5), inbound flood (7), and the switch-isolated scenario-3 variant for a clean ≤65 s detection number. ### W4 — KNX ACK-retransmit knob in `TunnelConfig` (from the #135 review) @@ -84,7 +86,7 @@ The registry keeps storing `Box`; consumers upcast to the capabil **Size:** S–M. File after #140 merges (touches `tunnel.rs` on the #140 baseline). -**Status: deferred pending W3 bench data (decision 2026-06-12).** Scenario 1's AckTimeout observations were always meant to size this knob, no field failure motivates it (both previous implementations behaved identically), and scenario 1's pass bar is zero AckTimeout warnings — so the bench decides. Implement only if W3 shows AckTimeouts on healthy hardware or a lossy-link deployment appears. The (a)-vs-(b) design question is pre-decided for when the trigger fires: `GroupWrite.data` already carries a full 254-byte APDU buffer (`MAX_APDU`), so storing semantic content (b) saves only ~22 bytes per slot vs buffering the sent 278-byte frame (a) — ~350 B total at `PENDING_ACK_CAPACITY` 16. The RAM argument for (b) evaporates; **choose (a)** unless 035 §2.2's semantic-actions trigger has fired independently. +**Status: implemented in PR [#144](https://github.com/aimdb-dev/aimdb/pull/144)** (stacked on #143). The item was first deferred pending W3 data (decision 2026-06-12, same day), and the trigger fired within hours: the W3 bench's scenario-2 variant showed ten button-press writes issued during a link outage's heartbeat-detection window (~65 s) silently lost with warn-only AckTimeouts. Design as pre-decided: option **(a)** — the pending-ACK slot buffers the sent 278-byte frame for byte-identical retransmit (`GroupWrite.data` already carries a full 254-byte APDU, so semantic-content storage (b) would save only ~350 B total at `PENDING_ACK_CAPACITY` 16; the RAM argument evaporated). `ack_retransmits` default `1` = retransmit once then disconnect (spec 3.8.4); `0` = the legacy expire-and-warn, pinned by tests. Retransmit delay is `ack_timeout_ms` (3 s, the pre-engine constant) rather than the spec's hardcoded 1 s — strict timing is one knob away. Hardware validation: re-run scenario 2 on the #144 build (expect reconnect within ~6 s and queued writes flushing after re-handshake). ### W5 — `StringKey::intern`: dedup interner + loud contract (034 §3.10) @@ -149,7 +151,7 @@ Both protocols now ride the session engine (the hard part), but two subscribe/wr | W1 data-plane de-`Any` | PR [#141](https://github.com/aimdb-dev/aimdb/pull/141) | done — no separate issue, direct PR | | W2 `AnyRecord` split | PR [#142](https://github.com/aimdb-dev/aimdb/pull/142) | done — stacked on #141, no separate issue | | W3 hardware matrix | — | prep done 2026-06-12 (firmware build verified); bench session pending — the gate for closing 035 | -| W4 ACK-retransmit knob | — | deferred 2026-06-12 — implement only on W3 AckTimeout evidence; design pre-decided (option a) | +| W4 ACK-retransmit knob | PR [#144](https://github.com/aimdb-dev/aimdb/pull/144) | done — trigger fired same day via W3 scenario-2 evidence; stacked on #143 | | W5 `StringKey` interner | PR [#143](https://github.com/aimdb-dev/aimdb/pull/143) | done — with W6, stacked on #142 | | W6 `host_test_stubs!` | PR [#143](https://github.com/aimdb-dev/aimdb/pull/143) | done — with W5, stacked on #142 | | W7 data-contracts audit | — | skipped entirely (decision 2026-06-12) | From e94311e6db65d21a214ee428a48410c2b5606cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 12 Jun 2026 07:51:47 +0000 Subject: [PATCH 14/17] docs(design): record W4 hardware validation (partial) and pre-release W3 scope Co-Authored-By: Claude Fable 5 --- docs/design/036-followup-refactoring.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/036-followup-refactoring.md b/docs/design/036-followup-refactoring.md index 13d2ff6..bf071d1 100644 --- a/docs/design/036-followup-refactoring.md +++ b/docs/design/036-followup-refactoring.md @@ -74,7 +74,7 @@ The registry keeps storing `Box`; consumers upcast to the capabil **Prep done 2026-06-12:** the demo firmware builds clean (`cargo build -p embassy-knx-connector-demo --target thumbv8m.main-none-eabihf`); flash from the host via the demo's `flash.sh` (probe-rs, STM32H563ZITx), defmt over RTT; set `KNX_GATEWAY_IP` in the demo's `main.rs` first. Since #140 merged, 035's "run the matrix twice" guidance is moot — one run is the bar. (The session actually ran on the 036 stack-tip build, which is fine — the stack's core changes ride under the connector and got hardware exposure for free.) -**Bench findings so far (2026-06-12, partial):** baseline boot → DHCP → tunnel connect → inbound telegrams ✅; outbound button-press round trip with zero AckTimeouts on a healthy link ✅ (scenario 1 preview); reconnect after a gateway outage via the heartbeat path ✅ (link-bounce variant of scenario 3). The scenario-2 variant (writes during the undetected-outage window) showed **ten writes silently lost** — warn-only AckTimeouts, no retransmit, no re-queue — which fired W4's trigger; see W4. Remaining: the 30-min soak (1), scenario 2 re-run on the #144 build, stale-channel NACK (4), backoff pacing with Wireshark (5), inbound flood (7), and the switch-isolated scenario-3 variant for a clean ≤65 s detection number. +**Bench findings so far (2026-06-12, partial):** baseline boot → DHCP → tunnel connect → inbound telegrams ✅; outbound button-press round trip with zero AckTimeouts on a healthy link ✅ (scenario 1 preview); reconnect after a gateway outage via the heartbeat path ✅ (link-bounce variant of scenario 3). The scenario-2 variant (writes during the undetected-outage window) showed **ten writes silently lost** — warn-only AckTimeouts, no retransmit, no re-queue — which fired W4's trigger; see W4. **Re-run on the #144 build (same day):** a press during the outage produced exactly one AckTimeout ~7 s after the press (silent retransmit in between), disconnect, and reconnect attempts paced one CONNECT_REQUEST per backoff cycle (scenario 5's criterion, observed from defmt alone) — W4's detection + bounded-loss half validated on hardware. **Remaining, to run before the next release that ships the KNX connector (decision 2026-06-12):** the 30-min soak (1), scenario 2's queue-flush half (press *after* the AckTimeout warning, expect flush on re-handshake), stale-channel NACK (4), backoff pacing under Wireshark (5), inbound flood (7), and the switch-isolated scenario-3 variant for a clean ≤65 s detection number. ### W4 — KNX ACK-retransmit knob in `TunnelConfig` (from the #135 review) From b33e47f5d790816af1c7808ee9694d7b02387c24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 12 Jun 2026 09:41:33 +0000 Subject: [PATCH 15/17] docs: fix AimX v1/v2 rot, dead spec links, and removed-API references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doc-rot fixes from the post-036 debt scan; no wire or API behavior changes (the one wire-visible byte change — the client hello version — is corrected from a stale "1.0" to the "2.0" the server already announces; the server never validates it). - remote::PROTOCOL_VERSION corrected to "2.0", documented, and exported; the AimX dispatch Welcome and aimdb-client both use it now, so client and server can no longer drift. The dead, never-exported v1 untagged Message envelope and its helpers are deleted. - remote module docs: "AimX v1" + link to the nonexistent docs/design/remote-access/aimx-v1.md replaced with the v2 NDJSON tagged-frame description pointing at session::aimx and remote-access-via-connectors.md; stale .build()? example fixed to the (db, runner) = build().await? shape. - connector module docs: removed-`.link()` API replaced with the real configure/link_to pattern (the old example used a RecordConfig::builder API that never existed); ConnectorUrl no longer advertises Kafka/HTTP connector semantics for connectors that don't exist — documented as scheme-agnostic with real schemes (mqtt, knx, ws, uds, serial). - builder.rs AimDb example fixed: register_record returns &mut Self, so the old chained .build() could not compile. - aimdb-client README rewritten to match reality (AimxConnection, v2 wire, endpoint URLs incl. serial); crate doc + aimdb-cli doc updated. Co-Authored-By: Claude Fable 5 --- aimdb-client/CHANGELOG.md | 4 + aimdb-client/README.md | 46 +++++------ aimdb-client/src/lib.rs | 2 +- aimdb-client/src/protocol.rs | 4 +- aimdb-core/CHANGELOG.md | 1 + aimdb-core/src/builder.rs | 8 +- aimdb-core/src/connector.rs | 52 ++++++------ aimdb-core/src/remote/mod.rs | 17 ++-- aimdb-core/src/remote/protocol.rs | 104 +++--------------------- aimdb-core/src/session/aimx/dispatch.rs | 4 +- tools/aimdb-cli/src/main.rs | 2 +- 11 files changed, 84 insertions(+), 160 deletions(-) diff --git a/aimdb-client/CHANGELOG.md b/aimdb-client/CHANGELOG.md index a5efd2b..6823101 100644 --- a/aimdb-client/CHANGELOG.md +++ b/aimdb-client/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Transport-agnostic endpoint resolver — pick the transport at runtime via a `scheme://` URL (Issue #123, follow-up to #39 / #122).** New `endpoint` module: `parse_endpoint` (pure, feature-independent grammar) and `dial(url) -> Box` map an endpoint string to a transport `Dialer`, the way records already pick one for links. Schemes: `unix://PATH` / `uds://PATH`, a bare path (the `unix://` shorthand), and `serial://DEVICE?baud=N`. An unknown scheme — or one whose transport isn't compiled in — is rejected with a clear error. New `AimxConnection::connect_over(dialer)` / `connect_over_with_timeout` dial over an explicit `Dialer`, bypassing resolution. (Rides a new `impl Dialer for Box` in `aimdb-core`.) +### Fixed + +- **The `hello` handshake now reports protocol version `"2.0"` (was a stale `"1.0"`).** The client has spoken the AimX-v2 wire since the engine rewrite, but its local `PROTOCOL_VERSION` constant was never bumped; it is now a re-export of `aimdb_core::remote::PROTOCOL_VERSION`, so client and server can no longer drift. (The server does not validate the hello version, so this is cosmetic on the wire.) README updated to match the v2 reality (`AimxConnection`, endpoint URLs, v2 framing). + ### Changed (breaking) - **`AimxConnection::connect` now takes a `&str` endpoint, and transports are feature-gated (Issue #123).** `connect`/`connect_with_timeout` accept an endpoint string (was `impl AsRef`) — a `scheme://` URL or a bare path — resolved through the new `endpoint` module. The transports are now opt-in Cargo features: `transport-uds` (default; makes `aimdb-uds-connector` optional and gates the `discovery` module — a Unix-socket scan) and `transport-serial` (off by default; pulls `aimdb-serial-connector`, i.e. `tokio-serial` → libudev). `ClientError::ConnectionFailed`'s `socket` field is renamed `endpoint`, and a new `ClientError::UnsupportedEndpoint` covers malformed / not-built-in endpoints. The discovery `InstanceInfo.socket_path` field is likewise renamed `endpoint` — it now also carries a caller-supplied endpoint, not just a discovered socket path. diff --git a/aimdb-client/README.md b/aimdb-client/README.md index 68ce97a..435091c 100644 --- a/aimdb-client/README.md +++ b/aimdb-client/README.md @@ -1,10 +1,14 @@ # aimdb-client -Internal client library for the AimX v1 protocol. +Internal client library for the AimX remote access protocol (v2 NDJSON wire). ## Overview -`aimdb-client` is an **internal library** that provides Rust client implementation for the AimX v1 remote access protocol. It enables programmatic connections to running AimDB instances via Unix domain sockets. +`aimdb-client` is an **internal library** that provides the Rust client +implementation for the AimX remote access protocol. It enables programmatic +connections to running AimDB instances over a transport picked at runtime via +a `scheme://` endpoint URL — Unix domain sockets (`unix://PATH` / `uds://PATH`, +or a bare path) and serial (`serial://DEVICE?baud=N`). **This library is used by:** - `tools/aimdb-cli` - Command-line interface for AimDB @@ -14,17 +18,17 @@ Internal client library for the AimX v1 protocol. ## Features -- **Async Connection Management**: Non-blocking Unix socket communication -- **Protocol Implementation**: Full AimX v1 handshake and message handling -- **Instance Discovery**: Automatic detection of running AimDB instances -- **Record Operations**: List, get, set, and subscribe to records +- **Async Connection Management**: `AimxConnection` over the shared session engine +- **Protocol Implementation**: AimX-v2 handshake plus RPC and streaming subscriptions +- **Instance Discovery**: Automatic detection of running AimDB instances (UDS) +- **Record Operations**: List, get, set, subscribe, drain, graph introspection, query - **Type-Safe**: Strongly typed API with serde integration ## API Overview ### Core Types -- `AimxClient` - Main client for connecting to AimDB instances +- `AimxConnection` - Main client for connecting to AimDB instances - `InstanceInfo` - Information about discovered instances - `RecordMetadata` - Metadata about registered records - `ClientError` - Error types for client operations @@ -32,9 +36,10 @@ Internal client library for the AimX v1 protocol. ### Main Operations - **Discovery**: `discover_instances()`, `find_instance()` -- **Connection**: `AimxClient::connect()` -- **Records**: `list_records()`, `get_record()`, `set_record()` -- **Subscriptions**: `subscribe()`, `unsubscribe()`, `receive_event()` +- **Connection**: `AimxConnection::connect(endpoint)`, `connect_over(dialer)` +- **Records**: `list_records()`, `get_record()`, `set_record()`, `drain_record()` +- **Subscriptions**: `subscribe()` (returns a `Stream` of values) +- **Introspection**: `graph_nodes()`, `graph_edges()`, `graph_topo_order()`, `query()` ### Discovery @@ -42,23 +47,14 @@ Automatically scans for running AimDB instances: - `/tmp/*.sock` - `/var/run/aimdb/*.sock` -### Error Types - -- `ClientError::NoInstancesFound` - No running instances discovered -- `ClientError::ConnectionFailed` - Socket connection failed -- `ClientError::ServerError` - Server returned error response -- `ClientError::Io` - I/O operation failed -- `ClientError::Json` - JSON serialization failed - ## Protocol -The client implements **AimX v1** protocol over Unix domain sockets: +The client speaks the **AimX v2** wire: NDJSON (newline-delimited JSON) tagged +frames mapping onto the session engine's role-neutral message set. It is not +backward-compatible with the legacy AimX v1 framing. -- **Transport**: Unix domain sockets -- **Encoding**: NDJSON (Newline Delimited JSON) -- **Pattern**: JSON-RPC 2.0 style request/response - -See `docs/design/008-M3-remote-access.md` for full protocol specification. +See `docs/design/remote-access-via-connectors.md` for the architecture and +`aimdb-core/src/session/aimx/` for the codec. ## Usage Examples @@ -86,8 +82,6 @@ For detailed API documentation: cargo doc -p aimdb-client --open ``` -For protocol specification, see `docs/design/008-M3-remote-access.md`. - ## License See [LICENSE](../LICENSE) file. diff --git a/aimdb-client/src/lib.rs b/aimdb-client/src/lib.rs index 86bfdb3..b57d65d 100644 --- a/aimdb-client/src/lib.rs +++ b/aimdb-client/src/lib.rs @@ -2,7 +2,7 @@ //! //! This library provides a client implementation for the AimX remote access //! protocol, enabling connections to running AimDB instances via Unix domain -//! sockets. +//! sockets or serial. //! //! ## Overview //! diff --git a/aimdb-client/src/protocol.rs b/aimdb-client/src/protocol.rs index f41b91b..ec6f653 100644 --- a/aimdb-client/src/protocol.rs +++ b/aimdb-client/src/protocol.rs @@ -8,11 +8,9 @@ use serde::{Deserialize, Serialize}; // Re-export protocol types from aimdb-core pub use aimdb_core::remote::{ ErrorObject, Event, HelloMessage, RecordMetadata, Request, Response, WelcomeMessage, + PROTOCOL_VERSION, }; -/// Protocol version supported by this client -pub const PROTOCOL_VERSION: &str = "1.0"; - /// Client identifier pub const CLIENT_NAME: &str = "aimdb-cli"; diff --git a/aimdb-core/CHANGELOG.md b/aimdb-core/CHANGELOG.md index 71702ff..51c25e9 100644 --- a/aimdb-core/CHANGELOG.md +++ b/aimdb-core/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **AimX protocol doc rot cleaned up; `remote::PROTOCOL_VERSION` corrected to `"2.0"` and exported.** The `remote` module docs claimed "AimX v1" and linked a spec file that no longer exists; they now describe the v2 NDJSON tagged-frame wire and point at `crate::session::aimx` / `docs/design/remote-access-via-connectors.md`. The AimX dispatch's Welcome uses the constant instead of a hardcoded `"2.0"` (same bytes on the wire). The dead, never-exported v1 `Message` untagged envelope and its helpers were removed from `remote::protocol`. Also de-advertised Kafka/HTTP connector semantics from `ConnectorUrl` docs (the parser is scheme-agnostic; those connectors never existed) and updated the `connector` module docs from the removed `.link()` API to `.link_to()`/`.link_from()`. - **`build()` reports a missing runtime alongside every other configuration error (issue #133 contract).** The missing-runtime check no longer short-circuits: it is collected as a `ConfigError` and returned in the one `DbError::InvalidConfiguration` with all other findings (previously the collected errors were silently dropped and only a `RuntimeError` surfaced). The error type for a runtime-less build changes accordingly from `DbError::RuntimeError` to `DbError::InvalidConfiguration`. ### Changed (breaking) diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index 624fbe8..f75d5b8 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -871,11 +871,9 @@ impl Default for AimDbBuilder { /// ```rust,ignore /// use aimdb_tokio_adapter::TokioAdapter; /// -/// let runtime = Arc::new(TokioAdapter); -/// let (db, runner) = AimDbBuilder::new() -/// .runtime(runtime) -/// .register_record::(&TemperatureConfig) -/// .build().await?; +/// let mut builder = AimDbBuilder::new().runtime(Arc::new(TokioAdapter::new()?)); +/// builder.register_record::(&TemperatureConfig); +/// let (db, runner) = builder.build().await?; /// ``` #[derive(Clone)] pub struct AimDb { diff --git a/aimdb-core/src/connector.rs b/aimdb-core/src/connector.rs index 90b0f2a..d5630e4 100644 --- a/aimdb-core/src/connector.rs +++ b/aimdb-core/src/connector.rs @@ -1,8 +1,8 @@ //! Connector infrastructure for external protocol integration //! -//! Provides the `.link()` builder API for ergonomic connector setup with -//! automatic client lifecycle management. Connectors bridge AimDB records -//! to external systems (MQTT, Kafka, HTTP, etc.). +//! Provides the `.link_to()` / `.link_from()` builder API for ergonomic +//! connector setup with automatic client lifecycle management. Connectors +//! bridge AimDB records to external systems (MQTT, KNX, WebSocket, …). //! //! # Design Philosophy //! @@ -14,17 +14,14 @@ //! # Example //! //! ```rust,ignore -//! use aimdb_core::{RecordConfig, BufferCfg}; +//! use aimdb_core::BufferCfg; //! -//! fn weather_alert_record() -> RecordConfig { -//! RecordConfig::builder() -//! .buffer(BufferCfg::SingleLatest) -//! .link_to("mqtt://broker.example.com:1883") -//! .out::(|reader, mqtt| { -//! publish_alerts_to_mqtt(reader, mqtt) -//! }) -//! .build() -//! } +//! builder.configure::("weather.alert", |reg| { +//! reg.buffer(BufferCfg::SingleLatest) +//! .link_to("mqtt://alerts/weather") +//! .with_serializer_raw(|alert: &WeatherAlert| Ok(alert.to_json_vec())) +//! .finish(); +//! }); //! ``` use core::fmt::{self, Debug}; @@ -182,23 +179,25 @@ pub trait TopicProvider: Send + Sync { /// Parsed connector URL with protocol, host, port, and credentials /// -/// Supports multiple protocol schemes: +/// The parser is scheme-agnostic: any `scheme://…` URL parses, and the scheme +/// is matched against whatever connectors are registered on the builder. +/// Connectors in this workspace use e.g.: /// - MQTT: `mqtt://host:port`, `mqtts://host:port` -/// - Kafka: `kafka://broker1:port,broker2:port/topic` -/// - HTTP: `http://host:port/path`, `https://host:port/path` +/// - KNX: `knx://gateway:3671` /// - WebSocket: `ws://host:port/path`, `wss://host:port/path` +/// - UDS / serial (session transports): `uds://topic`, `serial://topic` #[derive(Clone, Debug, PartialEq)] pub struct ConnectorUrl { - /// Protocol scheme (mqtt, mqtts, kafka, http, https, ws, wss) + /// Protocol scheme (e.g. mqtt, mqtts, knx, ws, wss, uds, serial) pub scheme: String, - /// Host or comma-separated list of hosts (for Kafka) + /// Host, or a comma-separated host list (preserved verbatim) pub host: String, /// Port number (optional, protocol-specific defaults) pub port: Option, - /// Path component (for HTTP/WebSocket) + /// Path component (optional) pub path: Option, /// Username for authentication (optional) @@ -219,11 +218,12 @@ impl ConnectorUrl { /// - `mqtt://host:port` /// - `mqtt://user:pass@host:port` /// - `mqtts://host:port` (TLS) - /// - `kafka://broker1:9092,broker2:9092/topic` - /// - `http://host:port/path` - /// - `https://host:port/path?key=value` - /// - `ws://host:port/mqtt` (WebSocket) - /// - `wss://host:port/mqtt` (WebSocket Secure) + /// - `knx://gateway:3671` + /// - `ws://host:port/path?key=value` (WebSocket) + /// - `wss://host:port/path` (WebSocket Secure) + /// + /// Any other `scheme://…` parses the same way; comma-separated host + /// lists are preserved verbatim in [`host`](ConnectorUrl::host). /// /// # Example /// @@ -281,7 +281,7 @@ impl ConnectorUrl { /// /// - `mqtt://commands/temperature` → `"commands/temperature"` (topic) /// - `mqtt://sensors/temp` → `"sensors/temp"` (topic) - /// - `kafka://events` → `"events"` (topic) + /// - `uds://events` → `"events"` (topic) /// /// The format is `scheme://resource` where resource = host + path combined. pub fn resource_id(&self) -> String { @@ -665,7 +665,7 @@ pub trait ConnectorBuilder: Send + Sync { /// The URL scheme this connector handles /// - /// Returns the scheme (e.g., "mqtt", "kafka", "http") that this connector + /// Returns the scheme (e.g., "mqtt", "knx", "uds") that this connector /// will be registered under. Used for routing `.link_from()` and `.link_to()` /// declarations to the appropriate connector. fn scheme(&self) -> &str; diff --git a/aimdb-core/src/remote/mod.rs b/aimdb-core/src/remote/mod.rs index e94e792..b4abd00 100644 --- a/aimdb-core/src/remote/mod.rs +++ b/aimdb-core/src/remote/mod.rs @@ -6,8 +6,12 @@ //! //! # Protocol //! -//! AimX v1 uses NDJSON (newline-delimited JSON) over Unix domain sockets. -//! See `docs/design/remote-access/aimx-v1.md` for full specification. +//! AimX v2 uses NDJSON (newline-delimited JSON) tagged frames over a session +//! transport (Unix domain sockets via `aimdb-uds-connector`, serial via +//! `aimdb-serial-connector`). The envelope codec lives in +//! [`crate::session::aimx`]; see `docs/design/remote-access-via-connectors.md` +//! for the architecture. The v2 wire is not backward-compatible with the +//! legacy AimX v1 framing. //! //! # Security //! @@ -32,10 +36,11 @@ //! .max_connections(16) //! .max_subs_per_connection(32); //! -//! let db = AimDbBuilder::new() +//! let (db, runner) = AimDbBuilder::new() //! .runtime(tokio_adapter) //! .with_connector(UdsServer::from_config(config)) -//! .build()?; +//! .build() +//! .await?; //! ``` mod config; @@ -47,7 +52,9 @@ mod query; pub use config::{AimxConfig, SecurityPolicy}; pub use error::{RemoteError, RemoteResult}; pub use metadata::RecordMetadata; -pub use protocol::{ErrorObject, Event, HelloMessage, Request, Response, WelcomeMessage}; +pub use protocol::{ + ErrorObject, Event, HelloMessage, Request, Response, WelcomeMessage, PROTOCOL_VERSION, +}; pub use query::{QueryHandlerFn, QueryHandlerParams}; // Internal exports for implementation diff --git a/aimdb-core/src/remote/protocol.rs b/aimdb-core/src/remote/protocol.rs index 07d0116..09cb427 100644 --- a/aimdb-core/src/remote/protocol.rs +++ b/aimdb-core/src/remote/protocol.rs @@ -1,15 +1,19 @@ -//! AimX v1 Protocol Message Types +//! AimX Protocol Message Types //! -//! Defines request, response, and event types for the remote access protocol. +//! Defines the method-level payloads for the remote access protocol: the +//! hello/welcome handshake bodies and the request/response/event shapes. +//! These ride the **AimX-v2** NDJSON envelope; the wire framing itself lives +//! in [`crate::session::aimx`] (feature `connector-session`). See +//! `docs/design/remote-access-via-connectors.md` for the architecture. -use alloc::string::{String, ToString}; +use alloc::string::String; use alloc::vec::Vec; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; -// Allow dead code for now - these are part of the public API for future implementation -#[allow(dead_code)] -pub const PROTOCOL_VERSION: &str = "1.0"; +/// Version of the AimX wire protocol spoken by this crate (v2 NDJSON tagged +/// frames; not backward-compatible with the legacy v1 framing). +pub const PROTOCOL_VERSION: &str = "2.0"; /// Client hello message #[derive(Debug, Clone, Serialize, Deserialize)] @@ -157,105 +161,23 @@ pub struct Event { pub dropped: Option, } -/// Top-level message envelope for protocol communication -#[allow(dead_code)] // Part of public API for future use -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum Message { - /// Client hello - Hello { hello: HelloMessage }, - /// Server welcome - Welcome { welcome: WelcomeMessage }, - /// Client request - Request(Request), - /// Server response - Response(Response), - /// Server event - Event { event: Event }, -} - -#[allow(dead_code)] // Helper methods for future implementation -impl Message { - /// Creates a hello message - pub fn hello(client: impl Into) -> Self { - Self::Hello { - hello: HelloMessage { - version: PROTOCOL_VERSION.to_string(), - client: client.into(), - capabilities: None, - auth_token: None, - }, - } - } - - /// Creates a welcome message - pub fn welcome(server: impl Into, permissions: Vec) -> Self { - Self::Welcome { - welcome: WelcomeMessage { - version: PROTOCOL_VERSION.to_string(), - server: server.into(), - permissions, - writable_records: Vec::new(), - max_subscriptions: None, - authenticated: None, - }, - } - } - - /// Creates a request message - pub fn request(id: u64, method: impl Into, params: Option) -> Self { - Self::Request(Request { - id, - method: method.into(), - params, - }) - } - - /// Creates a success response message - pub fn response_success(id: u64, result: JsonValue) -> Self { - Self::Response(Response::success(id, result)) - } - - /// Creates an error response message - pub fn response_error(id: u64, code: impl Into, message: impl Into) -> Self { - Self::Response(Response::error(id, code, message)) - } - - /// Creates an event message - pub fn event( - subscription_id: impl Into, - sequence: u64, - data: JsonValue, - timestamp: impl Into, - ) -> Self { - Self::Event { - event: Event { - subscription_id: subscription_id.into(), - sequence, - data, - timestamp: timestamp.into(), - dropped: None, - }, - } - } -} - #[cfg(test)] mod tests { use super::*; + use alloc::string::ToString; use alloc::vec; #[test] fn test_hello_serialization() { let hello = HelloMessage { - version: "1.0".to_string(), + version: PROTOCOL_VERSION.to_string(), client: "test-client".to_string(), capabilities: Some(vec!["read".to_string()]), auth_token: None, }; let json = serde_json::to_string(&hello).unwrap(); - assert!(json.contains("\"version\":\"1.0\"")); + assert!(json.contains("\"version\":\"2.0\"")); assert!(json.contains("\"client\":\"test-client\"")); } diff --git a/aimdb-core/src/session/aimx/dispatch.rs b/aimdb-core/src/session/aimx/dispatch.rs index d60304e..bab1c27 100644 --- a/aimdb-core/src/session/aimx/dispatch.rs +++ b/aimdb-core/src/session/aimx/dispatch.rs @@ -29,7 +29,7 @@ use futures_util::StreamExt; use serde_json::{json, Value}; use crate::buffer::JsonBufferReader; -use crate::remote::{AimxConfig, RecordMetadata, SecurityPolicy, WelcomeMessage}; +use crate::remote::{AimxConfig, RecordMetadata, SecurityPolicy, WelcomeMessage, PROTOCOL_VERSION}; use crate::session::{ AuthError, BoxFut, BoxStream, Dispatch, Payload, PeerInfo, RpcError, Session, SessionCtx, }; @@ -321,7 +321,7 @@ impl AimxSession { } }; let welcome = WelcomeMessage { - version: "2.0".to_string(), + version: PROTOCOL_VERSION.to_string(), server: "aimdb".to_string(), permissions, writable_records, diff --git a/tools/aimdb-cli/src/main.rs b/tools/aimdb-cli/src/main.rs index 5a5a031..265dca0 100644 --- a/tools/aimdb-cli/src/main.rs +++ b/tools/aimdb-cli/src/main.rs @@ -1,7 +1,7 @@ //! AimDB CLI - Command-line interface for AimDB introspection and management //! //! This tool provides commands to discover, inspect, and interact with running -//! AimDB instances via the AimX v1 remote access protocol. +//! AimDB instances via the AimX remote access protocol (v2 NDJSON wire). use clap::{Parser, Subcommand}; use commands::{ From b626a258c4e6ee274a8cfad64858b5711104f49e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 12 Jun 2026 10:57:19 +0000 Subject: [PATCH 16/17] =?UTF-8?q?docs:=20purge=20never-compiled=20doc=20ex?= =?UTF-8?q?amples=20=E2=80=94=20compile,=20delete,=20or=20justify=20(#146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the ignored-doctest debt from the post-036 scan with the revised policy (maintainer decision on #146): remove `rust,ignore` examples unless there is a good reason to keep one. Result: 108 ignored fences → 24, and every survivor carries an explicit "Illustrative (not compiled: …)" reason. - Converted to compiled doctests (`no_run`/`rust`) where the crate's deps allow it: the crate-level quick starts of aimdb-sync, aimdb-uds-connector, aimdb-websocket-connector, aimdb-mqtt-connector, aimdb-knx-connector, aimdb-persistence(+sqlite); core's producer/consumer module examples, TopicProvider, extensions, AimxConfig, RecordKey Hash-contract; the websocket AuthHandler and MQTT link-ext trait docs. ~30 examples now compile under `make test` (186 doctests passing suite-wide). - Conversion surfaced real rot, now fixed in the surviving examples: key-less `configure()`/`producer()`/`consumer()` calls (API takes a key), builder chains that can't compile (`&mut Self` → by-value `build()`), `with_buffer`/`finish()` on the wrong receiver, a deserializer returning `Box` instead of `T`, sync's `AimDbSyncExt` example calling the async `build()` synchronously, and `DbError::RecordNotFound` vs `RecordKeyNotFound`. - Deleted ~50 method-level fragments that restated the signature (builder key-access helpers, registrar setters, connector constructors, internal SPI sketches) plus duplicates of the crate-level examples. - Kept 24 as `ignore`, each with a written reason: downstream-crate types core cannot depend on (adapter/connector wiring, `.buffer()` ext trait), proc-macro expansion targeting aimdb-core (circular dev-dependency), embedded/wasm-only code (Embassy peripherals, `#[wasm_bindgen]`), and macro grammar sketches with placeholder types. - Dev-deps: aimdb-uds-connector gains serde/serde_json for its quick start; aimdb-sync's example uses its existing aimdb-tokio-adapter dependency. Closes #146. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 2 + aimdb-core/src/buffer/mod.rs | 11 +- aimdb-core/src/buffer/traits.rs | 24 --- aimdb-core/src/builder.rs | 151 ++---------------- aimdb-core/src/connector.rs | 20 ++- aimdb-core/src/extensions.rs | 14 +- aimdb-core/src/record_id.rs | 32 ++-- aimdb-core/src/remote/config.rs | 7 +- aimdb-core/src/remote/mod.rs | 3 +- aimdb-core/src/router.rs | 20 --- aimdb-core/src/session/pump.rs | 3 +- aimdb-core/src/transform/join.rs | 20 +-- aimdb-core/src/transport.rs | 3 + aimdb-core/src/typed_api.rs | 129 ++------------- aimdb-core/src/typed_record.rs | 42 ----- aimdb-data-contracts/src/lib.rs | 4 + aimdb-data-contracts/src/migratable.rs | 8 +- aimdb-data-contracts/src/observable.rs | 21 ++- aimdb-derive/src/lib.rs | 7 + aimdb-embassy-adapter/src/buffer.rs | 3 + aimdb-embassy-adapter/src/lib.rs | 29 ++-- aimdb-knx-connector/src/embassy_client.rs | 3 + aimdb-knx-connector/src/lib.rs | 62 ++++--- aimdb-knx-connector/src/tokio_client.rs | 22 --- aimdb-mqtt-connector/src/embassy_client.rs | 3 + aimdb-mqtt-connector/src/lib.rs | 59 ++++--- aimdb-mqtt-connector/src/link_ext.rs | 7 +- aimdb-mqtt-connector/src/tokio_client.rs | 29 ---- aimdb-persistence-sqlite/src/lib.rs | 5 +- aimdb-persistence/src/lib.rs | 39 ++++- .../src/embassy_transport.rs | 3 +- aimdb-sync/src/consumer.rs | 2 +- aimdb-sync/src/handle.rs | 24 ++- aimdb-sync/src/lib.rs | 67 +++++--- aimdb-tokio-adapter/src/buffer.rs | 8 - aimdb-uds-connector/Cargo.toml | 3 + aimdb-uds-connector/src/lib.rs | 35 +++- aimdb-wasm-adapter/src/bindings.rs | 3 + .../src/client/builder.rs | 14 +- aimdb-websocket-connector/src/lib.rs | 73 +++++---- aimdb-websocket-connector/src/server/auth.rs | 6 +- .../src/server/builder.rs | 40 ++--- 42 files changed, 399 insertions(+), 661 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4db18eb..690940f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -311,6 +311,8 @@ dependencies = [ "aimdb-core", "aimdb-executor", "aimdb-tokio-adapter", + "serde", + "serde_json", "tokio", "tracing", ] diff --git a/aimdb-core/src/buffer/mod.rs b/aimdb-core/src/buffer/mod.rs index 7d4067f..74591b9 100644 --- a/aimdb-core/src/buffer/mod.rs +++ b/aimdb-core/src/buffer/mod.rs @@ -38,18 +38,21 @@ //! //! # Example //! +//! Illustrative (not compiled: `.buffer()` comes from your runtime adapter's +//! registrar extension trait, which `aimdb-core` cannot depend on): +//! //! ```rust,ignore //! use aimdb_core::buffer::BufferCfg; //! //! // High-frequency sensor data //! reg.buffer(BufferCfg::SpmcRing { capacity: 2048 }) -//! .source(|em, data| async { ... }) -//! .tap(|em, data| async { ... }); +//! .source(|ctx, producer| async move { /* … */ }) +//! .tap(|ctx, consumer| async move { /* … */ }); //! //! // Configuration updates //! reg.buffer(BufferCfg::SingleLatest) -//! .source(|em, cfg| async { ... }) -//! .tap(|em, cfg| async { ... }); +//! .source(|ctx, producer| async move { /* … */ }) +//! .tap(|ctx, consumer| async move { /* … */ }); //! ``` // Module structure diff --git a/aimdb-core/src/buffer/traits.rs b/aimdb-core/src/buffer/traits.rs index 5f324ad..e49b5e1 100644 --- a/aimdb-core/src/buffer/traits.rs +++ b/aimdb-core/src/buffer/traits.rs @@ -163,15 +163,6 @@ pub trait BufferReader: Send { /// # Requirements /// - Record must be configured with `.with_remote_access()` /// - Only available with the `remote-access` feature (requires serde_json) -/// -/// # Example -/// ```rust,ignore -/// // Internal use in remote access handler -/// let json_reader: Box = record.subscribe_json()?; -/// while let Ok(json_val) = json_reader.recv_json().await { -/// // Forward JSON value to remote client... -/// } -/// ``` #[cfg(feature = "remote-access")] pub trait JsonBufferReader: Send { /// Receive the next value as JSON (async) @@ -225,21 +216,6 @@ pub struct BufferMetricsSnapshot { /// /// Implemented by buffer types when the `metrics` feature is enabled. /// Provides counters for diagnosing producer-consumer imbalances. -/// -/// # Example -/// ```rust,ignore -/// use aimdb_core::buffer::BufferMetrics; -/// -/// // After enabling `metrics` feature -/// let metrics = buffer.metrics(); -/// if metrics.produced_count > metrics.consumed_count + 1000 { -/// println!("Warning: consumer is {} items behind", -/// metrics.produced_count - metrics.consumed_count); -/// } -/// if metrics.dropped_count > 0 { -/// println!("Warning: {} items dropped due to overflow", metrics.dropped_count); -/// } -/// ``` #[cfg(feature = "metrics")] pub trait BufferMetrics { /// Get a snapshot of current buffer metrics diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index f75d5b8..09be8c2 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -355,16 +355,6 @@ impl AimDbBuilder { /// must return a future that runs for as long as needed (e.g. an infinite /// cleanup loop). Tasks are spawned in registration order, after all /// record tasks and connectors have been started. - /// - /// # Example - /// ```rust,ignore - /// builder.on_start(|ctx| async move { - /// loop { - /// do_cleanup().await; - /// ctx.time().sleep_secs(3600).await; - /// } - /// }); - /// ``` pub fn on_start(&mut self, f: F) -> &mut Self where F: FnOnce(crate::RuntimeContext) -> Fut + Send + 'static, @@ -396,12 +386,17 @@ impl AimDbBuilder { /// /// # Examples /// + /// Illustrative (not compiled: the connector types live in downstream + /// crates `aimdb-core` cannot depend on): + /// /// ```rust,ignore /// // (1) data-plane link to an MQTT topic - /// AimDbBuilder::new().runtime(rt) - /// .with_connector(MqttConnector::new("mqtt://broker.local:1883")) - /// .configure::(|r| { r.link_from("mqtt://commands/temp"); }) - /// .build().await?; + /// let mut b = AimDbBuilder::new().runtime(rt) + /// .with_connector(MqttConnector::new("mqtt://broker.local:1883")); + /// b.configure::("commands.temp", |r| { + /// r.link_from("mqtt://commands/temp").with_deserializer_raw(parse).finish(); + /// }); + /// b.build().await?; /// /// // (2a) remote-access SERVER — no links, just expose this db over UDS /// AimDbBuilder::new().runtime(rt) @@ -409,10 +404,10 @@ impl AimDbBuilder { /// .build().await?; /// /// // (2b) remote-access CLIENT — mirror a record to a peer over UDS - /// AimDbBuilder::new().runtime(rt) - /// .with_connector(UdsClient::new("/run/aimdb.sock")) - /// .configure::(|r| { r.with_remote_access().link_to("uds://temp"); }) - /// .build().await?; + /// let mut b = AimDbBuilder::new().runtime(rt) + /// .with_connector(UdsClient::new("/run/aimdb.sock")); + /// b.configure::("temp", |r| { r.with_remote_access().link_to("uds://temp"); }); + /// b.build().await?; /// ``` pub fn with_connector( mut self, @@ -441,15 +436,6 @@ impl AimDbBuilder { /// * `key` - A unique identifier for this record. Can be a string literal, `StringKey`, /// or any type implementing `RecordKey` (including user-defined enum keys). /// * `f` - Configuration closure - /// - /// # Example - /// ```rust,ignore - /// // Using string literal - /// builder.configure::("sensor.temp.room1", |reg| { ... }); - /// - /// // Using compile-time safe enum key - /// builder.configure::(SensorKey::TempRoom1, |reg| { ... }); - /// ``` pub fn configure( &mut self, key: impl RecordKey, @@ -569,22 +555,6 @@ impl AimDbBuilder { /// # Returns /// `DbResult<()>` — Ok once the database starts; the call then blocks until /// every future the runner is driving has completed (typically forever). - /// - /// # Example - /// - /// ```rust,ignore - /// #[tokio::main] - /// async fn main() -> DbResult<()> { - /// AimDbBuilder::new() - /// .runtime(Arc::new(TokioAdapter::new()?)) - /// .configure::(|reg| { - /// reg.with_buffer(BufferCfg::SpmcRing { capacity: 100 }) - /// .with_source(my_producer) - /// .with_tap(my_consumer); - /// }) - /// .run().await // Runs forever - /// } - /// ``` pub async fn run(self) -> DbResult<()> { log_info!("Building database and spawning background tasks..."); @@ -626,19 +596,6 @@ impl AimDbBuilder { /// buffer, duplicate keys, dependency-graph cycles) is collected and /// returned as one [`DbError::InvalidConfiguration`] carrying **all** /// findings — one run surfaces every mistake. - /// - /// # Example - /// - /// ```rust,ignore - /// let (db, runner) = AimDbBuilder::new() - /// .runtime(runtime) - /// .configure::("temp", |reg| { /* … */ }) - /// .with_connector(mqtt_builder) - /// .build().await?; - /// - /// let handle = db.clone(); // clone freely before runner.run() - /// runner.run().await; // drives everything to completion - /// ``` pub async fn build(mut self) -> DbResult<(AimDb, AimDbRunner)> { use crate::error::ConfigError; @@ -868,6 +825,9 @@ impl Default for AimDbBuilder { /// /// # Examples /// +/// Illustrative (not compiled: the runtime adapter lives in a downstream +/// crate `aimdb-core` cannot depend on): +/// /// ```rust,ignore /// use aimdb_tokio_adapter::TokioAdapter; /// @@ -952,12 +912,6 @@ impl AimDb { /// /// External crates (e.g. `aimdb-persistence`) retrieve their typed state here /// to service query calls. The extensions are read-only on the live handle. - /// - /// # Example - /// ```rust,ignore - /// use aimdb_persistence::PersistenceState; - /// let state = db.extensions().get::().unwrap(); - /// ``` pub fn extensions(&self) -> &Extensions { &self.inner.extensions } @@ -989,13 +943,6 @@ impl AimDb { /// # Arguments /// * `key` - The record key (e.g., "sensor.temperature") /// * `value` - The value to produce - /// - /// # Example - /// - /// ```rust,ignore - /// db.produce::("sensors.indoor", indoor_temp)?; - /// db.produce::("sensors.outdoor", outdoor_temp)?; - /// ``` pub fn produce(&self, key: impl AsRef, value: T) -> DbResult<()> where T: Send + 'static + Debug + Clone, @@ -1013,15 +960,6 @@ impl AimDb { /// /// # Arguments /// * `key` - The record key (e.g., "sensor.temperature") - /// - /// # Example - /// - /// ```rust,ignore - /// let mut reader = db.subscribe::("sensors.indoor")?; - /// while let Ok(temp) = reader.recv().await { - /// println!("Indoor: {:.1}°C", temp.celsius); - /// } - /// ``` pub fn subscribe( &self, key: impl AsRef, @@ -1039,17 +977,6 @@ impl AimDb { /// /// # Arguments /// * `key` - The record key (e.g., "sensor.temperature") - /// - /// # Example - /// - /// ```rust,ignore - /// let indoor_producer = db.producer::("sensors.indoor"); - /// let outdoor_producer = db.producer::("sensors.outdoor"); - /// - /// // Each producer writes to its own record - /// indoor_producer.produce(indoor_temp); - /// outdoor_producer.produce(outdoor_temp); - /// ``` pub fn producer( &self, key: impl Into, @@ -1070,16 +997,6 @@ impl AimDb { /// /// # Arguments /// * `key` - The record key (e.g., "sensor.temperature") - /// - /// # Example - /// - /// ```rust,ignore - /// let indoor_consumer = db.consumer::("sensors.indoor"); - /// let outdoor_consumer = db.consumer::("sensors.outdoor"); - /// - /// // Each consumer reads from its own record - /// let mut rx = indoor_consumer.subscribe(); - /// ``` pub fn consumer( &self, key: impl Into, @@ -1102,14 +1019,6 @@ impl AimDb { /// Resolve a record key to its RecordId /// /// Useful for checking if a record exists before operations. - /// - /// # Example - /// - /// ```rust,ignore - /// if let Some(id) = db.resolve_key("sensors.temperature") { - /// println!("Record exists with ID: {}", id); - /// } - /// ``` pub fn resolve_key(&self, key: &str) -> Option { self.inner.resolve_str(key) } @@ -1118,13 +1027,6 @@ impl AimDb { /// /// Returns a slice of RecordIds for all records of type T. /// Useful for introspection when multiple records of the same type exist. - /// - /// # Example - /// - /// ```rust,ignore - /// let temp_ids = db.records_of_type::(); - /// println!("Found {} temperature records", temp_ids.len()); - /// ``` pub fn records_of_type(&self) -> &[crate::record_id::RecordId] { self.inner.records_of_type::() } @@ -1149,14 +1051,6 @@ impl AimDb { /// /// Returns metadata for all registered records, useful for remote access introspection. /// Available only when the `std` feature is enabled. - /// - /// # Example - /// ```rust,ignore - /// let records = db.list_records(); - /// for record in records { - /// println!("Record: {} ({})", record.name, record.type_id); - /// } - /// ``` #[cfg(feature = "remote-access")] pub fn list_records(&self) -> Vec { self.inner.list_records() @@ -1210,11 +1104,6 @@ impl AimDb { /// /// # Returns /// `Ok(())` on success, error if record not found, has producers, or deserialization fails - /// - /// # Example (internal use) - /// ```rust,ignore - /// db.set_record_from_json("AppConfig", json!({"debug": true}))?; - /// ``` #[cfg(feature = "remote-access")] pub fn set_record_from_json( &self, @@ -1238,14 +1127,6 @@ impl AimDb { /// /// The topic is resolved dynamically if a `TopicResolverFn` is configured, /// otherwise the static topic from the URL is used. - /// - /// # Example - /// ```rust,ignore - /// // In MqttConnector after db.build() - /// let routes = db.collect_inbound_routes("mqtt"); - /// let router = RouterBuilder::from_routes(routes).build(); - /// connector.set_router(router).await?; - /// ``` pub fn collect_inbound_routes( &self, scheme: &str, diff --git a/aimdb-core/src/connector.rs b/aimdb-core/src/connector.rs index d5630e4..2d97b5f 100644 --- a/aimdb-core/src/connector.rs +++ b/aimdb-core/src/connector.rs @@ -13,15 +13,17 @@ //! //! # Example //! -//! ```rust,ignore -//! use aimdb_core::BufferCfg; -//! +//! ```no_run +//! # use aimdb_core::AimDbBuilder; +//! # #[derive(Clone, Debug)] struct WeatherAlert { level: u8 } +//! # fn wire(builder: &mut AimDbBuilder) { //! builder.configure::("weather.alert", |reg| { -//! reg.buffer(BufferCfg::SingleLatest) -//! .link_to("mqtt://alerts/weather") -//! .with_serializer_raw(|alert: &WeatherAlert| Ok(alert.to_json_vec())) +//! // .buffer(BufferCfg::SingleLatest) — via your runtime adapter's ext trait +//! reg.link_to("mqtt://alerts/weather") +//! .with_serializer_raw(|alert: &WeatherAlert| Ok(vec![alert.level])) //! .finish(); //! }); +//! # } //! ``` use core::fmt::{self, Debug}; @@ -158,8 +160,9 @@ pub type SourceFactoryFn = Arc Box + Sen /// /// # Example /// -/// ```rust,ignore +/// ```rust /// use aimdb_core::connector::TopicProvider; +/// # #[derive(Clone, Debug)] struct Temperature { sensor_id: u32 } /// /// struct SensorTopicProvider; /// @@ -614,6 +617,9 @@ fn parse_connector_url(url: &str) -> DbResult { /// /// # Example /// +/// Illustrative sketch of a connector author's `build()` (not compiled: the +/// client types are fictional — see `aimdb-mqtt-connector` for a real one): +/// /// ```rust,ignore /// pub struct MqttConnectorBuilder { /// broker_url: String, diff --git a/aimdb-core/src/extensions.rs b/aimdb-core/src/extensions.rs index 174a68a..d26beb5 100644 --- a/aimdb-core/src/extensions.rs +++ b/aimdb-core/src/extensions.rs @@ -7,15 +7,21 @@ //! modifying `aimdb-core`. //! //! # Example -//! ```rust,ignore +//! ```no_run +//! # use aimdb_core::{AimDb, AimDbBuilder, RecordRegistrar}; +//! # struct MyState { flag: bool } +//! # fn at_configure_time(builder: &mut AimDbBuilder) { //! // Storing a value (e.g. from an external "with_persistence" builder ext): -//! builder.extensions_mut().insert(MyState { ... }); -//! +//! builder.extensions_mut().insert(MyState { flag: true }); +//! # } +//! # fn in_registrar(reg: &mut RecordRegistrar<'_, u32>) { //! // Retrieving it from a RecordRegistrar closure: //! let state = reg.extensions().get::().expect("MyState not configured"); -//! +//! # } +//! # fn at_query_time(db: &AimDb) { //! // Retrieving it from a live AimDb handle (query time): //! let state = db.extensions().get::().expect("MyState not configured"); +//! # } //! ``` use alloc::boxed::Box; diff --git a/aimdb-core/src/record_id.rs b/aimdb-core/src/record_id.rs index 71ccbf9..bb63be8 100644 --- a/aimdb-core/src/record_id.rs +++ b/aimdb-core/src/record_id.rs @@ -35,6 +35,9 @@ //! //! ## Enum Keys (compile-time safe, embedded) //! +//! Illustrative (not compiled: the derive macro lives in `aimdb-derive`, +//! behind the optional `derive` feature): +//! //! ```rust,ignore //! use aimdb_derive::RecordKey; //! @@ -47,7 +50,7 @@ //! } //! //! // Compile-time typo detection! -//! let producer = db.producer::(AppKey::TempIndoor); +//! builder.configure::(AppKey::TempIndoor, |reg| { /* … */ }); //! ``` use alloc::{boxed::Box, collections::BTreeSet, string::ToString}; @@ -105,7 +108,8 @@ pub use aimdb_derive::RecordKey; /// /// # Implementing RecordKey /// -/// The easiest way is to use the derive macro: +/// The easiest way is to use the derive macro (illustrative, not compiled: +/// the macro lives in `aimdb-derive`, behind the optional `derive` feature): /// /// ```rust,ignore /// #[derive(RecordKey, Clone, Copy, PartialEq, Eq)] @@ -172,7 +176,12 @@ pub use aimdb_derive::RecordKey; /// /// **Manual implementation:** Implement `Hash` by hashing `self.as_str()`: /// -/// ```rust,ignore +/// ```rust +/// use core::hash::{Hash, Hasher}; +/// # pub enum MyKey { Temperature } +/// # impl MyKey { +/// # fn as_str(&self) -> &'static str { "sensor.temp" } +/// # } /// impl Hash for MyKey { /// fn hash(&self, state: &mut H) { /// self.as_str().hash(state); @@ -189,23 +198,6 @@ pub trait RecordKey: /// /// Returns the URL/address to use with connectors (MQTT topics, KNX addresses, etc.). /// Use with `.link_to()` for outbound or `.link_from()` for inbound connections. - /// - /// # Example - /// - /// ```rust,ignore - /// #[derive(RecordKey)] - /// pub enum SensorKey { - /// #[key = "temp.indoor"] - /// #[link_address = "mqtt://sensors/temp/indoor"] - /// TempIndoor, - /// } - /// - /// // Use with link_to for outbound - /// reg.link_to(SensorKey::TempIndoor.link_address().unwrap()) - /// - /// // Or with link_from for inbound - /// reg.link_from(SensorKey::TempIndoor.link_address().unwrap()) - /// ``` #[inline] fn link_address(&self) -> Option<&str> { None diff --git a/aimdb-core/src/remote/config.rs b/aimdb-core/src/remote/config.rs index c189e3e..c1d1f4c 100644 --- a/aimdb-core/src/remote/config.rs +++ b/aimdb-core/src/remote/config.rs @@ -92,9 +92,10 @@ impl AimxConfig { /// Sets the socket file permissions (Unix only) /// /// # Example - /// ```rust,ignore - /// config.socket_permissions(0o600) // Owner only - /// config.socket_permissions(0o660) // Owner + group + /// ```rust + /// # use aimdb_core::remote::AimxConfig; + /// let config = AimxConfig::uds_default() + /// .socket_permissions(0o600); // Owner only (0o660: owner + group) /// ``` pub fn socket_permissions(mut self, mode: u32) -> Self { self.socket_permissions = Some(mode); diff --git a/aimdb-core/src/remote/mod.rs b/aimdb-core/src/remote/mod.rs index b4abd00..187ef10 100644 --- a/aimdb-core/src/remote/mod.rs +++ b/aimdb-core/src/remote/mod.rs @@ -24,7 +24,8 @@ //! //! Remote access is registered like any other connector — via `with_connector` //! using `aimdb_uds_connector::UdsServer` (this replaced the former -//! `AimDbBuilder::with_remote_access(config)`): +//! `AimDbBuilder::with_remote_access(config)`). Illustrative (not compiled: +//! the connector lives in a downstream crate `aimdb-core` cannot depend on): //! //! ```rust,ignore //! use aimdb_core::remote::{AimxConfig, SecurityPolicy}; diff --git a/aimdb-core/src/router.rs b/aimdb-core/src/router.rs index 694a3bc..344646a 100644 --- a/aimdb-core/src/router.rs +++ b/aimdb-core/src/router.rs @@ -167,19 +167,6 @@ impl Router { /// Builder for constructing routers /// /// Provides a fluent API for adding routes before creating the router. -/// -/// # Example -/// -/// ```rust,ignore -/// use aimdb_core::router::RouterBuilder; -/// -/// // Ingest callbacks are normally built by `InboundConnectorBuilder::finish()` -/// // and collected via `AimDb::collect_inbound_routes()`. -/// let router = RouterBuilder::new() -/// .add_route(Arc::from("sensors/temperature"), temperature_ingest) -/// .add_route(Arc::from("sensors/humidity"), humidity_ingest) -/// .build(); -/// ``` pub struct RouterBuilder { routes: Vec, } @@ -198,13 +185,6 @@ impl RouterBuilder { /// /// # Arguments /// * `routes` - Vector of (resource_id, ingest) tuples - /// - /// # Example - /// ```rust,ignore - /// let routes = db.collect_inbound_routes("mqtt"); - /// let router = RouterBuilder::from_routes(routes).build(); - /// connector.set_router(router).await?; - /// ``` pub fn from_routes(routes: Vec<(String, IngestFn)>) -> Self { let mut builder = Self::new(); for (resource_id, ingest) in routes { diff --git a/aimdb-core/src/session/pump.rs b/aimdb-core/src/session/pump.rs index e6942df..ed55ee3 100644 --- a/aimdb-core/src/session/pump.rs +++ b/aimdb-core/src/session/pump.rs @@ -3,7 +3,8 @@ //! Two free functions that own the boilerplate a data-plane connector used to //! hand-roll. The author writes only the pure I/O adapter — a //! [`Connector`](crate::transport::Connector) (outbound) and a [`Source`] -//! (inbound) — and composes the helpers in `build()`: +//! (inbound) — and composes the helpers in `build()` +//! (illustrative — `sink()`/`subscription()` are the author's own constructors): //! //! ```rust,ignore //! let mut f = pump_sink(db, "redis", self.sink().await?); // outbound diff --git a/aimdb-core/src/transform/join.rs b/aimdb-core/src/transform/join.rs index 9592c9f..ec2ac44 100644 --- a/aimdb-core/src/transform/join.rs +++ b/aimdb-core/src/transform/join.rs @@ -89,23 +89,6 @@ impl JoinTrigger { /// Obtained as the first argument to the [`JoinBuilder::on_triggers`] closure. /// Call `.recv().await` in a loop to consume trigger events from all input forwarders. /// Returns `Err` when all input forwarders have exited and the channel is closed. -/// -/// ```rust,ignore -/// .on_triggers(|mut rx, producer| async move { -/// let mut last_a: Option = None; -/// let mut last_b: Option = None; -/// while let Ok(trigger) = rx.recv().await { -/// match trigger.index() { -/// 0 => last_a = trigger.as_input::().copied(), -/// 1 => last_b = trigger.as_input::().copied(), -/// _ => {} -/// } -/// if let (Some(a), Some(b)) = (last_a, last_b) { -/// producer.produce(compute(a, b)); -/// } -/// } -/// }) -/// ``` pub struct JoinEventRx { inner: async_channel::Receiver, } @@ -233,6 +216,9 @@ where /// /// The task runs until all input forwarders close (i.e., all upstream records stop producing). /// + /// Illustrative (not compiled: fragment of a `transform_join` pipeline with + /// user-defined input types): + /// /// ```rust,ignore /// .on_triggers(|mut rx, producer| async move { /// let mut last_a: Option = None; diff --git a/aimdb-core/src/transport.rs b/aimdb-core/src/transport.rs index dfab5b4..94aa31e 100644 --- a/aimdb-core/src/transport.rs +++ b/aimdb-core/src/transport.rs @@ -132,6 +132,9 @@ impl std::error::Error for PublishError {} /// /// # Example Implementation /// +/// Illustrative sketch (not compiled: the MQTT client types are fictional — +/// see `aimdb-mqtt-connector` for a real implementation): +/// /// ```rust,ignore /// impl Connector for MqttConnector { /// fn publish( diff --git a/aimdb-core/src/typed_api.rs b/aimdb-core/src/typed_api.rs index 0fc282e..dda6e3f 100644 --- a/aimdb-core/src/typed_api.rs +++ b/aimdb-core/src/typed_api.rs @@ -8,7 +8,10 @@ //! //! # Producer Example //! -//! ```rust,ignore +//! ```no_run +//! # use aimdb_core::{Producer, RuntimeContext}; +//! # #[derive(Clone, Debug)] struct Temperature { celsius: f32 } +//! # async fn read_sensor() -> Temperature { Temperature { celsius: 21.0 } } //! async fn temperature_producer( //! ctx: RuntimeContext, //! producer: Producer, @@ -23,7 +26,9 @@ //! //! # Consumer Example //! -//! ```rust,ignore +//! ```no_run +//! # use aimdb_core::{Consumer, RuntimeContext}; +//! # #[derive(Clone, Debug)] struct Temperature { celsius: f32 } //! async fn temperature_monitor( //! ctx: RuntimeContext, //! consumer: Consumer, @@ -37,6 +42,9 @@ //! //! # Record Registration Example //! +//! Illustrative (not compiled: `.buffer()` comes from your runtime adapter's +//! registrar extension trait, which `aimdb-core` cannot depend on): +//! //! ```rust,ignore //! builder.configure::("sensors.outdoor", |reg| { //! reg.buffer(cfg) @@ -381,11 +389,6 @@ where /// /// The name shows up in stage profiling output. This method is always /// available; when the `profiling` feature is disabled it is a no-op. - /// - /// ```rust,ignore - /// reg.source(|ctx, producer| async move { /* ... */ }) - /// .with_name("sensor_reader"); - /// ``` pub fn with_name(&mut self, name: &str) -> &mut Self { #[cfg(feature = "profiling")] if let Some((kind, idx)) = self.last_stage { @@ -401,15 +404,6 @@ where /// The closure receives the [`RuntimeContext`](crate::RuntimeContext) /// (time + logging capabilities) and a pre-resolved [`Producer`]; it is /// collected at `build()` time and driven by the `AimDbRunner`. - /// - /// ```rust,ignore - /// reg.source(|ctx, producer| async move { - /// loop { - /// producer.produce(read_sensor().await); - /// ctx.time().sleep_secs(1).await; - /// } - /// }); - /// ``` pub fn source(&mut self, f: F) -> &mut Self where F: FnOnce(crate::RuntimeContext, crate::Producer) -> Fut + Send + 'static, @@ -433,15 +427,6 @@ where /// The closure receives the [`RuntimeContext`](crate::RuntimeContext) and a /// pre-resolved [`Consumer`]; it is collected at `build()` time and /// driven by the `AimDbRunner`. Multiple taps per record are allowed. - /// - /// ```rust,ignore - /// reg.tap(|ctx, consumer| async move { - /// let mut rx = consumer.subscribe(); - /// while let Ok(value) = rx.recv().await { - /// ctx.log().info("observed value"); - /// } - /// }); - /// ``` pub fn tap(&mut self, f: F) -> &mut Self where F: FnOnce(crate::RuntimeContext, crate::Consumer) -> Fut + Send + 'static, @@ -502,14 +487,6 @@ where /// / `set` / `subscribe` protocol. Requires `T: RemoteSerialize` /// (blanket-impl'd for every `Serialize + DeserializeOwned` type). Works on /// no_std + alloc. - /// - /// # Example - /// ```rust,ignore - /// builder.configure::(|reg| { - /// reg.buffer(BufferCfg::SingleLatest) - /// .with_remote_access(); // Enable remote queries - /// }); - /// ``` #[cfg(feature = "json-serialize")] pub fn with_remote_access(&mut self) -> &mut Self where @@ -568,17 +545,6 @@ where /// Link TO external system (outbound: AimDB → External) /// /// Subscribes to buffer updates and publishes them to an external system. - /// - /// # Example - /// - /// ```rust,ignore - /// builder.configure::(|reg| { - /// reg.buffer(BufferCfg::SingleLatest) - /// .link_to("mqtt://broker/sensors/temp") - /// .with_serializer_raw(|t| serde_json::to_vec(t).unwrap()) - /// .finish() - /// }); - /// ``` pub fn link_to(&mut self, url: &str) -> OutboundConnectorBuilder<'_, 'a, T> { OutboundConnectorBuilder { registrar: self, @@ -593,17 +559,6 @@ where /// Link FROM external system (inbound: External → AimDB) /// /// Subscribes to an external data source and produces values into this record's buffer. - /// - /// # Example - /// - /// ```rust,ignore - /// builder.configure::(|reg| { - /// reg.buffer(BufferCfg::SingleLatest) - /// .link_from("mqtt://broker/lights/+/state") - /// .with_deserializer(|_ctx, bytes: &[u8]| parse_light_state(bytes)) - /// .finish() - /// }); - /// ``` pub fn link_from(&mut self, url: &str) -> InboundConnectorBuilder<'_, 'a, T> { InboundConnectorBuilder { registrar: self, @@ -662,17 +617,6 @@ where /// The closure receives the [`RuntimeContext`](crate::RuntimeContext) for /// platform-independent timestamps and logging, plus the typed value being /// serialized. - /// - /// # Example - /// - /// ```rust,ignore - /// .link_to("mqtt://broker/sensors/temp") - /// .with_serializer(|ctx, value: &Temperature| { - /// ctx.log().debug("Serializing temperature for MQTT"); - /// value.to_bytes() - /// .map_err(|_| SerializeError::InvalidData) - /// }) - /// ``` pub fn with_serializer(mut self, f: F) -> Self where F: Fn(crate::RuntimeContext, &T) -> Result, crate::connector::SerializeError> @@ -709,25 +653,6 @@ where /// The provider is type-checked at compile time against `T` and stays /// typed end-to-end: it is fused into the link's serialized source and /// called with `&T` per value (design 036 W1). - /// - /// # Example - /// - /// ```rust,ignore - /// use aimdb_core::connector::TopicProvider; - /// - /// struct SensorTopicProvider; - /// - /// impl TopicProvider for SensorTopicProvider { - /// fn topic(&self, value: &Temperature) -> Option { - /// Some(format!("sensors/temp/{}", value.sensor_id)) - /// } - /// } - /// - /// reg.link_to("mqtt://sensors/default") - /// .with_topic_provider(SensorTopicProvider) - /// .with_serializer(...) - /// .finish(); - /// ``` pub fn with_topic_provider

(mut self, provider: P) -> Self where P: crate::connector::TopicProvider + 'static, @@ -912,16 +837,6 @@ where /// Prefer `.with_deserializer(|ctx, data| ...)` for access to /// `RuntimeContext` (timestamps, logging). Use this raw variant /// only when context is unnecessary. - /// - /// # Example - /// - /// ```rust,ignore - /// .link_from("mqtt://broker/sensors/temp") - /// .with_deserializer_raw(|bytes| { - /// serde_json::from_slice::(bytes) - /// .map_err(|e| e.to_string()) - /// }) - /// ``` pub fn with_deserializer_raw(mut self, f: F) -> Self where F: Fn(&[u8]) -> Result + Send + Sync + 'static, @@ -936,17 +851,6 @@ where /// The closure receives the [`RuntimeContext`](crate::RuntimeContext) for /// platform-independent timestamps and logging, plus the raw bytes from /// the external system. - /// - /// # Example - /// - /// ```rust,ignore - /// .link_from("knx://gateway/9/1/0") - /// .with_deserializer(|ctx, data: &[u8]| { - /// let mut temp = from_knx(data, "9/1/0")?; - /// temp.timestamp = ctx.time().now(); - /// Ok(temp) - /// }) - /// ``` pub fn with_deserializer(mut self, f: F) -> Self where F: Fn(crate::RuntimeContext, &[u8]) -> Result + Send + Sync + 'static, @@ -980,19 +884,6 @@ where /// - Topics determined from smart contracts at runtime /// - Service discovery integration /// - Environment-specific topic configuration - /// - /// # Example - /// - /// ```rust,ignore - /// reg.link_from("mqtt://mesh/default/data") // Fallback topic - /// .with_topic_resolver(|| { - /// // Read from smart contract, config service, etc. - /// let node_id = smart_contract.get_producer_node_id()?; - /// Some(format!("mesh/{}/data", node_id)) - /// }) - /// .with_deserializer(|_ctx, bytes: &[u8]| parse_sensor_data(bytes)) - /// .finish(); - /// ``` pub fn with_topic_resolver(mut self, resolver: F) -> Self where F: Fn() -> Option + Send + Sync + 'static, diff --git a/aimdb-core/src/typed_record.rs b/aimdb-core/src/typed_record.rs index 08aa860..d50d4e3 100644 --- a/aimdb-core/src/typed_record.rs +++ b/aimdb-core/src/typed_record.rs @@ -343,16 +343,6 @@ pub trait JsonRecordAccess { /// Returns error if: /// - Record not configured with `.with_remote_access()` /// - Buffer subscription fails (shouldn't happen in practice) - /// - /// # Example (internal use) - /// ```rust,ignore - /// let record: &Box = db.storage(id)?; - /// let mut json_reader = record.json_access().unwrap().subscribe_json()?; - /// - /// while let Ok(json_val) = json_reader.recv_json().await { - /// // Forward to remote client... - /// } - /// ``` fn subscribe_json(&self) -> crate::DbResult>; /// Sets a record value from JSON @@ -378,14 +368,6 @@ pub trait JsonRecordAccess { /// - JSON deserialization fails (schema mismatch) /// - Record not configured with buffer /// - Record not configured with `.with_remote_access()` - /// - /// # Example (internal use) - /// ```rust,ignore - /// let record: &Box = db.storage(id)?; - /// let json_val = serde_json::json!({"log_level": "debug"}); - /// // Only works if producer_count == 0 - /// record.json_access().unwrap().set_from_json(json_val)?; - /// ``` fn set_from_json(&self, json_value: serde_json::Value) -> crate::DbResult<()>; } @@ -670,17 +652,6 @@ impl TypedRecord { /// /// # Arguments /// * `f` - A function that takes the `RuntimeContext` and a `Consumer`, and returns a Future - /// - /// # Example - /// - /// ```rust,ignore - /// record.add_consumer(|ctx, consumer| async move { - /// let mut rx = consumer.subscribe(); - /// while let Ok(value) = rx.recv().await { - /// println!("Consumer: {:?}", value); - /// } - /// }); - /// ``` pub fn add_consumer(&mut self, f: F) where F: FnOnce(crate::RuntimeContext, crate::Consumer) -> Fut + Send + 'static, @@ -1114,19 +1085,6 @@ impl TypedRecord { /// **Both std and no_std**: Direct access via `Deref`, `.get()`, `.into_inner()` /// /// **std only**: `.as_json()` (if `.with_remote_access()` configured) - /// - /// # Examples - /// ```rust,ignore - /// // Direct access (std and no_std) - /// if let Some(value) = record.latest() { - /// println!("Temp: {:.1}°C", value.celsius); - /// } - /// - /// // JSON serialization (std only) - /// if let Some(json) = record.latest()?.as_json() { - /// println!("{}", json); - /// } - /// ``` pub fn latest(&self) -> Option> { // Read buffer-native storage via peek() (design 031). Records without // a buffer return None — see Breaking Changes in design 031. diff --git a/aimdb-data-contracts/src/lib.rs b/aimdb-data-contracts/src/lib.rs index 9befa63..b569cf7 100644 --- a/aimdb-data-contracts/src/lib.rs +++ b/aimdb-data-contracts/src/lib.rs @@ -213,6 +213,10 @@ pub trait Observable: SchemaType { /// /// # Example /// +/// Not compiled: the snippet needs `aimdb-core`'s builder, which this crate +/// only depends on under the `observable` feature — `linkable` alone has no +/// core dependency. +/// /// ```rust,ignore /// use aimdb_data_contracts::Linkable; /// use my_app::Temperature; // user-defined type implementing Linkable diff --git a/aimdb-data-contracts/src/migratable.rs b/aimdb-data-contracts/src/migratable.rs index 13dbd24..d778cd1 100644 --- a/aimdb-data-contracts/src/migratable.rs +++ b/aimdb-data-contracts/src/migratable.rs @@ -139,7 +139,10 @@ impl core::fmt::Display for MigrationError { /// /// # Example /// -/// ```rust,ignore +/// ```rust +/// # use aimdb_data_contracts::{MigrationError, MigrationStep}; +/// # struct TemperatureV1 { schema_version: u32, temp: f64, timestamp: u64, unit: String } +/// # struct TemperatureV2 { schema_version: u32, celsius: f64, timestamp: u64 } /// struct TemperatureV1ToV2; /// impl MigrationStep for TemperatureV1ToV2 { /// type Older = TemperatureV1; @@ -212,6 +215,9 @@ pub trait MigrationChain: SchemaType + serde::de::DeserializeOwned + serde::Seri /// /// # Syntax /// +/// Grammar sketch with placeholder types (not compiled — see +/// `examples/weather-mesh-demo`'s temperature contract for a compiled chain): +/// /// ```rust,ignore /// migration_chain! { /// type Current = MyType; diff --git a/aimdb-data-contracts/src/observable.rs b/aimdb-data-contracts/src/observable.rs index 10743f2..105c35c 100644 --- a/aimdb-data-contracts/src/observable.rs +++ b/aimdb-data-contracts/src/observable.rs @@ -18,14 +18,23 @@ use crate::Observable; /// /// # Example /// -/// ```ignore +/// ```no_run /// use aimdb_data_contracts::log_tap; -/// -/// builder.configure::(NodeKey::Alpha, |reg| { -/// reg.buffer(BufferCfg::SingleLatest) -/// .tap(|ctx, consumer| log_tap(ctx, consumer, "alpha")) -/// .finish(); +/// # use aimdb_core::AimDbBuilder; +/// # use aimdb_data_contracts::{Observable, SchemaType}; +/// # #[derive(Clone, Debug)] +/// # struct Temperature { celsius: f32 } +/// # impl SchemaType for Temperature { const NAME: &'static str = "temperature"; } +/// # impl Observable for Temperature { +/// # type Signal = f32; +/// # fn signal(&self) -> f32 { self.celsius } +/// # } +/// # fn wire(builder: &mut AimDbBuilder) { +/// builder.configure::("node.alpha", |reg| { +/// // .buffer(BufferCfg::SingleLatest) — via your runtime adapter's ext trait +/// reg.tap(|ctx, consumer| log_tap(ctx, consumer, "alpha")); /// }); +/// # } /// ``` #[cfg(feature = "observable")] pub async fn log_tap( diff --git a/aimdb-derive/src/lib.rs b/aimdb-derive/src/lib.rs index 282642c..c160453 100644 --- a/aimdb-derive/src/lib.rs +++ b/aimdb-derive/src/lib.rs @@ -5,6 +5,10 @@ //! //! # Example //! +//! Illustrative (not compiled: the generated impl targets `aimdb_core`'s +//! `RecordKey` trait, a circular dev-dependency for this proc-macro crate — +//! compiled integration tests live in `aimdb-core`): +//! //! ```rust,ignore //! use aimdb_derive::RecordKey; //! @@ -38,6 +42,9 @@ use syn::{parse_macro_input, Data, DeriveInput, Error, Fields, Lit, Meta}; /// /// # Example /// +/// Illustrative (not compiled: see the crate-level note — compiled +/// integration tests live in `aimdb-core`): +/// /// ```rust,ignore /// // Note: Hash is auto-generated to satisfy the Borrow contract /// #[derive(RecordKey, Clone, Copy, PartialEq, Eq)] diff --git a/aimdb-embassy-adapter/src/buffer.rs b/aimdb-embassy-adapter/src/buffer.rs index e70c848..8a79f15 100644 --- a/aimdb-embassy-adapter/src/buffer.rs +++ b/aimdb-embassy-adapter/src/buffer.rs @@ -332,6 +332,9 @@ impl< /// An async closure that can be passed to `embassy_executor::Spawner::spawn()` /// /// # Example + /// + /// Illustrative (not compiled: an `embassy_executor` task on a thumb target): + /// /// ```rust,ignore /// // In your Embassy application: /// #[embassy_executor::task] diff --git a/aimdb-embassy-adapter/src/lib.rs b/aimdb-embassy-adapter/src/lib.rs index 1abf8ae..7ff5bb5 100644 --- a/aimdb-embassy-adapter/src/lib.rs +++ b/aimdb-embassy-adapter/src/lib.rs @@ -289,26 +289,12 @@ where /// /// # Recommended Values /// - /// **For SPMC Ring Buffer:** - /// ```ignore - /// // Small buffer: 16 items, 2 consumers - /// reg.buffer_sized::<16, 2>(EmbassyBufferType::SpmcRing) - /// - /// // Large buffer: 64 items, 4 consumers - /// reg.buffer_sized::<64, 4>(EmbassyBufferType::SpmcRing) - /// ``` - /// - /// **For SingleLatest (only latest value stored):** - /// ```ignore - /// // 4 consumers watching the latest value - /// reg.buffer_sized::<1, 4>(EmbassyBufferType::SingleLatest) - /// ``` - /// - /// **For Mailbox (single-slot overwrite):** - /// ```ignore - /// // Parameters are ignored, single slot - /// reg.buffer_sized::<1, 4>(EmbassyBufferType::Mailbox) - /// ``` + /// - SPMC ring: `buffer_sized::<16, 2>(EmbassyBufferType::SpmcRing)` for a + /// small buffer (16 items, 2 consumers); `::<64, 4>` for a large one. + /// - SingleLatest: `buffer_sized::<1, 4>(EmbassyBufferType::SingleLatest)` + /// — only the latest value is stored, 4 consumers watch it. + /// - Mailbox: `buffer_sized::<1, 4>(EmbassyBufferType::Mailbox)` — single + /// slot with overwrite; the parameters are ignored. /// /// # Counting CONSUMERS /// @@ -336,6 +322,9 @@ where /// - `F`: Closure that takes (RuntimeContext, Producer, Context) and returns a Future /// /// # Example + /// + /// Illustrative (not compiled: uses device peripherals on a thumb target): + /// /// ```ignore /// use embassy_stm32::exti::ExtiInput; /// diff --git a/aimdb-knx-connector/src/embassy_client.rs b/aimdb-knx-connector/src/embassy_client.rs index 196a7d2..52cbed2 100644 --- a/aimdb-knx-connector/src/embassy_client.rs +++ b/aimdb-knx-connector/src/embassy_client.rs @@ -23,6 +23,9 @@ //! //! # Usage //! +//! Illustrative (not compiled: requires the `embassy-runtime` feature and a +//! device network stack): +//! //! ```rust,ignore //! use aimdb_knx_connector::KnxConnectorBuilder; //! use aimdb_core::AimDbBuilder; diff --git a/aimdb-knx-connector/src/lib.rs b/aimdb-knx-connector/src/lib.rs index d42c6f3..4956bc8 100644 --- a/aimdb-knx-connector/src/lib.rs +++ b/aimdb-knx-connector/src/lib.rs @@ -31,10 +31,11 @@ //! //! ## Tokio Usage (Standard Library) //! -//! ```rust,ignore +//! ```no_run +//! use aimdb_core::buffer::BufferCfg; //! use aimdb_core::AimDbBuilder; -//! use aimdb_tokio_adapter::TokioAdapter; //! use aimdb_knx_connector::KnxConnector; +//! use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; //! use std::sync::Arc; //! //! #[derive(Debug, Clone)] @@ -42,32 +43,38 @@ //! is_on: bool, //! } //! +//! # async fn demo() -> Result<(), Box> { //! let runtime = Arc::new(TokioAdapter::new()?); //! -//! let db = AimDbBuilder::new() +//! let mut builder = AimDbBuilder::new() //! .runtime(runtime) -//! .with_connector(KnxConnector::new("knx://192.168.1.19:3671")) -//! .configure::(|reg| { -//! reg.buffer(BufferCfg::SingleLatest) -//! // Inbound: Monitor KNX bus -//! .link_from("knx://1/0/7") -//! .with_deserializer_raw(|data: &[u8]| { -//! let is_on = data.get(0).map(|&b| b != 0).unwrap_or(false); -//! Ok(Box::new(LightState { is_on })) -//! }) -//! .finish() -//! // Outbound: Send commands to KNX -//! .link_to("knx://1/0/8") -//! .with_serializer_raw(|state: &LightState| { -//! Ok(vec![if state.is_on { 1 } else { 0 }]) -//! }) -//! .finish(); -//! }) -//! .build().await?; +//! .with_connector(KnxConnector::new("knx://192.168.1.19:3671")); +//! builder.configure::("light.state", |reg| { +//! reg.buffer(BufferCfg::SingleLatest) +//! // Inbound: Monitor KNX bus +//! .link_from("knx://1/0/7") +//! .with_deserializer_raw(|data: &[u8]| { +//! let is_on = data.first().map(|&b| b != 0).unwrap_or(false); +//! Ok(LightState { is_on }) +//! }) +//! .finish() +//! // Outbound: Send commands to KNX +//! .link_to("knx://1/0/8") +//! .with_serializer_raw(|state: &LightState| { +//! Ok(vec![if state.is_on { 1 } else { 0 }]) +//! }) +//! .finish(); +//! }); +//! let (db, runner) = builder.build().await?; +//! # Ok(()) +//! # } //! ``` //! //! ## Embassy Usage (Embedded) //! +//! Illustrative (not compiled: requires the `embassy-runtime` feature and a +//! device network stack): +//! //! ```rust,ignore //! use aimdb_core::AimDbBuilder; //! use aimdb_embassy_adapter::EmbassyAdapter; @@ -106,19 +113,6 @@ //! ## DPT Support //! //! This connector uses `knx-pico` for Data Point Type conversion: -//! -//! ```rust,ignore -//! use knx_pico::dpt::{Dpt1, Dpt5, Dpt9, DptDecode, DptEncode}; -//! -//! // DPT 1.001 - Boolean (switch) -//! let is_on = Dpt1::Switch.decode(data)?; -//! -//! // DPT 5.001 - 8-bit unsigned (0-100%) -//! let percentage = Dpt5::Percentage.decode(data)?; -//! -//! // DPT 9.001 - 2-byte float (temperature) -//! let temp = Dpt9::Temperature.decode(data)?; -//! ``` #![cfg_attr(not(feature = "std"), no_std)] diff --git a/aimdb-knx-connector/src/tokio_client.rs b/aimdb-knx-connector/src/tokio_client.rs index 193bd60..85d0d2e 100644 --- a/aimdb-knx-connector/src/tokio_client.rs +++ b/aimdb-knx-connector/src/tokio_client.rs @@ -34,22 +34,6 @@ use tokio::sync::mpsc; /// /// # Usage Pattern /// -/// ```rust,ignore -/// use aimdb_knx_connector::KnxConnector; -/// -/// // Configure database with KNX links -/// let db = AimDbBuilder::new() -/// .runtime(runtime) -/// .with_connector(KnxConnector::new("knx://192.168.1.19:3671")) -/// .configure::(|reg| { -/// reg.link_from("knx://1/0/7") -/// .with_deserializer(deserialize_light) -/// .with_buffer(BufferCfg::SingleLatest) -/// .finish(); -/// }) -/// .build().await?; -/// ``` -/// /// The connector collects routes from the database during build() and /// automatically monitors all required KNX group addresses. pub struct KnxConnectorBuilder { @@ -64,12 +48,6 @@ impl KnxConnectorBuilder { /// /// # Arguments /// * `gateway_url` - Gateway URL (knx://host:port) - /// - /// # Example - /// - /// ```rust,ignore - /// let builder = KnxConnector::new("knx://192.168.1.19:3671"); - /// ``` pub fn new(gateway_url: impl Into) -> Self { Self { gateway_url: gateway_url.into(), diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index af2f71e..713f4d8 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -19,6 +19,9 @@ //! //! # Usage //! +//! Illustrative (not compiled: requires the `embassy-runtime` feature and a +//! device network stack): +//! //! ```rust,ignore //! use aimdb_mqtt_connector::embassy_client::MqttConnectorBuilder; //! use aimdb_core::AimDbBuilder; diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 2cb30dd..411747b 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -13,38 +13,55 @@ //! //! ## Tokio Usage (Standard Library) //! -//! ```rust,ignore +//! ```no_run //! use aimdb_core::AimDbBuilder; -//! use aimdb_tokio_adapter::TokioAdapter; //! use aimdb_mqtt_connector::{MqttConnector, MqttLinkExt, MqttOutboundLinkExt}; +//! use aimdb_tokio_adapter::TokioAdapter; //! use std::sync::Arc; //! +//! # #[derive(Clone, Debug)] struct Temperature { celsius: f32 } +//! # #[derive(Clone, Debug)] struct TempCommand { target: f32 } +//! # async fn temperature_producer( +//! # ctx: aimdb_core::RuntimeContext, +//! # producer: aimdb_core::Producer, +//! # ) {} +//! # async fn demo() -> Result<(), Box> { //! let runtime = Arc::new(TokioAdapter::new()?); //! -//! let db = AimDbBuilder::new() +//! let mut builder = AimDbBuilder::new() //! .runtime(runtime) -//! .with_connector(MqttConnector::new("mqtt://localhost:1883")) -//! .configure::(|reg| { -//! reg.source(temperature_producer) -//! // Outbound: Publish to MQTT (QoS/retain via MqttLinkExt traits) -//! .link_to("mqtt://sensors/temperature") -//! .with_qos(1) -//! .with_retain(false) -//! .with_serializer_raw(|t| { -//! serde_json::to_vec(t) -//! .map_err(|_| aimdb_core::connector::SerializeError::InvalidData) -//! }) -//! .finish() -//! // Inbound: Subscribe from MQTT -//! .link_from("mqtt://commands/temperature") -//! .with_deserializer_raw(|data| Temperature::from_json(data)) -//! .finish(); -//! }) -//! .build().await?; +//! .with_connector(MqttConnector::new("mqtt://localhost:1883")); +//! +//! // Outbound: publish to MQTT (QoS/retain via the MqttLinkExt traits) +//! builder.configure::("sensor.temp", |reg| { +//! reg.source(temperature_producer) +//! .link_to("mqtt://sensors/temperature") +//! .with_qos(1) +//! .with_retain(false) +//! .with_serializer_raw(|t: &Temperature| Ok(t.celsius.to_be_bytes().to_vec())) +//! .finish(); +//! }); +//! +//! // Inbound: subscribe from MQTT +//! builder.configure::("command.temp", |reg| { +//! reg.link_from("mqtt://commands/temperature") +//! .with_deserializer_raw(|data| match data.try_into() { +//! Ok(bytes) => Ok(TempCommand { target: f32::from_be_bytes(bytes) }), +//! Err(_) => Err("bad frame".to_string()), +//! }) +//! .finish(); +//! }); +//! +//! let (db, runner) = builder.build().await?; +//! # Ok(()) +//! # } //! ``` //! //! ## Embassy Usage (Embedded) //! +//! Illustrative (not compiled: requires the `embassy-runtime` feature and a +//! device network stack): +//! //! ```rust,ignore //! use aimdb_core::AimDbBuilder; //! use aimdb_embassy_adapter::EmbassyAdapter; diff --git a/aimdb-mqtt-connector/src/link_ext.rs b/aimdb-mqtt-connector/src/link_ext.rs index 90dd5b1..7912630 100644 --- a/aimdb-mqtt-connector/src/link_ext.rs +++ b/aimdb-mqtt-connector/src/link_ext.rs @@ -7,14 +7,17 @@ //! `("qos", …)` / `("retain", …)` option keys the MQTT clients have always //! read from `protocol_options` — wire behavior is unchanged. //! -//! ```rust,ignore +//! ```no_run //! use aimdb_mqtt_connector::{MqttLinkExt, MqttOutboundLinkExt}; +//! # #[derive(Clone, Debug)] struct Temperature { celsius: f32 } +//! # fn wire(reg: &mut aimdb_core::RecordRegistrar<'_, Temperature>) { //! //! reg.link_to("mqtt://sensors/temp") //! .with_qos(1) //! .with_retain(true) -//! .with_serializer_raw(serialize) +//! .with_serializer_raw(|t: &Temperature| Ok(t.celsius.to_be_bytes().to_vec())) //! .finish(); +//! # } //! ``` use aimdb_core::{InboundConnectorBuilder, OutboundConnectorBuilder}; diff --git a/aimdb-mqtt-connector/src/tokio_client.rs b/aimdb-mqtt-connector/src/tokio_client.rs index cafb159..923e0ce 100644 --- a/aimdb-mqtt-connector/src/tokio_client.rs +++ b/aimdb-mqtt-connector/src/tokio_client.rs @@ -23,22 +23,6 @@ use std::time::Duration; /// /// # Usage Pattern /// -/// ```rust,ignore -/// use aimdb_mqtt_connector::MqttConnector; -/// -/// // Configure database with MQTT links -/// let db = AimDbBuilder::new() -/// .runtime(runtime) -/// .with_connector(MqttConnector::new("mqtt://localhost:1883")) -/// .configure::(|reg| { -/// reg.link_from("mqtt://commands/temp") -/// .with_deserializer(deserialize_temp) -/// .with_buffer(BufferCfg::SingleLatest) -/// .with_remote_access(); -/// }) -/// .build().await?; -/// ``` -/// /// The connector collects routes from the database during build() and /// automatically subscribes to all required MQTT topics. pub struct MqttConnectorBuilder { @@ -55,12 +39,6 @@ impl MqttConnectorBuilder { /// /// # Arguments /// * `broker_url` - Broker URL (mqtt://host:port or mqtts://host:port) - /// - /// # Example - /// - /// ```rust,ignore - /// let builder = MqttConnector::new("mqtt://localhost:1883"); - /// ``` pub fn new(broker_url: impl Into) -> Self { Self { broker_url: broker_url.into(), @@ -77,13 +55,6 @@ impl MqttConnectorBuilder { /// /// # Arguments /// * `client_id` - Unique identifier for this client - /// - /// # Example - /// - /// ```rust,ignore - /// let builder = MqttConnector::new("mqtt://localhost:1883") - /// .with_client_id("my-app-001"); - /// ``` pub fn with_client_id(mut self, client_id: impl Into) -> Self { self.client_id = Some(client_id.into()); self diff --git a/aimdb-persistence-sqlite/src/lib.rs b/aimdb-persistence-sqlite/src/lib.rs index 4e16721..528eda9 100644 --- a/aimdb-persistence-sqlite/src/lib.rs +++ b/aimdb-persistence-sqlite/src/lib.rs @@ -13,11 +13,14 @@ //! //! # Example //! -//! ```rust,ignore +//! ```no_run //! use aimdb_persistence_sqlite::SqliteBackend; //! use std::sync::Arc; //! +//! # fn demo() -> Result<(), Box> { //! let backend = Arc::new(SqliteBackend::new("./data/history.db")?); +//! # Ok(()) +//! # } //! ``` use std::path::Path; diff --git a/aimdb-persistence/src/lib.rs b/aimdb-persistence/src/lib.rs index 800ab91..6e2db9b 100644 --- a/aimdb-persistence/src/lib.rs +++ b/aimdb-persistence/src/lib.rs @@ -13,25 +13,48 @@ //! //! # Usage //! -//! ```rust,ignore +//! ```no_run //! use aimdb_persistence::{AimDbBuilderPersistExt, RecordRegistrarPersistExt, AimDbQueryExt}; -//! use aimdb_persistence_sqlite::SqliteBackend; -//! +//! // A real backend, e.g. `aimdb_persistence_sqlite::SqliteBackend`: +//! # use aimdb_persistence::{BoxFuture, PersistenceBackend, PersistenceError, QueryParams, StoredValue}; +//! # struct SqliteBackend; +//! # impl SqliteBackend { fn new(_p: &str) -> Result { Ok(Self) } } +//! # impl PersistenceBackend for SqliteBackend { +//! # fn store<'a>( +//! # &'a self, _n: &'a str, _v: &'a serde_json::Value, _t: u64, +//! # ) -> BoxFuture<'a, Result<(), PersistenceError>> { Box::pin(async { Ok(()) }) } +//! # fn query<'a>( +//! # &'a self, _p: &'a str, _q: QueryParams, +//! # ) -> BoxFuture<'a, Result, PersistenceError>> { Box::pin(async { Ok(vec![]) }) } +//! # fn cleanup(&self, _t: u64) -> BoxFuture<'_, Result> { +//! # Box::pin(async { Ok(0) }) +//! # } +//! # } +//! # use aimdb_core::buffer::BufferCfg; +//! # use aimdb_core::AimDbBuilder; +//! # use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +//! # use std::sync::Arc; +//! # use std::time::Duration; +//! # #[derive(Clone, Debug, serde::Serialize)] struct MyRecord { value: f32 } +//! # async fn demo() -> Result<(), Box> { +//! # let runtime = Arc::new(TokioAdapter::new()?); //! let backend = Arc::new(SqliteBackend::new("./data/history.db")?); //! //! let mut builder = AimDbBuilder::new() //! .runtime(runtime) //! .with_persistence(backend.clone(), Duration::from_secs(7 * 24 * 3600)); //! -//! builder.configure::(key, |reg| { +//! builder.configure::("my.record", |reg| { //! reg.buffer(BufferCfg::SpmcRing { capacity: 500 }) -//! .persist(key.to_string()); +//! .persist("my.record"); //! }); //! -//! let db = builder.build().await?; +//! let (db, runner) = builder.build().await?; //! -//! // Query historical data -//! let latest: Vec = db.query_latest("my_record::*", 1).await?; +//! // Query historical data (any `DeserializeOwned` shape; `Value` shown here) +//! let latest: Vec = db.query_latest("my_record::*", 1).await?; +//! # Ok(()) +//! # } //! ``` pub mod backend; diff --git a/aimdb-serial-connector/src/embassy_transport.rs b/aimdb-serial-connector/src/embassy_transport.rs index 96294ce..e982371 100644 --- a/aimdb-serial-connector/src/embassy_transport.rs +++ b/aimdb-serial-connector/src/embassy_transport.rs @@ -130,7 +130,8 @@ impl SerialClient { /// Serves the full AimX toolset over a serial UART, so a host (or another board) /// can `record.list`/`get`/`set`/`subscribe`/`drain` this db over the wire. -/// Register it directly with `with_connector`: +/// Register it directly with `with_connector` (illustrative — the UART halves +/// come from device init on a thumb target): /// /// ```ignore /// builder.with_connector( diff --git a/aimdb-sync/src/consumer.rs b/aimdb-sync/src/consumer.rs index ba5e620..e54642f 100644 --- a/aimdb-sync/src/consumer.rs +++ b/aimdb-sync/src/consumer.rs @@ -19,7 +19,7 @@ use std::time::Duration; /// /// # Example /// -/// ```rust,ignore +/// ```no_run /// # use aimdb_sync::*; /// # use serde::{Serialize, Deserialize}; /// # #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/aimdb-sync/src/handle.rs b/aimdb-sync/src/handle.rs index 62335bf..8e8c139 100644 --- a/aimdb-sync/src/handle.rs +++ b/aimdb-sync/src/handle.rs @@ -42,16 +42,17 @@ pub trait AimDbBuilderSyncExt { /// /// # Example /// - /// ```rust,ignore + /// ```no_run /// use aimdb_core::AimDbBuilder; /// use aimdb_tokio_adapter::TokioAdapter; /// use aimdb_sync::AimDbBuilderSyncExt; /// use std::sync::Arc; /// + /// # #[derive(Debug, Clone)] struct MyData { value: f32 } /// # fn main() -> Result<(), Box> { /// let mut builder = AimDbBuilder::new() - /// .runtime(Arc::new(TokioAdapter)); // Create adapter (it's just a marker) - /// builder.configure::(|reg| { + /// .runtime(Arc::new(TokioAdapter::new()?)); + /// builder.configure::("my.data", |reg| { /// // Configure buffer, sources, taps, etc. /// }); /// let handle = builder.attach()?; // Build happens in runtime thread @@ -83,17 +84,12 @@ pub trait AimDbSyncExt { /// /// # Example /// - /// ```rust,ignore - /// use aimdb_core::AimDbBuilder; - /// use aimdb_tokio_adapter::TokioAdapter; + /// ```no_run + /// use aimdb_core::AimDb; /// use aimdb_sync::AimDbSyncExt; - /// use std::sync::Arc; - /// - /// # fn main() -> Result<(), Box> { - /// let db = AimDbBuilder::new() - /// .runtime(Arc::new(TokioAdapter::new()?)) - /// .build()?; /// + /// // `db` comes out of an async `AimDbBuilder::build()` elsewhere + /// # fn demo(db: AimDb) -> Result<(), Box> { /// let handle = db.attach()?; /// # Ok(()) /// # } @@ -298,7 +294,7 @@ impl AimDbHandle { /// /// # Example /// - /// ```rust,ignore + /// ```no_run /// # use aimdb_sync::*; /// # use serde::{Serialize, Deserialize}; /// # #[derive(Debug, Clone, Serialize, Deserialize)] @@ -372,7 +368,7 @@ impl AimDbHandle { /// /// # Example /// - /// ```rust,ignore + /// ```no_run /// # use aimdb_sync::*; /// # use serde::{Serialize, Deserialize}; /// # #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/aimdb-sync/src/lib.rs b/aimdb-sync/src/lib.rs index 0bf0473..72cb989 100644 --- a/aimdb-sync/src/lib.rs +++ b/aimdb-sync/src/lib.rs @@ -43,32 +43,31 @@ //! //! ## Quick Start //! -//! ```rust,ignore +//! ```no_run //! use aimdb_core::{AimDbBuilder, buffer::BufferCfg}; //! use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; //! use aimdb_sync::AimDbBuilderSyncExt; -//! use serde::{Serialize, Deserialize}; //! use std::sync::Arc; //! -//! #[derive(Debug, Clone, Serialize, Deserialize)] +//! #[derive(Debug, Clone)] //! struct Temperature { //! celsius: f32, //! } //! //! # fn main() -> Result<(), Box> { //! // Build and attach database (NO #[tokio::main] NEEDED!) -//! let adapter = Arc::new(TokioAdapter); +//! let adapter = Arc::new(TokioAdapter::new()?); //! let mut builder = AimDbBuilder::new().runtime(adapter); //! -//! builder.configure::(|reg| { +//! builder.configure::("sensor.temp", |reg| { //! reg.buffer(BufferCfg::SpmcRing { capacity: 10 }); //! }); //! //! let handle = builder.attach()?; //! //! // Create producer and consumer -//! let producer = handle.producer::()?; -//! let consumer = handle.consumer::()?; +//! let producer = handle.producer::("sensor.temp")?; +//! let consumer = handle.consumer::("sensor.temp")?; //! //! // Producer: blocking operations //! producer.set(Temperature { celsius: 25.0 })?; @@ -87,8 +86,11 @@ //! //! Both `SyncProducer` and `SyncConsumer` can be cloned and shared across threads: //! -//! ```rust,ignore +//! ```no_run //! use std::thread; +//! # use aimdb_sync::{SyncConsumer, SyncProducer}; +//! # #[derive(Debug, Clone)] struct Temperature { celsius: f32 } +//! # fn demo(producer: SyncProducer, consumer: SyncConsumer) { //! //! // Clone for use in another thread //! let producer_clone = producer.clone(); @@ -103,6 +105,7 @@ //! println!("Got: {:.1}°C", temp.celsius); //! } //! }); +//! # } //! ``` //! //! ## Independent Subscriptions @@ -110,11 +113,16 @@ //! Note: Cloning a `SyncConsumer` shares the same channel, so only one thread //! will receive each value. For independent subscriptions, create multiple consumers: //! -//! ```rust,ignore -//! let consumer1 = handle.consumer::()?; -//! let consumer2 = handle.consumer::()?; +//! ```no_run +//! # use aimdb_sync::AimDbHandle; +//! # #[derive(Debug, Clone)] struct Temperature { celsius: f32 } +//! # fn demo(handle: &AimDbHandle) -> Result<(), Box> { +//! let consumer1 = handle.consumer::("sensor.temp")?; +//! let consumer2 = handle.consumer::("sensor.temp")?; //! //! // Both receive independent copies of all values +//! # Ok(()) +//! # } //! ``` //! //! ## Channel Capacity Configuration @@ -122,15 +130,22 @@ //! By default, both producers and consumers use a channel capacity of 100. //! You can customize this per record type using the `_with_capacity` methods: //! -//! ```rust,ignore +//! ```no_run +//! # use aimdb_sync::AimDbHandle; +//! # #[derive(Debug, Clone)] struct SensorData { value: f32 } +//! # #[derive(Debug, Clone)] struct RareEvent { code: u8 } +//! # #[derive(Debug, Clone)] struct LatestOnly { state: u8 } +//! # fn demo(handle: &AimDbHandle) -> Result<(), Box> { //! // High-frequency sensor data needs larger buffer -//! let producer = handle.producer_with_capacity::(1000)?; +//! let producer = handle.producer_with_capacity::("sensor.fast", 1000)?; //! //! // Rare events can use smaller buffer -//! let consumer = handle.consumer_with_capacity::(10)?; +//! let consumer = handle.consumer_with_capacity::("events.rare", 10)?; //! //! // SingleLatest-like behavior: use capacity=1 to minimize queueing -//! let consumer = handle.consumer_with_capacity::(1)?; +//! let consumer = handle.consumer_with_capacity::("state.latest", 1)?; +//! # Ok(()) +//! # } //! ``` //! //! **When to adjust capacity:** @@ -151,14 +166,22 @@ //! ### Solutions for SingleLatest Semantics //! //! 1. **Use `get_latest()`** - Drains the channel to get the most recent value: -//! ```rust,ignore +//! ```no_run +//! # #[derive(Debug, Clone)] struct Temperature { celsius: f32 } +//! # fn demo(consumer: &aimdb_sync::SyncConsumer) -> aimdb_core::DbResult<()> { //! // Always get the latest value, skipping queued intermediates //! let latest = consumer.get_latest()?; +//! # Ok(()) +//! # } //! ``` //! //! 2. **Use capacity=1** - Minimize queueing: -//! ```rust,ignore -//! let consumer = handle.consumer_with_capacity::(1)?; +//! ```no_run +//! # #[derive(Debug, Clone)] struct Temperature { celsius: f32 } +//! # fn demo(handle: &aimdb_sync::AimDbHandle) -> aimdb_core::DbResult<()> { +//! let consumer = handle.consumer_with_capacity::("sensor.temp", 1)?; +//! # Ok(()) +//! # } //! ``` //! //! 3. **Use the async API directly** - For perfect semantic preservation. @@ -197,13 +220,17 @@ //! and return any errors that occur in the async context //! - `try_set()` sends immediately without waiting for the produce result (fire-and-forget) //! -//! ```rust,ignore +//! ```no_run +//! # use aimdb_sync::{DbError, SyncProducer}; +//! # #[derive(Debug, Clone)] struct Temperature { celsius: f32 } +//! # fn demo(producer: &SyncProducer, data: Temperature) { //! // Errors are properly propagated to the caller //! match producer.set(data) { //! Ok(()) => println!("Successfully produced"), -//! Err(DbError::RecordNotFound { .. }) => eprintln!("Type not registered"), +//! Err(DbError::RecordKeyNotFound { .. }) => eprintln!("Record not registered"), //! Err(e) => eprintln!("Production failed: {}", e), //! } +//! # } //! ``` //! //! ## Safety diff --git a/aimdb-tokio-adapter/src/buffer.rs b/aimdb-tokio-adapter/src/buffer.rs index 67b24c4..b9934df 100644 --- a/aimdb-tokio-adapter/src/buffer.rs +++ b/aimdb-tokio-adapter/src/buffer.rs @@ -209,14 +209,6 @@ impl TokioBuffer { /// /// # Returns /// A `tokio::task::JoinHandle` that can be used to await task completion - /// - /// # Example - /// ```rust,ignore - /// let handle = buffer.spawn_dispatcher(|value| async move { - /// println!("Processing: {:?}", value); - /// // Call producer and consumers here - /// }); - /// ``` pub fn spawn_dispatcher(&self, handler: F) -> tokio::task::JoinHandle<()> where F: Fn(T) -> Fut + Send + Sync + 'static, diff --git a/aimdb-uds-connector/Cargo.toml b/aimdb-uds-connector/Cargo.toml index d6d9fe0..85d64e1 100644 --- a/aimdb-uds-connector/Cargo.toml +++ b/aimdb-uds-connector/Cargo.toml @@ -30,3 +30,6 @@ tracing = { version = "0.1", optional = true } [dev-dependencies] aimdb-tokio-adapter = { version = "0.6.0", path = "../aimdb-tokio-adapter" } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } +# For the crate-level doc example (a mirrored record needs a JSON shape) +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/aimdb-uds-connector/src/lib.rs b/aimdb-uds-connector/src/lib.rs index 1d9b9c8..d89f6db 100644 --- a/aimdb-uds-connector/src/lib.rs +++ b/aimdb-uds-connector/src/lib.rs @@ -14,19 +14,35 @@ //! register it with `with_connector` to stand up remote access. Sugar over //! [`SessionServerConnector`]. //! -//! ```rust,ignore +//! ```no_run +//! use aimdb_core::buffer::BufferCfg; +//! use aimdb_core::AimDbBuilder; //! use aimdb_uds_connector::{UdsClient, UdsServer}; +//! # use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +//! # use std::sync::Arc; +//! # #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +//! # struct Temp { celsius: f32 } +//! # async fn demo() -> Result<(), Box> { +//! # let rt = Arc::new(TokioAdapter::new()?); //! //! // server: expose this db over a socket (no links) -//! AimDbBuilder::new().runtime(rt) +//! AimDbBuilder::new().runtime(rt.clone()) //! .with_connector(UdsServer::new("/run/aimdb.sock").max_connections(32)) //! .build().await?; //! //! // client: mirror a record to a peer over the socket -//! AimDbBuilder::new().runtime(rt) -//! .with_connector(UdsClient::new("/run/aimdb.sock")) -//! .configure::("temp", |r| { r.with_remote_access().link_to("uds://temp")...; }) -//! .build().await?; +//! let mut b = AimDbBuilder::new().runtime(rt) +//! .with_connector(UdsClient::new("/run/aimdb.sock")); +//! b.configure::("temp", |r| { +//! r.buffer(BufferCfg::SingleLatest) +//! .with_remote_access() +//! .link_to("uds://temp") +//! .with_serializer_raw(|t: &Temp| Ok(serde_json::to_vec(t).expect("serialize"))) +//! .finish(); +//! }); +//! b.build().await?; +//! # Ok(()) +//! # } //! ``` mod transport; @@ -83,8 +99,11 @@ impl UdsClient { /// Accepts AimX connections over a Unix-domain socket and serves the full AimX /// toolset. Register it via `with_connector` to stand up remote access: /// -/// ```rust,ignore -/// builder.with_connector(UdsServer::new("/run/aimdb.sock").max_connections(32)) +/// ```no_run +/// # use aimdb_uds_connector::UdsServer; +/// # fn demo(builder: aimdb_core::AimDbBuilder) { +/// builder.with_connector(UdsServer::new("/run/aimdb.sock").max_connections(32)); +/// # } /// ``` /// /// Unlike a data-plane connector, a server takes **no** `link_to`/`link_from` — diff --git a/aimdb-wasm-adapter/src/bindings.rs b/aimdb-wasm-adapter/src/bindings.rs index 4a0e41e..bfe9c30 100644 --- a/aimdb-wasm-adapter/src/bindings.rs +++ b/aimdb-wasm-adapter/src/bindings.rs @@ -322,6 +322,9 @@ impl WasmDb { /// generic type parameters cannot cross the WASM boundary. Typical usage /// is in a factory function that builds a pre-configured `WasmDb`: /// + /// Illustrative (not compiled: `#[wasm_bindgen]` exports only build for + /// the wasm32 target): + /// /// ```rust,ignore /// #[wasm_bindgen] /// pub fn create_db() -> WasmDb { diff --git a/aimdb-websocket-connector/src/client/builder.rs b/aimdb-websocket-connector/src/client/builder.rs index 1e304dc..f40d053 100644 --- a/aimdb-websocket-connector/src/client/builder.rs +++ b/aimdb-websocket-connector/src/client/builder.rs @@ -35,7 +35,7 @@ use crate::transport::WsDialer; /// /// # Example /// -/// ```rust,ignore +/// ```no_run /// use aimdb_websocket_connector::WsClientConnector; /// /// let connector = WsClientConnector::new("wss://cloud.example.com/ws") @@ -61,13 +61,6 @@ pub struct WsClientConnectorBuilder { impl WsClientConnectorBuilder { /// Create a new builder targeting the given WebSocket URL. - /// - /// # Examples - /// - /// ```rust,ignore - /// WsClientConnector::new("wss://cloud.example.com/ws") - /// WsClientConnector::new("ws://192.168.1.100:8080/ws") - /// ``` pub fn new(url: impl Into) -> Self { Self { url: url.into(), @@ -115,9 +108,10 @@ impl WsClientConnectorBuilder { /// /// # Example /// - /// ```rust,ignore + /// ```no_run + /// # use aimdb_websocket_connector::WsClientConnector; /// WsClientConnector::new("wss://cloud/ws") - /// .with_subscribe_topics(["sensors/#", "config/#"]) + /// .with_subscribe_topics(["sensors/#", "config/#"]); /// ``` pub fn with_subscribe_topics( mut self, diff --git a/aimdb-websocket-connector/src/lib.rs b/aimdb-websocket-connector/src/lib.rs index ab2a9ad..eb1053a 100644 --- a/aimdb-websocket-connector/src/lib.rs +++ b/aimdb-websocket-connector/src/lib.rs @@ -17,47 +17,64 @@ //! //! ## Server Quick Start //! -//! ```rust,ignore -//! use aimdb_tokio_adapter::TokioAdapter; +//! ```no_run +//! use aimdb_core::buffer::BufferCfg; +//! use aimdb_core::AimDbBuilder; +//! use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; //! use aimdb_websocket_connector::WebSocketConnector; -//! -//! let db = AimDbBuilder::new() -//! .runtime(TokioAdapter::new()) +//! # use std::sync::Arc; +//! # #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +//! # struct Temperature { celsius: f32 } +//! # async fn demo() -> Result<(), Box> { +//! let mut builder = AimDbBuilder::new() +//! .runtime(Arc::new(TokioAdapter::new()?)) //! .with_connector( //! WebSocketConnector::new() //! .bind("0.0.0.0:8080") //! .path("/ws") //! .with_late_join(true), -//! ) -//! .configure::(TempKey::Vienna, |reg| { -//! reg.buffer(BufferCfg::SpmcRing { capacity: 100 }) -//! .link_to("ws://sensors/temperature/vienna") -//! .with_serializer_raw(|t| serde_json::to_vec(t).map_err(Into::into)) -//! .finish(); -//! }) -//! .build().await?; +//! ); +//! builder.configure::("sensors.temp.vienna", |reg| { +//! reg.buffer(BufferCfg::SpmcRing { capacity: 100 }) +//! .link_to("ws://sensors/temperature/vienna") +//! .with_serializer_raw(|t: &Temperature| Ok(serde_json::to_vec(t).expect("serialize"))) +//! .finish(); +//! }); +//! let (db, runner) = builder.build().await?; +//! # Ok(()) +//! # } //! ``` //! //! ## Client Quick Start //! -//! ```rust,ignore +//! ```no_run +//! use aimdb_core::buffer::BufferCfg; +//! use aimdb_core::AimDbBuilder; +//! use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; //! use aimdb_websocket_connector::WsClientConnector; -//! -//! let db = AimDbBuilder::new() -//! .runtime(TokioAdapter::new()) +//! # use std::sync::Arc; +//! # #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +//! # struct Temperature { celsius: f32 } +//! # async fn demo() -> Result<(), Box> { +//! let mut builder = AimDbBuilder::new() +//! .runtime(Arc::new(TokioAdapter::new()?)) //! .with_connector( //! WsClientConnector::new("wss://cloud.example.com/ws"), -//! ) -//! .configure::("sensors/temp", |reg| { -//! reg.buffer(BufferCfg::SpmcRing { capacity: 100 }) -//! .link_to("ws-client://sensors/temp") -//! .with_serializer_raw(|t| serde_json::to_vec(t).map_err(Into::into)) -//! .finish() -//! .link_from("ws-client://config/threshold") -//! .with_deserializer_raw(|data| serde_json::from_slice(data)) -//! .finish(); -//! }) -//! .build().await?; +//! ); +//! builder.configure::("sensors.temp", |reg| { +//! reg.buffer(BufferCfg::SpmcRing { capacity: 100 }) +//! .link_to("ws-client://sensors/temp") +//! .with_serializer_raw(|t: &Temperature| Ok(serde_json::to_vec(t).expect("serialize"))) +//! .finish() +//! .link_from("ws-client://config/threshold") +//! .with_deserializer_raw(|data| { +//! serde_json::from_slice::(data).map_err(|e| e.to_string()) +//! }) +//! .finish(); +//! }); +//! let (db, runner) = builder.build().await?; +//! # Ok(()) +//! # } //! ``` //! //! ## Wire Protocol diff --git a/aimdb-websocket-connector/src/server/auth.rs b/aimdb-websocket-connector/src/server/auth.rs index 9a56911..03d5937 100644 --- a/aimdb-websocket-connector/src/server/auth.rs +++ b/aimdb-websocket-connector/src/server/auth.rs @@ -111,8 +111,10 @@ impl AuthError { /// /// # Example — Bearer token auth /// -/// ```rust,ignore -/// use aimdb_websocket_connector::auth::{AuthHandler, AuthRequest, AuthError, Permissions}; +/// ```no_run +/// use aimdb_websocket_connector::{AuthHandler, AuthRequest, AuthError, Permissions}; +/// # use core::future::Future; +/// # use core::pin::Pin; /// /// struct BearerAuth { valid_token: String } /// diff --git a/aimdb-websocket-connector/src/server/builder.rs b/aimdb-websocket-connector/src/server/builder.rs index 3a63116..67e4ccf 100644 --- a/aimdb-websocket-connector/src/server/builder.rs +++ b/aimdb-websocket-connector/src/server/builder.rs @@ -46,13 +46,16 @@ use aimdb_ws_protocol::TopicInfo; /// /// # Example /// -/// ```rust,ignore +/// ```no_run /// use aimdb_websocket_connector::WebSocketConnector; +/// # use aimdb_data_contracts::{SchemaType, Streamable}; +/// # #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +/// # struct Temperature { celsius: f32 } +/// # impl SchemaType for Temperature { const NAME: &'static str = "temperature"; } +/// # impl Streamable for Temperature {} /// /// let mut connector = WebSocketConnector::new(); /// connector.register::(); -/// connector.register::(); -/// connector.register::(); /// /// let connector = connector /// .bind("0.0.0.0:8080") @@ -119,13 +122,6 @@ impl WebSocketConnectorBuilder { } /// Set the TCP address to bind the WebSocket server to. - /// - /// # Examples - /// - /// ```rust,ignore - /// .bind("0.0.0.0:9090") - /// .bind(([127, 0, 0, 1], 8765)) - /// ``` pub fn bind(mut self, addr: impl ToSocketAddrs) -> Self { self.bind_addr = addr .to_socket_addrs() @@ -189,10 +185,11 @@ impl WebSocketConnectorBuilder { /// /// # Example /// - /// ```rust,ignore + /// ```no_run /// use axum::{routing::get, Router}; + /// # use aimdb_websocket_connector::WebSocketConnector; /// - /// let rest = Router::new().route("/api/status", get(status_handler)); + /// let rest = Router::new().route("/api/status", get(|| async { "ok" })); /// let connector = WebSocketConnector::new().with_additional_routes(rest); /// ``` pub fn with_additional_routes(mut self, router: AxumRouter) -> Self { @@ -207,10 +204,10 @@ impl WebSocketConnectorBuilder { /// /// # Example /// - /// ```rust,ignore + /// ```no_run + /// # use aimdb_websocket_connector::WebSocketConnector; /// WebSocketConnector::new() - /// .with_auto_subscribe(["#"]) // push everything - /// .with_auto_subscribe(["sensors/#"]) // only sensor topics + /// .with_auto_subscribe(["sensors/#"]); // or ["#"] to push everything /// ``` pub fn with_auto_subscribe( mut self, @@ -246,19 +243,6 @@ impl WebSocketConnectorBuilder { /// Each call monomorphizes closures that capture `T` for serialization, /// deserialization, and routing. The serializer performs a `downcast_ref` /// on `&dyn Any` to recover the concrete type at dispatch. - /// - /// # Example - /// - /// ```rust,ignore - /// use aimdb_websocket_connector::WebSocketConnector; - /// - /// let mut connector = WebSocketConnector::new(); - /// connector.register::(); - /// connector.register::(); - /// connector.register::(); // user's own type - /// - /// let connector = connector.bind("0.0.0.0:8080"); - /// ``` /// # Panics /// /// Panics if a *different* type has already been registered under the From 4f83395658121d1e1e2ed8f186e8d79b4dad04ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sat, 13 Jun 2026 19:25:32 +0000 Subject: [PATCH 17/17] docs: remove deprecated settings.json file --- .claude/settings.json | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index cd80ab1..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(rustup target *)", - "Bash(rustc --version)", - "Bash(cargo check *)", - "Bash(cargo test *)", - "Bash(cargo clippy *)", - "Bash(cargo fmt *)", - "Bash(git mv *)", - "Bash(make check *)", - "Bash(cargo build *)", - "Bash(git checkout *)", - "Bash(git fetch *)", - "Bash(gh pr *)", - "Bash(gh api *)", - "Bash(git reset *)", - "Bash(git stash *)", - "Bash(git add *)", - "Bash(git commit -m 'feat\\(executor\\): dyn-safe RuntimeOps capability trait \\(#130\\) *)", - "Bash(python3 -)", - "Bash(make clippy *)", - "Bash(make test *)", - "Bash(echo \"EXIT: $?\")" - ] - } -}