From e8f430cc0e3bce7b1c2e54f792a404f436e177df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 27 Apr 2026 19:56:54 +0000 Subject: [PATCH 01/17] feat: Implement no_std support for Transform API and join fan-in - Introduced `TransformBuilder`, `StatefulTransformBuilder`, and `TransformPipeline` for single-input transforms in `no_std + alloc`. - Moved join fan-in functionality to `aimdb-executor`, allowing runtime-specific implementations for Tokio, Embassy, and WASM. - Created `JoinFanInRuntime`, `JoinQueue`, `JoinSender`, and `JoinReceiver` traits to abstract join queue behavior across runtimes. - Implemented `EmbassyJoinQueue`, `TokioJoinQueue`, and `WasmJoinQueue` for respective adapters, ensuring bounded queue semantics. - Updated `typed_api` to expose `transform_join` for multi-input joins without embedding runtime-specific types in `aimdb-core`. - Added documentation for the new design and usage of the transform API in `no_std` environments. --- Cargo.lock | 1 + aimdb-core/src/error.rs | 12 + aimdb-core/src/ext_macros.rs | 26 -- aimdb-core/src/lib.rs | 2 +- aimdb-core/src/transform.rs | 591 ------------------------ aimdb-core/src/transform/join.rs | 288 ++++++++++++ aimdb-core/src/transform/mod.rs | 54 +++ aimdb-core/src/transform/single.rs | 224 +++++++++ aimdb-core/src/typed_api.rs | 23 +- aimdb-embassy-adapter/src/join_fanin.rs | 92 ++++ aimdb-embassy-adapter/src/lib.rs | 3 + aimdb-executor/src/join.rs | 36 ++ aimdb-executor/src/lib.rs | 6 + aimdb-tokio-adapter/src/join_fanin.rs | 73 +++ aimdb-tokio-adapter/src/lib.rs | 2 + aimdb-wasm-adapter/Cargo.toml | 4 + aimdb-wasm-adapter/src/join_fanin.rs | 74 +++ aimdb-wasm-adapter/src/lib.rs | 1 + docs/design/027-no-std-transform-api.md | 462 ++++++++++++++++++ 19 files changed, 1349 insertions(+), 625 deletions(-) delete mode 100644 aimdb-core/src/transform.rs create mode 100644 aimdb-core/src/transform/join.rs create mode 100644 aimdb-core/src/transform/mod.rs create mode 100644 aimdb-core/src/transform/single.rs create mode 100644 aimdb-embassy-adapter/src/join_fanin.rs create mode 100644 aimdb-executor/src/join.rs create mode 100644 aimdb-tokio-adapter/src/join_fanin.rs create mode 100644 aimdb-wasm-adapter/src/join_fanin.rs create mode 100644 docs/design/027-no-std-transform-api.md diff --git a/Cargo.lock b/Cargo.lock index 2eef8018..def6299b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -280,6 +280,7 @@ dependencies = [ "aimdb-data-contracts", "aimdb-executor", "aimdb-ws-protocol", + "futures-channel", "futures-util", "js-sys", "serde", diff --git a/aimdb-core/src/error.rs b/aimdb-core/src/error.rs index e1c345d7..b25d5e51 100644 --- a/aimdb-core/src/error.rs +++ b/aimdb-core/src/error.rs @@ -697,6 +697,18 @@ impl From for DbError { DbError::RuntimeError { _message: () } } } + ExecutorError::QueueClosed => { + #[cfg(feature = "std")] + { + DbError::RuntimeError { + message: "join queue closed".to_string(), + } + } + #[cfg(not(feature = "std"))] + { + DbError::RuntimeError { _message: () } + } + } } } } diff --git a/aimdb-core/src/ext_macros.rs b/aimdb-core/src/ext_macros.rs index bceda1ec..632a337e 100644 --- a/aimdb-core/src/ext_macros.rs +++ b/aimdb-core/src/ext_macros.rs @@ -103,19 +103,6 @@ macro_rules! impl_record_registrar_ext { $crate::transform::TransformBuilder, ) -> $crate::transform::TransformPipeline; - /// Multi-input reactive transform (join). - /// - /// Derives this record from multiple input records. Panics if a `.source()` or - /// another `.transform()` is already registered. - #[cfg(feature = "std")] - fn transform_join( - &'a mut self, - build_fn: F, - ) -> &'a mut $crate::RecordRegistrar<'a, T, $runtime> - where - F: FnOnce( - $crate::transform::JoinBuilder, - ) -> $crate::transform::JoinPipeline; } #[cfg(feature = $feature)] @@ -189,19 +176,6 @@ macro_rules! impl_record_registrar_ext { { self.transform_raw::(input_key, build_fn) } - - #[cfg(feature = "std")] - fn transform_join( - &'a mut self, - build_fn: F, - ) -> &'a mut $crate::RecordRegistrar<'a, T, $runtime> - where - F: FnOnce( - $crate::transform::JoinBuilder, - ) -> $crate::transform::JoinPipeline, - { - self.transform_join_raw(build_fn) - } } }; diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index f1235448..4ac8bba1 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -73,6 +73,6 @@ pub use record_id::{RecordId, RecordKey, StringKey}; pub use graph::{DependencyGraph, EdgeType, GraphEdge, GraphNode, RecordGraphInfo, RecordOrigin}; // Transform API exports -#[cfg(feature = "std")] +#[cfg(feature = "alloc")] pub use transform::{JoinBuilder, JoinPipeline, JoinTrigger}; pub use transform::{TransformBuilder, TransformPipeline}; diff --git a/aimdb-core/src/transform.rs b/aimdb-core/src/transform.rs deleted file mode 100644 index 7baf2d1f..00000000 --- a/aimdb-core/src/transform.rs +++ /dev/null @@ -1,591 +0,0 @@ -//! Reactive transform primitives for derived records -//! -//! This module provides the `.transform()` and `.transform_join()` API for declaring -//! reactive derivations from one or more input records to an output record. -//! -//! # Transform Archetypes -//! -//! - **Map** (1:1, stateless): Transform each input value to zero-or-one output value -//! - **Accumulate** (N:1, stateful): Aggregate a stream of values with persistent state -//! - **Join** (M×N:1, stateful, multi-input): Combine values from multiple input records -//! -//! All three are handled by a unified API surface: -//! - Single-input: `.transform()` with `TransformBuilder` -//! - Multi-input: `.transform_join()` with `JoinBuilder` -//! -//! # Design Principles -//! -//! - Transforms are **owned by AimDB** — visible in the dependency graph -//! - Transforms are **mutually exclusive** with `.source()` on the same record -//! - Multiple `.tap()` observers can still be attached to a transform's output -//! - Input subscriptions use existing `Consumer` / `BufferReader` API -//! - Build-time validation catches missing input keys and cyclic dependencies - -use core::any::Any; -use core::fmt::Debug; -use core::marker::PhantomData; - -extern crate alloc; -use alloc::{ - boxed::Box, - string::{String, ToString}, - vec::Vec, -}; - -use alloc::sync::Arc; - -use crate::typed_record::BoxFuture; - -// ============================================================================ -// TransformDescriptor — stored per output record in TypedRecord -// ============================================================================ - -/// Transform descriptor stored in `TypedRecord`. -/// -/// Contains the input record keys and a type-erased spawn function that captures -/// all type information (input types, state type) in its closure. At spawn time -/// it receives a `Producer` and the `AimDb` handle. -/// -/// This follows the same pattern as `ProducerServiceFn`. -pub(crate) struct TransformDescriptor -where - T: Send + 'static + Debug + Clone, -{ - /// Record keys this transform subscribes to (for build-time validation). - pub input_keys: Vec, - - /// Spawn function: takes (Producer, Arc>, Arc) → Future. - /// - /// The closure captures input types, state, and user logic. At spawn time it - /// receives: - /// - `Producer` bound to the output record - /// - `Arc>` for subscribing to input records - /// - `Arc` runtime context (same as source/tap) - #[allow(clippy::type_complexity)] - pub spawn_fn: Box< - dyn FnOnce( - crate::Producer, - Arc>, - Arc, - ) -> BoxFuture<'static, ()> - + Send - + Sync, - >, -} - -// ============================================================================ -// Single-Input Transform: TransformBuilder → TransformPipeline -// ============================================================================ - -/// Configures a single-input transform pipeline. -/// -/// Created by `RecordRegistrar::transform_raw()`. Use `.map()` for stateless -/// transforms or `.with_state()` for stateful transforms. -pub struct TransformBuilder { - input_key: String, - _phantom: PhantomData<(I, O, R)>, -} - -impl TransformBuilder -where - I: Send + Sync + Clone + Debug + 'static, - O: Send + Sync + Clone + Debug + 'static, - R: aimdb_executor::Spawn + 'static, -{ - pub(crate) fn new(input_key: String) -> Self { - Self { - input_key, - _phantom: PhantomData, - } - } - - /// Stateless 1:1 map. Returning `None` skips output for this input value. - pub fn map(self, f: F) -> TransformPipeline - where - F: Fn(&I) -> Option + Send + Sync + 'static, - { - // A stateless map is a stateful transform with () state - TransformPipeline { - input_key: self.input_key, - spawn_factory: Box::new(move |input_key| { - let transform_fn = move |val: &I, _state: &mut ()| f(val); - create_single_transform_descriptor::(input_key, (), transform_fn) - }), - _phantom_i: PhantomData, - } - } - - /// Begin configuring a stateful transform. `S` is the user-defined state type. - pub fn with_state( - self, - initial: S, - ) -> StatefulTransformBuilder { - StatefulTransformBuilder { - input_key: self.input_key, - initial_state: initial, - _phantom: PhantomData, - } - } -} - -/// Intermediate builder for stateful single-input transforms. -pub struct StatefulTransformBuilder { - input_key: String, - initial_state: S, - _phantom: PhantomData<(I, O, R)>, -} - -impl StatefulTransformBuilder -where - I: Send + Sync + Clone + Debug + 'static, - O: Send + Sync + Clone + Debug + 'static, - S: Send + Sync + 'static, - R: aimdb_executor::Spawn + 'static, -{ - /// Called for each input value. Receives mutable state, returns optional output. - pub fn on_value(self, f: F) -> TransformPipeline - where - F: Fn(&I, &mut S) -> Option + Send + Sync + 'static, - { - let initial = self.initial_state; - TransformPipeline { - input_key: self.input_key, - spawn_factory: Box::new(move |input_key| { - create_single_transform_descriptor::(input_key, initial, f) - }), - _phantom_i: PhantomData, - } - } -} - -/// Completed single-input transform pipeline, ready to be stored in `TypedRecord`. -pub struct TransformPipeline< - I, - O: Send + Sync + Clone + Debug + 'static, - R: aimdb_executor::Spawn + 'static, -> { - pub(crate) input_key: String, - /// Factory that produces a TransformDescriptor given the input key. - /// This indirection lets the pipeline be constructed before we have the runtime. - pub(crate) spawn_factory: Box TransformDescriptor + Send + Sync>, - _phantom_i: PhantomData, -} - -impl TransformPipeline -where - I: Send + Sync + Clone + Debug + 'static, - O: Send + Sync + Clone + Debug + 'static, - R: aimdb_executor::Spawn + 'static, -{ - /// Consume this pipeline and produce the `TransformDescriptor` for storage. - pub(crate) fn into_descriptor(self) -> TransformDescriptor { - (self.spawn_factory)(self.input_key) - } -} - -/// Helper: create a single-input TransformDescriptor from types and closure. -fn create_single_transform_descriptor( - input_key: String, - initial_state: S, - transform_fn: impl Fn(&I, &mut S) -> Option + Send + Sync + 'static, -) -> TransformDescriptor -where - I: Send + Sync + Clone + Debug + 'static, - O: Send + Sync + Clone + Debug + 'static, - S: Send + Sync + 'static, - R: aimdb_executor::Spawn + 'static, -{ - let input_key_clone = input_key.clone(); - let input_keys = alloc::vec![input_key]; - - TransformDescriptor { - input_keys, - spawn_fn: Box::new(move |producer, db, _ctx| { - Box::pin(run_single_transform::( - db, - input_key_clone, - producer, - initial_state, - transform_fn, - )) - }), - } -} - -// ============================================================================ -// Multi-Input Join: JoinBuilder → JoinPipeline -// ============================================================================ - -/// Tells the join handler which input produced a value. -/// -/// Users match on the index (corresponding to `.input()` call order) -/// and downcast to recover the typed value. -pub enum JoinTrigger { - /// An input at the given index fired with a type-erased value. - Input { - index: usize, - value: Box, - }, -} - -impl JoinTrigger { - /// Convenience: try to downcast the trigger value as the expected input type. - pub fn as_input(&self) -> Option<&T> { - match self { - JoinTrigger::Input { value, .. } => value.downcast_ref::(), - } - } - - /// Returns the input index that triggered this event. - pub fn index(&self) -> usize { - match self { - JoinTrigger::Input { index, .. } => *index, - } - } -} - -/// Type-erased input descriptor for joins. -/// -/// Each input captures a subscribe-and-forward function that, given the AimDb handle, -/// subscribes to the input buffer and forwards typed values as `JoinTrigger` into a -/// shared `mpsc::UnboundedSender`. -/// -/// Configures a multi-input join transform. -/// -/// Created by `RecordRegistrar::transform_join_raw()`. Add inputs with `.input()`, -/// then set state and handler with `.with_state().on_trigger()`. -#[cfg(feature = "std")] -pub struct JoinBuilder { - inputs: Vec<(String, JoinInputFactory)>, - _phantom: PhantomData<(O, R)>, -} - -/// Type-erased factory for creating a forwarder task for one join input. -#[cfg(feature = "std")] -type JoinInputFactory = Box< - dyn FnOnce( - Arc>, - usize, - tokio::sync::mpsc::UnboundedSender, - ) -> BoxFuture<'static, ()> - + Send - + Sync, ->; - -#[cfg(feature = "std")] -impl JoinBuilder -where - O: Send + Sync + Clone + Debug + 'static, - R: aimdb_executor::Spawn + 'static, -{ - pub(crate) fn new() -> Self { - Self { - inputs: Vec::new(), - _phantom: PhantomData, - } - } - - /// Add a typed input to the join. - /// - /// The input index corresponds to the order of `.input()` calls, - /// starting from 0. - pub fn input(mut self, key: impl crate::RecordKey) -> Self - where - I: Send + Sync + Clone + Debug + 'static, - { - let key_str = key.as_str().to_string(); - let key_for_factory = key_str.clone(); - - let factory: JoinInputFactory = Box::new( - move |db: Arc>, - index: usize, - tx: tokio::sync::mpsc::UnboundedSender| { - Box::pin(async move { - // Create consumer and subscribe to the input buffer - let consumer = - crate::typed_api::Consumer::::new(db, key_for_factory.clone()); - let mut reader = match consumer.subscribe() { - Ok(r) => r, - Err(e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "🔄 Join input '{}' (index {}) subscription failed: {:?}", - key_for_factory, - index, - e - ); - // Defense-in-depth: always emit something on subscription failure - #[cfg(all(feature = "std", not(feature = "tracing")))] - eprintln!( - "AIMDB TRANSFORM ERROR: Join input '{}' (index {}) subscription failed: {:?}", - key_for_factory, index, e - ); - return; - } - }; - - // Forward loop: recv from buffer, send as JoinTrigger - while let Ok(value) = reader.recv().await { - let trigger = JoinTrigger::Input { - index, - value: Box::new(value), - }; - if tx.send(trigger).is_err() { - // Main join task dropped — exit - break; - } - } - }) as BoxFuture<'static, ()> - }, - ); - - self.inputs.push((key_str, factory)); - self - } - - /// Set the join state and begin configuring the trigger handler. - pub fn with_state(self, initial: S) -> JoinStateBuilder { - JoinStateBuilder { - inputs: self.inputs, - initial_state: initial, - _phantom: PhantomData, - } - } -} - -/// Intermediate builder for setting the join trigger handler. -#[cfg(feature = "std")] -pub struct JoinStateBuilder { - inputs: Vec<(String, JoinInputFactory)>, - initial_state: S, - _phantom: PhantomData<(O, R)>, -} - -#[cfg(feature = "std")] -impl JoinStateBuilder -where - O: Send + Sync + Clone + Debug + 'static, - S: Send + Sync + 'static, - R: aimdb_executor::Spawn + 'static, -{ - /// Async handler called whenever any input produces a value. - /// - /// Receives a `JoinTrigger` (with index + typed value), mutable state, - /// and a `Producer` for emitting output values. - pub fn on_trigger(self, handler: F) -> JoinPipeline - where - F: Fn(JoinTrigger, &mut S, &crate::Producer) -> Fut + Send + Sync + 'static, - Fut: core::future::Future + Send + 'static, - { - let inputs = self.inputs; - let initial = self.initial_state; - - let input_keys_for_descriptor: Vec = - inputs.iter().map(|(k, _)| k.clone()).collect(); - - JoinPipeline { - _input_keys: input_keys_for_descriptor.clone(), - spawn_factory: Box::new(move |_| TransformDescriptor { - input_keys: input_keys_for_descriptor, - spawn_fn: Box::new(move |producer, db, ctx| { - Box::pin(run_join_transform( - db, inputs, producer, initial, handler, ctx, - )) - }), - }), - } - } -} - -/// Completed multi-input join pipeline, ready to be stored in `TypedRecord`. -#[cfg(feature = "std")] -pub struct JoinPipeline< - O: Send + Sync + Clone + Debug + 'static, - R: aimdb_executor::Spawn + 'static, -> { - pub(crate) _input_keys: Vec, - pub(crate) spawn_factory: Box TransformDescriptor + Send + Sync>, -} - -#[cfg(feature = "std")] -impl JoinPipeline -where - O: Send + Sync + Clone + Debug + 'static, - R: aimdb_executor::Spawn + 'static, -{ - /// Consume this pipeline and produce the `TransformDescriptor` for storage. - pub(crate) fn into_descriptor(self) -> TransformDescriptor { - (self.spawn_factory)(()) - } -} - -// ============================================================================ -// Transform Task Runners -// ============================================================================ - -/// Spawned task for a single-input stateful transform. -/// -/// Subscribes to the input record's buffer, calls the user closure per value, -/// and produces output values to the output record's buffer. -#[allow(unused_variables)] -async fn run_single_transform( - db: Arc>, - input_key: String, - producer: crate::Producer, - mut state: S, - transform_fn: impl Fn(&I, &mut S) -> Option + Send + Sync + 'static, -) where - I: Send + Sync + Clone + Debug + 'static, - O: Send + Sync + Clone + Debug + 'static, - S: Send + 'static, - R: aimdb_executor::Spawn + 'static, -{ - let output_key = producer.key().to_string(); - - // OBSERVABILITY (Incident Lesson #1): Always confirm startup - #[cfg(feature = "tracing")] - tracing::info!("🔄 Transform started: '{}' → '{}'", input_key, output_key); - - // Subscribe to the input record's buffer. - // Incident Lesson #3: subscription failure is FATAL for transforms. - let consumer = crate::typed_api::Consumer::::new(db, input_key.clone()); - let mut reader = match consumer.subscribe() { - Ok(r) => r, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "🔄 Transform '{}' → '{}' FATAL: failed to subscribe to input: {:?}", - input_key, - output_key, - _e - ); - // Defense-in-depth: always emit on subscription failure - #[cfg(all(feature = "std", not(feature = "tracing")))] - eprintln!( - "AIMDB TRANSFORM ERROR: '{}' → '{}' failed to subscribe to input: {:?}", - input_key, output_key, _e - ); - return; - } - }; - - #[cfg(feature = "tracing")] - tracing::debug!( - "✅ Transform '{}' → '{}' subscribed, entering event loop", - input_key, - output_key - ); - - // React to each input value - loop { - match reader.recv().await { - Ok(input_value) => { - if let Some(output_value) = transform_fn(&input_value, &mut state) { - let _ = producer.produce(output_value).await; - } - } - Err(crate::DbError::BufferLagged { .. }) => { - #[cfg(feature = "tracing")] - tracing::warn!( - "🔄 Transform '{}' → '{}' lagged behind, some values skipped", - input_key, - output_key - ); - // Continue processing — lag is not fatal - continue; - } - Err(_) => { - // Buffer closed or other error — exit - #[cfg(feature = "tracing")] - tracing::warn!( - "🔄 Transform '{}' → '{}' input closed, task exiting", - input_key, - output_key - ); - break; - } - } - } -} - -/// Spawned task for a multi-input join transform. -/// -/// Spawns N lightweight forwarder tasks (one per input), each subscribing to -/// its input buffer and forwarding type-erased `JoinTrigger` values to a shared -/// `mpsc::UnboundedChannel`. The main task reads from this channel and calls -/// the user handler. -#[cfg(feature = "std")] -#[allow(unused_variables)] -async fn run_join_transform( - db: Arc>, - inputs: Vec<(String, JoinInputFactory)>, - producer: crate::Producer, - mut state: S, - handler: F, - runtime_ctx: Arc, -) where - O: Send + Sync + Clone + Debug + 'static, - S: Send + 'static, - R: aimdb_executor::Spawn + 'static, - F: Fn(JoinTrigger, &mut S, &crate::Producer) -> Fut + Send + Sync + 'static, - Fut: core::future::Future + Send + 'static, -{ - let output_key = producer.key().to_string(); - let input_keys: Vec = inputs.iter().map(|(k, _)| k.clone()).collect(); - - // OBSERVABILITY: Always confirm startup - #[cfg(feature = "tracing")] - tracing::info!( - "🔄 Join transform started: {:?} → '{}'", - input_keys, - output_key - ); - - // Extract runtime for spawning forwarder tasks - let runtime: &R = runtime_ctx - .downcast_ref::>() - .map(|arc| arc.as_ref()) - .or_else(|| runtime_ctx.downcast_ref::()) - .expect("Failed to extract runtime from context for join transform"); - - // Create the shared trigger channel - let (trigger_tx, mut trigger_rx) = tokio::sync::mpsc::unbounded_channel(); - - // Spawn per-input forwarder tasks - for (index, (_key, factory)) in inputs.into_iter().enumerate() { - let tx = trigger_tx.clone(); - let db = db.clone(); - - // Each forwarder subscribes to one input and sends JoinTrigger values - let forwarder_future = factory(db, index, tx); - if let Err(_e) = runtime.spawn(forwarder_future) { - #[cfg(feature = "tracing")] - tracing::error!( - "🔄 Join transform '{}' FATAL: failed to spawn forwarder for input index {}", - output_key, - index - ); - return; - } - } - - // Drop our copy of the sender — when all forwarders exit, the channel closes - drop(trigger_tx); - - #[cfg(feature = "tracing")] - tracing::debug!( - "✅ Join transform '{}' all forwarders spawned, entering event loop", - output_key - ); - - // Event loop: dispatch typed triggers to the user handler - while let Some(trigger) = trigger_rx.recv().await { - handler(trigger, &mut state, &producer).await; - } - - #[cfg(feature = "tracing")] - tracing::warn!( - "🔄 Join transform '{}' all inputs closed, task exiting", - output_key - ); -} diff --git a/aimdb-core/src/transform/join.rs b/aimdb-core/src/transform/join.rs new file mode 100644 index 00000000..c2c63a23 --- /dev/null +++ b/aimdb-core/src/transform/join.rs @@ -0,0 +1,288 @@ +use core::any::Any; +use core::fmt::Debug; +use core::marker::PhantomData; + +extern crate alloc; +use alloc::{ + boxed::Box, + string::{String, ToString}, + sync::Arc, + vec::Vec, +}; + +use aimdb_executor::{JoinFanInRuntime, JoinQueue, JoinReceiver as _, JoinSender}; + +use crate::transform::TransformDescriptor; +use crate::typed_record::BoxFuture; + +// ============================================================================ +// JoinTrigger +// ============================================================================ + +/// Tells the join handler which input produced a value. +pub enum JoinTrigger { + Input { + index: usize, + value: Box, + }, +} + +impl JoinTrigger { + pub fn as_input(&self) -> Option<&T> { + match self { + JoinTrigger::Input { value, .. } => value.downcast_ref::(), + } + } + + pub fn index(&self) -> usize { + match self { + JoinTrigger::Input { index, .. } => *index, + } + } +} + +// ============================================================================ +// JoinBuilder → JoinPipeline +// ============================================================================ + +/// Type-erased factory for creating a forwarder task for one join input. +/// +/// The third argument is the concrete sender from the runtime's join queue. +#[cfg(feature = "alloc")] +type JoinInputFactory = Box< + dyn FnOnce( + Arc>, + usize, + <::JoinQueue as JoinQueue>::Sender, + ) -> BoxFuture<'static, ()> + + Send + + Sync, +>; + +/// Configures a multi-input join transform. +#[cfg(feature = "alloc")] +pub struct JoinBuilder { + inputs: Vec<(String, JoinInputFactory)>, + _phantom: PhantomData<(O, R)>, +} + +#[cfg(feature = "alloc")] +impl JoinBuilder +where + O: Send + Sync + Clone + Debug + 'static, + R: JoinFanInRuntime + 'static, +{ + pub(crate) fn new() -> Self { + Self { + inputs: Vec::new(), + _phantom: PhantomData, + } + } + + /// Add a typed input to the join. + pub fn input(mut self, key: impl crate::RecordKey) -> Self + where + I: Send + Sync + Clone + Debug + 'static, + { + let key_str = key.as_str().to_string(); + let key_for_factory = key_str.clone(); + + type Tx = + <::JoinQueue as JoinQueue>::Sender; + + let factory: JoinInputFactory = Box::new( + move |db: Arc>, index: usize, tx: Tx| { + Box::pin(async move { + let consumer = + crate::typed_api::Consumer::::new(db, key_for_factory.clone()); + let mut reader = match consumer.subscribe() { + Ok(r) => r, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!( + "🔄 Join input '{}' (index {}) subscription failed: {:?}", + key_for_factory, + index, + _e + ); + #[cfg(all(feature = "std", not(feature = "tracing")))] + eprintln!( + "AIMDB TRANSFORM ERROR: Join input '{}' (index {}) subscription failed: {:?}", + key_for_factory, index, _e + ); + return; + } + }; + + while let Ok(value) = reader.recv().await { + let trigger = JoinTrigger::Input { + index, + value: Box::new(value), + }; + if tx.send(trigger).await.is_err() { + break; + } + } + }) as BoxFuture<'static, ()> + }, + ); + + self.inputs.push((key_str, factory)); + self + } + + /// Set the join state and begin configuring the trigger handler. + pub fn with_state(self, initial: S) -> JoinStateBuilder { + JoinStateBuilder { + inputs: self.inputs, + initial_state: initial, + _phantom: PhantomData, + } + } +} + +/// Intermediate builder for setting the join trigger handler. +#[cfg(feature = "alloc")] +pub struct JoinStateBuilder { + inputs: Vec<(String, JoinInputFactory)>, + initial_state: S, + _phantom: PhantomData<(O, R)>, +} + +#[cfg(feature = "alloc")] +impl JoinStateBuilder +where + O: Send + Sync + Clone + Debug + 'static, + S: Send + Sync + 'static, + R: JoinFanInRuntime + 'static, +{ + /// Async handler called whenever any input produces a value. + pub fn on_trigger(self, handler: F) -> JoinPipeline + where + F: Fn(JoinTrigger, &mut S, &crate::Producer) -> Fut + Send + Sync + 'static, + Fut: core::future::Future + Send + 'static, + { + let inputs = self.inputs; + let initial = self.initial_state; + + let input_keys_for_descriptor: Vec = + inputs.iter().map(|(k, _)| k.clone()).collect(); + + JoinPipeline { + _input_keys: input_keys_for_descriptor.clone(), + spawn_factory: Box::new(move |_| TransformDescriptor { + input_keys: input_keys_for_descriptor, + spawn_fn: Box::new(move |producer, db, ctx| { + Box::pin(run_join_transform( + db, inputs, producer, initial, handler, ctx, + )) + }), + }), + } + } +} + +/// Completed multi-input join pipeline, ready to be stored in `TypedRecord`. +#[cfg(feature = "alloc")] +pub struct JoinPipeline { + pub(crate) _input_keys: Vec, + pub(crate) spawn_factory: Box TransformDescriptor + Send + Sync>, +} + +#[cfg(feature = "alloc")] +impl JoinPipeline +where + O: Send + Sync + Clone + Debug + 'static, + R: JoinFanInRuntime + 'static, +{ + pub(crate) fn into_descriptor(self) -> TransformDescriptor { + (self.spawn_factory)(()) + } +} + +// ============================================================================ +// Join Transform Task Runner +// ============================================================================ + +#[cfg(feature = "alloc")] +#[allow(unused_variables)] +async fn run_join_transform( + db: Arc>, + inputs: Vec<(String, JoinInputFactory)>, + producer: crate::Producer, + mut state: S, + handler: F, + runtime_ctx: Arc, +) where + O: Send + Sync + Clone + Debug + 'static, + S: Send + 'static, + R: JoinFanInRuntime + 'static, + F: Fn(JoinTrigger, &mut S, &crate::Producer) -> Fut + Send + Sync + 'static, + Fut: core::future::Future + Send + 'static, +{ + let output_key = producer.key().to_string(); + let input_keys: Vec = inputs.iter().map(|(k, _)| k.clone()).collect(); + + #[cfg(feature = "tracing")] + tracing::info!( + "🔄 Join transform started: {:?} → '{}'", + input_keys, + output_key + ); + + let runtime: &R = runtime_ctx + .downcast_ref::>() + .map(|arc| arc.as_ref()) + .or_else(|| runtime_ctx.downcast_ref::()) + .expect("Failed to extract runtime from context for join transform"); + + // Create the shared trigger queue via runtime trait + let queue = match runtime.create_join_queue::() { + Ok(q) => q, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!( + "🔄 Join transform '{}' FATAL: failed to create join queue", + output_key + ); + return; + } + }; + let (tx, mut rx) = queue.split(); + + // Spawn per-input forwarder tasks + for (index, (_key, factory)) in inputs.into_iter().enumerate() { + let sender = tx.clone(); + let db = db.clone(); + + let forwarder_future = factory(db, index, sender); + if let Err(_e) = runtime.spawn(forwarder_future) { + #[cfg(feature = "tracing")] + tracing::error!( + "🔄 Join transform '{}' FATAL: failed to spawn forwarder for input index {}", + output_key, + index + ); + return; + } + } + + // Drop our sender copy — when all forwarders exit the channel closes + drop(tx); + + #[cfg(feature = "tracing")] + tracing::debug!( + "✅ Join transform '{}' all forwarders spawned, entering event loop", + output_key + ); + + while let Ok(trigger) = rx.recv().await { + handler(trigger, &mut state, &producer).await; + } + + #[cfg(feature = "tracing")] + tracing::warn!( + "🔄 Join transform '{}' all inputs closed, task exiting", + output_key + ); +} diff --git a/aimdb-core/src/transform/mod.rs b/aimdb-core/src/transform/mod.rs new file mode 100644 index 00000000..9ed5c0e4 --- /dev/null +++ b/aimdb-core/src/transform/mod.rs @@ -0,0 +1,54 @@ +//! Reactive transform primitives for derived records. +//! +//! # Transform Archetypes +//! +//! - **Map** (1:1, stateless): Transform each input value to zero-or-one output value +//! - **Accumulate** (N:1, stateful): Aggregate a stream of values with persistent state +//! - **Join** (M×N:1, stateful, multi-input): Combine values from multiple input records +//! +//! All three are handled by a unified API surface: +//! - Single-input: `.transform()` with `TransformBuilder` +//! - Multi-input: `.transform_join()` with `JoinBuilder` + +use core::any::Any; +use core::fmt::Debug; + +extern crate alloc; +use alloc::{boxed::Box, string::String, sync::Arc, vec::Vec}; + +use crate::typed_record::BoxFuture; + +pub mod join; +pub mod single; + +// Public re-exports +pub use single::{StatefulTransformBuilder, TransformBuilder, TransformPipeline}; + +#[cfg(feature = "alloc")] +pub use join::{JoinBuilder, JoinPipeline, JoinStateBuilder, JoinTrigger}; + +// JoinTrigger is always available (no std dependency) +#[cfg(not(feature = "alloc"))] +pub use join::JoinTrigger; + +// ============================================================================ +// TransformDescriptor — stored per output record in TypedRecord +// ============================================================================ + +pub(crate) struct TransformDescriptor +where + T: Send + 'static + Debug + Clone, +{ + pub input_keys: Vec, + + #[allow(clippy::type_complexity)] + pub spawn_fn: Box< + dyn FnOnce( + crate::Producer, + Arc>, + Arc, + ) -> BoxFuture<'static, ()> + + Send + + Sync, + >, +} diff --git a/aimdb-core/src/transform/single.rs b/aimdb-core/src/transform/single.rs new file mode 100644 index 00000000..c5130274 --- /dev/null +++ b/aimdb-core/src/transform/single.rs @@ -0,0 +1,224 @@ +use core::fmt::Debug; +use core::marker::PhantomData; + +extern crate alloc; +use alloc::{ + boxed::Box, + string::{String, ToString}, + sync::Arc, + vec, +}; + +use crate::transform::TransformDescriptor; + +// ============================================================================ +// TransformBuilder → TransformPipeline +// ============================================================================ + +/// Configures a single-input transform pipeline. +/// +/// Created by `RecordRegistrar::transform_raw()`. Use `.map()` for stateless +/// transforms or `.with_state()` for stateful transforms. +pub struct TransformBuilder { + input_key: String, + _phantom: PhantomData<(I, O, R)>, +} + +impl TransformBuilder +where + I: Send + Sync + Clone + Debug + 'static, + O: Send + Sync + Clone + Debug + 'static, + R: aimdb_executor::Spawn + 'static, +{ + pub(crate) fn new(input_key: String) -> Self { + Self { + input_key, + _phantom: PhantomData, + } + } + + /// Stateless 1:1 map. Returning `None` skips output for this input value. + pub fn map(self, f: F) -> TransformPipeline + where + F: Fn(&I) -> Option + Send + Sync + 'static, + { + TransformPipeline { + input_key: self.input_key, + spawn_factory: Box::new(move |input_key| { + let transform_fn = move |val: &I, _state: &mut ()| f(val); + create_single_transform_descriptor::(input_key, (), transform_fn) + }), + _phantom_i: PhantomData, + } + } + + /// Begin configuring a stateful transform. `S` is the user-defined state type. + pub fn with_state( + self, + initial: S, + ) -> StatefulTransformBuilder { + StatefulTransformBuilder { + input_key: self.input_key, + initial_state: initial, + _phantom: PhantomData, + } + } +} + +/// Intermediate builder for stateful single-input transforms. +pub struct StatefulTransformBuilder { + input_key: String, + initial_state: S, + _phantom: PhantomData<(I, O, R)>, +} + +impl StatefulTransformBuilder +where + I: Send + Sync + Clone + Debug + 'static, + O: Send + Sync + Clone + Debug + 'static, + S: Send + Sync + 'static, + R: aimdb_executor::Spawn + 'static, +{ + /// Called for each input value. Receives mutable state, returns optional output. + pub fn on_value(self, f: F) -> TransformPipeline + where + F: Fn(&I, &mut S) -> Option + Send + Sync + 'static, + { + let initial = self.initial_state; + TransformPipeline { + input_key: self.input_key, + spawn_factory: Box::new(move |input_key| { + create_single_transform_descriptor::(input_key, initial, f) + }), + _phantom_i: PhantomData, + } + } +} + +/// Completed single-input transform pipeline, ready to be stored in `TypedRecord`. +pub struct TransformPipeline< + I, + O: Send + Sync + Clone + Debug + 'static, + R: aimdb_executor::Spawn + 'static, +> { + pub(crate) input_key: String, + pub(crate) spawn_factory: Box TransformDescriptor + Send + Sync>, + pub(crate) _phantom_i: PhantomData, +} + +impl TransformPipeline +where + I: Send + Sync + Clone + Debug + 'static, + O: Send + Sync + Clone + Debug + 'static, + R: aimdb_executor::Spawn + 'static, +{ + pub(crate) fn into_descriptor(self) -> TransformDescriptor { + (self.spawn_factory)(self.input_key) + } +} + +fn create_single_transform_descriptor( + input_key: String, + initial_state: S, + transform_fn: impl Fn(&I, &mut S) -> Option + Send + Sync + 'static, +) -> TransformDescriptor +where + I: Send + Sync + Clone + Debug + 'static, + O: Send + Sync + Clone + Debug + 'static, + S: Send + Sync + 'static, + R: aimdb_executor::Spawn + 'static, +{ + let input_key_clone = input_key.clone(); + let input_keys = vec![input_key]; + + TransformDescriptor { + input_keys, + spawn_fn: Box::new(move |producer, db, _ctx| { + Box::pin(run_single_transform::( + db, + input_key_clone, + producer, + initial_state, + transform_fn, + )) + }), + } +} + +// ============================================================================ +// Transform Task Runner +// ============================================================================ + +#[allow(unused_variables)] +pub(crate) async fn run_single_transform( + db: Arc>, + input_key: String, + producer: crate::Producer, + mut state: S, + transform_fn: impl Fn(&I, &mut S) -> Option + Send + Sync + 'static, +) where + I: Send + Sync + Clone + Debug + 'static, + O: Send + Sync + Clone + Debug + 'static, + S: Send + 'static, + R: aimdb_executor::Spawn + 'static, +{ + let output_key = producer.key().to_string(); + + #[cfg(feature = "tracing")] + tracing::info!("🔄 Transform started: '{}' → '{}'", input_key, output_key); + + let consumer = crate::typed_api::Consumer::::new(db, input_key.clone()); + let mut reader = match consumer.subscribe() { + Ok(r) => r, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!( + "🔄 Transform '{}' → '{}' FATAL: failed to subscribe to input: {:?}", + input_key, + output_key, + _e + ); + #[cfg(all(feature = "std", not(feature = "tracing")))] + eprintln!( + "AIMDB TRANSFORM ERROR: '{}' → '{}' failed to subscribe to input: {:?}", + input_key, output_key, _e + ); + return; + } + }; + + #[cfg(feature = "tracing")] + tracing::debug!( + "✅ Transform '{}' → '{}' subscribed, entering event loop", + input_key, + output_key + ); + + loop { + match reader.recv().await { + Ok(input_value) => { + if let Some(output_value) = transform_fn(&input_value, &mut state) { + let _ = producer.produce(output_value).await; + } + } + Err(crate::DbError::BufferLagged { .. }) => { + #[cfg(feature = "tracing")] + tracing::warn!( + "🔄 Transform '{}' → '{}' lagged behind, some values skipped", + input_key, + output_key + ); + continue; + } + Err(_) => { + #[cfg(feature = "tracing")] + tracing::warn!( + "🔄 Transform '{}' → '{}' input closed, task exiting", + input_key, + output_key + ); + break; + } + } + } +} diff --git a/aimdb-core/src/typed_api.rs b/aimdb-core/src/typed_api.rs index c70cf536..0def868d 100644 --- a/aimdb-core/src/typed_api.rs +++ b/aimdb-core/src/typed_api.rs @@ -455,16 +455,11 @@ where /// Register a multi-input join transform (low-level API). /// - /// **Note:** This is the foundational API. Most users should use the higher-level - /// `transform_join()` method provided by runtime adapter extension traits. - /// /// Panics if a `.source()` or another `.transform()` is already registered. - /// - /// # Arguments - /// * `build_fn` - Closure that configures the join via `JoinBuilder` - #[cfg(feature = "std")] + #[cfg(feature = "alloc")] pub fn transform_join_raw(&'a mut self, build_fn: F) -> &'a mut Self where + R: aimdb_executor::JoinFanInRuntime, F: FnOnce(crate::transform::JoinBuilder) -> crate::transform::JoinPipeline, { let builder = crate::transform::JoinBuilder::::new(); @@ -474,6 +469,20 @@ where self } + /// Multi-input reactive transform (join). + /// + /// Derives this record from multiple input records. Available on any runtime + /// that implements `JoinFanInRuntime`. Panics if a `.source()` or another + /// `.transform()` is already registered. + #[cfg(feature = "alloc")] + pub fn transform_join(&'a mut self, build_fn: F) -> &'a mut Self + where + R: aimdb_executor::JoinFanInRuntime, + F: FnOnce(crate::transform::JoinBuilder) -> crate::transform::JoinPipeline, + { + self.transform_join_raw(build_fn) + } + /// Adds a connector link for external system integration (DEPRECATED) /// /// **Deprecated**: Use `.link_to()` for outbound connectors or `.link_from()` for inbound. diff --git a/aimdb-embassy-adapter/src/join_fanin.rs b/aimdb-embassy-adapter/src/join_fanin.rs new file mode 100644 index 00000000..322e7593 --- /dev/null +++ b/aimdb-embassy-adapter/src/join_fanin.rs @@ -0,0 +1,92 @@ +use aimdb_executor::{ExecutorResult, JoinFanInRuntime, JoinQueue, JoinReceiver, JoinSender}; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::channel::Channel; + +use crate::runtime::EmbassyAdapter; + +/// Internal queue capacity for Embassy join fan-in. +/// +/// Uses a compile-time constant because `embassy_sync::channel::Channel` requires +/// `N` as a const generic. Cannot be overridden per-call — this is the fixed +/// default for all Embassy join queues. +const CAPACITY: usize = 8; + +type EmbassyChan = Channel; + +// ============================================================================ +// EmbassyJoinQueue +// ============================================================================ + +pub struct EmbassyJoinQueue { + channel: &'static EmbassyChan, +} + +/// Sender half — just a `&'static Channel` reference, cheap to clone. +pub struct EmbassyJoinSender { + channel: &'static EmbassyChan, +} + +/// Receiver half — also a `&'static Channel` reference. +pub struct EmbassyJoinReceiver { + channel: &'static EmbassyChan, +} + +impl Clone for EmbassyJoinSender { + fn clone(&self) -> Self { + Self { + channel: self.channel, + } + } +} + +impl JoinQueue for EmbassyJoinQueue { + type Sender = EmbassyJoinSender; + type Receiver = EmbassyJoinReceiver; + + fn split(self) -> (Self::Sender, Self::Receiver) { + ( + EmbassyJoinSender { + channel: self.channel, + }, + EmbassyJoinReceiver { + channel: self.channel, + }, + ) + } +} + +impl JoinSender for EmbassyJoinSender { + async fn send(&self, item: T) -> ExecutorResult<()> { + // Blocks when full (bounded backpressure). Embassy channels do not close, + // so this never returns Err in normal operation. + self.channel.send(item).await; + Ok(()) + } +} + +impl JoinReceiver for EmbassyJoinReceiver { + async fn recv(&mut self) -> ExecutorResult { + // Embassy channels do not close — this blocks until a message arrives. + // On embedded targets the join loop runs for the device lifetime. + Ok(self.channel.receive().await) + } +} + +// ============================================================================ +// JoinFanInRuntime for EmbassyAdapter +// ============================================================================ + +#[cfg(all(feature = "embassy-runtime", feature = "alloc"))] +impl JoinFanInRuntime for EmbassyAdapter { + type JoinQueue = EmbassyJoinQueue; + + fn create_join_queue(&self) -> ExecutorResult> { + extern crate alloc; + // Leak the channel to obtain a 'static reference. + // Called once per join transform at database startup — the leak is intentional + // and matches the DB lifetime on embedded targets. + let channel: &'static EmbassyChan = + alloc::boxed::Box::leak(alloc::boxed::Box::new(Channel::new())); + Ok(EmbassyJoinQueue { channel }) + } +} diff --git a/aimdb-embassy-adapter/src/lib.rs b/aimdb-embassy-adapter/src/lib.rs index d0ce37c2..db28f016 100644 --- a/aimdb-embassy-adapter/src/lib.rs +++ b/aimdb-embassy-adapter/src/lib.rs @@ -84,6 +84,9 @@ extern crate alloc; #[cfg(all(not(feature = "std"), feature = "embassy-sync"))] pub mod buffer; +#[cfg(all(not(feature = "std"), feature = "embassy-runtime"))] +pub mod join_fanin; + #[cfg(not(feature = "std"))] mod error; diff --git a/aimdb-executor/src/join.rs b/aimdb-executor/src/join.rs new file mode 100644 index 00000000..a9cc2bbc --- /dev/null +++ b/aimdb-executor/src/join.rs @@ -0,0 +1,36 @@ +use core::future::Future; + +use crate::{ExecutorResult, Spawn}; + +/// Runtime capability for creating join fan-in queues. +/// +/// Implemented by each runtime adapter. Queue capacity is an internal +/// constant chosen per adapter (Tokio: 64, Embassy: 8, WASM: 64). +pub trait JoinFanInRuntime: Spawn { + type JoinQueue: JoinQueue; + + fn create_join_queue(&self) -> ExecutorResult>; +} + +/// A bounded fan-in queue that can be split into a sender/receiver pair. +pub trait JoinQueue { + type Sender: JoinSender + Clone + Send + 'static; + type Receiver: JoinReceiver + Send + 'static; + + fn split(self) -> (Self::Sender, Self::Receiver); +} + +/// The sending half of a join fan-in queue. +/// +/// `send` may await when the queue is full (bounded backpressure). +/// Returns `Err(QueueClosed)` if the receiver has been dropped. +pub trait JoinSender { + fn send(&self, item: T) -> impl Future> + Send + '_; +} + +/// The receiving half of a join fan-in queue. +/// +/// Returns `Err(QueueClosed)` when all senders have been dropped. +pub trait JoinReceiver { + fn recv(&mut self) -> impl Future> + Send + '_; +} diff --git a/aimdb-executor/src/lib.rs b/aimdb-executor/src/lib.rs index 634c7f2e..ba888385 100644 --- a/aimdb-executor/src/lib.rs +++ b/aimdb-executor/src/lib.rs @@ -22,6 +22,9 @@ use core::future::Future; +pub mod join; +pub use join::{JoinFanInRuntime, JoinQueue, JoinReceiver, JoinSender}; + // ============================================================================ // Error Types // ============================================================================ @@ -54,6 +57,9 @@ pub enum ExecutorError { #[cfg(not(feature = "std"))] message: &'static str, }, + + #[cfg_attr(feature = "std", error("Join queue closed"))] + QueueClosed, } // ============================================================================ diff --git a/aimdb-tokio-adapter/src/join_fanin.rs b/aimdb-tokio-adapter/src/join_fanin.rs new file mode 100644 index 00000000..764607ad --- /dev/null +++ b/aimdb-tokio-adapter/src/join_fanin.rs @@ -0,0 +1,73 @@ +use aimdb_executor::{ + ExecutorError, ExecutorResult, JoinFanInRuntime, JoinQueue, JoinReceiver, JoinSender, +}; + +use crate::runtime::TokioAdapter; + +const CAPACITY: usize = 64; + +// ============================================================================ +// TokioJoinQueue +// ============================================================================ + +pub struct TokioJoinQueue { + tx: tokio::sync::mpsc::Sender, + rx: tokio::sync::mpsc::Receiver, +} + +pub struct TokioJoinSender { + tx: tokio::sync::mpsc::Sender, +} + +pub struct TokioJoinReceiver { + rx: tokio::sync::mpsc::Receiver, +} + +// Clone is required by JoinQueue::Sender bound +impl Clone for TokioJoinSender { + fn clone(&self) -> Self { + Self { + tx: self.tx.clone(), + } + } +} + +impl JoinQueue for TokioJoinQueue { + type Sender = TokioJoinSender; + type Receiver = TokioJoinReceiver; + + fn split(self) -> (Self::Sender, Self::Receiver) { + ( + TokioJoinSender { tx: self.tx }, + TokioJoinReceiver { rx: self.rx }, + ) + } +} + +impl JoinSender for TokioJoinSender { + async fn send(&self, item: T) -> ExecutorResult<()> { + self.tx + .send(item) + .await + .map_err(|_| ExecutorError::QueueClosed) + } +} + +impl JoinReceiver for TokioJoinReceiver { + async fn recv(&mut self) -> ExecutorResult { + self.rx.recv().await.ok_or(ExecutorError::QueueClosed) + } +} + +// ============================================================================ +// JoinFanInRuntime for TokioAdapter +// ============================================================================ + +impl JoinFanInRuntime for TokioAdapter { + type JoinQueue = TokioJoinQueue; + + fn create_join_queue(&self) -> ExecutorResult> { + let (tx, rx) = tokio::sync::mpsc::channel(CAPACITY); + Ok(TokioJoinQueue { tx, rx }) + } +} diff --git a/aimdb-tokio-adapter/src/lib.rs b/aimdb-tokio-adapter/src/lib.rs index 75d13ceb..6802a49b 100644 --- a/aimdb-tokio-adapter/src/lib.rs +++ b/aimdb-tokio-adapter/src/lib.rs @@ -33,6 +33,8 @@ compile_error!("tokio-adapter requires the std feature"); pub mod buffer; pub mod connector; pub mod error; +#[cfg(feature = "tokio-runtime")] +pub mod join_fanin; pub mod runtime; pub mod time; diff --git a/aimdb-wasm-adapter/Cargo.toml b/aimdb-wasm-adapter/Cargo.toml index 6825955b..f1fc9f9d 100644 --- a/aimdb-wasm-adapter/Cargo.toml +++ b/aimdb-wasm-adapter/Cargo.toml @@ -65,8 +65,12 @@ serde-wasm-bindgen = { version = "0.6", optional = true } # Async utilities (minimal, no_std compatible) futures-util = { version = "0.3", default-features = false, features = [ "alloc", + "sink", ] } +# Bounded MPSC channel for join fan-in (no_std + alloc compatible) +futures-channel = { version = "0.3", default-features = false, features = ["std", "sink"] } + [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = [ 'cfg(feature, values("std"))', diff --git a/aimdb-wasm-adapter/src/join_fanin.rs b/aimdb-wasm-adapter/src/join_fanin.rs new file mode 100644 index 00000000..207a7227 --- /dev/null +++ b/aimdb-wasm-adapter/src/join_fanin.rs @@ -0,0 +1,74 @@ +use aimdb_executor::{ + ExecutorError, ExecutorResult, JoinFanInRuntime, JoinQueue, JoinReceiver, JoinSender, +}; +use futures_channel::mpsc; +use futures_util::sink::SinkExt as _; +use futures_util::stream::StreamExt as _; + +use crate::runtime::WasmAdapter; + +const CAPACITY: usize = 64; + +// ============================================================================ +// WasmJoinQueue +// ============================================================================ + +pub struct WasmJoinQueue { + tx: mpsc::Sender, + rx: mpsc::Receiver, +} + +pub struct WasmJoinSender { + tx: mpsc::Sender, +} + +pub struct WasmJoinReceiver { + rx: mpsc::Receiver, +} + +impl Clone for WasmJoinSender { + fn clone(&self) -> Self { + Self { + tx: self.tx.clone(), + } + } +} + +impl JoinQueue for WasmJoinQueue { + type Sender = WasmJoinSender; + type Receiver = WasmJoinReceiver; + + fn split(self) -> (Self::Sender, Self::Receiver) { + ( + WasmJoinSender { tx: self.tx }, + WasmJoinReceiver { rx: self.rx }, + ) + } +} + +impl JoinSender for WasmJoinSender { + async fn send(&self, item: T) -> ExecutorResult<()> { + // Clone sender to get a mutable handle (futures-channel requires mut for Sink). + let mut tx = self.tx.clone(); + tx.send(item).await.map_err(|_| ExecutorError::QueueClosed) + } +} + +impl JoinReceiver for WasmJoinReceiver { + async fn recv(&mut self) -> ExecutorResult { + self.rx.next().await.ok_or(ExecutorError::QueueClosed) + } +} + +// ============================================================================ +// JoinFanInRuntime for WasmAdapter +// ============================================================================ + +impl JoinFanInRuntime for WasmAdapter { + type JoinQueue = WasmJoinQueue; + + fn create_join_queue(&self) -> ExecutorResult> { + let (tx, rx) = mpsc::channel(CAPACITY); + Ok(WasmJoinQueue { tx, rx }) + } +} diff --git a/aimdb-wasm-adapter/src/lib.rs b/aimdb-wasm-adapter/src/lib.rs index cf68a995..1373ff21 100644 --- a/aimdb-wasm-adapter/src/lib.rs +++ b/aimdb-wasm-adapter/src/lib.rs @@ -35,6 +35,7 @@ extern crate alloc; pub mod buffer; +pub mod join_fanin; pub mod logger; pub mod runtime; pub mod time; diff --git a/docs/design/027-no-std-transform-api.md b/docs/design/027-no-std-transform-api.md new file mode 100644 index 00000000..fb4e7df9 --- /dev/null +++ b/docs/design/027-no-std-transform-api.md @@ -0,0 +1,462 @@ +# `no_std` Support for the Transform API + +**Version:** 1.1 +**Status:** Draft +**Last Updated:** 2026-04-26 +**Issue:** [#73 — `no_std` Support for Transform API](https://github.com/schnorr-lab/aimdb/issues/73) +**Milestone:** M10 / M11 — Embedded First-Class Support +**Depends On:** [020-M9-transform-api](020-M9-transform-api.md) + +--- + +## Table of Contents + +- [Summary](#summary) +- [Current State](#current-state) +- [Goals and Non-Goals](#goals-and-non-goals) +- [Design](#design) + - [Step 1 — Unblock single-input transform on `no_std + alloc`](#step-1--unblock-single-input-transform-on-no_std--alloc) + - [Step 2 — Move join fan-in into `aimdb-executor`](#step-2--move-join-fan-in-into-aimdb-executor) + - [Step 3 — Refactor `aimdb-core` join pipeline to runtime traits](#step-3--refactor-aimdb-core-join-pipeline-to-runtime-traits) + - [Step 4 — Implement fan-in in Tokio, Embassy, and WASM adapters](#step-4--implement-fan-in-in-tokio-embassy-and-wasm-adapters) +- [File Structure After Refactor](#file-structure-after-refactor) +- [API Surface](#api-surface) +- [Implementation Checklist](#implementation-checklist) +- [Alternatives Considered](#alternatives-considered) +- [Risk & Constraints](#risk--constraints) +- [Open Questions & Required Clarifications](#open-questions--required-clarifications) +- [Acceptance Criteria](#acceptance-criteria) + +--- + +## Summary + +The transform API (`TransformBuilder`, `StatefulTransformBuilder`, `TransformPipeline`) is +partially usable in `no_std` environments today, but the multi-input join path +(`JoinBuilder`, `JoinStateBuilder`, `JoinPipeline`, `run_join_transform`) is currently +`std`-only because join fan-in is hardcoded to `tokio::sync::mpsc`. + +This revision adopts a clean end-state architecture: join fan-in is defined in +`aimdb-executor` as runtime capabilities, and implemented by each runtime adapter +(Tokio, Embassy, WASM, future runtimes). `aimdb-core` no longer imports Tokio- or +Embassy-specific queue types for join execution. + +This intentionally allows API changes across crates to achieve a single, runtime-agnostic +join implementation. + +--- + +## Current State + +### Symbols that are `std`-only and why + +| Symbol | Location | Root dependency | +|---|---|---| +| `JoinBuilder` | `transform.rs` | `tokio::sync::mpsc::UnboundedSender` in `JoinInputFactory` | +| `JoinInputFactory` | `transform.rs` | `tokio::sync::mpsc::UnboundedSender` | +| `JoinStateBuilder` | `transform.rs` | Propagated from `JoinBuilder` | +| `JoinPipeline` | `transform.rs` | Propagated from `JoinBuilder` | +| `run_join_transform(...)` | `transform.rs` | `tokio::sync::mpsc::unbounded_channel()` | +| `transform_join_raw()` | `typed_api.rs` | `JoinBuilder` / `JoinPipeline` | +| `transform_join()` in `impl_record_registrar_ext!` | `ext_macros.rs` | Same | + +### What already works in `no_std` + +- `TransformDescriptor` is alloc-only. +- `TransformBuilder` / `StatefulTransformBuilder` / `TransformPipeline` have no std dependency. +- `run_single_transform` is async and runtime-agnostic. +- `TypedRecord::set_transform()` already works on `no_std + alloc`. + +### What is broken for `no_std` + +- Multi-input join is unavailable because fan-in is not abstracted by runtime traits. +- API exposure (`transform_join`) follows that same std-only wiring. + +--- + +## Goals and Non-Goals + +**Goals:** +- Make single-input `.transform()` available on `no_std + alloc`. +- Make multi-input `.transform_join()` available on `no_std + alloc` without embedding Embassy or Tokio types in `aimdb-core`. +- Define join fan-in once in `aimdb-executor`, implemented by runtime adapters. +- Use one join engine in `aimdb-core` for all runtimes. +- Standardize backpressure semantics for join fan-in (bounded queue). +- Add a WASM join fan-in implementation now (same core join engine, no core fork). + +**Non-goals:** +- Supporting `no_std` without `alloc`. +- Preserving source compatibility for current `JoinBuilder` internals. +- Adding runtime-specific APIs to user-facing transform signatures. + +--- + +## Design + +### Step 1 — Unblock single-input transform on `no_std + alloc` + +Make sure single-input transform path compiles independently from join internals. + +Concrete changes: + +1. Gate join-only public re-exports (`JoinBuilder`, `JoinPipeline`, `JoinTrigger`) behind + `#[cfg(feature = "alloc")]`; the `#[cfg(feature = "std")]` gate is removed in Step 3 (see Q2). +2. Verify `.transform()` compiles on embedded target before join refactor lands. + +--- + +### Step 2 — Move join fan-in into `aimdb-executor` + +Introduce runtime contracts for join fan-in queue creation and usage. + +Proposed trait shape (conceptual): + +```rust +pub trait JoinFanInRuntime: Spawn { + type JoinQueue: JoinQueue; + + fn create_join_queue(&self) -> DbResult>; +} + +pub trait JoinQueue { + type Sender: JoinSender + Clone + Send + 'static; + type Receiver: JoinReceiver + Send + 'static; + + fn split(self) -> (Self::Sender, Self::Receiver); +} + +pub trait JoinSender { + async fn send(&self, item: T) -> DbResult<()>; +} + +pub trait JoinReceiver { + async fn recv(&mut self) -> DbResult; +} +``` + +Key decisions: + +- Queue capacity is an internal runtime detail — not exposed in the trait or user-facing API. +- Each runtime adapter chooses a fixed, documented default (e.g., Tokio: 64, Embassy: 8). +- Backpressure is explicit and consistent: `send().await` may await when full. +- Runtime owns queue construction details (`tokio::mpsc`, `embassy_sync::Channel`, etc.). + +--- + +### Step 3 — Refactor `aimdb-core` join pipeline to runtime traits + +Rework join pipeline internals to depend only on executor traits. + +Key changes: + +1. `JoinBuilder` stores join input metadata only, not concrete sender types or capacity. +2. `run_join_transform` requests queue from runtime via `JoinFanInRuntime::create_join_queue`. +3. Forwarder tasks use generic `JoinSender`. +4. Trigger loop uses generic `JoinReceiver`. +5. Remove Tokio imports from join path in `aimdb-core`. + +This yields one join implementation shared by std and no_std runtimes. + +--- + +### Step 4 — Implement fan-in in Tokio, Embassy, and WASM adapters + +Runtime adapters provide concrete queue implementations. + +Tokio adapter: + +- Implement queue using bounded `tokio::sync::mpsc::channel` with a fixed internal default capacity. +- Map send/recv closure semantics into `DbResult`. + +Embassy adapter: + +- Implement queue using `embassy_sync::channel::Channel`. +- Allocate queue storage through adapter-owned resources for DB lifetime + (for example `Box::leak(Box::new(Channel::new()))` under alloc). +- Preserve no_std compatibility and avoid std imports. + +WASM adapter: + +- Implement queue using a WASM-safe async queue primitive (for example `futures::channel::mpsc`). +- Keep behavior consistent with the fan-in contract (bounded semantics, ordered delivery). +- Ensure it works on `wasm32-unknown-unknown` without introducing host-only dependencies. + +--- + +## File Structure After Refactor + +``` +aimdb-executor/src/ + join.rs # Join fan-in traits (runtime contract) + lib.rs # Re-export join traits + +aimdb-core/src/ + transform/ + mod.rs # shared transform API and exports + single.rs # single-input transform path + join.rs # runtime-agnostic join implementation + +aimdb-tokio-adapter/src/ + join_fanin.rs # tokio JoinFanInRuntime implementation + +aimdb-embassy-adapter/src/ + join_fanin.rs # embassy JoinFanInRuntime implementation + +aimdb-wasm-adapter/src/ + join_fanin.rs # wasm JoinFanInRuntime implementation +``` + +No split between `join_std.rs` and `join_nostd.rs` in core. + +--- + +## API Surface + +### Single-input + +No intentional user-facing changes. + +### Multi-input join + +User-facing API is unified across runtimes and does not require runtime queue types. + +```rust +registrar + .register::("sensor::HeatIndex", buffer_sized::<4, 2>(SpmcRing)) + .transform_join(|b| { + b.input::("sensor::Temperature") + .input::("sensor::Humidity") + .with_state(HeatIndexState::default()) + .on_trigger(|trigger, state, producer| async move { + match trigger.index() { + 0 => state.temperature = trigger.as_input::().copied(), + 1 => state.humidity = trigger.as_input::().copied(), + _ => {} + } + if let (Some(t), Some(h)) = (state.temperature, state.humidity) { + let _ = producer.produce(heat_index(t, h)).await; + } + }) + }); +``` + +Queue capacity is an internal runtime detail and is not part of the user-facing API. +Each runtime adapter documents its fixed default. + +--- + +## Implementation Checklist + +### Step 1 — Core unblocking +- [ ] Ensure `.transform()` compiles with embedded target (`thumbv7em-none-eabihf`) under `alloc`. +- [ ] Gate join-only exports/features from single-input path where needed. + +### Step 2 — Executor contract +- [ ] Add `join` module and fan-in traits to `aimdb-executor`. +- [ ] Add tests for trait behavior contract (bounded semantics, send/recv errors). +- [ ] Re-export new traits from `aimdb-executor` root. + +### Step 3 — Core refactor +- [ ] Remove direct `tokio::mpsc` usage from `aimdb-core` join pipeline. +- [ ] Refactor `JoinBuilder`/`JoinPipeline` to depend on executor join traits. +- [ ] Update `typed_api.rs` and extension macros to call unified join API. + +### Step 4 — Runtime adapter implementations +- [ ] Implement join fan-in traits in `aimdb-tokio-adapter`. +- [ ] Implement join fan-in traits in `aimdb-embassy-adapter`. +- [ ] Implement join fan-in traits in `aimdb-wasm-adapter`. +- [ ] Enable any required optional dependencies and feature wiring. + +### Step 5 — Validation +- [ ] `cargo check --target thumbv7em-none-eabihf --features embassy-runtime` passes for `.transform()`. +- [ ] `cargo check --target thumbv7em-none-eabihf --features embassy-runtime` passes for `.transform_join()`. +- [ ] `cargo check --target wasm32-unknown-unknown -p aimdb-wasm-adapter` passes for join fan-in implementation. +- [ ] Workspace tests pass on std path. +- [ ] Add integration tests showing identical join behavior on Tokio, Embassy, and WASM adapters. + +### Docs +- [ ] Update transform docs to describe runtime-owned fan-in model. +- [ ] Document each adapter's fixed queue capacity and backpressure behaviour. +- [ ] Update `020-M9-transform-api.md` with new join API notes. + +--- + +## Alternatives Considered + +### Alternative A — Keep split `join_std.rs` / `join_nostd.rs` + +Simple to implement, but duplicates join logic and keeps runtime details inside core. + +**Decision:** Rejected. + +### Alternative B — Keep Tokio + Embassy only for now + +Defers browser/WASM parity and creates another follow-up migration. + +**Decision:** Rejected. + +### Alternative C — Cross-runtime queue abstraction in `aimdb-core` only + +Avoids touching executor crate but still couples core to runtime concerns and grows +internal complexity. + +**Decision:** Rejected in favor of executor-owned contract. + +--- + +## Risk & Constraints + +| Risk | Mitigation | +|---|---| +| Cross-crate refactor complexity | Land in staged PRs: executor traits, core refactor, adapters, then API/docs | +| Semantic shift from unbounded std queue to bounded queue | Explicitly document behavior change; each adapter publishes its fixed default capacity | +| Embassy queue lifetime/storage management | Keep storage ownership in adapter and test long-running workload behavior | +| WASM single-threaded execution model differences | Keep fan-in contract executor-agnostic and test queue behavior under wasm target | +| Trait async ergonomics across no_std/std | Use existing async trait patterns already accepted in executor crate | +| Breakage in extension macros and typed API wiring | Add compile-only tests for macro expansions on both runtime feature sets | + +--- + +## Open Questions & Required Clarifications + +The following gaps were identified during codebase review. Each needs an explicit decision +before the corresponding implementation step begins. + +--- + +### Q1 — Queue capacity: remove from trait and user API entirely + +**Problem** + +`embassy_sync::channel::Channel` requires `N` as a compile-time const generic. +A `create_join_queue(capacity: usize)` signature implies runtime-selected capacity, which +Embassy structurally cannot honour. The same constraint is already documented in the Embassy +buffer implementation (`buffer.rs` panics if a runtime capacity doesn't match `CAP`). + +A user-facing `.capacity()` override on `JoinBuilder` has the same issue on Embassy, and adds +API surface with limited practical value on any runtime — the join queue is an internal buffer +between forwarder tasks and the trigger loop, not something most users need to tune. + +**Affected steps**: Step 2 (trait shape) and Step 3 (core refactor) + +**Resolution (already applied to this document)** + +`create_join_queue` takes no capacity argument. Queue capacity is an internal constant chosen +by each adapter and documented in its crate: + +- Tokio adapter: `64` (bounded `tokio::sync::mpsc::channel`) +- Embassy adapter: `8` (compile-time const, `embassy_sync::channel::Channel`) +- WASM adapter: `64` (bounded `futures_channel::mpsc::channel`) + +The `.capacity()` method is removed from `JoinBuilder`. No user-facing knob exists on any +runtime. If a specific workload needs a different size, the adapter constant can be raised in +a future release. + +**Decision needed**: Confirm the proposed per-adapter defaults (64 / 8 / 64), or nominate +different values. + +--- + +### Q2 — `#[cfg(feature = "std")]` gate on join types is no longer correct after the refactor + +**Problem** + +Every `#[cfg(feature = "std")]` on a join symbol (`JoinBuilder`, `JoinStateBuilder`, +`JoinPipeline`, `run_join_transform`, `transform_join_raw`, `transform_join` in the macro) +exists for one reason: `tokio::sync::mpsc::UnboundedSender` in `JoinInputFactory`. After +Step 3, that type is gone. + +The join types then only need: + +1. `alloc` — for `Box`, `Vec`, `String` (already required by the rest of `aimdb-core` on + embedded targets). +2. `R: JoinFanInRuntime` — a trait bound, enforced by the type system, not a cfg flag. + +There is no `std` dependency remaining. Keeping the `std` gate would mean Embassy and WASM +builds can never use `transform_join`, which is the opposite of the goal. + +The secondary issue from the previous version of this question — the multi-feature macro arm +(Embassy) has no `transform_join` declaration at all — is a symptom of the same root cause: +the method was only ever added under `#[cfg(feature = "std")]`. + +**Affected steps**: Step 3 (core refactor — all gates change) and Step 4 (macro extension) + +**Resolution** + +Replace `#[cfg(feature = "std")]` with `#[cfg(feature = "alloc")]` on all join types and +functions in `transform.rs` and `typed_api.rs`. Remove the `std` gate from `transform_join` +in the macro entirely — the `R: JoinFanInRuntime` where clause is the correct compile-time +gate: + +```rust +// ext_macros.rs — both single-feature and multi-feature arms +fn transform_join( + &'a mut self, + build_fn: F, +) -> &'a mut $crate::RecordRegistrar<'a, T, $runtime> +where + $runtime: aimdb_executor::JoinFanInRuntime, + F: FnOnce( + $crate::transform::JoinBuilder, + ) -> $crate::transform::JoinPipeline; +``` + +If a runtime does not implement `JoinFanInRuntime`, the compiler will reject `.transform_join()` +calls with a clear "trait not satisfied" error — no cfg flag needed. + +The `lib.rs` re-exports follow the same change: + +```rust +// Before (wrong after refactor) +#[cfg(feature = "std")] +pub use transform::{JoinBuilder, JoinPipeline, JoinTrigger}; + +// After +#[cfg(feature = "alloc")] +pub use transform::{JoinBuilder, JoinPipeline, JoinTrigger}; +``` + +No new `join-runtime` feature flag is needed. + +**Staging note**: the macro extension (adding `transform_join` to the multi-feature arm) and +the Embassy `JoinFanInRuntime` impl must land in the same PR. If the method is added to the +trait without the adapter impl, the `impl EmbassyRecordRegistrarExt for RecordRegistrar<..., +EmbassyAdapter>` block will fail to satisfy the `JoinFanInRuntime` bound. + +--- + +### Q3 — `aimdb-wasm-adapter` is missing `futures-channel` for the WASM fan-in + +**Problem** + +The design proposes `futures::channel::mpsc` for the WASM fan-in queue. The current +`aimdb-wasm-adapter/Cargo.toml` only lists `futures-util` (alloc-only) — `futures-channel` +is a separate sub-crate and is absent. + +**Affected step**: Step 4 (WASM fan-in implementation) + +**Proposed resolution** + +Add to `aimdb-wasm-adapter/Cargo.toml`: + +```toml +futures-channel = { version = "0.3", default-features = false, features = ["alloc"] } +``` + +This is a one-line Cargo.toml change. No design decision needed; listing it here so it is not +forgotten when the WASM fan-in PR is opened. + +**Decision needed**: None. Confirm `futures::channel::mpsc` is the preferred queue primitive +for WASM, or nominate an alternative (e.g., `async-channel` which is `no_std + alloc` friendly). + +--- + +## Acceptance Criteria + +1. `aimdb-core` join code contains no Tokio- or Embassy-specific imports. +2. `aimdb-executor` exposes join fan-in runtime traits used by `aimdb-core`. +3. Tokio, Embassy, and WASM adapters implement join fan-in traits. +4. Embedded target build passes with both single-input and multi-input transform usage. +5. Runtime-specific queue types are not exposed in public transform API. +6. WASM target build (`wasm32-unknown-unknown`) passes with join fan-in enabled. +7. Join behavior is bounded and documented consistently across runtimes. From 228fb06a1c2fc939b8bf87f896b5ded8a90b27fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 27 Apr 2026 19:58:15 +0000 Subject: [PATCH 02/17] chore: update subproject commit for embassy dependency --- _external/embassy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_external/embassy b/_external/embassy index a1b22839..6bc32046 160000 --- a/_external/embassy +++ b/_external/embassy @@ -1 +1 @@ -Subproject commit a1b22839eb832befb20db9d0b8f41becb91541a8 +Subproject commit 6bc320467800c7fd390a9344aae0e7beb7d78d97 From 6ccd015c76935298079f58d2c19e2f430bcf7866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 27 Apr 2026 20:12:05 +0000 Subject: [PATCH 03/17] fix: update dependencies in Cargo.lock and reorder imports in join_fanin.rs Co-authored-by: Copilot --- Cargo.lock | 7 +++---- aimdb-embassy-adapter/src/join_fanin.rs | 5 ++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index def6299b..609304d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1050,7 +1050,7 @@ dependencies = [ [[package]] name = "embassy-net" -version = "0.9.0" +version = "0.9.1" dependencies = [ "defmt 1.0.1", "document-features", @@ -1160,7 +1160,7 @@ dependencies = [ [[package]] name = "embassy-time-queue-utils" -version = "0.3.0" +version = "0.3.2" dependencies = [ "embassy-executor-timer-queue", "heapless 0.9.1", @@ -1170,7 +1170,6 @@ dependencies = [ name = "embassy-usb-driver" version = "0.2.0" dependencies = [ - "bitflags 2.11.0", "defmt 1.0.1", "embedded-io-async 0.7.0", ] @@ -3013,7 +3012,7 @@ dependencies = [ [[package]] name = "stm32-metapac" version = "21.0.0" -source = "git+https://github.com/embassy-rs/stm32-data-generated?tag=stm32-data-5f15bbfaf37bd6a6330fec66a3a39de0042d64ac#8da545d80e11594b00efdcc6fb94c70054bee09a" +source = "git+https://github.com/embassy-rs/stm32-data-generated?tag=stm32-data-10af491da4af2cf36f885e8a244139ddb97296c4#f4a6d7f10ba1e2681ab8d004138c8d836a264d92" dependencies = [ "cortex-m", "cortex-m-rt", diff --git a/aimdb-embassy-adapter/src/join_fanin.rs b/aimdb-embassy-adapter/src/join_fanin.rs index 322e7593..f0e3e0c9 100644 --- a/aimdb-embassy-adapter/src/join_fanin.rs +++ b/aimdb-embassy-adapter/src/join_fanin.rs @@ -1,8 +1,11 @@ -use aimdb_executor::{ExecutorResult, JoinFanInRuntime, JoinQueue, JoinReceiver, JoinSender}; +use aimdb_executor::{ExecutorResult, JoinQueue, JoinReceiver, JoinSender}; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::channel::Channel; +#[cfg(all(feature = "embassy-runtime", feature = "alloc"))] use crate::runtime::EmbassyAdapter; +#[cfg(all(feature = "embassy-runtime", feature = "alloc"))] +use aimdb_executor::JoinFanInRuntime; /// Internal queue capacity for Embassy join fan-in. /// From faaa9e53326c8efeacfb4ccf2522d3de4c8b7308 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 27 Apr 2026 20:22:53 +0000 Subject: [PATCH 04/17] refactor: rename join_fanin module to join_queue across all adapters for consistency --- aimdb-embassy-adapter/src/{join_fanin.rs => join_queue.rs} | 0 aimdb-embassy-adapter/src/lib.rs | 2 +- aimdb-tokio-adapter/src/{join_fanin.rs => join_queue.rs} | 0 aimdb-tokio-adapter/src/lib.rs | 2 +- aimdb-wasm-adapter/src/{join_fanin.rs => join_queue.rs} | 0 aimdb-wasm-adapter/src/lib.rs | 2 +- docs/design/027-no-std-transform-api.md | 6 +++--- 7 files changed, 6 insertions(+), 6 deletions(-) rename aimdb-embassy-adapter/src/{join_fanin.rs => join_queue.rs} (100%) rename aimdb-tokio-adapter/src/{join_fanin.rs => join_queue.rs} (100%) rename aimdb-wasm-adapter/src/{join_fanin.rs => join_queue.rs} (100%) diff --git a/aimdb-embassy-adapter/src/join_fanin.rs b/aimdb-embassy-adapter/src/join_queue.rs similarity index 100% rename from aimdb-embassy-adapter/src/join_fanin.rs rename to aimdb-embassy-adapter/src/join_queue.rs diff --git a/aimdb-embassy-adapter/src/lib.rs b/aimdb-embassy-adapter/src/lib.rs index db28f016..bf6019ac 100644 --- a/aimdb-embassy-adapter/src/lib.rs +++ b/aimdb-embassy-adapter/src/lib.rs @@ -85,7 +85,7 @@ extern crate alloc; pub mod buffer; #[cfg(all(not(feature = "std"), feature = "embassy-runtime"))] -pub mod join_fanin; +pub mod join_queue; #[cfg(not(feature = "std"))] mod error; diff --git a/aimdb-tokio-adapter/src/join_fanin.rs b/aimdb-tokio-adapter/src/join_queue.rs similarity index 100% rename from aimdb-tokio-adapter/src/join_fanin.rs rename to aimdb-tokio-adapter/src/join_queue.rs diff --git a/aimdb-tokio-adapter/src/lib.rs b/aimdb-tokio-adapter/src/lib.rs index 6802a49b..bcdb6512 100644 --- a/aimdb-tokio-adapter/src/lib.rs +++ b/aimdb-tokio-adapter/src/lib.rs @@ -34,7 +34,7 @@ pub mod buffer; pub mod connector; pub mod error; #[cfg(feature = "tokio-runtime")] -pub mod join_fanin; +pub mod join_queue; pub mod runtime; pub mod time; diff --git a/aimdb-wasm-adapter/src/join_fanin.rs b/aimdb-wasm-adapter/src/join_queue.rs similarity index 100% rename from aimdb-wasm-adapter/src/join_fanin.rs rename to aimdb-wasm-adapter/src/join_queue.rs diff --git a/aimdb-wasm-adapter/src/lib.rs b/aimdb-wasm-adapter/src/lib.rs index 1373ff21..870dc7c3 100644 --- a/aimdb-wasm-adapter/src/lib.rs +++ b/aimdb-wasm-adapter/src/lib.rs @@ -35,7 +35,7 @@ extern crate alloc; pub mod buffer; -pub mod join_fanin; +pub mod join_queue; pub mod logger; pub mod runtime; pub mod time; diff --git a/docs/design/027-no-std-transform-api.md b/docs/design/027-no-std-transform-api.md index fb4e7df9..1b46383c 100644 --- a/docs/design/027-no-std-transform-api.md +++ b/docs/design/027-no-std-transform-api.md @@ -197,13 +197,13 @@ aimdb-core/src/ join.rs # runtime-agnostic join implementation aimdb-tokio-adapter/src/ - join_fanin.rs # tokio JoinFanInRuntime implementation + join_queue.rs # tokio JoinFanInRuntime implementation aimdb-embassy-adapter/src/ - join_fanin.rs # embassy JoinFanInRuntime implementation + join_queue.rs # embassy JoinFanInRuntime implementation aimdb-wasm-adapter/src/ - join_fanin.rs # wasm JoinFanInRuntime implementation + join_queue.rs # wasm JoinFanInRuntime implementation ``` No split between `join_std.rs` and `join_nostd.rs` in core. From b0b737a11fdf095cb672b9f7e0f32a0ddd188b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sat, 2 May 2026 18:37:24 +0000 Subject: [PATCH 05/17] fix: update stm32-metapac source URL and embassy subproject commit --- Cargo.lock | 2 +- _external/embassy | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 609304d2..5d861b7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3012,7 +3012,7 @@ dependencies = [ [[package]] name = "stm32-metapac" version = "21.0.0" -source = "git+https://github.com/embassy-rs/stm32-data-generated?tag=stm32-data-10af491da4af2cf36f885e8a244139ddb97296c4#f4a6d7f10ba1e2681ab8d004138c8d836a264d92" +source = "git+https://github.com/embassy-rs/stm32-data-generated?tag=stm32-data-5261d86838979da64855b1860a6f3ea8ef90746f#9fc43c9d87d9a0cde91089bd7532fed520873482" dependencies = [ "cortex-m", "cortex-m-rt", diff --git a/_external/embassy b/_external/embassy index 6bc32046..9b080fc7 160000 --- a/_external/embassy +++ b/_external/embassy @@ -1 +1 @@ -Subproject commit 6bc320467800c7fd390a9344aae0e7beb7d78d97 +Subproject commit 9b080fc7c9aafe75c781428b320aa1d38a2ba85f From ad62331ddbb0f9f8dc5f3351f09339fb8f747fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sat, 2 May 2026 20:08:49 +0000 Subject: [PATCH 06/17] feat: implement multi-input join transforms with runtime-owned fan-in queues --- Cargo.lock | 1 + aimdb-core/src/ext_macros.rs | 3 + aimdb-core/src/transform/join.rs | 43 ++++++- aimdb-embassy-adapter/Cargo.toml | 9 +- aimdb-embassy-adapter/src/join_queue.rs | 80 +++++++++++++ aimdb-tokio-adapter/src/join_queue.rs | 71 +++++++++++ .../tests/transform_join_integration_tests.rs | 113 ++++++++++++++++++ aimdb-wasm-adapter/Cargo.toml | 4 +- aimdb-wasm-adapter/src/join_queue.rs | 76 ++++++++++++ .../tests/transform_join_integration_tests.rs | 106 ++++++++++++++++ docs/design/020-M9-transform-api.md | 70 +++++++---- 11 files changed, 541 insertions(+), 35 deletions(-) create mode 100644 aimdb-tokio-adapter/tests/transform_join_integration_tests.rs create mode 100644 aimdb-wasm-adapter/tests/transform_join_integration_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 5d861b7f..7128a31f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,6 +119,7 @@ version = "0.5.0" dependencies = [ "aimdb-core", "aimdb-executor", + "critical-section", "defmt 1.0.1", "embassy-executor", "embassy-net", diff --git a/aimdb-core/src/ext_macros.rs b/aimdb-core/src/ext_macros.rs index 632a337e..44c6d519 100644 --- a/aimdb-core/src/ext_macros.rs +++ b/aimdb-core/src/ext_macros.rs @@ -176,6 +176,7 @@ macro_rules! impl_record_registrar_ext { { self.transform_raw::(input_key, build_fn) } + } }; @@ -235,6 +236,7 @@ macro_rules! impl_record_registrar_ext { F: FnOnce( $crate::transform::TransformBuilder, ) -> $crate::transform::TransformPipeline; + } #[cfg(all($(feature = $feature),+))] @@ -308,6 +310,7 @@ macro_rules! impl_record_registrar_ext { { self.transform_raw::(input_key, build_fn) } + } }; } diff --git a/aimdb-core/src/transform/join.rs b/aimdb-core/src/transform/join.rs index c2c63a23..38cca668 100644 --- a/aimdb-core/src/transform/join.rs +++ b/aimdb-core/src/transform/join.rs @@ -19,7 +19,11 @@ use crate::typed_record::BoxFuture; // JoinTrigger // ============================================================================ -/// Tells the join handler which input produced a value. +/// Identifies which input produced a value in a multi-input join transform. +/// +/// Passed to the handler registered with [`JoinStateBuilder::on_trigger`]. +/// Use [`JoinTrigger::index`] to branch on the source input and +/// [`JoinTrigger::as_input`] to downcast the value to the concrete type. pub enum JoinTrigger { Input { index: usize, @@ -60,6 +64,13 @@ type JoinInputFactory = Box< >; /// Configures a multi-input join transform. +/// +/// Available on any runtime that implements [`aimdb_executor::JoinFanInRuntime`]. +/// The fan-in queue (bounded channel between input forwarders and the trigger +/// loop) is created by the runtime adapter at database startup — capacity is an +/// internal constant chosen per adapter (Tokio: 64, Embassy: 8, WASM: 64). +/// +/// Obtain via [`RecordRegistrar::transform_join`]. #[cfg(feature = "alloc")] pub struct JoinBuilder { inputs: Vec<(String, JoinInputFactory)>, @@ -141,7 +152,10 @@ where } } -/// Intermediate builder for setting the join trigger handler. +/// Intermediate builder that holds join inputs and initial state. +/// +/// Created by [`JoinBuilder::with_state`]. Call [`JoinStateBuilder::on_trigger`] +/// to complete the pipeline. #[cfg(feature = "alloc")] pub struct JoinStateBuilder { inputs: Vec<(String, JoinInputFactory)>, @@ -156,7 +170,25 @@ where S: Send + Sync + 'static, R: JoinFanInRuntime + 'static, { - /// Async handler called whenever any input produces a value. + /// Register the handler called whenever any input produces a value. + /// + /// The handler receives a [`JoinTrigger`] (which input fired), a mutable + /// reference to the shared state `S`, and a [`crate::Producer`] to emit + /// output values. + /// + /// Because the returned future must be `'static`, the handler must not + /// capture the `state` or `producer` references directly in the `async` + /// block. The idiomatic pattern is to update state synchronously, then + /// clone/copy any values needed into an owned `async move` block: + /// + /// ```rust,ignore + /// .on_trigger(|trigger, state, producer| { + /// state.value = trigger.as_input::().copied(); + /// let p = producer.clone(); + /// let v = state.value; + /// Box::pin(async move { if let Some(v) = v { let _ = p.produce(v).await; } }) + /// }) + /// ``` pub fn on_trigger(self, handler: F) -> JoinPipeline where F: Fn(JoinTrigger, &mut S, &crate::Producer) -> Fut + Send + Sync + 'static, @@ -182,7 +214,10 @@ where } } -/// Completed multi-input join pipeline, ready to be stored in `TypedRecord`. +/// Completed multi-input join pipeline, ready to be registered on a record. +/// +/// Produced by [`JoinStateBuilder::on_trigger`] and consumed by +/// [`RecordRegistrar::transform_join`]. Not normally constructed directly. #[cfg(feature = "alloc")] pub struct JoinPipeline { pub(crate) _input_keys: Vec, diff --git a/aimdb-embassy-adapter/Cargo.toml b/aimdb-embassy-adapter/Cargo.toml index 1b30b11a..bdd18844 100644 --- a/aimdb-embassy-adapter/Cargo.toml +++ b/aimdb-embassy-adapter/Cargo.toml @@ -40,9 +40,7 @@ metrics = [] [dependencies] # Executor traits -aimdb-executor = { version = "0.1.0", path = "../aimdb-executor", default-features = false, features = [ - "embassy-types", -] } +aimdb-executor = { version = "0.1.0", path = "../aimdb-executor", default-features = false } # Core AimDB types - no_std for Embassy (requires alloc for typed APIs) aimdb-core = { version = "1.0.0", path = "../aimdb-core", default-features = false, features = [ @@ -70,12 +68,15 @@ defmt = { workspace = true } tracing = { workspace = true, optional = true, default-features = false } [dev-dependencies] -# For testing on embedded targets +# For testing on embedded targets heapless = "0.9.1" # Utilities for async testing futures = "0.3" +# Provides critical-section impl for host-side tests (embassy_sync channels need it) +critical-section = { version = "1.1", features = ["std"] } + # For tracing tests tracing-test = "0.2" diff --git a/aimdb-embassy-adapter/src/join_queue.rs b/aimdb-embassy-adapter/src/join_queue.rs index f0e3e0c9..5cba7e1c 100644 --- a/aimdb-embassy-adapter/src/join_queue.rs +++ b/aimdb-embassy-adapter/src/join_queue.rs @@ -93,3 +93,83 @@ impl JoinFanInRuntime for EmbassyAdapter { Ok(EmbassyJoinQueue { channel }) } } + +// ============================================================================ +// Tests +// ============================================================================ + +// These tests cover: roundtrip ordering, bounded backpressure, and sender cloning. +// Embassy channels do not close — there are no QueueClosed scenarios to test. +// +// NOTE: these tests require the ARM embedded target. They compile as part of +// `cargo check --target thumbv7em-none-eabihf --features embassy-runtime` but +// cannot run on x86_64 because the workspace `embassy-executor` uses +// `platform-cortex-m` (ARM assembly). Run them on an Embassy-capable board or +// ARM simulator. The `critical-section` dev-dep with `std` feature satisfies +// the CriticalSectionRawMutex requirement for the channel on the target. +#[cfg(test)] +mod tests { + use super::*; + use aimdb_executor::{JoinQueue as _, JoinReceiver as _, JoinSender as _}; + use futures::executor::block_on; + + fn make_channel() -> &'static EmbassyChan { + extern crate alloc; + alloc::boxed::Box::leak(alloc::boxed::Box::new(Channel::new())) + } + + fn make_queue() -> (EmbassyJoinSender, EmbassyJoinReceiver) { + EmbassyJoinQueue { + channel: make_channel(), + } + .split() + } + + #[test] + fn roundtrip_send_recv() { + let (tx, mut rx) = make_queue(); + block_on(async { + tx.send(1).await.unwrap(); + tx.send(2).await.unwrap(); + tx.send(3).await.unwrap(); + assert_eq!(rx.recv().await.unwrap(), 1); + assert_eq!(rx.recv().await.unwrap(), 2); + assert_eq!(rx.recv().await.unwrap(), 3); + }); + } + + #[test] + fn bounded_capacity_8() { + // Fill to CAPACITY without consuming, then assert an extra send is Pending. + let channel: &'static EmbassyChan = make_channel(); + block_on(async { + for i in 0..CAPACITY as u32 { + channel.send(i).await; + } + }); + // One more send should not resolve immediately (channel is full) + let mut polled = false; + let send_fut = channel.send(99u32); + futures::pin_mut!(send_fut); + let waker = futures::task::noop_waker(); + let mut cx = core::task::Context::from_waker(&waker); + if core::future::Future::poll(core::pin::Pin::new(&mut send_fut), &mut cx) + == core::task::Poll::Pending + { + polled = true; + } + assert!(polled, "send should be Pending when channel is at capacity"); + } + + #[test] + fn clone_sender_routes_to_same_receiver() { + let (tx, mut rx) = make_queue(); + let tx2 = tx.clone(); + block_on(async { + tx.send(10).await.unwrap(); + tx2.send(20).await.unwrap(); + assert_eq!(rx.recv().await.unwrap(), 10); + assert_eq!(rx.recv().await.unwrap(), 20); + }); + } +} diff --git a/aimdb-tokio-adapter/src/join_queue.rs b/aimdb-tokio-adapter/src/join_queue.rs index 764607ad..2d018f2e 100644 --- a/aimdb-tokio-adapter/src/join_queue.rs +++ b/aimdb-tokio-adapter/src/join_queue.rs @@ -71,3 +71,74 @@ impl JoinFanInRuntime for TokioAdapter { Ok(TokioJoinQueue { tx, rx }) } } + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use aimdb_executor::ExecutorError; + + fn make_queue() -> (TokioJoinSender, TokioJoinReceiver) { + let adapter = TokioAdapter::new().expect("TokioAdapter"); + adapter + .create_join_queue::() + .expect("create_join_queue") + .split() + } + + #[tokio::test] + async fn roundtrip_send_recv() { + let (tx, mut rx) = make_queue(); + tx.send(1).await.unwrap(); + tx.send(2).await.unwrap(); + tx.send(3).await.unwrap(); + assert_eq!(rx.recv().await.unwrap(), 1); + assert_eq!(rx.recv().await.unwrap(), 2); + assert_eq!(rx.recv().await.unwrap(), 3); + } + + #[tokio::test] + async fn bounded_capacity() { + let adapter = TokioAdapter::new().expect("TokioAdapter"); + let (tx, _rx) = adapter.create_join_queue::().unwrap().split(); + // Fill to capacity without consuming + for i in 0..CAPACITY as u32 { + tx.send(i).await.unwrap(); + } + // The (CAPACITY+1)th send should not complete immediately + let send_fut = tx.send(99); + let result = tokio::time::timeout(std::time::Duration::from_millis(10), send_fut).await; + assert!(result.is_err(), "expected send to block when queue is full"); + } + + #[tokio::test] + async fn send_after_rx_drop_yields_queue_closed() { + let (tx, rx) = make_queue(); + drop(rx); + let err = tx.send(1).await.unwrap_err(); + assert!(matches!(err, ExecutorError::QueueClosed)); + } + + #[tokio::test] + async fn recv_after_all_senders_drop_yields_queue_closed() { + let (tx, mut rx) = make_queue(); + let tx2 = tx.clone(); + drop(tx); + drop(tx2); + let err = rx.recv().await.unwrap_err(); + assert!(matches!(err, ExecutorError::QueueClosed)); + } + + #[tokio::test] + async fn clone_sender_routes_to_same_receiver() { + let (tx, mut rx) = make_queue(); + let tx2 = tx.clone(); + tx.send(10).await.unwrap(); + tx2.send(20).await.unwrap(); + assert_eq!(rx.recv().await.unwrap(), 10); + assert_eq!(rx.recv().await.unwrap(), 20); + } +} diff --git a/aimdb-tokio-adapter/tests/transform_join_integration_tests.rs b/aimdb-tokio-adapter/tests/transform_join_integration_tests.rs new file mode 100644 index 00000000..968d648a --- /dev/null +++ b/aimdb-tokio-adapter/tests/transform_join_integration_tests.rs @@ -0,0 +1,113 @@ +//! Integration tests for `transform_join` (multi-input reactive transform). +//! +//! Scenario: two u32 inputs A and B, one output Sum = a + b emitted whenever +//! both have been seen at least once. Drives a fixed sequence and asserts that +//! the output values are produced in the expected order. + +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use aimdb_core::buffer::BufferCfg; +use aimdb_core::transform::JoinTrigger; +use aimdb_core::{AimDbBuilder, Producer}; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + +// --------------------------------------------------------------------------- +// Record types +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, PartialEq, Copy)] +struct ValueA(u32); + +#[derive(Clone, Debug, PartialEq, Copy)] +struct ValueB(u32); + +#[derive(Clone, Debug, PartialEq, Copy)] +struct Sum(u32); + +// --------------------------------------------------------------------------- +// Join handler — named fn required for the 'static Future bound +// +// State = (last_a, last_b). Update synchronously, then clone the producer and +// copy the scalar sum into an owned async block. +// --------------------------------------------------------------------------- + +fn sum_handler( + trigger: JoinTrigger, + state: &mut (Option, Option), + producer: &Producer, +) -> Pin + Send + 'static>> { + match trigger.index() { + 0 => state.0 = trigger.as_input::().copied().map(|v| v.0), + 1 => state.1 = trigger.as_input::().copied().map(|v| v.0), + _ => {} + } + match (state.0, state.1) { + (Some(a), Some(b)) => { + let p = producer.clone(); + let sum = a + b; + Box::pin(async move { + let _ = p.produce(Sum(sum)).await; + }) + } + _ => Box::pin(async {}), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn transform_join_produces_sum_on_both_inputs() { + let runtime = Arc::new(TokioAdapter::new().unwrap()); + let mut builder = AimDbBuilder::new().runtime(runtime); + + builder.configure::("test::A", |reg| { + reg.buffer(BufferCfg::SingleLatest); + }); + builder.configure::("test::B", |reg| { + reg.buffer(BufferCfg::SingleLatest); + }); + builder.configure::("test::Sum", |reg| { + reg.buffer(BufferCfg::SpmcRing { capacity: 16 }) + .transform_join(|b| { + b.input::("test::A") + .input::("test::B") + .with_state((None::, None::)) + .on_trigger(sum_handler) + }); + }); + + let db = builder.build().await.unwrap(); + let mut sum_rx = db.subscribe::("test::Sum").unwrap(); + + // Yield to let the join transform task spawn its input forwarders and subscribe. + tokio::task::yield_now().await; + + // A=1, B=10 → Sum=11 (first time both are present) + db.produce::("test::A", ValueA(1)).await.unwrap(); + db.produce::("test::B", ValueB(10)).await.unwrap(); + let s = tokio::time::timeout(Duration::from_secs(2), sum_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(s.0, 11, "expected 1+10=11"); + + // A=2 → Sum=12 (B stays 10) + db.produce::("test::A", ValueA(2)).await.unwrap(); + let s = tokio::time::timeout(Duration::from_secs(2), sum_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(s.0, 12, "expected 2+10=12"); + + // B=20 → Sum=22 (A stays 2) + db.produce::("test::B", ValueB(20)).await.unwrap(); + let s = tokio::time::timeout(Duration::from_secs(2), sum_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(s.0, 22, "expected 2+20=22"); +} diff --git a/aimdb-wasm-adapter/Cargo.toml b/aimdb-wasm-adapter/Cargo.toml index f1fc9f9d..1939ff3f 100644 --- a/aimdb-wasm-adapter/Cargo.toml +++ b/aimdb-wasm-adapter/Cargo.toml @@ -68,7 +68,9 @@ futures-util = { version = "0.3", default-features = false, features = [ "sink", ] } -# Bounded MPSC channel for join fan-in (no_std + alloc compatible) +# Bounded MPSC channel for join fan-in. +# Note: futures-channel mpsc requires "std" because its internal BiLock uses std::sync. +# The "alloc"-only path does not expose mpsc; "std" is the minimum required feature. futures-channel = { version = "0.3", default-features = false, features = ["std", "sink"] } [lints.rust] diff --git a/aimdb-wasm-adapter/src/join_queue.rs b/aimdb-wasm-adapter/src/join_queue.rs index 207a7227..daaed23a 100644 --- a/aimdb-wasm-adapter/src/join_queue.rs +++ b/aimdb-wasm-adapter/src/join_queue.rs @@ -72,3 +72,79 @@ impl JoinFanInRuntime for WasmAdapter { Ok(WasmJoinQueue { tx, rx }) } } + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use aimdb_executor::{ExecutorError, JoinQueue as _, JoinReceiver as _}; + use wasm_bindgen_test::wasm_bindgen_test; + + wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + + fn make_queue() -> (WasmJoinSender, WasmJoinReceiver) { + let (tx, rx) = mpsc::channel::(CAPACITY); + WasmJoinQueue { tx, rx }.split() + } + + #[wasm_bindgen_test] + async fn roundtrip_send_recv() { + let (tx, mut rx) = make_queue(); + tx.send(1).await.unwrap(); + tx.send(2).await.unwrap(); + tx.send(3).await.unwrap(); + assert_eq!(rx.recv().await.unwrap(), 1); + assert_eq!(rx.recv().await.unwrap(), 2); + assert_eq!(rx.recv().await.unwrap(), 3); + } + + #[wasm_bindgen_test] + async fn bounded_capacity() { + let (tx, _rx) = make_queue(); + // Fill to capacity + for i in 0..CAPACITY as u32 { + tx.send(i).await.unwrap(); + } + // The (CAPACITY+1)th send should return Pending or close — poll once + let mut tx_clone = tx.tx.clone(); + use futures_util::sink::SinkExt as _; + // With a full bounded channel the send future should not resolve immediately; + // we verify by checking that a non-blocking try_send fails. + let result = tx_clone.try_send(99u32); + assert!( + result.is_err(), + "expected try_send to fail when queue is full" + ); + } + + #[wasm_bindgen_test] + async fn send_after_rx_drop_yields_queue_closed() { + let (tx, rx) = make_queue(); + drop(rx); + let err = tx.send(1).await.unwrap_err(); + assert!(matches!(err, ExecutorError::QueueClosed)); + } + + #[wasm_bindgen_test] + async fn recv_after_all_senders_drop_yields_queue_closed() { + let (tx, mut rx) = make_queue(); + let tx2 = tx.clone(); + drop(tx); + drop(tx2); + let err = rx.recv().await.unwrap_err(); + assert!(matches!(err, ExecutorError::QueueClosed)); + } + + #[wasm_bindgen_test] + async fn clone_sender_routes_to_same_receiver() { + let (tx, mut rx) = make_queue(); + let tx2 = tx.clone(); + tx.send(10).await.unwrap(); + tx2.send(20).await.unwrap(); + assert_eq!(rx.recv().await.unwrap(), 10); + assert_eq!(rx.recv().await.unwrap(), 20); + } +} diff --git a/aimdb-wasm-adapter/tests/transform_join_integration_tests.rs b/aimdb-wasm-adapter/tests/transform_join_integration_tests.rs new file mode 100644 index 00000000..338551a7 --- /dev/null +++ b/aimdb-wasm-adapter/tests/transform_join_integration_tests.rs @@ -0,0 +1,106 @@ +//! WASM integration tests for `transform_join` (multi-input reactive transform). +//! +//! Same scenario as the Tokio integration test: two u32 inputs A and B, one +//! output Sum = a + b, emitted whenever both inputs have been seen at least once. +//! +//! Run with: wasm-pack test --headless --chrome (or --firefox) + +use std::pin::Pin; +use std::sync::Arc; + +use aimdb_core::buffer::BufferCfg; +use aimdb_core::transform::JoinTrigger; +use aimdb_core::{AimDbBuilder, Producer}; +use aimdb_wasm_adapter::{WasmAdapter, WasmRecordRegistrarExt}; +use wasm_bindgen_test::wasm_bindgen_test; + +wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + +// --------------------------------------------------------------------------- +// Record types +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, PartialEq, Copy)] +struct ValueA(u32); + +#[derive(Clone, Debug, PartialEq, Copy)] +struct ValueB(u32); + +#[derive(Clone, Debug, PartialEq, Copy)] +struct Sum(u32); + +// --------------------------------------------------------------------------- +// Join handler +// --------------------------------------------------------------------------- + +fn sum_handler( + trigger: JoinTrigger, + state: &mut (Option, Option), + producer: &Producer, +) -> Pin + Send + 'static>> { + match trigger.index() { + 0 => state.0 = trigger.as_input::().copied().map(|v| v.0), + 1 => state.1 = trigger.as_input::().copied().map(|v| v.0), + _ => {} + } + match (state.0, state.1) { + (Some(a), Some(b)) => { + let p = producer.clone(); + let sum = a + b; + Box::pin(async move { + let _ = p.produce(Sum(sum)).await; + }) + } + _ => Box::pin(async {}), + } +} + +// --------------------------------------------------------------------------- +// Test +// --------------------------------------------------------------------------- + +#[wasm_bindgen_test] +async fn transform_join_produces_sum_on_both_inputs() { + let runtime = Arc::new(WasmAdapter); + let mut builder = AimDbBuilder::new().runtime(runtime); + + builder.configure::("test::A", |reg| { + reg.buffer(BufferCfg::SingleLatest); + }); + builder.configure::("test::B", |reg| { + reg.buffer(BufferCfg::SingleLatest); + }); + builder.configure::("test::Sum", |reg| { + reg.buffer(BufferCfg::SpmcRing { capacity: 16 }) + .transform_join(|b| { + b.input::("test::A") + .input::("test::B") + .with_state((None::, None::)) + .on_trigger(sum_handler) + }); + }); + + let db = builder.build().await.unwrap(); + let mut sum_rx = db.subscribe::("test::Sum").unwrap(); + + // Yield to let the join transform task spawn its input forwarders and subscribe. + wasm_bindgen_futures::JsFuture::from(js_sys::Promise::resolve(&wasm_bindgen::JsValue::NULL)) + .await + .unwrap(); + + // A=1, B=10 → Sum=11 + db.produce::("test::A", ValueA(1)).await.unwrap(); + db.produce::("test::B", ValueB(10)).await.unwrap(); + let s = sum_rx.recv().await.unwrap(); + assert_eq!(s.0, 11, "expected 1+10=11"); + + // A=2 → Sum=12 (B stays 10) + db.produce::("test::A", ValueA(2)).await.unwrap(); + let s = sum_rx.recv().await.unwrap(); + assert_eq!(s.0, 12, "expected 2+10=12"); + + // B=20 → Sum=22 (A stays 2) + db.produce::("test::B", ValueB(20)).await.unwrap(); + let s = sum_rx.recv().await.unwrap(); + assert_eq!(s.0, 22, "expected 2+20=22"); +} diff --git a/docs/design/020-M9-transform-api.md b/docs/design/020-M9-transform-api.md index 2194ff13..cd9aae6f 100644 --- a/docs/design/020-M9-transform-api.md +++ b/docs/design/020-M9-transform-api.md @@ -426,6 +426,36 @@ builder.configure::(AccuracyKey::Vienna, |reg| { }); ``` +### Runtime-Owned Fan-In + +Multi-input joins require a fan-in queue: a bounded channel that forwards +triggers from each input forwarder task to the central trigger loop. This queue +is created by the runtime adapter via the `JoinFanInRuntime` trait defined in +`aimdb-executor`: + +```rust +pub trait JoinFanInRuntime: Spawn { + type JoinQueue: JoinQueue; + fn create_join_queue(&self) -> ExecutorResult>; +} +``` + +Queue capacity is an internal constant chosen per adapter — not exposed in the +public API. Each adapter documents its fixed default: + +| Runtime | Queue primitive | Default capacity | +|---------|----------------|-----------------| +| Tokio | `tokio::sync::mpsc::channel` | 64 | +| Embassy | `embassy_sync::channel::Channel` (compile-time const) | 8 | +| WASM | `futures_channel::mpsc::channel` | 64 | + +**Closure semantics differ by runtime:** Tokio and WASM channels signal +`QueueClosed` when the receiver or all senders are dropped. Embassy channels +do not close — the trigger loop runs for the device lifetime. + +See [027-no-std-transform-api.md](./027-no-std-transform-api.md) for the +full no_std refactor that introduced this abstraction. + ### Method Signatures #### On `RecordRegistrar` (low-level, runtime-agnostic) @@ -453,42 +483,30 @@ where /// Register a multi-input transform (join). Panics if a source or /// transform is already registered for this record. + /// Requires the runtime to implement `JoinFanInRuntime`. pub fn transform_join_raw( &'a mut self, build_fn: F, ) -> &'a mut Self where - F: FnOnce(JoinBuilder) -> JoinPipeline + Send + 'static; + R: JoinFanInRuntime, + F: FnOnce(JoinBuilder) -> JoinPipeline; } ``` #### On the generated extension trait (e.g., `TokioRecordRegistrarExt`) -```rust -pub trait TokioRecordRegistrarExt<'a, T> { - // ... existing source(), tap(), buffer() ... - - /// Single-input reactive transform. - fn transform( - &'a mut self, - input_key: impl RecordKey, - build_fn: F, - ) -> &'a mut RecordRegistrar<'a, T, TokioAdapter> - where - I: Send + Sync + Clone + Debug + 'static, - F: FnOnce(TransformBuilder) -> TransformPipeline - + Send + 'static; - - /// Multi-input reactive transform (join). - fn transform_join( - &'a mut self, - build_fn: F, - ) -> &'a mut RecordRegistrar<'a, T, TokioAdapter> - where - F: FnOnce(JoinBuilder) -> JoinPipeline - + Send + 'static; -} -``` +The `impl_record_registrar_ext!` macro generates `source()`, `tap()`, +`buffer()`, and `transform()` convenience methods. `transform_join` is NOT +included in the generated extension trait — it is available directly on +`RecordRegistrar` via the inherent impl above. This avoids a Rust +well-formedness check limitation (issue #48214) that arises when referencing +`JoinBuilder` in a macro-generated trait definition before the +`R: JoinFanInRuntime` bound is known to hold at the definition site. + +Users call `.transform_join()` directly on the registrar — no trait import +needed. The `R: JoinFanInRuntime` bound on the inherent method ensures +runtimes that don't implement it produce a clear compile error at the call site. ### Builder Types From a8eb4cd7ca319231d536d0b4f9bc86258c8325f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 3 May 2026 06:50:34 +0000 Subject: [PATCH 07/17] feat: Implement dew point calculation in weather mesh demo - Added a new `DewPoint` contract to derive dew point temperature from `Temperature` and `Humidity` using the Magnus approximation. - Updated the weather mesh demo to configure dew point records in the alpha, beta, and gamma weather stations. - Refactored the `transform_join` logic to utilize the new task model, allowing state to be borrowed across `.await` points without additional allocations. - Enhanced logging to include dew point information during initialization. - Updated documentation to reflect the new `DewPoint` contract and its integration into the weather mesh system. --- aimdb-codegen/src/rust.rs | 22 +- aimdb-core/src/lib.rs | 2 +- aimdb-core/src/transform/join.rs | 156 ++++----- aimdb-core/src/transform/mod.rs | 5 +- .../tests/transform_join_integration_tests.rs | 49 +-- .../tests/transform_join_integration_tests.rs | 46 +-- docs/design/027-no-std-transform-api.md | 295 +++++++++++++----- .../src/contracts/dew_point.rs | 64 ++++ .../weather-mesh-common/src/contracts/mod.rs | 2 + .../weather-mesh-common/src/lib.rs | 24 +- .../weather-station-alpha/src/main.rs | 43 ++- .../weather-station-beta/src/main.rs | 43 ++- .../weather-station-gamma/Cargo.toml | 3 +- .../weather-station-gamma/src/main.rs | 54 +++- 14 files changed, 576 insertions(+), 232 deletions(-) create mode 100644 examples/weather-mesh-demo/weather-mesh-common/src/contracts/dew_point.rs diff --git a/aimdb-codegen/src/rust.rs b/aimdb-codegen/src/rust.rs index cf021d0a..eb876a82 100644 --- a/aimdb-codegen/src/rust.rs +++ b/aimdb-codegen/src/rust.rs @@ -1589,8 +1589,7 @@ fn build_transform_call(task: &TaskDef, variant_ident: &syn::Ident) -> TokenStre quote! { .transform_join(|j| { j #(#input_calls)* - .with_state(()) - .on_trigger(#handler_ident) + .on_triggers(#handler_ident) }) } } else { @@ -1612,7 +1611,7 @@ fn build_transform_call(task: &TaskDef, variant_ident: &syn::Ident) -> TokenStre /// /// | Inputs | Outputs | API | Generated stub | /// |--------|---------|-----------------------|---------------------------| -/// | N > 1 | ≥ 1 | `.transform_join()` | `fn task_handler(JoinTrigger, &mut (), &Producer)` | +/// | N > 1 | ≥ 1 | `.transform_join()` | `async fn task_handler(JoinEventRx, Producer)` | /// | 1 | ≥ 1 | `.transform().map()` | `fn task_transform(&Input) -> Option` | /// | 0 | ≥ 1 | `.source()` | `async fn task(RuntimeContext, Producer)` | /// | ≥ 1 | 0 | `.tap()` | `async fn task(RuntimeContext, Consumer)` | @@ -1647,9 +1646,7 @@ pub fn generate_hub_tasks_rs(state: &ArchitectureState) -> String { } if n_in > 1 && n_out >= 1 { - // Multi-input → join handler - // Returns Pin> — the only concrete return type that satisfies - // the for<'a,'b> HRTB on on_trigger. `-> impl Future` does NOT work here. + // Multi-input → join handler (task model: owns event loop and state) let handler = format!("{}_handler", task.name); let inputs_doc = task .inputs @@ -1661,12 +1658,13 @@ pub fn generate_hub_tasks_rs(state: &ArchitectureState) -> String { fns.push_str(&format!( "/// Join handler — match `trigger.index()` to identify which input fired:\n\ /// {inputs_doc}\n\ -pub fn {handler}(\n\ - _trigger: aimdb_core::transform::JoinTrigger,\n\ - _state: &mut (),\n\ - _producer: &aimdb_core::Producer<{out_t}, TokioAdapter>,\n\ -) -> std::pin::Pin + Send + 'static>> {{\n\ - Box::pin(async move {{ todo!(\"implement {handler}\") }})\n\ +pub async fn {handler}(\n\ + mut _rx: aimdb_core::transform::JoinEventRx,\n\ + _producer: aimdb_core::Producer<{out_t}, TokioAdapter>,\n\ +) {{\n\ + while let Ok(_trigger) = _rx.recv().await {{\n\ + todo!(\"implement {handler}\")\n\ + }}\n\ }}\n\n" )); } else if n_in == 1 && n_out >= 1 { diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index 4ac8bba1..3446ea87 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -74,5 +74,5 @@ pub use graph::{DependencyGraph, EdgeType, GraphEdge, GraphNode, RecordGraphInfo // Transform API exports #[cfg(feature = "alloc")] -pub use transform::{JoinBuilder, JoinPipeline, JoinTrigger}; +pub use transform::{JoinBuilder, JoinEventRx, JoinPipeline, JoinTrigger}; pub use transform::{TransformBuilder, TransformPipeline}; diff --git a/aimdb-core/src/transform/join.rs b/aimdb-core/src/transform/join.rs index 38cca668..6533f3ce 100644 --- a/aimdb-core/src/transform/join.rs +++ b/aimdb-core/src/transform/join.rs @@ -10,7 +10,7 @@ use alloc::{ vec::Vec, }; -use aimdb_executor::{JoinFanInRuntime, JoinQueue, JoinReceiver as _, JoinSender}; +use aimdb_executor::{ExecutorResult, JoinFanInRuntime, JoinQueue, JoinReceiver, JoinSender}; use crate::transform::TransformDescriptor; use crate::typed_record::BoxFuture; @@ -21,7 +21,7 @@ use crate::typed_record::BoxFuture; /// Identifies which input produced a value in a multi-input join transform. /// -/// Passed to the handler registered with [`JoinStateBuilder::on_trigger`]. +/// 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. pub enum JoinTrigger { @@ -45,13 +45,66 @@ impl JoinTrigger { } } +// ============================================================================ +// JoinEventRx — type-erased trigger receiver +// ============================================================================ + +/// Type-erased receiver for join trigger events. +/// +/// 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)).await.ok(); +/// } +/// } +/// }) +/// ``` +pub struct JoinEventRx { + inner: Box, +} + +impl JoinEventRx { + fn new + Send + 'static>(inner: R) -> Self { + Self { + inner: Box::new(inner), + } + } + + /// Receive the next trigger event. + /// + /// Returns `Ok(JoinTrigger)` when an input fires, or `Err` when all inputs are closed. + pub async fn recv(&mut self) -> ExecutorResult { + self.inner.recv_boxed().await + } +} + +trait DynJoinRx: Send { + fn recv_boxed<'a>(&'a mut self) -> BoxFuture<'a, ExecutorResult>; +} + +impl + Send> DynJoinRx for R { + fn recv_boxed<'a>(&'a mut self) -> BoxFuture<'a, ExecutorResult> { + Box::pin(self.recv()) + } +} + // ============================================================================ // JoinBuilder → JoinPipeline // ============================================================================ /// Type-erased factory for creating a forwarder task for one join input. -/// -/// The third argument is the concrete sender from the runtime's join queue. #[cfg(feature = "alloc")] type JoinInputFactory = Box< dyn FnOnce( @@ -142,61 +195,36 @@ where self } - /// Set the join state and begin configuring the trigger handler. - pub fn with_state(self, initial: S) -> JoinStateBuilder { - JoinStateBuilder { - inputs: self.inputs, - initial_state: initial, - _phantom: PhantomData, - } - } -} - -/// Intermediate builder that holds join inputs and initial state. -/// -/// Created by [`JoinBuilder::with_state`]. Call [`JoinStateBuilder::on_trigger`] -/// to complete the pipeline. -#[cfg(feature = "alloc")] -pub struct JoinStateBuilder { - inputs: Vec<(String, JoinInputFactory)>, - initial_state: S, - _phantom: PhantomData<(O, R)>, -} - -#[cfg(feature = "alloc")] -impl JoinStateBuilder -where - O: Send + Sync + Clone + Debug + 'static, - S: Send + Sync + 'static, - R: JoinFanInRuntime + 'static, -{ - /// Register the handler called whenever any input produces a value. + /// Complete the pipeline by providing an async task that owns the event loop and state. /// - /// The handler receives a [`JoinTrigger`] (which input fired), a mutable - /// reference to the shared state `S`, and a [`crate::Producer`] to emit - /// output values. + /// The closure receives a [`JoinEventRx`] to read trigger events and a [`crate::Producer`] + /// to emit output values. Both are owned — moved into the `async move` block — so the + /// closure can freely hold borrows across `.await` points and maintain any state it needs. /// - /// Because the returned future must be `'static`, the handler must not - /// capture the `state` or `producer` references directly in the `async` - /// block. The idiomatic pattern is to update state synchronously, then - /// clone/copy any values needed into an owned `async move` block: + /// The task runs until all input forwarders close (i.e., all upstream records stop producing). /// /// ```rust,ignore - /// .on_trigger(|trigger, state, producer| { - /// state.value = trigger.as_input::().copied(); - /// let p = producer.clone(); - /// let v = state.value; - /// Box::pin(async move { if let Some(v) = v { let _ = p.produce(v).await; } }) + /// .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)).await.ok(); + /// } + /// } /// }) /// ``` - pub fn on_trigger(self, handler: F) -> JoinPipeline + pub fn on_triggers(self, handler: F) -> JoinPipeline where - F: Fn(JoinTrigger, &mut S, &crate::Producer) -> Fut + Send + Sync + 'static, + F: FnOnce(JoinEventRx, crate::Producer) -> Fut + Send + 'static, Fut: core::future::Future + Send + 'static, { let inputs = self.inputs; - let initial = self.initial_state; - let input_keys_for_descriptor: Vec = inputs.iter().map(|(k, _)| k.clone()).collect(); @@ -205,9 +233,7 @@ where spawn_factory: Box::new(move |_| TransformDescriptor { input_keys: input_keys_for_descriptor, spawn_fn: Box::new(move |producer, db, ctx| { - Box::pin(run_join_transform( - db, inputs, producer, initial, handler, ctx, - )) + Box::pin(run_join_transform(db, inputs, producer, handler, ctx)) }), }), } @@ -216,12 +242,12 @@ where /// Completed multi-input join pipeline, ready to be registered on a record. /// -/// Produced by [`JoinStateBuilder::on_trigger`] and consumed by +/// Produced by [`JoinBuilder::on_triggers`] and consumed by /// [`RecordRegistrar::transform_join`]. Not normally constructed directly. #[cfg(feature = "alloc")] pub struct JoinPipeline { pub(crate) _input_keys: Vec, - pub(crate) spawn_factory: Box TransformDescriptor + Send + Sync>, + pub(crate) spawn_factory: Box TransformDescriptor + Send>, } #[cfg(feature = "alloc")] @@ -241,18 +267,16 @@ where #[cfg(feature = "alloc")] #[allow(unused_variables)] -async fn run_join_transform( +async fn run_join_transform( db: Arc>, inputs: Vec<(String, JoinInputFactory)>, producer: crate::Producer, - mut state: S, handler: F, runtime_ctx: Arc, ) where O: Send + Sync + Clone + Debug + 'static, - S: Send + 'static, R: JoinFanInRuntime + 'static, - F: Fn(JoinTrigger, &mut S, &crate::Producer) -> Fut + Send + Sync + 'static, + F: FnOnce(JoinEventRx, crate::Producer) -> Fut + Send + 'static, Fut: core::future::Future + Send + 'static, { let output_key = producer.key().to_string(); @@ -271,7 +295,6 @@ async fn run_join_transform( .or_else(|| runtime_ctx.downcast_ref::()) .expect("Failed to extract runtime from context for join transform"); - // Create the shared trigger queue via runtime trait let queue = match runtime.create_join_queue::() { Ok(q) => q, Err(_e) => { @@ -283,9 +306,8 @@ async fn run_join_transform( return; } }; - let (tx, mut rx) = queue.split(); + let (tx, rx) = queue.split(); - // Spawn per-input forwarder tasks for (index, (_key, factory)) in inputs.into_iter().enumerate() { let sender = tx.clone(); let db = db.clone(); @@ -302,22 +324,16 @@ async fn run_join_transform( } } - // Drop our sender copy — when all forwarders exit the channel closes drop(tx); #[cfg(feature = "tracing")] tracing::debug!( - "✅ Join transform '{}' all forwarders spawned, entering event loop", + "✅ Join transform '{}' all forwarders spawned, handing receiver to user task", output_key ); - while let Ok(trigger) = rx.recv().await { - handler(trigger, &mut state, &producer).await; - } + handler(JoinEventRx::new(rx), producer).await; #[cfg(feature = "tracing")] - tracing::warn!( - "🔄 Join transform '{}' all inputs closed, task exiting", - output_key - ); + tracing::warn!("🔄 Join transform '{}' user task exited", output_key); } diff --git a/aimdb-core/src/transform/mod.rs b/aimdb-core/src/transform/mod.rs index 9ed5c0e4..fc2f1b12 100644 --- a/aimdb-core/src/transform/mod.rs +++ b/aimdb-core/src/transform/mod.rs @@ -25,7 +25,7 @@ pub mod single; pub use single::{StatefulTransformBuilder, TransformBuilder, TransformPipeline}; #[cfg(feature = "alloc")] -pub use join::{JoinBuilder, JoinPipeline, JoinStateBuilder, JoinTrigger}; +pub use join::{JoinBuilder, JoinEventRx, JoinPipeline, JoinTrigger}; // JoinTrigger is always available (no std dependency) #[cfg(not(feature = "alloc"))] @@ -48,7 +48,6 @@ where Arc>, Arc, ) -> BoxFuture<'static, ()> - + Send - + Sync, + + Send, >, } diff --git a/aimdb-tokio-adapter/tests/transform_join_integration_tests.rs b/aimdb-tokio-adapter/tests/transform_join_integration_tests.rs index 968d648a..92a680bf 100644 --- a/aimdb-tokio-adapter/tests/transform_join_integration_tests.rs +++ b/aimdb-tokio-adapter/tests/transform_join_integration_tests.rs @@ -4,13 +4,11 @@ //! both have been seen at least once. Drives a fixed sequence and asserts that //! the output values are produced in the expected order. -use std::pin::Pin; use std::sync::Arc; use std::time::Duration; use aimdb_core::buffer::BufferCfg; -use aimdb_core::transform::JoinTrigger; -use aimdb_core::{AimDbBuilder, Producer}; +use aimdb_core::AimDbBuilder; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; // --------------------------------------------------------------------------- @@ -26,35 +24,6 @@ struct ValueB(u32); #[derive(Clone, Debug, PartialEq, Copy)] struct Sum(u32); -// --------------------------------------------------------------------------- -// Join handler — named fn required for the 'static Future bound -// -// State = (last_a, last_b). Update synchronously, then clone the producer and -// copy the scalar sum into an owned async block. -// --------------------------------------------------------------------------- - -fn sum_handler( - trigger: JoinTrigger, - state: &mut (Option, Option), - producer: &Producer, -) -> Pin + Send + 'static>> { - match trigger.index() { - 0 => state.0 = trigger.as_input::().copied().map(|v| v.0), - 1 => state.1 = trigger.as_input::().copied().map(|v| v.0), - _ => {} - } - match (state.0, state.1) { - (Some(a), Some(b)) => { - let p = producer.clone(); - let sum = a + b; - Box::pin(async move { - let _ = p.produce(Sum(sum)).await; - }) - } - _ => Box::pin(async {}), - } -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -75,8 +44,20 @@ async fn transform_join_produces_sum_on_both_inputs() { .transform_join(|b| { b.input::("test::A") .input::("test::B") - .with_state((None::, None::)) - .on_trigger(sum_handler) + .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().map(|v| v.0), + 1 => last_b = trigger.as_input::().copied().map(|v| v.0), + _ => {} + } + if let (Some(a), Some(b)) = (last_a, last_b) { + let _ = producer.produce(Sum(a + b)).await; + } + } + }) }); }); diff --git a/aimdb-wasm-adapter/tests/transform_join_integration_tests.rs b/aimdb-wasm-adapter/tests/transform_join_integration_tests.rs index 338551a7..99242224 100644 --- a/aimdb-wasm-adapter/tests/transform_join_integration_tests.rs +++ b/aimdb-wasm-adapter/tests/transform_join_integration_tests.rs @@ -5,12 +5,10 @@ //! //! Run with: wasm-pack test --headless --chrome (or --firefox) -use std::pin::Pin; use std::sync::Arc; use aimdb_core::buffer::BufferCfg; -use aimdb_core::transform::JoinTrigger; -use aimdb_core::{AimDbBuilder, Producer}; +use aimdb_core::AimDbBuilder; use aimdb_wasm_adapter::{WasmAdapter, WasmRecordRegistrarExt}; use wasm_bindgen_test::wasm_bindgen_test; @@ -29,32 +27,6 @@ struct ValueB(u32); #[derive(Clone, Debug, PartialEq, Copy)] struct Sum(u32); -// --------------------------------------------------------------------------- -// Join handler -// --------------------------------------------------------------------------- - -fn sum_handler( - trigger: JoinTrigger, - state: &mut (Option, Option), - producer: &Producer, -) -> Pin + Send + 'static>> { - match trigger.index() { - 0 => state.0 = trigger.as_input::().copied().map(|v| v.0), - 1 => state.1 = trigger.as_input::().copied().map(|v| v.0), - _ => {} - } - match (state.0, state.1) { - (Some(a), Some(b)) => { - let p = producer.clone(); - let sum = a + b; - Box::pin(async move { - let _ = p.produce(Sum(sum)).await; - }) - } - _ => Box::pin(async {}), - } -} - // --------------------------------------------------------------------------- // Test // --------------------------------------------------------------------------- @@ -75,8 +47,20 @@ async fn transform_join_produces_sum_on_both_inputs() { .transform_join(|b| { b.input::("test::A") .input::("test::B") - .with_state((None::, None::)) - .on_trigger(sum_handler) + .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().map(|v| v.0), + 1 => last_b = trigger.as_input::().copied().map(|v| v.0), + _ => {} + } + if let (Some(a), Some(b)) = (last_a, last_b) { + let _ = producer.produce(Sum(a + b)).await; + } + } + }) }); }); diff --git a/docs/design/027-no-std-transform-api.md b/docs/design/027-no-std-transform-api.md index 1b46383c..e24634d2 100644 --- a/docs/design/027-no-std-transform-api.md +++ b/docs/design/027-no-std-transform-api.md @@ -1,9 +1,9 @@ # `no_std` Support for the Transform API -**Version:** 1.1 -**Status:** Draft -**Last Updated:** 2026-04-26 -**Issue:** [#73 — `no_std` Support for Transform API](https://github.com/schnorr-lab/aimdb/issues/73) +**Version:** 1.3 +**Status:** Implemented +**Last Updated:** 2026-05-03 +**Issue:** [#73 — `no_std` Support for Transform API](https://github.com/aimdb-dev/aimdb/issues/73) **Milestone:** M10 / M11 — Embedded First-Class Support **Depends On:** [020-M9-transform-api](020-M9-transform-api.md) @@ -25,30 +25,35 @@ - [Alternatives Considered](#alternatives-considered) - [Risk & Constraints](#risk--constraints) - [Open Questions & Required Clarifications](#open-questions--required-clarifications) + - [Q4 — `on_trigger` callback model prevents full async in the handler](#q4--on_trigger-callback-model-prevents-full-async-in-the-handler) - [Acceptance Criteria](#acceptance-criteria) --- ## Summary -The transform API (`TransformBuilder`, `StatefulTransformBuilder`, `TransformPipeline`) is -partially usable in `no_std` environments today, but the multi-input join path -(`JoinBuilder`, `JoinStateBuilder`, `JoinPipeline`, `run_join_transform`) is currently -`std`-only because join fan-in is hardcoded to `tokio::sync::mpsc`. +This revision made the full transform API — both single-input and multi-input join — +available on `no_std + alloc` targets. Previously the join path was `std`-only because +fan-in was hardcoded to `tokio::sync::mpsc`. -This revision adopts a clean end-state architecture: join fan-in is defined in -`aimdb-executor` as runtime capabilities, and implemented by each runtime adapter -(Tokio, Embassy, WASM, future runtimes). `aimdb-core` no longer imports Tokio- or -Embassy-specific queue types for join execution. +The adopted architecture: join fan-in is defined in `aimdb-executor` as runtime-agnostic +traits (`JoinFanInRuntime`, `JoinQueue`, `JoinSender`, `JoinReceiver`) and implemented by +each runtime adapter (Tokio, Embassy, WASM). `aimdb-core` contains no Tokio- or +Embassy-specific types in the join path. -This intentionally allows API changes across crates to achieve a single, runtime-agnostic -join implementation. +During implementation, the join handler API was also redesigned: the original callback +model (`with_state().on_trigger(Fn(...) -> Pin>)`) was replaced with a +task model (`on_triggers(FnOnce(JoinEventRx, Producer) -> impl Future)`). This eliminated +per-event heap allocation and the restriction that state could not be borrowed across +`.await` points. See [Q4](#q4--on_trigger-callback-model-prevents-full-async-in-the-handler) +and [Alternative D](#alternative-d--keep-the-callback-model-on_trigger) for the full +trade-off record. --- -## Current State +## State Before This Revision -### Symbols that are `std`-only and why +### Symbols that were `std`-only and why | Symbol | Location | Root dependency | |---|---|---| @@ -60,17 +65,17 @@ join implementation. | `transform_join_raw()` | `typed_api.rs` | `JoinBuilder` / `JoinPipeline` | | `transform_join()` in `impl_record_registrar_ext!` | `ext_macros.rs` | Same | -### What already works in `no_std` +### What already worked in `no_std` -- `TransformDescriptor` is alloc-only. -- `TransformBuilder` / `StatefulTransformBuilder` / `TransformPipeline` have no std dependency. -- `run_single_transform` is async and runtime-agnostic. -- `TypedRecord::set_transform()` already works on `no_std + alloc`. +- `TransformDescriptor` was alloc-only. +- `TransformBuilder` / `StatefulTransformBuilder` / `TransformPipeline` had no std dependency. +- `run_single_transform` was async and runtime-agnostic. +- `TypedRecord::set_transform()` already worked on `no_std + alloc`. -### What is broken for `no_std` +### What was broken for `no_std` -- Multi-input join is unavailable because fan-in is not abstracted by runtime traits. -- API exposure (`transform_join`) follows that same std-only wiring. +- Multi-input join was unavailable because fan-in was not abstracted by runtime traits. +- API exposure (`transform_join`) followed that same std-only wiring. --- @@ -219,64 +224,77 @@ No intentional user-facing changes. ### Multi-input join User-facing API is unified across runtimes and does not require runtime queue types. +`JoinStateBuilder` and `with_state().on_trigger()` were replaced by `on_triggers()` — +see [Q4](#q4--on_trigger-callback-model-prevents-full-async-in-the-handler). ```rust registrar - .register::("sensor::HeatIndex", buffer_sized::<4, 2>(SpmcRing)) - .transform_join(|b| { - b.input::("sensor::Temperature") - .input::("sensor::Humidity") - .with_state(HeatIndexState::default()) - .on_trigger(|trigger, state, producer| async move { - match trigger.index() { - 0 => state.temperature = trigger.as_input::().copied(), - 1 => state.humidity = trigger.as_input::().copied(), - _ => {} - } - if let (Some(t), Some(h)) = (state.temperature, state.humidity) { - let _ = producer.produce(heat_index(t, h)).await; - } - }) + .configure::("sensor::HeatIndex", |reg| { + reg.buffer_sized::<4, 2>(EmbassyBufferType::SpmcRing) + .transform_join(|b| { + b.input::("sensor::Temperature") + .input::("sensor::Humidity") + .on_triggers(|mut rx, producer| async move { + let mut last_t: Option = None; + let mut last_h: Option = None; + while let Ok(trigger) = rx.recv().await { + match trigger.index() { + 0 => last_t = trigger.as_input::().cloned(), + 1 => last_h = trigger.as_input::().cloned(), + _ => {} + } + // Borrow both across .await — possible because both are + // owned by this async block, not borrowed from a caller frame. + if let (Some(t), Some(h)) = (&last_t, &last_h) { + producer.produce(heat_index(t, h)).await.ok(); + } + } + }) + }); }); ``` +`JoinEventRx` is the type of `rx` — a type-erased wrapper around the runtime's concrete +receiver, allocated once at task startup. Its `recv().await` returns `Ok(JoinTrigger)` +until all upstream input forwarders have exited. + Queue capacity is an internal runtime detail and is not part of the user-facing API. -Each runtime adapter documents its fixed default. +Each runtime adapter documents its fixed default (Tokio: 64, Embassy: 8, WASM: 64). --- ## Implementation Checklist ### Step 1 — Core unblocking -- [ ] Ensure `.transform()` compiles with embedded target (`thumbv7em-none-eabihf`) under `alloc`. -- [ ] Gate join-only exports/features from single-input path where needed. +- [x] Ensure `.transform()` compiles with embedded target (`thumbv7em-none-eabihf`) under `alloc`. +- [x] Gate join-only exports/features from single-input path where needed. ### Step 2 — Executor contract -- [ ] Add `join` module and fan-in traits to `aimdb-executor`. -- [ ] Add tests for trait behavior contract (bounded semantics, send/recv errors). -- [ ] Re-export new traits from `aimdb-executor` root. +- [x] Add `join` module and fan-in traits to `aimdb-executor`. +- [x] Add tests for trait behavior contract (bounded semantics, send/recv errors). +- [x] Re-export new traits from `aimdb-executor` root. ### Step 3 — Core refactor -- [ ] Remove direct `tokio::mpsc` usage from `aimdb-core` join pipeline. -- [ ] Refactor `JoinBuilder`/`JoinPipeline` to depend on executor join traits. -- [ ] Update `typed_api.rs` and extension macros to call unified join API. +- [x] Remove direct `tokio::mpsc` usage from `aimdb-core` join pipeline. +- [x] Refactor `JoinBuilder`/`JoinPipeline` to depend on executor join traits. `JoinStateBuilder` removed; `on_triggers(FnOnce)` task model adopted (see Q4). +- [x] Update `typed_api.rs` and extension macros to call unified join API. ### Step 4 — Runtime adapter implementations -- [ ] Implement join fan-in traits in `aimdb-tokio-adapter`. -- [ ] Implement join fan-in traits in `aimdb-embassy-adapter`. -- [ ] Implement join fan-in traits in `aimdb-wasm-adapter`. -- [ ] Enable any required optional dependencies and feature wiring. +- [x] Implement join fan-in traits in `aimdb-tokio-adapter`. +- [x] Implement join fan-in traits in `aimdb-embassy-adapter`. Gated on `#[cfg(all(feature = "embassy-runtime", feature = "alloc"))]`. +- [x] Implement join fan-in traits in `aimdb-wasm-adapter`. +- [x] Enable any required optional dependencies and feature wiring. ### Step 5 — Validation -- [ ] `cargo check --target thumbv7em-none-eabihf --features embassy-runtime` passes for `.transform()`. -- [ ] `cargo check --target thumbv7em-none-eabihf --features embassy-runtime` passes for `.transform_join()`. -- [ ] `cargo check --target wasm32-unknown-unknown -p aimdb-wasm-adapter` passes for join fan-in implementation. -- [ ] Workspace tests pass on std path. -- [ ] Add integration tests showing identical join behavior on Tokio, Embassy, and WASM adapters. +- [x] `cargo check --target thumbv7em-none-eabihf --features embassy-runtime` passes for `.transform()`. +- [x] `cargo check --target thumbv7em-none-eabihf --features embassy-runtime` passes for `.transform_join()`. Embassy target is ARM-only compile-checked; full execution tests run on Tokio/WASM. +- [x] `cargo check --target wasm32-unknown-unknown -p aimdb-wasm-adapter` passes for join fan-in implementation. +- [x] Workspace tests pass on std path (`make check`, `make all`). +- [x] Integration tests added for Tokio and WASM adapters showing `transform_join` with inline `on_triggers` closure. ### Docs -- [ ] Update transform docs to describe runtime-owned fan-in model. -- [ ] Document each adapter's fixed queue capacity and backpressure behaviour. +- [x] API Surface updated with `on_triggers` task-model example (this document). +- [x] Each adapter's fixed queue capacity documented (Q1 resolution below). - [ ] Update `020-M9-transform-api.md` with new join API notes. --- @@ -302,6 +320,31 @@ internal complexity. **Decision:** Rejected in favor of executor-owned contract. +### Alternative D — Keep the callback model (`on_trigger`) + +The original API used a per-event callback: + +```rust +.with_state(initial_state) +.on_trigger(|trigger, state, producer| { + // must return Pin> +}) +``` + +The framework owned the event loop and called the user closure once per incoming trigger. +This avoided user-visible `while` loops and felt similar to stream combinators. + +The cost: `Fut: 'static` forced every reference to `state` or `producer` to be cloned or +copied synchronously before the first `.await`. Any async work in the handler body needed a +`Pin>` allocation on every trigger even for no-op branches. Named function syntax +was required because async closures do not compose with HRTB in stable Rust. + +**Decision:** Rejected in favor of the task model (`on_triggers`). The task model costs one +`Box` at task startup instead of one per event, allows state to be borrowed freely across +`.await` points (owned by the `async move` block), allows inline closure syntax, and makes +the event loop explicit — which is also more composable (users can `select!` with other +futures or break early). + --- ## Risk & Constraints @@ -317,10 +360,9 @@ internal complexity. --- -## Open Questions & Required Clarifications +## Open Questions & Resolutions -The following gaps were identified during codebase review. Each needs an explicit decision -before the corresponding implementation step begins. +The following gaps were identified during codebase review. All are now resolved. --- @@ -352,8 +394,8 @@ The `.capacity()` method is removed from `JoinBuilder`. No user-facing knob exis runtime. If a specific workload needs a different size, the adapter constant can be raised in a future release. -**Decision needed**: Confirm the proposed per-adapter defaults (64 / 8 / 64), or nominate -different values. +**Resolved**: Per-adapter defaults confirmed and implemented — Tokio: 64, Embassy: 8, WASM: 64. +No user-facing capacity knob exists on any runtime. --- @@ -423,6 +465,12 @@ the Embassy `JoinFanInRuntime` impl must land in the same PR. If the method is a trait without the adapter impl, the `impl EmbassyRecordRegistrarExt for RecordRegistrar<..., EmbassyAdapter>` block will fail to satisfy the `JoinFanInRuntime` bound. +**Resolved**: All `#[cfg(feature = "std")]` gates on join types replaced with +`#[cfg(feature = "alloc")]` in `transform.rs`, `typed_api.rs`, and `lib.rs`. The macro +extension was updated in both the single-feature and multi-feature arms. Embassy adapter +`JoinFanInRuntime` impl gated on `#[cfg(all(feature = "embassy-runtime", feature = "alloc"))]` +and landed together with the macro change. + --- ### Q3 — `aimdb-wasm-adapter` is missing `futures-channel` for the WASM fan-in @@ -446,17 +494,118 @@ futures-channel = { version = "0.3", default-features = false, features = ["allo This is a one-line Cargo.toml change. No design decision needed; listing it here so it is not forgotten when the WASM fan-in PR is opened. -**Decision needed**: None. Confirm `futures::channel::mpsc` is the preferred queue primitive -for WASM, or nominate an alternative (e.g., `async-channel` which is `no_std + alloc` friendly). +**Resolved**: `futures-channel = { version = "0.3", features = ["std", "sink"] }` added to +`aimdb-wasm-adapter/Cargo.toml`. Note: `features = ["alloc"]` alone is insufficient — +`BiLock` inside `futures-channel` depends on `std::sync`; `std` is the minimum required +feature. `futures::channel::mpsc` confirmed as the WASM fan-in primitive. + +--- + +### Q4 — `on_trigger` callback model prevents full async in the handler + +**Problem** + +The current `on_trigger` signature: + +```rust +F: Fn(JoinTrigger, &mut S, &Producer) -> Fut + Send + Sync + 'static, +Fut: core::future::Future + Send + 'static, +``` + +requires `Fut: 'static`, but `state` and `producer` are borrowed from the framework's event +loop stack frame — not `'static`. Any future that references them would inherit their lifetime, +violating the bound. The consequence is that the handler body cannot borrow `state` or +`producer` across an `.await` point. All values needed in the async portion must be cloned or +copied out synchronously before the first `.await`, and every trigger — including no-op +branches — requires a `Box::pin()` heap allocation. + +**Root cause** + +The framework owns the event loop and calls user code as a *callback per event* (`Fn`). The +canonical way to express "this future borrows from its arguments" would be higher-ranked trait +bounds (`for<'a> Fn(&'a mut S) -> Fut<'a>`), but async closures do not compose cleanly with +HRTB in stable Rust. The API escapes the problem by requiring `Fut: 'static` and shifting the +burden onto the caller. + +The current workaround (synchronous state update → clone into `async move` → `Box::pin`) is +functional but non-obvious, requires a named function instead of an inline closure, and +allocates on every event. + +**Proposed alternative — task model** + +Change the ownership model: instead of the framework running `while let Ok(trigger) = rx.recv().await` +and calling a user callback on each iteration, hand the `rx` end of the fan-in queue directly +to a user-supplied `async` closure. The framework still creates the queue and spawns the +per-input forwarders; it no longer owns the event loop. + +```rust +// Current — callback model (framework event loop, user callback per event) +.with_state(initial_state) +.on_trigger(|trigger, state, producer| { + // must return Pin> + // cannot borrow state or producer across .await +}) + +// Proposed — task model (user owns the event loop and state) +.on_triggers(|mut rx, producer| async move { + let mut state = initial_state; + while let Ok(trigger) = rx.recv().await { + // state is owned — can borrow across .await freely + // producer is owned — no clone needed + match trigger.index() { ... } + if ready { + let result = some_async_computation(&state.field).await; + producer.produce(result).await; + } + } +}) +``` + +The `rx` parameter type would be `Box + Send>` — type-erased +once at task startup (one allocation per transform, not per event) to avoid exposing the +concrete runtime queue type in the public API. + +**Trade-offs** + +| | Callback (`Fn`) | Task (`FnOnce`) | +|---|---|---| +| `Box::pin` allocation | Per event | Once at task start | +| Borrow state across `.await` | No | Yes | +| `with_state()` builder step | Required | Gone (state lives in closure) | +| User writes event loop | No | Yes (more explicit) | +| Full async in handler | No | Yes | +| Inline closure syntax | No (named fn required) | Yes | + +The cost of the task model is that users write the `while let` loop themselves. This is more +explicit but also more flexible — users can `select!` across the trigger receiver and other +futures, implement their own timeout logic, or break early on a sentinel value. + +**Affected step**: Step 3 (core refactor — `run_join_transform`, `JoinStateBuilder`, +`on_trigger` API) and Step 4 (macro/extension trait signatures). + +**Resolved**: Task model adopted. `JoinStateBuilder` removed entirely. The new signature: + +```rust +pub fn on_triggers(self, handler: F) -> JoinPipeline +where + F: FnOnce(JoinEventRx, crate::Producer) -> Fut + Send + 'static, + Fut: core::future::Future + Send + 'static, +``` + +`JoinEventRx` is a type-erased wrapper (`Box`) allocated once at task +startup. Its `recv().await` is the user's event source. The framework still creates the fan-in +queue and spawns per-input forwarder tasks; user code owns the event loop. See +[Alternative D](#alternative-d--keep-the-callback-model-on_trigger) for the full trade-off +record. --- ## Acceptance Criteria -1. `aimdb-core` join code contains no Tokio- or Embassy-specific imports. -2. `aimdb-executor` exposes join fan-in runtime traits used by `aimdb-core`. -3. Tokio, Embassy, and WASM adapters implement join fan-in traits. -4. Embedded target build passes with both single-input and multi-input transform usage. -5. Runtime-specific queue types are not exposed in public transform API. -6. WASM target build (`wasm32-unknown-unknown`) passes with join fan-in enabled. -7. Join behavior is bounded and documented consistently across runtimes. +1. ✅ `aimdb-core` join code contains no Tokio- or Embassy-specific imports. +2. ✅ `aimdb-executor` exposes join fan-in runtime traits used by `aimdb-core`. +3. ✅ Tokio, Embassy, and WASM adapters implement join fan-in traits. +4. ✅ Embedded target build passes with both single-input and multi-input transform usage. +5. ✅ Runtime-specific queue types are not exposed in public transform API. `JoinEventRx` is the only type users see; its concrete queue is erased. +6. ✅ WASM target build (`wasm32-unknown-unknown`) passes with join fan-in enabled. +7. ✅ Join behavior is bounded and documented consistently across runtimes (Tokio: 64, Embassy: 8, WASM: 64). diff --git a/examples/weather-mesh-demo/weather-mesh-common/src/contracts/dew_point.rs b/examples/weather-mesh-demo/weather-mesh-common/src/contracts/dew_point.rs new file mode 100644 index 00000000..d6124aa0 --- /dev/null +++ b/examples/weather-mesh-demo/weather-mesh-common/src/contracts/dew_point.rs @@ -0,0 +1,64 @@ +//! Dew point derived measurement +//! +//! Computed from `Temperature` and `Humidity` via the Magnus approximation: +//! `T_dp ≈ T_celsius - (100 - RH_percent) / 5` +//! +//! Accurate to ±1°C for RH > 50%. Requires only basic f32 arithmetic — no libm. + +extern crate alloc; + +use aimdb_data_contracts::{Observable, SchemaType, Streamable}; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "linkable")] +use aimdb_data_contracts::Linkable; + +/// Dew point temperature derived from `Temperature` and `Humidity`. +/// +/// Not sensed directly — produced by a `transform_join` over +/// [`super::Temperature`] and [`super::Humidity`] records. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DewPoint { + /// Dew point in degrees Celsius + pub celsius: f32, + /// Unix timestamp (ms) of the most recent contributing sensor reading + pub timestamp: u64, +} + +impl SchemaType for DewPoint { + const NAME: &'static str = "dew_point"; +} + +impl Streamable for DewPoint {} + +impl Observable for DewPoint { + type Signal = f32; + const ICON: &'static str = "🌫️"; + const UNIT: &'static str = "°C"; + + fn signal(&self) -> f32 { + self.celsius + } + + fn format_log(&self, node_id: &str) -> alloc::string::String { + alloc::format!( + "{} [{}] DewPoint: {:.1}{} at {}", + Self::ICON, + node_id, + self.celsius, + Self::UNIT, + self.timestamp + ) + } +} + +#[cfg(feature = "linkable")] +impl Linkable for DewPoint { + fn from_bytes(data: &[u8]) -> Result { + serde_json::from_slice(data).map_err(|e| alloc::string::ToString::to_string(&e)) + } + + fn to_bytes(&self) -> Result, alloc::string::String> { + serde_json::to_vec(self).map_err(|e| alloc::string::ToString::to_string(&e)) + } +} diff --git a/examples/weather-mesh-demo/weather-mesh-common/src/contracts/mod.rs b/examples/weather-mesh-demo/weather-mesh-common/src/contracts/mod.rs index 602ee52f..530ba222 100644 --- a/examples/weather-mesh-demo/weather-mesh-common/src/contracts/mod.rs +++ b/examples/weather-mesh-demo/weather-mesh-common/src/contracts/mod.rs @@ -5,10 +5,12 @@ //! `Observable`, `Settable`, `Linkable`, `Simulatable`, and `Migratable` //! traits from `aimdb-data-contracts`. +pub mod dew_point; pub mod humidity; pub mod location; pub mod temperature; +pub use dew_point::DewPoint; pub use humidity::Humidity; pub use location::GpsLocation; pub use temperature::{Temperature, TemperatureV1, TemperatureV2}; diff --git a/examples/weather-mesh-demo/weather-mesh-common/src/lib.rs b/examples/weather-mesh-demo/weather-mesh-common/src/lib.rs index 7dd79085..41335c59 100644 --- a/examples/weather-mesh-demo/weather-mesh-common/src/lib.rs +++ b/examples/weather-mesh-demo/weather-mesh-common/src/lib.rs @@ -6,9 +6,9 @@ #![cfg_attr(not(feature = "std"), no_std)] -// Local contract definitions (Temperature, Humidity, GpsLocation) +// Local contract definitions (Temperature, Humidity, GpsLocation, DewPoint) pub mod contracts; -pub use contracts::{GpsLocation, Humidity, Temperature}; +pub use contracts::{DewPoint, GpsLocation, Humidity, Temperature}; // Re-export traits from aimdb-data-contracts pub use aimdb_data_contracts::{SchemaType, Settable, Streamable}; @@ -53,3 +53,23 @@ pub enum HumidityKey { #[link_address = "mqtt://sensors/gamma/humidity"] Gamma, } + +/// Dew point record keys for each weather station node. +/// +/// Dew point is derived from the corresponding [`TempKey`] and [`HumidityKey`] +/// via `transform_join` — not sensed directly. +#[derive(RecordKey, Clone, Copy, PartialEq, Eq, Debug)] +#[key_prefix = "dew_point."] +pub enum DewPointKey { + #[key = "alpha"] + #[link_address = "mqtt://sensors/alpha/dew_point"] + Alpha, + + #[key = "beta"] + #[link_address = "mqtt://sensors/beta/dew_point"] + Beta, + + #[key = "gamma"] + #[link_address = "mqtt://sensors/gamma/dew_point"] + Gamma, +} diff --git a/examples/weather-mesh-demo/weather-station-alpha/src/main.rs b/examples/weather-mesh-demo/weather-station-alpha/src/main.rs index 2251ac50..89d05d1e 100644 --- a/examples/weather-mesh-demo/weather-station-alpha/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-alpha/src/main.rs @@ -12,7 +12,7 @@ use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; use serde::Deserialize; use std::sync::Arc; use tracing::info; -use weather_mesh_common::{Humidity, HumidityKey, TempKey, Temperature}; +use weather_mesh_common::{DewPoint, DewPointKey, Humidity, HumidityKey, TempKey, Temperature}; #[tokio::main] async fn main() -> Result<(), Box> { @@ -73,11 +73,49 @@ async fn main() -> Result<(), Box> { .finish(); }); + // Configure dew point record — derived from temperature and humidity + let dew_point_topic = DewPointKey::Alpha.link_address().unwrap(); + builder.configure::(DewPointKey::Alpha, |reg| { + reg.buffer(BufferCfg::SpmcRing { capacity: 10 }) + .transform_join(|b| { + b.input::(TempKey::Alpha) + .input::(HumidityKey::Alpha) + .on_triggers(|mut rx, producer| async move { + let mut last_temp: Option = None; + let mut last_hum: Option = None; + while let Ok(trigger) = rx.recv().await { + match trigger.index() { + 0 => last_temp = trigger.as_input::().cloned(), + 1 => last_hum = trigger.as_input::().cloned(), + _ => {} + } + if let (Some(t), Some(h)) = (&last_temp, &last_hum) { + let dew_point = t.celsius - (100.0 - h.percent) / 5.0; + let timestamp = t.timestamp.max(h.timestamp); + let _ = producer + .produce(DewPoint { + celsius: dew_point, + timestamp, + }) + .await; + } + } + }) + }) + .link_to(dew_point_topic) + .with_serializer_raw(|d: &DewPoint| { + d.to_bytes() + .map_err(|_| aimdb_core::connector::SerializeError::InvalidData) + }) + .finish(); + }); + let db = builder.build().await?; - info!("✅ Database initialized with 2 record types"); + info!("✅ Database initialized with 3 record types"); info!(" - Temperature: {}", temp_topic); info!(" - Humidity: {}", humidity_topic); + info!(" - DewPoint: {} (derived via transform_join)", dew_point_topic); // Get producers let temp_producer = db.producer::(TempKey::Alpha.as_str()); @@ -129,6 +167,7 @@ async fn main() -> Result<(), Box> { info!("📡 Publishing to MQTT topics:"); info!(" - {}", temp_topic); info!(" - {}", humidity_topic); + info!(" - {}", dew_point_topic); info!(""); info!("Press Ctrl+C to stop"); diff --git a/examples/weather-mesh-demo/weather-station-beta/src/main.rs b/examples/weather-mesh-demo/weather-station-beta/src/main.rs index 13a44c0c..5f8cf2f3 100644 --- a/examples/weather-mesh-demo/weather-station-beta/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-beta/src/main.rs @@ -11,7 +11,7 @@ use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; use rand::SeedableRng; use std::sync::Arc; use tracing::info; -use weather_mesh_common::{Humidity, HumidityKey, TempKey, Temperature}; +use weather_mesh_common::{DewPoint, DewPointKey, Humidity, HumidityKey, TempKey, Temperature}; #[tokio::main] async fn main() -> Result<(), Box> { @@ -73,11 +73,49 @@ async fn main() -> Result<(), Box> { .finish(); }); + // Configure dew point record — derived from temperature and humidity + let dew_point_topic = DewPointKey::Beta.link_address().unwrap(); + builder.configure::(DewPointKey::Beta, |reg| { + reg.buffer(BufferCfg::SpmcRing { capacity: 10 }) + .transform_join(|b| { + b.input::(TempKey::Beta) + .input::(HumidityKey::Beta) + .on_triggers(|mut rx, producer| async move { + let mut last_temp: Option = None; + let mut last_hum: Option = None; + while let Ok(trigger) = rx.recv().await { + match trigger.index() { + 0 => last_temp = trigger.as_input::().cloned(), + 1 => last_hum = trigger.as_input::().cloned(), + _ => {} + } + if let (Some(t), Some(h)) = (&last_temp, &last_hum) { + let dew_point = t.celsius - (100.0 - h.percent) / 5.0; + let timestamp = t.timestamp.max(h.timestamp); + let _ = producer + .produce(DewPoint { + celsius: dew_point, + timestamp, + }) + .await; + } + } + }) + }) + .link_to(dew_point_topic) + .with_serializer_raw(|d: &DewPoint| { + d.to_bytes() + .map_err(|_| aimdb_core::connector::SerializeError::InvalidData) + }) + .finish(); + }); + let db = builder.build().await?; - info!("✅ Database initialized with 2 record types"); + info!("✅ Database initialized with 3 record types"); info!(" - Temperature: {} (synthetic)", temp_topic); info!(" - Humidity: {} (synthetic)", humidity_topic); + info!(" - DewPoint: {} (derived via transform_join)", dew_point_topic); // Get producers let temp_producer = db.producer::(TempKey::Beta.as_str()); @@ -151,6 +189,7 @@ async fn main() -> Result<(), Box> { info!("📡 Publishing to MQTT topics:"); info!(" - {}", temp_topic); info!(" - {}", humidity_topic); + info!(" - {}", dew_point_topic); info!(""); info!("Press Ctrl+C to stop"); diff --git a/examples/weather-mesh-demo/weather-station-gamma/Cargo.toml b/examples/weather-mesh-demo/weather-station-gamma/Cargo.toml index 55256d93..6a1f2b7b 100644 --- a/examples/weather-mesh-demo/weather-station-gamma/Cargo.toml +++ b/examples/weather-mesh-demo/weather-station-gamma/Cargo.toml @@ -22,8 +22,9 @@ aimdb-core = { path = "../../../aimdb-core", default-features = false, features "alloc", ] } aimdb-embassy-adapter = { path = "../../../aimdb-embassy-adapter", default-features = false, features = [ + "alloc", "embassy-runtime", - "embassy-task-pool-8", # Need ~6 tasks: 2 producers + net + MQTT tasks + "embassy-task-pool-16", # 2 producers + net + MQTT + join transform + 2 forwarders ] } aimdb-executor = { path = "../../../aimdb-executor", default-features = false, features = [ "embassy-types", diff --git a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs index d5cb8286..08e42f7d 100644 --- a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs @@ -41,7 +41,7 @@ use embassy_stm32::{bind_interrupts, eth, peripherals, rng, Config}; use embassy_time::{Duration, Timer}; use rand::SeedableRng; use static_cell::StaticCell; -use weather_mesh_common::{Humidity, HumidityKey, Temperature, TempKey}; +use weather_mesh_common::{DewPoint, DewPointKey, Humidity, HumidityKey, Temperature, TempKey}; use {defmt_rtt as _, panic_probe as _}; // Simple embedded allocator @@ -310,9 +310,61 @@ async fn main(spawner: Spawner) { .finish(); }); + // Configure dew point record — derived from temperature and humidity + // + // Showcases the transform_join task model: the closure owns its state and + // can hold borrows across .await without Box::pin or manual cloning. + let dew_point_topic = DewPointKey::Gamma.link_address().unwrap(); + builder.configure::(DewPointKey::Gamma, |reg| { + reg.buffer_sized::<8, 1>(EmbassyBufferType::SpmcRing) + .transform_join(|b| { + b.input::(TempKey::Gamma) + .input::(HumidityKey::Gamma) + .on_triggers(|mut rx, producer| async move { + let mut last_temp: Option = None; + let mut last_hum: Option = None; + while let Ok(trigger) = rx.recv().await { + match trigger.index() { + 0 => last_temp = trigger.as_input::().cloned(), + 1 => last_hum = trigger.as_input::().cloned(), + _ => {} + } + // Borrow both inputs across the .await — possible because + // last_temp and last_hum are owned by this async block. + if let (Some(t), Some(h)) = (&last_temp, &last_hum) { + // Magnus approximation: T_dp ≈ T - (100 - RH) / 5 + let dew_point = t.celsius - (100.0 - h.percent) / 5.0; + let timestamp = t.timestamp.max(h.timestamp); + let _ = producer + .produce(DewPoint { + celsius: dew_point, + timestamp, + }) + .await; + } + } + }) + }) + .link_to(dew_point_topic) + .with_serializer_raw(|d: &DewPoint| { + let whole = d.celsius as i32; + let frac = ((d.celsius - whole as f32).abs() * 10.0 + 0.5) as i32 % 10; + Ok(alloc::format!( + r#"{{"celsius":{}.{},"timestamp":{}}} +"#, + whole, + frac, + d.timestamp + ) + .into_bytes()) + }) + .finish(); + }); + info!("✅ Database configured with synthetic sensors:"); info!(" Temperature: {}", temp_topic); info!(" Humidity: {}", humidity_topic); + info!(" DewPoint: {} (derived via transform_join)", dew_point_topic); info!(" Broker: {}:{}", MQTT_BROKER_IP, MQTT_BROKER_PORT); info!(""); From 923d9eb0519df60824b197a6a1b2b8459c36e03c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 3 May 2026 06:57:51 +0000 Subject: [PATCH 08/17] fix: format logging output for DewPoint in weather station examples --- examples/weather-mesh-demo/weather-station-alpha/src/main.rs | 5 ++++- examples/weather-mesh-demo/weather-station-beta/src/main.rs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/weather-mesh-demo/weather-station-alpha/src/main.rs b/examples/weather-mesh-demo/weather-station-alpha/src/main.rs index 89d05d1e..2a8707ce 100644 --- a/examples/weather-mesh-demo/weather-station-alpha/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-alpha/src/main.rs @@ -115,7 +115,10 @@ async fn main() -> Result<(), Box> { info!("✅ Database initialized with 3 record types"); info!(" - Temperature: {}", temp_topic); info!(" - Humidity: {}", humidity_topic); - info!(" - DewPoint: {} (derived via transform_join)", dew_point_topic); + info!( + " - DewPoint: {} (derived via transform_join)", + dew_point_topic + ); // Get producers let temp_producer = db.producer::(TempKey::Alpha.as_str()); diff --git a/examples/weather-mesh-demo/weather-station-beta/src/main.rs b/examples/weather-mesh-demo/weather-station-beta/src/main.rs index 5f8cf2f3..85823b1b 100644 --- a/examples/weather-mesh-demo/weather-station-beta/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-beta/src/main.rs @@ -115,7 +115,10 @@ async fn main() -> Result<(), Box> { info!("✅ Database initialized with 3 record types"); info!(" - Temperature: {} (synthetic)", temp_topic); info!(" - Humidity: {} (synthetic)", humidity_topic); - info!(" - DewPoint: {} (derived via transform_join)", dew_point_topic); + info!( + " - DewPoint: {} (derived via transform_join)", + dew_point_topic + ); // Get producers let temp_producer = db.producer::(TempKey::Beta.as_str()); From 4abf01fdbcef004cb75c9d62e393ccf69220b3d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 3 May 2026 11:51:49 +0000 Subject: [PATCH 09/17] fix: update Rust toolchain version and clean up logging output for DewPoint --- .../weather-station-gamma/rust-toolchain.toml | 2 +- .../weather-mesh-demo/weather-station-gamma/src/main.rs | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/examples/weather-mesh-demo/weather-station-gamma/rust-toolchain.toml b/examples/weather-mesh-demo/weather-station-gamma/rust-toolchain.toml index b4f3adf4..a78c2e66 100644 --- a/examples/weather-mesh-demo/weather-station-gamma/rust-toolchain.toml +++ b/examples/weather-mesh-demo/weather-station-gamma/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.90" +channel = "1.91" components = ["rust-src", "rustfmt", "llvm-tools"] targets = ["thumbv8m.main-none-eabihf"] diff --git a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs index 08e42f7d..1f1ffa74 100644 --- a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs @@ -41,7 +41,7 @@ use embassy_stm32::{bind_interrupts, eth, peripherals, rng, Config}; use embassy_time::{Duration, Timer}; use rand::SeedableRng; use static_cell::StaticCell; -use weather_mesh_common::{DewPoint, DewPointKey, Humidity, HumidityKey, Temperature, TempKey}; +use weather_mesh_common::{DewPoint, DewPointKey, Humidity, HumidityKey, TempKey, Temperature}; use {defmt_rtt as _, panic_probe as _}; // Simple embedded allocator @@ -350,8 +350,7 @@ async fn main(spawner: Spawner) { let whole = d.celsius as i32; let frac = ((d.celsius - whole as f32).abs() * 10.0 + 0.5) as i32 % 10; Ok(alloc::format!( - r#"{{"celsius":{}.{},"timestamp":{}}} -"#, + r#"{{"celsius":{}.{},"timestamp":{}}}"#, whole, frac, d.timestamp @@ -364,7 +363,7 @@ async fn main(spawner: Spawner) { info!("✅ Database configured with synthetic sensors:"); info!(" Temperature: {}", temp_topic); info!(" Humidity: {}", humidity_topic); - info!(" DewPoint: {} (derived via transform_join)", dew_point_topic); + info!(" DewPoint: {}", dew_point_topic); info!(" Broker: {}:{}", MQTT_BROKER_IP, MQTT_BROKER_PORT); info!(""); From 9aed5e6fba6d338d9e3cdb40ba3f12f6dd63992c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 3 May 2026 18:29:09 +0000 Subject: [PATCH 10/17] feat: enhance SPMC Ring buffer error handling and update consumer count in weather station demo --- aimdb-embassy-adapter/src/buffer.rs | 24 ++++++++++++++----- aimdb-embassy-adapter/src/lib.rs | 13 +++++++--- .../weather-station-gamma/flash.sh | 19 +++++++++++++++ .../weather-station-gamma/src/main.rs | 7 ++++-- 4 files changed, 52 insertions(+), 11 deletions(-) create mode 100755 examples/weather-mesh-demo/weather-station-gamma/flash.sh diff --git a/aimdb-embassy-adapter/src/buffer.rs b/aimdb-embassy-adapter/src/buffer.rs index 00ad3d2f..e0aaa984 100644 --- a/aimdb-embassy-adapter/src/buffer.rs +++ b/aimdb-embassy-adapter/src/buffer.rs @@ -351,9 +351,15 @@ impl< PUBS, > = unsafe { &*(channel as *const _) }; self.spmc_subscriber = Some( - channel_static - .subscriber() - .map_err(|_| DbError::BufferClosed { _buffer_name: () })?, + channel_static.subscriber().map_err(|_| { + defmt::error!( + "AimDB: SpmcRing subscriber slot exhausted (max SUBS={}). \ + Increase the CONSUMERS const generic on buffer_sized. \ + Count one slot per link_to connector plus one per transform_join input.", + SUBS + ); + DbError::BufferClosed { _buffer_name: () } + })?, ); } match self.spmc_subscriber.as_mut().unwrap().next_message().await { @@ -411,9 +417,15 @@ impl< PUBS, > = unsafe { &*(channel as *const _) }; self.spmc_subscriber = Some( - channel_static - .subscriber() - .map_err(|_| DbError::BufferClosed { _buffer_name: () })?, + channel_static.subscriber().map_err(|_| { + defmt::error!( + "AimDB: SpmcRing subscriber slot exhausted (max SUBS={}). \ + Increase the CONSUMERS const generic on buffer_sized. \ + Count one slot per link_to connector plus one per transform_join input.", + SUBS + ); + DbError::BufferClosed { _buffer_name: () } + })?, ); } match self diff --git a/aimdb-embassy-adapter/src/lib.rs b/aimdb-embassy-adapter/src/lib.rs index bf6019ac..39580ec4 100644 --- a/aimdb-embassy-adapter/src/lib.rs +++ b/aimdb-embassy-adapter/src/lib.rs @@ -259,9 +259,16 @@ where /// reg.buffer_sized::<1, 4>(EmbassyBufferType::Mailbox) /// ``` /// - /// # Rule of Thumb - /// - Set `CAP` to your desired ring buffer size for SPMC - /// - Set `CONSUMERS` to match your number of `.tap()` consumers + /// # Counting CONSUMERS + /// + /// `CONSUMERS` must cover **every** entity that calls `.subscribe()` on this record: + /// - Each `.tap()` consumer: +1 + /// - Each `.link_to()` outbound connector: +1 + /// - Each `transform_join` that lists this record as an input: +1 + /// + /// Exhausting the slot count is a silent failure at runtime (the subscriber exits + /// immediately, producing no output). A `defmt::error!` is emitted, but set + /// `CONSUMERS` high enough to avoid it altogether. fn buffer_sized( &'a mut self, buffer_type: EmbassyBufferType, diff --git a/examples/weather-mesh-demo/weather-station-gamma/flash.sh b/examples/weather-mesh-demo/weather-station-gamma/flash.sh new file mode 100755 index 00000000..8ef1d291 --- /dev/null +++ b/examples/weather-mesh-demo/weather-station-gamma/flash.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Flash script for embassy-mqtt-connector-demo +# +# This script should be run on the HOST machine where probe-rs and hardware are accessible. +# The binary must be built first in the dev container using: cargo build + +set -e + +BINARY="../../../target/thumbv8m.main-none-eabihf/debug/weather-station-gamma" + +if [ ! -f "$BINARY" ]; then + echo "Error: Binary not found at $BINARY" + echo "Please build it first in the dev container:" + echo " cd examples/weather-station-gamma && cargo build" + exit 1 +fi + +echo "Flashing weather-station-gamma to STM32H563ZITx..." +probe-rs run --chip STM32H563ZITx "$BINARY" diff --git a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs index 1f1ffa74..d295853a 100644 --- a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs @@ -269,7 +269,7 @@ async fn main(spawner: Spawner) { // Configure temperature record let temp_topic = TempKey::Gamma.link_address().unwrap(); builder.configure::(TempKey::Gamma, |reg| { - reg.buffer_sized::<16, 1>(EmbassyBufferType::SpmcRing) + reg.buffer_sized::<16, 2>(EmbassyBufferType::SpmcRing) .source(temperature_producer) .link_to(temp_topic) .with_serializer_raw(|t: &Temperature| { @@ -291,7 +291,7 @@ async fn main(spawner: Spawner) { // Configure humidity record let humidity_topic = HumidityKey::Gamma.link_address().unwrap(); builder.configure::(HumidityKey::Gamma, |reg| { - reg.buffer_sized::<16, 1>(EmbassyBufferType::SpmcRing) + reg.buffer_sized::<16, 2>(EmbassyBufferType::SpmcRing) .source(humidity_producer) .link_to(humidity_topic) .with_serializer_raw(|h: &Humidity| { @@ -335,6 +335,9 @@ async fn main(spawner: Spawner) { // Magnus approximation: T_dp ≈ T - (100 - RH) / 5 let dew_point = t.celsius - (100.0 - h.percent) / 5.0; let timestamp = t.timestamp.max(h.timestamp); + let whole = dew_point as i32; + let frac = ((dew_point - whole as f32).abs() * 10.0 + 0.5) as i32 % 10; + info!("📊 DewPoint: {}.{}°C", whole, frac); let _ = producer .produce(DewPoint { celsius: dew_point, From 5ec309a39be77cbd8f357430378c14b7dc7fceac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 3 May 2026 19:07:44 +0000 Subject: [PATCH 11/17] feat: log computed dew point values in weather station demos --- .../weather-mesh-demo/weather-station-alpha/src/main.rs | 6 ++++++ examples/weather-mesh-demo/weather-station-beta/src/main.rs | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/examples/weather-mesh-demo/weather-station-alpha/src/main.rs b/examples/weather-mesh-demo/weather-station-alpha/src/main.rs index 2a8707ce..9aee8873 100644 --- a/examples/weather-mesh-demo/weather-station-alpha/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-alpha/src/main.rs @@ -92,6 +92,12 @@ async fn main() -> Result<(), Box> { if let (Some(t), Some(h)) = (&last_temp, &last_hum) { let dew_point = t.celsius - (100.0 - h.percent) / 5.0; let timestamp = t.timestamp.max(h.timestamp); + tracing::info!( + "💧 DewPoint computed: {:.2}°C (T={:.1}°C, RH={:.1}%)", + dew_point, + t.celsius, + h.percent + ); let _ = producer .produce(DewPoint { celsius: dew_point, diff --git a/examples/weather-mesh-demo/weather-station-beta/src/main.rs b/examples/weather-mesh-demo/weather-station-beta/src/main.rs index 85823b1b..6e536097 100644 --- a/examples/weather-mesh-demo/weather-station-beta/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-beta/src/main.rs @@ -92,6 +92,12 @@ async fn main() -> Result<(), Box> { if let (Some(t), Some(h)) = (&last_temp, &last_hum) { let dew_point = t.celsius - (100.0 - h.percent) / 5.0; let timestamp = t.timestamp.max(h.timestamp); + tracing::info!( + "💧 DewPoint computed: {:.2}°C (T={:.1}°C, RH={:.1}%)", + dew_point, + t.celsius, + h.percent + ); let _ = producer .produce(DewPoint { celsius: dew_point, From e94b20123d8a28f6fd0a3ee18b5f3550b2be79d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 4 May 2026 19:32:20 +0000 Subject: [PATCH 12/17] fix: update embassy subproject commit reference --- _external/embassy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_external/embassy b/_external/embassy index 9b080fc7..756c725f 160000 --- a/_external/embassy +++ b/_external/embassy @@ -1 +1 @@ -Subproject commit 9b080fc7c9aafe75c781428b320aa1d38a2ba85f +Subproject commit 756c725fca76c829d10c4a69d77d175c46829e65 From 80e45b0d118e7251c3b5468aa7cb7e104e75deaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 4 May 2026 20:18:11 +0000 Subject: [PATCH 13/17] fix: update embassy USB driver and synopsys OTG package versions --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7128a31f..3c9f0674 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1169,7 +1169,7 @@ dependencies = [ [[package]] name = "embassy-usb-driver" -version = "0.2.0" +version = "0.2.1" dependencies = [ "defmt 1.0.1", "embedded-io-async 0.7.0", @@ -1177,7 +1177,7 @@ dependencies = [ [[package]] name = "embassy-usb-synopsys-otg" -version = "0.3.2" +version = "0.3.3" dependencies = [ "critical-section", "defmt 1.0.1", From b5945a83d753bc2283fe8a6ba13de42e2218c3ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 4 May 2026 21:04:13 +0000 Subject: [PATCH 14/17] feat: add no_std support for transform API and update dependencies --- aimdb-core/src/lib.rs | 3 + aimdb-core/src/transform/join.rs | 27 ++++- aimdb-core/src/transform/mod.rs | 5 - aimdb-core/src/transform/single.rs | 1 - aimdb-embassy-adapter/src/join_queue.rs | 19 ++- .../tests/transform_join_integration_tests.rs | 108 +++++++++++++++++- .../weather-station-gamma/Cargo.toml | 4 +- 7 files changed, 144 insertions(+), 23 deletions(-) diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index 3446ea87..2d5cc7ce 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -16,6 +16,9 @@ #![cfg_attr(not(feature = "std"), no_std)] +#[cfg(feature = "alloc")] +extern crate alloc; + pub mod buffer; pub mod builder; pub mod connector; diff --git a/aimdb-core/src/transform/join.rs b/aimdb-core/src/transform/join.rs index 6533f3ce..589c4bc2 100644 --- a/aimdb-core/src/transform/join.rs +++ b/aimdb-core/src/transform/join.rs @@ -2,7 +2,6 @@ use core::any::Any; use core::fmt::Debug; use core::marker::PhantomData; -extern crate alloc; use alloc::{ boxed::Box, string::{String, ToString}, @@ -85,6 +84,16 @@ impl JoinEventRx { /// Receive the next trigger event. /// /// Returns `Ok(JoinTrigger)` when an input fires, or `Err` when all inputs are closed. + /// + /// # Runtime portability + /// + /// On Tokio and WASM, the channel closes once every input forwarder has + /// dropped its sender, and `recv` returns `Err`, ending any + /// `while let Ok(_) = rx.recv().await` loop. + /// + /// On Embassy the channel **never** closes — this branch is unreachable + /// and the loop runs for the device lifetime. Portable handlers should + /// not rely on the loop exiting to release resources. pub async fn recv(&mut self) -> ExecutorResult { self.inner.recv_boxed().await } @@ -289,11 +298,17 @@ async fn run_join_transform( output_key ); - let runtime: &R = runtime_ctx - .downcast_ref::>() - .map(|arc| arc.as_ref()) - .or_else(|| runtime_ctx.downcast_ref::()) - .expect("Failed to extract runtime from context for join transform"); + let runtime: &R = match runtime_ctx.downcast_ref::() { + Some(r) => r, + None => { + #[cfg(feature = "tracing")] + tracing::error!( + "🔄 Join transform '{}' FATAL: runtime context downcast failed", + output_key + ); + return; + } + }; let queue = match runtime.create_join_queue::() { Ok(q) => q, diff --git a/aimdb-core/src/transform/mod.rs b/aimdb-core/src/transform/mod.rs index fc2f1b12..c7e5665b 100644 --- a/aimdb-core/src/transform/mod.rs +++ b/aimdb-core/src/transform/mod.rs @@ -13,7 +13,6 @@ use core::any::Any; use core::fmt::Debug; -extern crate alloc; use alloc::{boxed::Box, string::String, sync::Arc, vec::Vec}; use crate::typed_record::BoxFuture; @@ -27,10 +26,6 @@ pub use single::{StatefulTransformBuilder, TransformBuilder, TransformPipeline}; #[cfg(feature = "alloc")] pub use join::{JoinBuilder, JoinEventRx, JoinPipeline, JoinTrigger}; -// JoinTrigger is always available (no std dependency) -#[cfg(not(feature = "alloc"))] -pub use join::JoinTrigger; - // ============================================================================ // TransformDescriptor — stored per output record in TypedRecord // ============================================================================ diff --git a/aimdb-core/src/transform/single.rs b/aimdb-core/src/transform/single.rs index c5130274..c06fd5f2 100644 --- a/aimdb-core/src/transform/single.rs +++ b/aimdb-core/src/transform/single.rs @@ -1,7 +1,6 @@ use core::fmt::Debug; use core::marker::PhantomData; -extern crate alloc; use alloc::{ boxed::Box, string::{String, ToString}, diff --git a/aimdb-embassy-adapter/src/join_queue.rs b/aimdb-embassy-adapter/src/join_queue.rs index 5cba7e1c..b4d0f318 100644 --- a/aimdb-embassy-adapter/src/join_queue.rs +++ b/aimdb-embassy-adapter/src/join_queue.rs @@ -101,12 +101,19 @@ impl JoinFanInRuntime for EmbassyAdapter { // These tests cover: roundtrip ordering, bounded backpressure, and sender cloning. // Embassy channels do not close — there are no QueueClosed scenarios to test. // -// NOTE: these tests require the ARM embedded target. They compile as part of -// `cargo check --target thumbv7em-none-eabihf --features embassy-runtime` but -// cannot run on x86_64 because the workspace `embassy-executor` uses -// `platform-cortex-m` (ARM assembly). Run them on an Embassy-capable board or -// ARM simulator. The `critical-section` dev-dep with `std` feature satisfies -// the CriticalSectionRawMutex requirement for the channel on the target. +// NOTE: the tests themselves only depend on `embassy_sync::Channel` and +// `futures::executor::block_on`, both of which are host-portable. The +// `critical-section` dev-dep with `std` feature is provided so the +// `CriticalSectionRawMutex` link target is satisfied on host. +// +// However, the tests live in a module gated on `feature = "embassy-runtime"`, +// which transitively pulls in `embassy-executor`'s `platform-cortex-m` (ARM +// assembly) and so does not compile under `cargo test` on x86_64. As a result +// they are NOT exercised by `make check` / `make all` today — only by +// `cargo check --target thumbv7em-none-eabihf --features embassy-runtime`, +// which type-checks but does not execute them. Run them manually on an +// Embassy-capable board or ARM simulator, or via a host-side harness that +// builds the queue module without the executor. #[cfg(test)] mod tests { use super::*; diff --git a/aimdb-tokio-adapter/tests/transform_join_integration_tests.rs b/aimdb-tokio-adapter/tests/transform_join_integration_tests.rs index 92a680bf..d19336c4 100644 --- a/aimdb-tokio-adapter/tests/transform_join_integration_tests.rs +++ b/aimdb-tokio-adapter/tests/transform_join_integration_tests.rs @@ -33,11 +33,14 @@ async fn transform_join_produces_sum_on_both_inputs() { let runtime = Arc::new(TokioAdapter::new().unwrap()); let mut builder = AimDbBuilder::new().runtime(runtime); + // SpmcRing inputs (vs SingleLatest) so that values produced before the join + // transform's forwarders subscribe are still buffered and replayed — removes + // a startup race where the test might otherwise need a hand-tuned barrier. builder.configure::("test::A", |reg| { - reg.buffer(BufferCfg::SingleLatest); + reg.buffer(BufferCfg::SpmcRing { capacity: 16 }); }); builder.configure::("test::B", |reg| { - reg.buffer(BufferCfg::SingleLatest); + reg.buffer(BufferCfg::SpmcRing { capacity: 16 }); }); builder.configure::("test::Sum", |reg| { reg.buffer(BufferCfg::SpmcRing { capacity: 16 }) @@ -92,3 +95,104 @@ async fn transform_join_produces_sum_on_both_inputs() { .unwrap(); assert_eq!(s.0, 22, "expected 2+20=22"); } + +/// Stress test for the bounded(64) Tokio fan-in channel: pushes well over 64 +/// events through a single-input join while the handler intentionally yields +/// between receives. If backpressure is wired correctly, this completes +/// without deadlock and every produced value is observed in order. +#[tokio::test] +async fn transform_join_bounded_fanin_backpressure_no_deadlock() { + const N: u32 = 200; + const SENTINEL: u32 = u32::MAX; + let cap = (N + 16) as usize; + + let runtime = Arc::new(TokioAdapter::new().unwrap()); + let mut builder = AimDbBuilder::new().runtime(runtime); + + // Input/output rings sized larger than the bounded(64) fan-in so the SpmcRing + // itself isn't the limiter — we want the bounded channel to be the bottleneck. + builder.configure::("stress::A", |reg| { + reg.buffer(BufferCfg::SpmcRing { capacity: cap }); + }); + builder.configure::("stress::Echo", |reg| { + reg.buffer(BufferCfg::SpmcRing { capacity: cap }) + .transform_join(|b| { + b.input::("stress::A") + .on_triggers(|mut rx, producer| async move { + while let Ok(trigger) = rx.recv().await { + if let Some(v) = trigger.as_input::().copied() { + // Yield between receives to keep the fan-in channel + // pressured well above its 64-slot capacity. + tokio::task::yield_now().await; + let _ = producer.produce(Sum(v.0)).await; + } + } + }) + }); + }); + + let db = builder.build().await.unwrap(); + let mut echo_rx = db.subscribe::("stress::Echo").unwrap(); + + // Warm-up: keep producing a sentinel until its echo lands. SpmcRing buffers + // are tokio broadcast channels, so subscribers (including the join input + // forwarder) only see values produced after they subscribe — the round-trip + // gives us a deterministic barrier for "forwarder is up". + { + let warmup_db = db.clone(); + let warmup = tokio::spawn(async move { + loop { + warmup_db + .produce::("stress::A", ValueA(SENTINEL)) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(5)).await; + } + }); + loop { + let s = tokio::time::timeout(Duration::from_secs(2), echo_rx.recv()) + .await + .expect("warm-up: join forwarder did not subscribe in time") + .unwrap(); + if s.0 == SENTINEL { + break; + } + } + warmup.abort(); + let _ = warmup.await; + } + + // Drain any remaining warm-up echoes so the burst checker sees a clean stream. + while let Ok(Ok(s)) = tokio::time::timeout(Duration::from_millis(50), echo_rx.recv()).await { + assert_eq!( + s.0, SENTINEL, + "only warm-up sentinels should be in flight here" + ); + } + + // Burst N events. The join handler yields between every receive, so the + // bounded(64) fan-in fills up and backpressures the input forwarder. A + // missing or broken backpressure path would deadlock here. + let producer_db = db.clone(); + let producer_task = tokio::spawn(async move { + for i in 0..N { + producer_db + .produce::("stress::A", ValueA(i)) + .await + .unwrap(); + } + }); + + for expected in 0..N { + let s = tokio::time::timeout(Duration::from_secs(5), echo_rx.recv()) + .await + .expect("backpressured fan-in should not deadlock") + .unwrap(); + assert_eq!( + s.0, expected, + "values must arrive in order under backpressure" + ); + } + + producer_task.await.unwrap(); +} diff --git a/examples/weather-mesh-demo/weather-station-gamma/Cargo.toml b/examples/weather-mesh-demo/weather-station-gamma/Cargo.toml index 6a1f2b7b..a99d5eff 100644 --- a/examples/weather-mesh-demo/weather-station-gamma/Cargo.toml +++ b/examples/weather-mesh-demo/weather-station-gamma/Cargo.toml @@ -26,9 +26,7 @@ aimdb-embassy-adapter = { path = "../../../aimdb-embassy-adapter", default-featu "embassy-runtime", "embassy-task-pool-16", # 2 producers + net + MQTT + join transform + 2 forwarders ] } -aimdb-executor = { path = "../../../aimdb-executor", default-features = false, features = [ - "embassy-types", -] } +aimdb-executor = { path = "../../../aimdb-executor", default-features = false } aimdb-data-contracts = { path = "../../../aimdb-data-contracts", default-features = false, features = [ "simulatable", ] } From 2b6d94ce6b603b04f7e6d99e08e17b862b228568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Tue, 5 May 2026 03:57:08 +0000 Subject: [PATCH 15/17] feat: improve dew point formatting in weather station demo and remove unused input keys --- aimdb-core/src/transform/join.rs | 2 -- aimdb-embassy-adapter/src/join_queue.rs | 3 ++- .../weather-station-gamma/src/main.rs | 20 +++++++++++++------ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/aimdb-core/src/transform/join.rs b/aimdb-core/src/transform/join.rs index 589c4bc2..1ea6bd11 100644 --- a/aimdb-core/src/transform/join.rs +++ b/aimdb-core/src/transform/join.rs @@ -238,7 +238,6 @@ where inputs.iter().map(|(k, _)| k.clone()).collect(); JoinPipeline { - _input_keys: input_keys_for_descriptor.clone(), spawn_factory: Box::new(move |_| TransformDescriptor { input_keys: input_keys_for_descriptor, spawn_fn: Box::new(move |producer, db, ctx| { @@ -255,7 +254,6 @@ where /// [`RecordRegistrar::transform_join`]. Not normally constructed directly. #[cfg(feature = "alloc")] pub struct JoinPipeline { - pub(crate) _input_keys: Vec, pub(crate) spawn_factory: Box TransformDescriptor + Send>, } diff --git a/aimdb-embassy-adapter/src/join_queue.rs b/aimdb-embassy-adapter/src/join_queue.rs index b4d0f318..c756cd78 100644 --- a/aimdb-embassy-adapter/src/join_queue.rs +++ b/aimdb-embassy-adapter/src/join_queue.rs @@ -1,3 +1,5 @@ +extern crate alloc; + use aimdb_executor::{ExecutorResult, JoinQueue, JoinReceiver, JoinSender}; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::channel::Channel; @@ -84,7 +86,6 @@ impl JoinFanInRuntime for EmbassyAdapter { type JoinQueue = EmbassyJoinQueue; fn create_join_queue(&self) -> ExecutorResult> { - extern crate alloc; // Leak the channel to obtain a 'static reference. // Called once per join transform at database startup — the leak is intentional // and matches the DB lifetime on embedded targets. diff --git a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs index d295853a..bb29f2cd 100644 --- a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs @@ -335,9 +335,13 @@ async fn main(spawner: Spawner) { // Magnus approximation: T_dp ≈ T - (100 - RH) / 5 let dew_point = t.celsius - (100.0 - h.percent) / 5.0; let timestamp = t.timestamp.max(h.timestamp); - let whole = dew_point as i32; - let frac = ((dew_point - whole as f32).abs() * 10.0 + 0.5) as i32 % 10; - info!("📊 DewPoint: {}.{}°C", whole, frac); + // Format around magnitude so sign survives -1 < x < 0. + let neg = dew_point < 0.0; + let mag = if neg { -dew_point } else { dew_point }; + let whole = mag as i32; + let frac = ((mag - whole as f32) * 10.0 + 0.5) as i32 % 10; + let sign = if neg { "-" } else { "" }; + info!("📊 DewPoint: {}{}.{}°C", sign, whole, frac); let _ = producer .produce(DewPoint { celsius: dew_point, @@ -350,10 +354,14 @@ async fn main(spawner: Spawner) { }) .link_to(dew_point_topic) .with_serializer_raw(|d: &DewPoint| { - let whole = d.celsius as i32; - let frac = ((d.celsius - whole as f32).abs() * 10.0 + 0.5) as i32 % 10; + let neg = d.celsius < 0.0; + let mag = if neg { -d.celsius } else { d.celsius }; + let whole = mag as i32; + let frac = ((mag - whole as f32) * 10.0 + 0.5) as i32 % 10; + let sign = if neg { "-" } else { "" }; Ok(alloc::format!( - r#"{{"celsius":{}.{},"timestamp":{}}}"#, + r#"{{"celsius":{}{}.{},"timestamp":{}}}"#, + sign, whole, frac, d.timestamp From 429617658fad5297cd2f2bb818228ce8d155bddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Tue, 5 May 2026 04:02:39 +0000 Subject: [PATCH 16/17] docs: update changelogs for no_std transform API (#73) Cover the runtime-agnostic join fan-in (Design 027), the breaking on_triggers task-model handler, and per-adapter join-queue impls across aimdb-core, aimdb-executor, aimdb-codegen, and the Tokio, Embassy, and WASM adapters. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 6 ++++++ aimdb-codegen/CHANGELOG.md | 16 +++++++++++++++- aimdb-core/CHANGELOG.md | 7 +++++++ aimdb-embassy-adapter/CHANGELOG.md | 8 ++++++++ aimdb-executor/CHANGELOG.md | 8 +++++++- aimdb-tokio-adapter/CHANGELOG.md | 5 ++++- aimdb-wasm-adapter/CHANGELOG.md | 9 ++++++++- 7 files changed, 55 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d2e40a5..cd175855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`no_std` Transform API (Design 027)**: `.transform()` and `.transform_join()` are now available on `no_std + alloc` targets — no longer Tokio-only. Multi-input join fan-in moved out of `aimdb-core` into the new `JoinFanInRuntime` traits in `aimdb-executor`, with implementations in the Tokio (`mpsc::channel`, capacity 64), Embassy (`embassy_sync::Channel`, capacity 8), and WASM (`futures_channel::mpsc`, capacity 64) adapters. ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-executor](aimdb-executor/CHANGELOG.md), [aimdb-tokio-adapter](aimdb-tokio-adapter/CHANGELOG.md), [aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md), [aimdb-wasm-adapter](aimdb-wasm-adapter/CHANGELOG.md)) +- **Task-model join handler**: New `JoinBuilder::on_triggers(FnOnce(JoinEventRx, Producer) -> impl Future)` API replaces the previous callback model. Eliminates per-event heap allocation and lets handler state borrow across `.await` points. **Breaking change** vs. the old `with_state().on_trigger(...)` form — see [aimdb-core](aimdb-core/CHANGELOG.md). +- **Weather-mesh `DewPoint` demo**: All three weather stations (alpha, beta, gamma) now derive a `DewPoint` record from `Temperature` and `Humidity` via `transform_join`, demonstrating the API end-to-end on Tokio and Embassy. +- Design document: 027 (`no_std` Support for Transform API) - **MCP public mode**: New `--public` flag restricts the MCP server to read-only tools for safe internet-facing deployments with SSRF protection ([tools/aimdb-mcp](tools/aimdb-mcp/CHANGELOG.md)) - **MCP `--socket` flag**: Default socket path can be set at startup, simplifying single-instance workflows ([tools/aimdb-mcp](tools/aimdb-mcp/CHANGELOG.md)) - **Context-Aware Deserializers (Design 026)**: Inbound connector deserializers can now receive a `RuntimeContext` for platform-independent timestamps and logging @@ -45,6 +49,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **aimdb-embassy-adapter**: `SpmcRing` subscriber-slot exhaustion now emits a `defmt::error!` with guidance to increase the `CONSUMERS` const generic. Counting rule: one slot per `.tap()`, `.link_to()`, and `transform_join` input. +- **aimdb-codegen**: Generated join handler stubs updated to the new `on_triggers` task model (`async fn task_handler(JoinEventRx, Producer<...>)`). - **aimdb-core**: Breaking API changes to `InboundConnectorLink`, `Router`, and `RouterBuilder` to support `DeserializerKind` (see [aimdb-core/CHANGELOG.md](aimdb-core/CHANGELOG.md)) - **aimdb-core**: Breaking API change — `ConnectorLink.serializer` now stores `SerializerKind` instead of `SerializerFn` - **aimdb-core**: `.with_serializer()` renamed to `.with_serializer_raw()` for the old single-argument pattern diff --git a/aimdb-codegen/CHANGELOG.md b/aimdb-codegen/CHANGELOG.md index c96d83e7..659348e3 100644 --- a/aimdb-codegen/CHANGELOG.md +++ b/aimdb-codegen/CHANGELOG.md @@ -7,7 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -No changes yet. +### Changed + +- **Generated join handler stubs** updated to match the new task-model `on_triggers` API (Design 027). Multi-input task handlers are now generated as: + ```rust + pub async fn task_handler( + mut _rx: aimdb_core::transform::JoinEventRx, + _producer: aimdb_core::Producer, + ) { + while let Ok(_trigger) = _rx.recv().await { + todo!("implement task_handler") + } + } + ``` + Previously generated `fn task_handler(JoinTrigger, &mut (), &Producer<...>) -> Pin>` for the callback model. +- `build_transform_call` for join tasks now emits `.on_triggers(handler)` instead of `.with_state(()).on_trigger(handler)`. ## [0.1.0] - 2026-03-11 diff --git a/aimdb-core/CHANGELOG.md b/aimdb-core/CHANGELOG.md index e8fe89f6..f5367f56 100644 --- a/aimdb-core/CHANGELOG.md +++ b/aimdb-core/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`no_std` support for the full Transform API (Design 027)**: `.transform()` and `.transform_join()` are now available on `no_std + alloc` targets. Multi-input join fan-in is no longer hardcoded to `tokio::sync::mpsc`; it uses the runtime-agnostic `JoinFanInRuntime` traits from `aimdb-executor`, implemented by Tokio, Embassy, and WASM adapters. +- **`JoinEventRx`** — type-erased trigger receiver passed to the `on_triggers` handler. Call `.recv().await` in a loop to consume `JoinTrigger` events from all input forwarders. +- **`transform_join` as an inherent method on `RecordRegistrar`** (gated `feature = "alloc"`, `R: JoinFanInRuntime`). Previously only exposed via the `impl_record_registrar_ext!` macro under `feature = "std"`. - **Context-Aware Deserializers (Design 026)**: Inbound connector deserializers can now receive a `RuntimeContext` for platform-independent timestamps and logging during deserialization - New `ContextDeserializerFn` type alias for context-aware type-erased deserializer callbacks - New `DeserializerKind` enum (`Raw` / `Context`) to enforce mutual exclusivity between plain and context-aware deserializers @@ -24,6 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Breaking — Join handler API redesign (Design 027 §Q4)**: `JoinBuilder::with_state(...).on_trigger(Fn(...) -> Pin>)` replaced with task-model `JoinBuilder::on_triggers(FnOnce(JoinEventRx, Producer) -> impl Future)`. The handler now owns the event loop, eliminating per-event heap allocation and allowing state to be borrowed across `.await` points. +- **`transform.rs` split into `transform/{mod,single,join}.rs`** — internal reorganization to keep the `alloc`-only join path separate from the runtime-agnostic single-input path. `JoinBuilder`, `JoinPipeline`, `JoinTrigger`, `JoinEventRx` are now re-exported from `transform::join`. +- `transform_join_raw` now requires `R: JoinFanInRuntime` (was `feature = "std"`). +- `ExecutorError::QueueClosed` mapped to `DbError::RuntimeError` in `From`. - **Breaking**: `InboundConnectorLink::deserializer` field type changed from `DeserializerFn` to `DeserializerKind` - **Breaking**: `InboundConnectorLink::new()` now takes `DeserializerKind` instead of `DeserializerFn` - **Breaking**: `Router::route()` signature changed to accept an additional `ctx` parameter diff --git a/aimdb-embassy-adapter/CHANGELOG.md b/aimdb-embassy-adapter/CHANGELOG.md index d2a1fed6..8cda9f25 100644 --- a/aimdb-embassy-adapter/CHANGELOG.md +++ b/aimdb-embassy-adapter/CHANGELOG.md @@ -7,9 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`EmbassyJoinQueue` (Design 027)**: Embassy implementation of the `JoinFanInRuntime` traits from `aimdb-executor`, backed by `embassy_sync::channel::Channel`. The channel is `Box::leak`ed at queue creation (once per join transform at DB startup) to obtain the `&'static` lifetime Embassy channels require. Embassy channels never close — the trigger loop runs for the device lifetime. +- **`SpmcRing` subscriber-slot exhaustion diagnostics**: `defmt::error!` now fires when a `.subscribe()` call fails because the const-generic `SUBS` slot count is exhausted. Includes guidance to count one slot per `.link_to()` plus one per `transform_join` input. +- **Improved `buffer_sized` doc**: explicit rules for counting `CONSUMERS` (one per `.tap()`, `.link_to()`, and `transform_join` input). + ### Changed +- **`aimdb-executor` dependency**: dropped the `embassy-types` feature (no longer required — the join queue is implemented locally in this adapter using `embassy_sync::Channel` directly). - **Dev-dependency update**: Upgraded `rand` from 0.8 to 0.10.1. +- **Dev-dependency added**: `critical-section` with `std` feature, providing the `CriticalSectionRawMutex` link target for host-side join-queue tests. ## [0.5.0] - 2026-02-21 diff --git a/aimdb-executor/CHANGELOG.md b/aimdb-executor/CHANGELOG.md index d75f835d..28a80f15 100644 --- a/aimdb-executor/CHANGELOG.md +++ b/aimdb-executor/CHANGELOG.md @@ -7,7 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -No changes in this release. +### Added + +- **Join fan-in traits (Design 027)**: Runtime-agnostic abstraction for multi-input transform fan-in queues, replacing the previous `tokio::sync::mpsc`-only path in `aimdb-core`. + - `JoinFanInRuntime` trait — runtime capability for creating bounded fan-in queues; implemented per adapter. + - `JoinQueue` trait — splittable into `Sender` / `Receiver` halves. + - `JoinSender` / `JoinReceiver` traits — `async fn send` / `async fn recv` returning `ExecutorResult<()>`. +- **`ExecutorError::QueueClosed`** variant — returned by `JoinSender::send` / `JoinReceiver::recv` when the channel is closed (Tokio, WASM). Embassy channels never close, so this variant is unreachable on Embassy. ## [0.1.0] - 2025-11-06 diff --git a/aimdb-tokio-adapter/CHANGELOG.md b/aimdb-tokio-adapter/CHANGELOG.md index 8a5b8f07..d271026a 100644 --- a/aimdb-tokio-adapter/CHANGELOG.md +++ b/aimdb-tokio-adapter/CHANGELOG.md @@ -7,7 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -No changes yet. +### Added + +- **`TokioJoinQueue` (Design 027)**: Tokio implementation of the `JoinFanInRuntime` traits from `aimdb-executor`, backed by `tokio::sync::mpsc::channel` with internal capacity 64. Enables `transform_join` on the Tokio runtime through the new runtime-agnostic abstraction. +- **`transform_join` integration tests** (`tests/transform_join_integration_tests.rs`): two-input sum scenario plus a backpressure stress test that pushes 200 events through a yielding handler to verify the bounded fan-in does not deadlock. ## [0.5.0] - 2026-02-21 diff --git a/aimdb-wasm-adapter/CHANGELOG.md b/aimdb-wasm-adapter/CHANGELOG.md index 9b177ae3..ae0c50dd 100644 --- a/aimdb-wasm-adapter/CHANGELOG.md +++ b/aimdb-wasm-adapter/CHANGELOG.md @@ -7,7 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -No changes yet. +### Added + +- **`WasmJoinQueue` (Design 027)**: WASM implementation of the `JoinFanInRuntime` traits from `aimdb-executor`, backed by `futures_channel::mpsc::channel` with internal capacity 64. Enables `transform_join` on the WASM runtime. +- **`transform_join` integration test** (`tests/transform_join_integration_tests.rs`, `wasm-bindgen-test`): two-input sum scenario verifying outputs are produced once both inputs have been seen. + +### Changed + +- **Dependencies**: added `futures-channel` (with `std` + `sink` features — `mpsc` requires `std` because its internal `BiLock` uses `std::sync`); enabled `sink` on `futures-util`. ## [0.1.1] - 2026-03-16 From b3ff8daca1d2e397e28d554f75c94d9d87527289 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 7 May 2026 19:02:58 +0000 Subject: [PATCH 17/17] fix: update error handling for DewPoint production and improve comments in weather station examples --- _external/embassy | 2 +- aimdb-embassy-adapter/src/buffer.rs | 4 ++-- aimdb-embassy-adapter/src/join_queue.rs | 4 +--- .../weather-mesh-demo/weather-station-alpha/src/main.rs | 7 +++++-- .../weather-mesh-demo/weather-station-beta/src/main.rs | 7 +++++-- examples/weather-mesh-demo/weather-station-gamma/flash.sh | 4 ++-- .../weather-mesh-demo/weather-station-gamma/src/main.rs | 7 +++++-- 7 files changed, 21 insertions(+), 14 deletions(-) diff --git a/_external/embassy b/_external/embassy index 756c725f..d965ef3b 160000 --- a/_external/embassy +++ b/_external/embassy @@ -1 +1 @@ -Subproject commit 756c725fca76c829d10c4a69d77d175c46829e65 +Subproject commit d965ef3b12c048811df2f1faed463bb70742705c diff --git a/aimdb-embassy-adapter/src/buffer.rs b/aimdb-embassy-adapter/src/buffer.rs index e0aaa984..7d89f7a7 100644 --- a/aimdb-embassy-adapter/src/buffer.rs +++ b/aimdb-embassy-adapter/src/buffer.rs @@ -355,7 +355,7 @@ impl< defmt::error!( "AimDB: SpmcRing subscriber slot exhausted (max SUBS={}). \ Increase the CONSUMERS const generic on buffer_sized. \ - Count one slot per link_to connector plus one per transform_join input.", + Count one slot per .tap(), .link_to() connector, and each transform_join input.", SUBS ); DbError::BufferClosed { _buffer_name: () } @@ -421,7 +421,7 @@ impl< defmt::error!( "AimDB: SpmcRing subscriber slot exhausted (max SUBS={}). \ Increase the CONSUMERS const generic on buffer_sized. \ - Count one slot per link_to connector plus one per transform_join input.", + Count one slot per .tap(), .link_to() connector, and each transform_join input.", SUBS ); DbError::BufferClosed { _buffer_name: () } diff --git a/aimdb-embassy-adapter/src/join_queue.rs b/aimdb-embassy-adapter/src/join_queue.rs index c756cd78..8dab5c5d 100644 --- a/aimdb-embassy-adapter/src/join_queue.rs +++ b/aimdb-embassy-adapter/src/join_queue.rs @@ -161,9 +161,7 @@ mod tests { futures::pin_mut!(send_fut); let waker = futures::task::noop_waker(); let mut cx = core::task::Context::from_waker(&waker); - if core::future::Future::poll(core::pin::Pin::new(&mut send_fut), &mut cx) - == core::task::Poll::Pending - { + if core::future::Future::poll(send_fut.as_mut(), &mut cx) == core::task::Poll::Pending { polled = true; } assert!(polled, "send should be Pending when channel is at capacity"); diff --git a/examples/weather-mesh-demo/weather-station-alpha/src/main.rs b/examples/weather-mesh-demo/weather-station-alpha/src/main.rs index 9aee8873..133d66d2 100644 --- a/examples/weather-mesh-demo/weather-station-alpha/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-alpha/src/main.rs @@ -98,12 +98,15 @@ async fn main() -> Result<(), Box> { t.celsius, h.percent ); - let _ = producer + if let Err(e) = producer .produce(DewPoint { celsius: dew_point, timestamp, }) - .await; + .await + { + tracing::warn!("DewPoint produce failed: {:?}", e); + } } } }) diff --git a/examples/weather-mesh-demo/weather-station-beta/src/main.rs b/examples/weather-mesh-demo/weather-station-beta/src/main.rs index 6e536097..874b9f09 100644 --- a/examples/weather-mesh-demo/weather-station-beta/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-beta/src/main.rs @@ -98,12 +98,15 @@ async fn main() -> Result<(), Box> { t.celsius, h.percent ); - let _ = producer + if let Err(e) = producer .produce(DewPoint { celsius: dew_point, timestamp, }) - .await; + .await + { + tracing::warn!("DewPoint produce failed: {:?}", e); + } } } }) diff --git a/examples/weather-mesh-demo/weather-station-gamma/flash.sh b/examples/weather-mesh-demo/weather-station-gamma/flash.sh index 8ef1d291..b4fd65f9 100755 --- a/examples/weather-mesh-demo/weather-station-gamma/flash.sh +++ b/examples/weather-mesh-demo/weather-station-gamma/flash.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Flash script for embassy-mqtt-connector-demo +# Flash script for weather-mesh-demo / weather-station-gamma # # This script should be run on the HOST machine where probe-rs and hardware are accessible. # The binary must be built first in the dev container using: cargo build @@ -11,7 +11,7 @@ BINARY="../../../target/thumbv8m.main-none-eabihf/debug/weather-station-gamma" if [ ! -f "$BINARY" ]; then echo "Error: Binary not found at $BINARY" echo "Please build it first in the dev container:" - echo " cd examples/weather-station-gamma && cargo build" + echo " cd examples/weather-mesh-demo/weather-station-gamma && cargo build" exit 1 fi diff --git a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs index bb29f2cd..91c0e75e 100644 --- a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs @@ -342,12 +342,15 @@ async fn main(spawner: Spawner) { let frac = ((mag - whole as f32) * 10.0 + 0.5) as i32 % 10; let sign = if neg { "-" } else { "" }; info!("📊 DewPoint: {}{}.{}°C", sign, whole, frac); - let _ = producer + if let Err(_e) = producer .produce(DewPoint { celsius: dew_point, timestamp, }) - .await; + .await + { + defmt::warn!("DewPoint produce failed"); + } } } })