From 3983a2b7aeb502f84f59a311162837e6fd1bf8ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sat, 3 Oct 2026 19:13:10 +0000 Subject: [PATCH 01/28] =?UTF-8?q?feat(core):=20add=20Reader::poll=5Frecv?= =?UTF-8?q?=20(design=20054=20=C2=A74.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- aimdb-core/src/buffer/reader.rs | 80 +++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/aimdb-core/src/buffer/reader.rs b/aimdb-core/src/buffer/reader.rs index c1ed7d23..faff5f9b 100644 --- a/aimdb-core/src/buffer/reader.rs +++ b/aimdb-core/src/buffer/reader.rs @@ -13,6 +13,7 @@ use alloc::boxed::Box; #[cfg(feature = "remote")] use alloc::vec::Vec; use core::future::poll_fn; +use core::task::{Context, Poll}; use crate::buffer::BufferReader; use crate::DbError; @@ -48,6 +49,17 @@ impl Reader { poll_fn(|cx| self.inner.poll_recv(cx)).await } + /// Poll for the next value, for hand-written `poll` code. + /// + /// Follows the [`BufferReader::poll_recv`] contract: on `Pending` the + /// waker in `cx` is registered (the latest one wins), and spurious + /// wake-ups are allowed. `Pending` does not always mean the buffer is + /// empty: on Tokio a broadcast reader also returns it, with a self-wake, + /// once the task's cooperative budget is spent. + pub fn poll_recv(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_recv(cx) + } + /// Non-blocking receive — returns immediately. /// /// Returns `Err(DbError::BufferEmpty)` if no pending values. @@ -97,3 +109,71 @@ impl JsonReader { self.inner.try_recv_json_bytes() } } + +#[cfg(test)] +mod tests { + use super::*; + use alloc::sync::Arc; + use alloc::task::Wake; + use core::sync::atomic::{AtomicBool, AtomicU32, Ordering}; + use futures_util::task::AtomicWaker; + + /// One-slot buffer: `0` is empty. Its reader registers the waker on + /// `Pending`, and `produce` wakes it. + #[derive(Default)] + struct Slot { + value: AtomicU32, + waker: AtomicWaker, + } + + impl Slot { + fn produce(&self, v: u32) { + self.value.store(v, Ordering::Release); + self.waker.wake(); + } + } + + struct SlotReader(Arc); + + impl BufferReader for SlotReader { + fn poll_recv(&mut self, cx: &mut Context<'_>) -> Poll> { + self.0.waker.register(cx.waker()); + match self.0.value.swap(0, Ordering::AcqRel) { + 0 => Poll::Pending, + v => Poll::Ready(Ok(v)), + } + } + + fn try_recv(&mut self) -> Result { + match self.0.value.swap(0, Ordering::AcqRel) { + 0 => Err(DbError::BufferEmpty), + v => Ok(v), + } + } + } + + #[derive(Default)] + struct Flag(AtomicBool); + + impl Wake for Flag { + fn wake(self: Arc) { + self.0.store(true, Ordering::Release); + } + } + + #[test] + fn poll_recv_registers_the_waker_and_is_woken_by_a_produce() { + let slot = Arc::new(Slot::default()); + let mut reader = Reader::new(Box::new(SlotReader(Arc::clone(&slot)))); + let flag = Arc::new(Flag::default()); + let waker = Arc::clone(&flag).into(); + let mut cx = Context::from_waker(&waker); + + assert!(reader.poll_recv(&mut cx).is_pending()); + assert!(!flag.0.load(Ordering::Acquire)); + + slot.produce(7); + assert!(flag.0.load(Ordering::Acquire)); + assert!(matches!(reader.poll_recv(&mut cx), Poll::Ready(Ok(7)))); + } +} From c9a248e22b11dd76dd1b614af9287b1cc22bd5ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sat, 3 Oct 2026 19:23:06 +0000 Subject: [PATCH 02/28] =?UTF-8?q?feat(core):=20add=20InboundDispatch=20(de?= =?UTF-8?q?sign=20054=20=C2=A74.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- aimdb-core/src/inbound_dispatch.rs | 54 +++++++++++++++++ aimdb-core/src/lib.rs | 2 + aimdb-core/src/typed_api.rs | 93 +++++++++++++++++++++++++++++- 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 aimdb-core/src/inbound_dispatch.rs diff --git a/aimdb-core/src/inbound_dispatch.rs b/aimdb-core/src/inbound_dispatch.rs new file mode 100644 index 00000000..3b55be4b --- /dev/null +++ b/aimdb-core/src/inbound_dispatch.rs @@ -0,0 +1,54 @@ +//! Inbound entry point for connectors. +//! +//! A connector builds one [`InboundDispatch`] per scheme and calls +//! [`dispatch`](InboundDispatch::dispatch) with borrows of each message it +//! receives. Core matches the topic, deserializes and produces in place. + +use alloc::sync::Arc; +use alloc::vec::Vec; + +use crate::{AimDb, DbResult, Router, RuntimeContext, TopicGrammar}; + +/// Every inbound link of one scheme, compiled against the connector's grammar. +/// +/// Cheap to clone: clones share the compiled routes, so a connector can +/// dispatch from several tasks into the same records. +#[derive(Clone)] +pub struct InboundDispatch { + router: Arc, + ctx: RuntimeContext, +} + +impl InboundDispatch { + /// Compiles every inbound link of `scheme` against `grammar`. + /// + /// Rejects every link the grammar or its key cannot compile, naming the + /// record and the resolved topic. + pub fn new(db: &AimDb, scheme: &str, grammar: &'static dyn TopicGrammar) -> DbResult { + Ok(Self { + router: Arc::new(db.inbound_router(scheme, grammar)?), + ctx: db.runtime_ctx(), + }) + } + + /// Matches `topic`, deserializes `payload` and produces into every + /// matching record. + /// + /// Synchronous and never blocks. Ingest failures, unmatched topics, full + /// buffers and full key tables are logged, not returned. + pub fn dispatch(&self, topic: &str, payload: &[u8]) { + // `Router::route` only ever returns `Ok`. + let _ = self.router.route(topic, payload, &self.ctx); + } + + /// Filters to subscribe at the transport. Allocates; call it at connect + /// time, not per message. + pub fn subscriptions(&self) -> Vec> { + self.router.subscriptions() + } + + /// Number of inbound routes. + pub fn route_count(&self) -> usize { + self.router.route_count() + } +} diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index 5dcac47d..79c852f3 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -85,6 +85,7 @@ mod error; pub mod executor; pub mod extensions; pub mod graph; +mod inbound_dispatch; mod inbound_key; #[cfg(feature = "observability")] pub mod profiling; @@ -167,6 +168,7 @@ pub use connector::{ }; // Router exports for connector implementations +pub use inbound_dispatch::InboundDispatch; pub use router::Router; // Topic grammar for connectors with wildcard subscriptions diff --git a/aimdb-core/src/typed_api.rs b/aimdb-core/src/typed_api.rs index e677ca3a..8457d189 100644 --- a/aimdb-core/src/typed_api.rs +++ b/aimdb-core/src/typed_api.rs @@ -2135,7 +2135,7 @@ mod tests { } } - fn config_errors(result: crate::DbResult) -> Vec { + fn config_errors(result: crate::DbResult) -> Vec { match result { Err(crate::DbError::InvalidConfiguration { errors }) => errors, Err(e) => panic!("unexpected error {e:?}"), @@ -2176,6 +2176,97 @@ mod tests { .contains("does not support topic patterns")); } + // ==================================================================== + // InboundDispatch: the same cases through the connector entry point + // ==================================================================== + + #[tokio::test] + async fn inbound_dispatch_routes_patterns_with_shared_keys() { + let (db, last, count) = inbound_db(|reg| { + reg.link_from("mqtt://temp/{d}") + .key("d", 2) + .with_match_deserializer(key_deser) + .finish(); + reg.link_from("mqtt://hum/{id}") + .key("id", 2) + .with_match_deserializer(key_deser) + .finish(); + reg.link_from("mqtt://cmd/in") + .with_deserializer(|_ctx, _bytes: &[u8]| Ok(TestRecord { value: 100 })) + .finish(); + }) + .await; + + let inbound = crate::InboundDispatch::new(&db, "mqtt", &Plus).expect("routes compile"); + let subscriptions: Vec = inbound + .subscriptions() + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!(subscriptions, ["cmd/in", "hum/+", "temp/+"]); + assert_eq!(inbound.route_count(), 3); + + let dispatch = |topic: &str| { + inbound.dispatch(topic, b""); + last.load(Ordering::SeqCst) + }; + assert_eq!(dispatch("temp/a"), 0); + assert_eq!(dispatch("hum/b"), 1); + assert_eq!(dispatch("hum/a"), 0, "one key table per record"); + assert_eq!(dispatch("cmd/in"), 100); + let produced = count.load(Ordering::SeqCst); + dispatch("temp/c"); + assert_eq!(count.load(Ordering::SeqCst), produced, "full table drops"); + } + + #[tokio::test] + async fn inbound_dispatch_rejects_links_it_cannot_compile() { + let (db, _, _) = inbound_db(|reg| { + reg.link_from("mqtt://r/one") + .with_topic_resolver(|| Some("r/{d".into())) + .with_deserializer(|_ctx, _bytes: &[u8]| Ok(TestRecord { value: 0 })) + .finish(); + reg.link_from("mqtt://k/{d}") + .key("d", 4) + .with_topic_resolver(|| Some("k/{x}".into())) + .with_match_deserializer(key_deser) + .finish(); + }) + .await; + + let errors = config_errors(crate::InboundDispatch::new(&db, "mqtt", &Plus)); + assert_eq!(errors.len(), 2, "{errors:?}"); + assert!(errors.iter().all(|e| e.record_key == "rec.in")); + assert!(errors[0].message.contains("unbalanced '{' in 'r/{d'")); + assert!(errors[1] + .message + .contains("key 'd' is not a capture of 'k/{x}'")); + } + + #[tokio::test] + async fn inbound_dispatch_clones_share_records() { + fn assert_send_sync() {} + assert_send_sync::(); + + let (db, last, count) = inbound_db(|reg| { + reg.link_from("mqtt://cmd/in") + .with_deserializer(|_ctx, bytes: &[u8]| { + Ok(TestRecord { + value: bytes.len() as i32, + }) + }) + .finish(); + }) + .await; + + let inbound = crate::InboundDispatch::new(&db, "mqtt", &Plus).unwrap(); + let clone = inbound.clone(); + drop(inbound); + clone.dispatch("cmd/in", b"abcd"); + assert_eq!(count.load(Ordering::SeqCst), 1); + assert_eq!(last.load(Ordering::SeqCst), 4); + } + #[cfg(feature = "connector-session")] #[tokio::test] async fn pump_source_routes_through_the_given_router() { From 7b58417e9141ce830bd76307c8144933b96af2e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sat, 3 Oct 2026 19:31:44 +0000 Subject: [PATCH 03/28] =?UTF-8?q?feat(core):=20add=20TopicWriter,=20with?= =?UTF-8?q?=5Ftopic=5Fwriter=20and=20with=5Ftopic=5Ffn=20(design=20054=20?= =?UTF-8?q?=C2=A74.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- aimdb-core/src/connector.rs | 174 ++++++++++++++++++++++++++ aimdb-core/src/lib.rs | 2 +- aimdb-core/src/typed_api.rs | 240 +++++++++++++++++++++++++++++++++--- 3 files changed, 401 insertions(+), 15 deletions(-) diff --git a/aimdb-core/src/connector.rs b/aimdb-core/src/connector.rs index 1364cd3f..6002edd1 100644 --- a/aimdb-core/src/connector.rs +++ b/aimdb-core/src/connector.rs @@ -247,6 +247,133 @@ pub trait TopicProvider: Send + Sync { fn topic(&self, value: &T) -> Option; } +// ============================================================================ +// TopicWriter - Destinations written into bounded storage +// ============================================================================ + +/// Writes an outbound link's destination for each value (outbound only). +/// +/// Closures implement it, and [`with_topic_fn`](crate::typed_api::OutboundConnectorBuilder::with_topic_fn) +/// takes one directly. A writer type goes to +/// [`with_topic_writer`](crate::typed_api::OutboundConnectorBuilder::with_topic_writer). +/// +/// # Example +/// +/// ```rust +/// use aimdb_core::connector::{TopicBuf, TopicOverflow, TopicWriter}; +/// use core::fmt::Write; +/// # #[derive(Clone, Debug)] struct Temperature { sensor_id: u32 } +/// +/// struct SensorTopic; +/// +/// impl TopicWriter for SensorTopic { +/// fn write_topic(&self, value: &Temperature, out: &mut TopicBuf<'_>) -> Result { +/// write!(out, "sensors/temp/{}", value.sensor_id)?; +/// Ok(true) +/// } +/// } +/// ``` +pub trait TopicWriter: Send + Sync { + /// Write the destination for `value` into `out`. + /// + /// `Ok(true)` publishes to what was written, `Ok(false)` to the static + /// topic from the `link_to()` URL. A value whose topic does not fit is + /// skipped, whatever this returns; the topic is never truncated. + fn write_topic(&self, value: &T, out: &mut TopicBuf<'_>) -> Result; +} + +impl TopicWriter for F +where + F: Fn(&T, &mut TopicBuf<'_>) -> Result + Send + Sync, +{ + fn write_topic(&self, value: &T, out: &mut TopicBuf<'_>) -> Result { + self(value, out) + } +} + +/// A topic did not fit in its link's topic capacity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TopicOverflow; + +impl From for TopicOverflow { + fn from(_: core::fmt::Error) -> Self { + TopicOverflow + } +} + +/// Bounded topic storage handed to a [`TopicWriter`]. +/// +/// A write that does not fit is refused whole, so the contents are always +/// valid UTF-8. After the first refused write every later write is refused +/// too. +pub struct TopicBuf<'a> { + buf: &'a mut [u8], + len: usize, + overflowed: bool, +} + +impl<'a> TopicBuf<'a> { + /// An empty topic over `buf`; its capacity is `buf.len()`. + pub fn new(buf: &'a mut [u8]) -> Self { + Self { + buf, + len: 0, + overflowed: false, + } + } + + /// Append `s`, or refuse it whole if it does not fit. + pub fn push_str(&mut self, s: &str) -> Result<(), TopicOverflow> { + let end = self.len + s.len(); + match self.buf.get_mut(self.len..end) { + Some(dst) if !self.overflowed => { + dst.copy_from_slice(s.as_bytes()); + self.len = end; + Ok(()) + } + _ => { + self.overflowed = true; + Err(TopicOverflow) + } + } + } + + /// Bytes written so far. + pub fn len(&self) -> usize { + self.len + } + + /// Whether nothing has been written. + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// The most bytes this topic can hold. + pub fn capacity(&self) -> usize { + self.buf.len() + } + + /// The topic written so far. + pub fn as_str(&self) -> &str { + // Only whole `&str`s are ever copied in, so this cannot fail. + self.buf + .get(..self.len) + .and_then(|b| core::str::from_utf8(b).ok()) + .unwrap_or_default() + } + + /// Whether a write was refused. + pub(crate) fn overflowed(&self) -> bool { + self.overflowed + } +} + +impl core::fmt::Write for TopicBuf<'_> { + fn write_str(&mut self, s: &str) -> core::fmt::Result { + self.push_str(s).map_err(|_| core::fmt::Error) + } +} + /// Address of a record link: `scheme://resource` (e.g. `mqtt://sensors/temp`). /// /// A link address names a topic/resource on a connector's already-configured @@ -995,6 +1122,53 @@ mod tests { assert_eq!(provider.topic(&temp_without_id), None); } + // ======================================================================== + // TopicBuf Tests + // ======================================================================== + + #[test] + fn topic_buf_accepts_an_exact_fit() { + let mut storage = [0u8; 6]; + let mut out = super::TopicBuf::new(&mut storage); + assert!(out.is_empty()); + out.push_str("ab/").unwrap(); + out.push_str("cde").unwrap(); + assert_eq!((out.as_str(), out.len(), out.capacity()), ("ab/cde", 6, 6)); + assert!(!out.overflowed()); + } + + #[test] + fn topic_buf_refuses_an_overflowing_write_whole() { + let mut storage = [0u8; 6]; + let mut out = super::TopicBuf::new(&mut storage); + out.push_str("ab/").unwrap(); + // "cdé" is 4 bytes: one over, and it ends in a two-byte character. + assert_eq!(out.push_str("cdé"), Err(super::TopicOverflow)); + assert_eq!(out.as_str(), "ab/"); + assert!(out.overflowed()); + // Latched: a write that would fit is refused too. + assert!(out.push_str("x").is_err()); + assert_eq!(out.as_str(), "ab/"); + } + + #[test] + fn topic_buf_overflow_propagates_through_write() { + use core::fmt::Write as _; + fn writer(v: u32, out: &mut super::TopicBuf<'_>) -> Result { + write!(out, "t/{v}")?; + Ok(true) + } + let mut storage = [0u8; 4]; + assert_eq!( + writer(12, &mut super::TopicBuf::new(&mut storage)), + Ok(true) + ); + assert_eq!( + writer(123, &mut super::TopicBuf::new(&mut storage)), + Err(super::TopicOverflow) + ); + } + // ======================================================================== // TopicResolverFn Tests // ======================================================================== diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index 79c852f3..51c669fa 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -158,7 +158,6 @@ pub use profiling::{ }; // Connector Infrastructure exports -pub use connector::TopicProvider; pub use connector::TopicResolverFn; pub use connector::{ConnectorLink, ConnectorUrl, LinkAddress, SerializeError}; pub use connector::{IngestFactoryFn, IngestFn}; @@ -166,6 +165,7 @@ pub use connector::{ SerializedPayload, SerializedReader, SerializedSource, SerializedValue, SerializedValueInto, SourceFactoryFn, }; +pub use connector::{TopicBuf, TopicOverflow, TopicProvider, TopicWriter}; // Router exports for connector implementations pub use inbound_dispatch::InboundDispatch; diff --git a/aimdb-core/src/typed_api.rs b/aimdb-core/src/typed_api.rs index 8457d189..c6bcf72e 100644 --- a/aimdb-core/src/typed_api.rs +++ b/aimdb-core/src/typed_api.rs @@ -308,6 +308,30 @@ type FusedSerializeIntoFn = Arc< + Sync, >; +/// How an outbound link picks each value's destination. Setting one replaces +/// the other. +enum TopicSelector { + None, + Provider(Arc>), + Writer { + capacity: usize, + writer: Arc>, + }, +} + +impl Clone for TopicSelector { + fn clone(&self) -> Self { + match self { + Self::None => Self::None, + Self::Provider(p) => Self::Provider(p.clone()), + Self::Writer { capacity, writer } => Self::Writer { + capacity: *capacity, + writer: writer.clone(), + }, + } + } +} + /// The [`SerializedSource`](crate::connector::SerializedSource) built by /// `OutboundConnectorBuilder::finish()` — holds the typed consumer, /// serializer, and optional topic provider, so every per-message step stays @@ -316,7 +340,7 @@ struct FusedSource { consumer: Consumer, serialize: FusedSerializeFn, serialize_into: Option<(usize, FusedSerializeIntoFn)>, - topic: Option>>, + topic: TopicSelector, } impl crate::connector::SerializedSource for FusedSource @@ -328,6 +352,10 @@ where } fn subscribe(&self) -> Box { + let topic_capacity = match &self.topic { + TopicSelector::Writer { capacity, .. } => *capacity, + _ => 0, + }; Box::new(FusedReader { inner: self.consumer.subscribe(), serialize: self.serialize.clone(), @@ -336,6 +364,7 @@ where .as_ref() .map(|(_, serialize_into)| serialize_into.clone()), topic: self.topic.clone(), + topic_buf: alloc::vec![0; topic_capacity].into_boxed_slice(), }) } } @@ -350,7 +379,36 @@ struct FusedReader { inner: crate::buffer::Reader, serialize: FusedSerializeFn, serialize_into: Option>, - topic: Option>>, + topic: TopicSelector, + /// Storage a [`TopicWriter`](crate::connector::TopicWriter) writes into; + /// empty without one. + topic_buf: Box<[u8]>, +} + +impl FusedReader { + /// The value's destination (`None`: the route's default), or `Err` when + /// its written topic overflowed and the value must be skipped. + fn resolve_dest(&mut self, value: &T) -> Result, ()> { + match &self.topic { + TopicSelector::None => Ok(None), + TopicSelector::Provider(p) => Ok(p.topic(value)), + TopicSelector::Writer { writer, .. } => { + let mut out = crate::connector::TopicBuf::new(&mut self.topic_buf); + match writer.write_topic(value, &mut out) { + Ok(true) if !out.overflowed() => Ok(Some(out.as_str().to_string())), + Ok(false) if !out.overflowed() => Ok(None), + _ => { + log_warn!( + "outbound link: topic for {} does not fit in {} bytes, value skipped", + core::any::type_name::(), + out.capacity() + ); + Err(()) + } + } + } + } + } } impl crate::connector::SerializedReader for FusedReader { @@ -364,7 +422,9 @@ impl crate::connector::SerializedReader for FusedRead // pump skip the gap and keep going; anything else ends it. let value = self.inner.recv().await?; // Resolve the destination while the typed value is in hand. - let dest = self.topic.as_ref().and_then(|p| p.topic(&value)); + let Ok(dest) = self.resolve_dest(&value) else { + continue; + }; match (self.serialize)(ctx, &value) { Ok(payload) => return Ok(crate::connector::SerializedValue { dest, payload }), Err(_e) => { @@ -391,7 +451,9 @@ impl crate::connector::SerializedReader for FusedRead Box::pin(async move { loop { let value = self.inner.recv().await?; - let dest = self.topic.as_ref().and_then(|p| p.topic(&value)); + let Ok(dest) = self.resolve_dest(&value) else { + continue; + }; let Some(serialize_into) = &self.serialize_into else { match (self.serialize)(ctx, &value) { @@ -742,7 +804,7 @@ where config: Vec::new(), context_serializer: None, context_serializer_into: None, - topic_provider: None, + topic: TopicSelector::None, } } @@ -776,7 +838,7 @@ pub struct OutboundConnectorBuilder<'r, 'a, T: Send + Sync + 'static + Debug + C config: Vec<(String, String)>, context_serializer: Option>, context_serializer_into: Option<(usize, TypedContextSerializerIntoFn)>, - topic_provider: Option>>, + topic: TopicSelector, } impl<'r, 'a, T> OutboundConnectorBuilder<'r, 'a, T> @@ -872,10 +934,49 @@ where P: crate::connector::TopicProvider + 'static, { // Stays typed: fused into the link's SerializedSource at finish(). - self.topic_provider = Some(Arc::new(provider)); + self.topic = TopicSelector::Provider(Arc::new(provider)); self } + /// Sets a [`TopicWriter`](crate::connector::TopicWriter) that writes each + /// value's destination. + /// + /// `capacity` is the longest topic the writer produces, in bytes. A value + /// whose topic does not fit is skipped and logged. For a closure, use + /// [`with_topic_fn`](Self::with_topic_fn). + pub fn with_topic_writer(mut self, capacity: usize, writer: W) -> Self + where + W: crate::connector::TopicWriter + 'static, + { + self.topic = TopicSelector::Writer { + capacity, + writer: Arc::new(writer), + }; + self + } + + /// Sets a closure that writes each value's destination; see + /// [`with_topic_writer`](Self::with_topic_writer). + /// + /// ```rust,ignore + /// .with_topic_fn(32, |v, out| { + /// write!(out, "sensors/{}/{}", v.site, v.id)?; + /// Ok(true) + /// }) + /// ``` + pub fn with_topic_fn(self, capacity: usize, f: F) -> Self + where + F: Fn( + &T, + &mut crate::connector::TopicBuf<'_>, + ) -> Result + + Send + + Sync + + 'static, + { + self.with_topic_writer(capacity, f) + } + /// Finalizes the connector registration /// /// Configuration mistakes — an invalid URL, a missing serializer, or an @@ -987,7 +1088,7 @@ where )] let source_factory: crate::connector::SourceFactoryFn = { let record_key = self.registrar.record_key.clone(); - let topic_provider = self.topic_provider; + let topic = self.topic; Arc::new(move |db: &AimDb| { let typed_rec = db .inner() @@ -1013,7 +1114,7 @@ where consumer, serialize: serialize.clone(), serialize_into: serialize_into.clone(), - topic: topic_provider.clone(), + topic: topic.clone(), }) as Box }) }; @@ -2338,13 +2439,18 @@ mod tests { fn fused_reader( script: Vec>, serialize: FusedSerializeFn, - topic: Option>>, + topic: TopicSelector, ) -> FusedReader { + let topic_capacity = match &topic { + TopicSelector::Writer { capacity, .. } => *capacity, + _ => 0, + }; FusedReader { inner: crate::buffer::Reader::new(Box::new(ScriptedReader { script })), serialize, serialize_into: None, topic, + topic_buf: alloc::vec![0; topic_capacity].into_boxed_slice(), } } @@ -2357,7 +2463,8 @@ mod tests { inner: crate::buffer::Reader::new(Box::new(ScriptedReader { script })), serialize, serialize_into: Some(serialize_into), - topic: None, + topic: TopicSelector::None, + topic_buf: Box::default(), } } @@ -2376,7 +2483,7 @@ mod tests { Ok(TestRecord { value: 2 }), ], Arc::new(|_ctx, r| Ok(r.value.to_le_bytes().to_vec())), - None, + TopicSelector::None, ); let ctx = test_ctx(); @@ -2407,7 +2514,7 @@ mod tests { Ok(r.value.to_le_bytes().to_vec()) } }), - None, + TopicSelector::None, ); // One recv: the failing value is skipped, the next good one returned. @@ -2538,7 +2645,7 @@ mod tests { let mut reader = fused_reader( vec![Ok(TestRecord { value: 5 }), Ok(TestRecord { value: 0 })], Arc::new(|_ctx, r| Ok(r.value.to_le_bytes().to_vec())), - Some(Arc::new(PositiveTopic)), + TopicSelector::Provider(Arc::new(PositiveTopic)), ); let ctx = test_ctx(); @@ -2549,6 +2656,111 @@ mod tests { assert_eq!(second.dest, None); // falls back to the route default } + fn writer( + capacity: usize, + f: impl Fn(&TestRecord, &mut crate::TopicBuf<'_>) -> Result + + Send + + Sync + + 'static, + ) -> TopicSelector { + TopicSelector::Writer { + capacity, + writer: Arc::new(f), + } + } + + /// The same case with a written topic; `Ok(false)` uses the default. + #[tokio::test] + async fn fused_reader_resolves_written_topic() { + use core::fmt::Write as _; + let mut reader = fused_reader( + vec![Ok(TestRecord { value: 5 }), Ok(TestRecord { value: 0 })], + Arc::new(|_ctx, r| Ok(r.value.to_le_bytes().to_vec())), + writer(8, |v, out| { + if v.value <= 0 { + return Ok(false); + } + write!(out, "dyn/{}", v.value)?; + Ok(true) + }), + ); + let ctx = test_ctx(); + + let first = reader.recv(&ctx).await.expect("value"); + assert_eq!(first.dest.as_deref(), Some("dyn/5")); + + let second = reader.recv(&ctx).await.expect("value"); + assert_eq!(second.dest, None); + } + + /// A topic that overflows skips its value, even when the writer ignores + /// the error and returns `Ok(true)`. + #[tokio::test] + async fn fused_reader_skips_overflowing_topics() { + use core::fmt::Write as _; + let mut reader = fused_reader( + vec![ + Ok(TestRecord { value: 123_456 }), + Ok(TestRecord { value: 1_234_567 }), + Ok(TestRecord { value: 7 }), + ], + Arc::new(|_ctx, r| Ok(r.value.to_le_bytes().to_vec())), + writer(6, |v, out| { + if v.value == 1_234_567 { + let _ = write!(out, "t/{}", v.value); + return Ok(true); + } + write!(out, "t/{}", v.value)?; + Ok(true) + }), + ); + let ctx = test_ctx(); + + // "t/123456" (8 bytes) and "t/1234567" (9 bytes) do not fit in 6. + let msg = reader.recv(&ctx).await.expect("value"); + assert_eq!(msg.dest.as_deref(), Some("t/7")); + assert_eq!(msg.payload, 7i32.to_le_bytes().to_vec()); + } + + /// `with_topic_fn` infers an unannotated closure's argument types, which + /// a generic `W: TopicWriter` bound cannot. + #[tokio::test] + async fn with_topic_fn_infers_and_writes_the_topic() { + use core::fmt::Write as _; + struct CannedBuffer; + impl crate::buffer::DynBuffer for CannedBuffer { + fn push(&self, _value: TestRecord) {} + fn subscribe_boxed(&self) -> Box + Send> { + Box::new(ScriptedReader { + script: vec![Ok(TestRecord { value: 5 })], + }) + } + fn as_any(&self) -> &dyn core::any::Any { + self + } + } + + let mut builder = crate::AimDbBuilder::new() + .runtime(Arc::new(MockRuntime)) + .with_connector(NoopConnectorBuilder); + builder.configure::("rec.out", |reg| { + reg.buffer_raw(Box::new(CannedBuffer)); + reg.link_to("mqtt://tele/out") + .with_topic_fn(16, |v, out| { + write!(out, "dyn/{}", v.value)?; + Ok(true) + }) + .with_serializer(|_ctx, r: &TestRecord| Ok(r.value.to_le_bytes().to_vec())) + .finish(); + }); + let (db, _runner) = builder.build().await.expect("build must succeed"); + + let routes = db.collect_outbound_routes("mqtt"); + let mut reader = routes[0].source.subscribe(); + let msg = reader.recv(&db.runtime_ctx()).await.expect("value"); + assert_eq!(msg.dest.as_deref(), Some("dyn/5")); + } + /// End-to-end outbound path: registrar → build → collect → subscribe → /// recv, pinning the factory wiring (raw and context serializers). #[tokio::test] From a4b02f9e61c2b9bef7738be961d37b653fb507a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sat, 3 Oct 2026 19:45:30 +0000 Subject: [PATCH 04/28] =?UTF-8?q?feat(core):=20add=20the=20lock-free=20out?= =?UTF-8?q?bound=20ready=20set=20(design=20054=20=C2=A74.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- aimdb-core/src/lib.rs | 2 + aimdb-core/src/outbound/mod.rs | 3 + aimdb-core/src/outbound/ready.rs | 454 +++++++++++++++++++++++++++++++ 3 files changed, 459 insertions(+) create mode 100644 aimdb-core/src/outbound/mod.rs create mode 100644 aimdb-core/src/outbound/ready.rs diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index 51c669fa..2f0f0416 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -87,6 +87,8 @@ pub mod extensions; pub mod graph; mod inbound_dispatch; mod inbound_key; +#[allow(dead_code, reason = "OutboundRoutes uses it from the next commit on")] +mod outbound; #[cfg(feature = "observability")] pub mod profiling; pub mod record_id; diff --git a/aimdb-core/src/outbound/mod.rs b/aimdb-core/src/outbound/mod.rs new file mode 100644 index 00000000..add2e4da --- /dev/null +++ b/aimdb-core/src/outbound/mod.rs @@ -0,0 +1,3 @@ +//! Outbound path: connectors pull serialized messages from their routes. + +mod ready; diff --git a/aimdb-core/src/outbound/ready.rs b/aimdb-core/src/outbound/ready.rs new file mode 100644 index 00000000..c1daed5d --- /dev/null +++ b/aimdb-core/src/outbound/ready.rs @@ -0,0 +1,454 @@ +//! Which outbound routes woke since they were last polled. +//! +//! Each route's reader is polled with a waker of its own. Waking it sets the +//! route's bit and wakes the transport task, which then polls only routes +//! whose bit is set. Wakers take no lock, so they may fire from any context, +//! including a producer that preempts the transport task. + +use alloc::boxed::Box; +use alloc::sync::Arc; +use alloc::task::Wake; +use core::sync::atomic::Ordering; +use core::task::Waker; + +use futures_util::task::AtomicWaker; +use portable_atomic::AtomicU32; + +/// Dense route index, `0..len`. +pub(crate) type RouteId = usize; + +/// State the route wakers share with the transport task. +struct Shared { + /// One bit per route: woken since it was last taken. + ready: Box<[AtomicU32]>, + /// The transport task. + task: AtomicWaker, +} + +impl Shared { + fn set(&self, id: RouteId) { + if let Some(word) = self.ready.get(id / 32) { + word.fetch_or(bit(id), Ordering::Release); + } + } + + fn clear(&self, id: RouteId) { + if let Some(word) = self.ready.get(id / 32) { + word.fetch_and(!bit(id), Ordering::Acquire); + } + } +} + +struct RouteWake { + id: RouteId, + shared: Arc, +} + +impl Wake for RouteWake { + fn wake(self: Arc) { + self.wake_by_ref() + } + + fn wake_by_ref(self: &Arc) { + self.shared.set(self.id); + self.shared.task.wake(); + } +} + +fn bit(id: RouteId) -> u32 { + 1 << (id % 32) +} + +/// The ready set of one connector's outbound routes, owned by its transport +/// task. +pub(crate) struct ReadyRoutes { + shared: Arc, + /// Built once; polling route `id` uses `wakers[id]`. + wakers: Box<[Waker]>, + /// One bit per route that is not closed. + open: Box<[u32]>, + open_count: usize, + /// The route served last; the next pass starts after it. + cursor: RouteId, +} + +/// One round-robin pass: every route at most once, starting after the +/// cursor. +pub(crate) struct Pass { + start: RouteId, + /// Positions after `start` already scanned. + offset: usize, +} + +impl ReadyRoutes { + /// `len` routes, all open and all ready: no reader has registered a waker + /// yet. + pub(crate) fn new(len: usize) -> Self { + let words = len.div_ceil(32); + let mut open = alloc::vec![0u32; words].into_boxed_slice(); + for id in 0..len { + if let Some(word) = open.get_mut(id / 32) { + *word |= bit(id); + } + } + let shared = Arc::new(Shared { + ready: open.iter().map(|&w| AtomicU32::new(w)).collect(), + task: AtomicWaker::new(), + }); + let wakers = (0..len) + .map(|id| { + Waker::from(Arc::new(RouteWake { + id, + shared: shared.clone(), + })) + }) + .collect(); + Self { + shared, + wakers, + open, + open_count: len, + cursor: len.saturating_sub(1), + } + } + + fn len(&self) -> usize { + self.wakers.len() + } + + /// Register the transport task. Call it before taking routes, so a route + /// that wakes after the scan comes up empty still wakes the task. + pub(crate) fn register(&self, waker: &Waker) { + self.shared.task.register(waker); + } + + /// The waker to poll route `id`'s reader with. + pub(crate) fn waker(&self, id: RouteId) -> Option<&Waker> { + self.wakers.get(id) + } + + /// Start a pass at the route after the one served last. + pub(crate) fn pass(&self) -> Pass { + Pass { + start: if self.len() == 0 { + 0 + } else { + (self.cursor + 1) % self.len() + }, + offset: 0, + } + } + + /// The next open route in `pass` that woke, with its bit cleared. + /// + /// Clearing before the caller polls means a wake that lands during the + /// poll sets the bit again: at most a spurious re-poll, never a lost + /// wake-up. + pub(crate) fn take_next(&self, pass: &mut Pass) -> Option { + let len = self.len(); + let pos = pass.start + pass.offset; + let id = if pos < len { + self.first_ready(pos, len) + .or_else(|| self.first_ready(0, pass.start)) + } else { + self.first_ready(pos - len, pass.start) + }?; + pass.offset = if id >= pass.start { + id - pass.start + } else { + id + len - pass.start + } + 1; + self.shared.clear(id); + Some(id) + } + + /// First open, ready route in `lo..hi`. + fn first_ready(&self, lo: RouteId, hi: RouteId) -> Option { + if lo >= hi { + return None; + } + let (first, last) = (lo / 32, (hi - 1) / 32); + for w in first..=last { + let ready = self.shared.ready.get(w)?.load(Ordering::Acquire); + let mut bits = ready & self.open.get(w).copied().unwrap_or(0); + if w == first { + bits &= u32::MAX << (lo % 32); + } + let top = hi - w * 32; + if w == last && top < 32 { + bits &= (1 << top) - 1; + } + if bits != 0 { + return Some(w * 32 + bits.trailing_zeros() as usize); + } + } + None + } + + /// Route `id` yielded a value. Its buffer may hold more, and its waker + /// does not fire again for values already in it, so its bit is set + /// again; the cursor moves to it, so every other ready route is served + /// first. + pub(crate) fn served(&mut self, id: RouteId) { + self.shared.set(id); + self.cursor = id; + } + + /// Route `id`'s buffer closed; it is never taken again. + pub(crate) fn close(&mut self, id: RouteId) { + if let Some(word) = self.open.get_mut(id / 32) { + if *word & bit(id) != 0 { + *word &= !bit(id); + self.open_count -= 1; + } + } + } + + /// Every route is closed (or there were none). + pub(crate) fn is_done(&self) -> bool { + self.open_count == 0 + } + + /// Some open route woke and has not been taken. + pub(crate) fn any_ready(&self) -> bool { + self.shared + .ready + .iter() + .zip(self.open.iter()) + .any(|(r, &o)| r.load(Ordering::Acquire) & o != 0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::collections::VecDeque; + use alloc::vec::Vec; + use core::sync::atomic::AtomicBool; + + /// Drives a [`ReadyRoutes`] the way `poll_stage` does, with a queue per + /// route standing in for its reader. + struct Sim { + ready: ReadyRoutes, + queues: Vec>, + } + + impl Sim { + fn new(len: usize) -> Self { + Self { + ready: ReadyRoutes::new(len), + queues: (0..len).map(|_| VecDeque::new()).collect(), + } + } + + fn produce(&mut self, id: RouteId, v: u32) { + self.queues[id].push_back(v); + self.ready.waker(id).unwrap().wake_by_ref(); + } + + /// One call: the first route in the pass with a value, or `None`. + fn next(&mut self) -> Option { + let mut pass = self.ready.pass(); + while let Some(id) = self.ready.take_next(&mut pass) { + if self.queues[id].pop_front().is_some() { + self.ready.served(id); + return Some(id); + } + } + None + } + } + + fn drain(ready: &ReadyRoutes) -> Vec { + let mut pass = ready.pass(); + core::iter::from_fn(|| ready.take_next(&mut pass)).collect() + } + + #[derive(Default)] + struct Flag(AtomicBool); + + impl Wake for Flag { + fn wake(self: Arc) { + self.0.store(true, Ordering::Release); + } + } + + #[test] + fn serves_ready_routes_round_robin() { + let mut sim = Sim::new(3); + for v in 0..3 { + for id in 0..3 { + sim.produce(id, v); + } + } + let order: Vec<_> = core::iter::from_fn(|| sim.next()).collect(); + assert_eq!(order, [0, 1, 2, 0, 1, 2, 0, 1, 2]); + } + + #[test] + fn a_hot_route_does_not_starve_a_quiet_one() { + let mut sim = Sim::new(2); + for v in 0..60 { + sim.produce(0, v); + } + sim.produce(1, 0); + assert_eq!((sim.next(), sim.next()), (Some(0), Some(1))); + + // Mid-stream: the quiet route is served right after the hot one. + for _ in 0..5 { + assert_eq!(sim.next(), Some(0)); + } + sim.produce(1, 1); + assert_eq!(sim.next(), Some(1)); + } + + #[test] + fn a_route_woken_by_a_producer_wakes_the_task() { + let ready = ReadyRoutes::new(3); + assert_eq!(drain(&ready), [0, 1, 2]); + let flag = Arc::new(Flag::default()); + ready.register(&Waker::from(flag.clone())); + + ready.waker(2).unwrap().wake_by_ref(); + assert!(flag.0.load(Ordering::Acquire)); + assert_eq!(drain(&ready), [2]); + } + + #[test] + fn a_wake_during_the_poll_is_not_lost() { + let ready = ReadyRoutes::new(1); + let mut pass = ready.pass(); + assert_eq!(ready.take_next(&mut pass), Some(0)); + // The reader registers its waker and a producer fires it before the + // poll returns `Pending`. + ready.waker(0).unwrap().wake_by_ref(); + assert!(ready.any_ready()); + assert_eq!(drain(&ready), [0]); + } + + #[test] + fn a_pass_takes_each_route_at_most_once() { + let ready = ReadyRoutes::new(3); + let mut pass = ready.pass(); + assert_eq!(ready.take_next(&mut pass), Some(0)); + ready.waker(0).unwrap().wake_by_ref(); + let rest: Vec<_> = core::iter::from_fn(|| ready.take_next(&mut pass)).collect(); + assert_eq!(rest, [1, 2]); + assert!(ready.any_ready(), "route 0 waits for the next pass"); + } + + #[test] + fn closed_routes_are_never_taken() { + let mut ready = ReadyRoutes::new(3); + ready.close(1); + ready.close(1); + ready.waker(1).unwrap().wake_by_ref(); + assert_eq!(drain(&ready), [0, 2]); + assert!(!ready.is_done()); + ready.close(0); + ready.close(2); + assert!(ready.is_done()); + ready.waker(1).unwrap().wake_by_ref(); + assert!(!ready.any_ready()); + } + + #[test] + fn no_routes_is_done_at_once() { + let ready = ReadyRoutes::new(0); + assert!(ready.is_done()); + assert!(drain(&ready).is_empty()); + assert!(ready.waker(0).is_none()); + } + + #[test] + fn scans_across_word_boundaries() { + for len in [1, 31, 32, 33, 64, 256] { + let mut ready = ReadyRoutes::new(len); + assert_eq!(drain(&ready), (0..len).collect::>(), "len {len}"); + assert!(!ready.any_ready()); + + let woken: Vec<_> = [0, 31, 32, len - 1] + .into_iter() + .filter(|&id| id < len) + .collect(); + for &id in &woken { + ready.waker(id).unwrap().wake_by_ref(); + } + let mut expected = woken.clone(); + expected.dedup(); + assert_eq!(drain(&ready), expected, "len {len}"); + + // From the middle: wraps past the end back to the cursor. + if len > 33 { + ready.served(31); + ready.waker(0).unwrap().wake_by_ref(); + ready.waker(len - 1).unwrap().wake_by_ref(); + assert_eq!(drain(&ready), [len - 1, 0, 31], "len {len}"); + } + } + } + + /// Producers on other threads wake routes while the transport task + /// takes them: nothing is lost and the task never stalls. + #[cfg(feature = "std")] + #[test] + fn concurrent_wakes_lose_nothing() { + use portable_atomic::AtomicU64; + use std::thread; + use std::time::{Duration, Instant}; + + struct Unpark(thread::Thread); + impl Wake for Unpark { + fn wake(self: Arc) { + self.0.unpark(); + } + } + + const ROUTES: usize = 64; + const PER_PRODUCER: u64 = 50_000; + let mut ready = ReadyRoutes::new(ROUTES); + let pending: Arc> = + Arc::new((0..ROUTES).map(|_| AtomicU64::new(0)).collect()); + let task = Waker::from(Arc::new(Unpark(thread::current()))); + + let producers: Vec<_> = (0..4) + .map(|t| { + let pending = pending.clone(); + let wakers: Vec = (0..ROUTES) + .map(|id| ready.waker(id).unwrap().clone()) + .collect(); + thread::spawn(move || { + for i in 0..PER_PRODUCER as usize { + let id = (i * 7 + t * 13) % ROUTES; + pending[id].fetch_add(1, Ordering::Release); + wakers[id].wake_by_ref(); + } + }) + }) + .collect(); + + let deadline = Instant::now() + Duration::from_secs(30); + let mut total = 0; + while total < 4 * PER_PRODUCER { + ready.register(&task); + let mut pass = ready.pass(); + let mut got = false; + while let Some(id) = ready.take_next(&mut pass) { + let n = pending[id].swap(0, Ordering::Acquire); + if n > 0 { + total += n; + ready.served(id); + got = true; + } + } + if !got { + assert!(Instant::now() < deadline, "stalled at {total}"); + thread::park_timeout(Duration::from_millis(100)); + } + } + for p in producers { + p.join().unwrap(); + } + assert_eq!(total, 4 * PER_PRODUCER); + } +} From d77ca43270ff4efc5a5650e694446631715d7258 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 20:47:37 +0000 Subject: [PATCH 05/28] =?UTF-8?q?refactor(core):=20harden=20the=20outbound?= =?UTF-8?q?=20ready=20set=20(design=20054=20=C2=A74.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the poll loop into ReadyRoutes::poll_ready, so the orderings that keep a wake-up from being lost live in one tested function instead of being rules every caller of take_next/served must keep. The caller passes a closure that polls one route with the context it is handed and reports Pending, Staged, Skipped or Closed. poll_ready registers the task before reading any bit, polls each route with its own waker, polls a route again after a skipped value or lag, and calls served only right before returning Ready. Getting any of these wrong stalls a route for good, since a reader that returned a value keeps no waker. - register, waker, pass, take_next, served, close and is_done are now private; any_ready is test-only. - Index directly where ids come from 0..len instead of turning an id bug into a silently dropped wake. - Document why Pass bounds a call (Tokio readers wake themselves once the budget is spent) and why set/clear must be single RMWs. - RouteId moves to outbound/mod.rs. - expect(dead_code) instead of allow, so the attribute must go once OutboundRoutes uses the module. Tests drive poll_ready through a Tokio-like reader model. New: skipped values, a spent budget, a producer racing the poll, a randomized liveness test (300 seeds: writes, skips, closes, spent budgets, racing producers; every value written must go out), and a stress test for neighbouring bits in one word. concurrent_wakes_lose_nothing now fails when a park times out with work outstanding instead of rescanning. Each of eight seeded bugs (no served, moving on after a skip, register after the scan, clear after the poll, polling with the task's waker, no task wake, load-then-store clear, no pass bound) fails at least one test; before, the concurrency test passed with the task wake deleted. Design 054: §4.2 says poll_ready owns the loop; §7 adds SelectAll and FuturesUnordered as alternative 13. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SHcxmvGhBYjDxU8wSzNpUQ --- aimdb-core/src/lib.rs | 5 +- aimdb-core/src/outbound/mod.rs | 3 + aimdb-core/src/outbound/ready.rs | 639 +++++++++++++----- .../054-zero-alloc-connector-boundary.md | 26 +- 4 files changed, 504 insertions(+), 169 deletions(-) diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index 2f0f0416..dbfaa13d 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -87,7 +87,10 @@ pub mod extensions; pub mod graph; mod inbound_dispatch; mod inbound_key; -#[allow(dead_code, reason = "OutboundRoutes uses it from the next commit on")] +#[cfg_attr( + not(test), + expect(dead_code, reason = "OutboundRoutes uses it from the next commit on") +)] mod outbound; #[cfg(feature = "observability")] pub mod profiling; diff --git a/aimdb-core/src/outbound/mod.rs b/aimdb-core/src/outbound/mod.rs index add2e4da..34b2ef27 100644 --- a/aimdb-core/src/outbound/mod.rs +++ b/aimdb-core/src/outbound/mod.rs @@ -1,3 +1,6 @@ //! Outbound path: connectors pull serialized messages from their routes. mod ready; + +/// Dense route index, `0..len`. +pub(crate) type RouteId = usize; diff --git a/aimdb-core/src/outbound/ready.rs b/aimdb-core/src/outbound/ready.rs index c1daed5d..d7632a38 100644 --- a/aimdb-core/src/outbound/ready.rs +++ b/aimdb-core/src/outbound/ready.rs @@ -4,18 +4,41 @@ //! route's bit and wakes the transport task, which then polls only routes //! whose bit is set. Wakers take no lock, so they may fire from any context, //! including a producer that preempts the transport task. +//! +//! [`ReadyRoutes::poll_ready`] runs the whole loop, so the orderings that +//! keep a wake-up from being lost are kept here and nowhere else. Getting one +//! wrong stalls a route for good: a reader that returned a value keeps no +//! waker, so nothing would set its bit again. +//! +//! `futures_util`'s `SelectAll` does the same job but allocates per message +//! (design 054 §7). use alloc::boxed::Box; use alloc::sync::Arc; use alloc::task::Wake; use core::sync::atomic::Ordering; -use core::task::Waker; +use core::task::{Context, Poll, Waker}; use futures_util::task::AtomicWaker; use portable_atomic::AtomicU32; -/// Dense route index, `0..len`. -pub(crate) type RouteId = usize; +use super::RouteId; + +/// What polling one route's reader did, as reported to +/// [`ReadyRoutes::poll_ready`]. +pub(crate) enum Polled { + /// Nothing to send. The reader kept the route's waker, or woke it itself + /// (Tokio's readers do once the task's budget is spent). + Pending, + /// A value was staged; `poll_ready` returns this route. + Staged, + /// A value was taken but not staged (topic overflow, serializer error), + /// or the reader lagged. The reader kept no waker, so the route is + /// polled again at once. + Skipped, + /// The reader's buffer is closed; the route is never polled again. + Closed, +} /// State the route wakers share with the transport task. struct Shared { @@ -25,17 +48,15 @@ struct Shared { task: AtomicWaker, } +// One read-modify-write each: 32 routes share a word, and a load followed by +// a store would erase a bit another route set in between. impl Shared { fn set(&self, id: RouteId) { - if let Some(word) = self.ready.get(id / 32) { - word.fetch_or(bit(id), Ordering::Release); - } + self.ready[id / 32].fetch_or(bit(id), Ordering::Release); } fn clear(&self, id: RouteId) { - if let Some(word) = self.ready.get(id / 32) { - word.fetch_and(!bit(id), Ordering::Acquire); - } + self.ready[id / 32].fetch_and(!bit(id), Ordering::Acquire); } } @@ -49,6 +70,8 @@ impl Wake for RouteWake { self.wake_by_ref() } + // Runs in the producer's context, possibly an interrupt-priority task + // that preempted the transport task: it must never take a lock. fn wake_by_ref(self: &Arc) { self.shared.set(self.id); self.shared.task.wake(); @@ -74,23 +97,32 @@ pub(crate) struct ReadyRoutes { /// One round-robin pass: every route at most once, starting after the /// cursor. -pub(crate) struct Pass { +/// +/// The bound is what ends a call. A Tokio reader whose task has spent its +/// budget returns `Pending` and wakes its own waker, setting its bit again +/// while it is polled; a scan that went back for set bits would never stop. +struct Pass { start: RouteId, - /// Positions after `start` already scanned. - offset: usize, + /// The next position to scan, counted from `start` without wrapping: + /// `start..len` are routes `start..len`, `len..start + len` are routes + /// `0..start`. + next: usize, } impl ReadyRoutes { /// `len` routes, all open and all ready: no reader has registered a waker /// yet. pub(crate) fn new(len: usize) -> Self { - let words = len.div_ceil(32); - let mut open = alloc::vec![0u32; words].into_boxed_slice(); - for id in 0..len { - if let Some(word) = open.get_mut(id / 32) { - *word |= bit(id); - } - } + let open: Box<[u32]> = (0..len.div_ceil(32)) + .map(|w| { + let rest = len - w * 32; + if rest < 32 { + (1 << rest) - 1 + } else { + u32::MAX + } + }) + .collect(); let shared = Arc::new(Shared { ready: open.iter().map(|&w| AtomicU32::new(w)).collect(), task: AtomicWaker::new(), @@ -112,52 +144,87 @@ impl ReadyRoutes { } } - fn len(&self) -> usize { - self.wakers.len() - } - - /// Register the transport task. Call it before taking routes, so a route - /// that wakes after the scan comes up empty still wakes the task. - pub(crate) fn register(&self, waker: &Waker) { - self.shared.task.register(waker); + /// Poll the routes that woke, in round-robin order, until one stages a + /// value. + /// + /// `poll_route(id, route_cx)` polls route `id`'s reader with `route_cx`, + /// which carries that route's own waker, and reports what happened. It + /// must not poll the reader with any other context: a reader holding the + /// task's waker wakes the task without marking its route. + /// + /// - `Ready(Some(id))`: route `id` staged a value. Every other route that + /// is ready is served before `id` is served again. + /// - `Ready(None)`: every route is closed, or there were none. Final. + /// - `Pending`: no woken route had a value. The task is woken when one + /// does. + pub(crate) fn poll_ready( + &mut self, + cx: &mut Context<'_>, + mut poll_route: impl FnMut(RouteId, &mut Context<'_>) -> Polled, + ) -> Poll> { + if self.open_count == 0 { + return Poll::Ready(None); + } + // Before any bit is read. Every wake takes the stored waker out, so + // a route that wakes after the scan has passed its word finds this + // one instead of an empty slot. + self.shared.task.register(cx.waker()); + let mut pass = self.pass(); + while let Some(id) = self.take_next(&mut pass) { + let mut route_cx = Context::from_waker(&self.wakers[id]); + loop { + match poll_route(id, &mut route_cx) { + Polled::Pending => break, + Polled::Staged => { + // `served` sets the bit without waking the task, so + // nothing may come between it and `Ready`. + self.served(id); + return Poll::Ready(Some(id)); + } + // Moving on would leave the route with a clear bit and a + // reader that kept no waker. + Polled::Skipped => {} + Polled::Closed => { + self.close(id); + break; + } + } + } + } + if self.open_count == 0 { + Poll::Ready(None) + } else { + Poll::Pending + } } - /// The waker to poll route `id`'s reader with. - pub(crate) fn waker(&self, id: RouteId) -> Option<&Waker> { - self.wakers.get(id) + fn len(&self) -> usize { + self.wakers.len() } /// Start a pass at the route after the one served last. - pub(crate) fn pass(&self) -> Pass { - Pass { - start: if self.len() == 0 { - 0 - } else { - (self.cursor + 1) % self.len() - }, - offset: 0, - } + fn pass(&self) -> Pass { + let start = match self.len() { + 0 => 0, + len => (self.cursor + 1) % len, + }; + Pass { start, next: start } } /// The next open route in `pass` that woke, with its bit cleared. /// - /// Clearing before the caller polls means a wake that lands during the - /// poll sets the bit again: at most a spurious re-poll, never a lost + /// Clearing before the route is polled means a wake that lands during + /// the poll sets the bit again: at most a spurious re-poll, never a lost /// wake-up. - pub(crate) fn take_next(&self, pass: &mut Pass) -> Option { + fn take_next(&self, pass: &mut Pass) -> Option { let len = self.len(); - let pos = pass.start + pass.offset; - let id = if pos < len { - self.first_ready(pos, len) + let id = if pass.next < len { + self.first_ready(pass.next, len) .or_else(|| self.first_ready(0, pass.start)) } else { - self.first_ready(pos - len, pass.start) + self.first_ready(pass.next - len, pass.start) }?; - pass.offset = if id >= pass.start { - id - pass.start - } else { - id + len - pass.start - } + 1; + pass.next = if id >= pass.start { id } else { id + len } + 1; self.shared.clear(id); Some(id) } @@ -169,8 +236,7 @@ impl ReadyRoutes { } let (first, last) = (lo / 32, (hi - 1) / 32); for w in first..=last { - let ready = self.shared.ready.get(w)?.load(Ordering::Acquire); - let mut bits = ready & self.open.get(w).copied().unwrap_or(0); + let mut bits = self.shared.ready[w].load(Ordering::Acquire) & self.open[w]; if w == first { bits &= u32::MAX << (lo % 32); } @@ -185,32 +251,27 @@ impl ReadyRoutes { None } - /// Route `id` yielded a value. Its buffer may hold more, and its waker + /// Route `id` staged a value. Its buffer may hold more, and its waker /// does not fire again for values already in it, so its bit is set /// again; the cursor moves to it, so every other ready route is served /// first. - pub(crate) fn served(&mut self, id: RouteId) { + fn served(&mut self, id: RouteId) { self.shared.set(id); self.cursor = id; } /// Route `id`'s buffer closed; it is never taken again. - pub(crate) fn close(&mut self, id: RouteId) { - if let Some(word) = self.open.get_mut(id / 32) { - if *word & bit(id) != 0 { - *word &= !bit(id); - self.open_count -= 1; - } + fn close(&mut self, id: RouteId) { + let word = &mut self.open[id / 32]; + if *word & bit(id) != 0 { + *word &= !bit(id); + self.open_count -= 1; } } - /// Every route is closed (or there were none). - pub(crate) fn is_done(&self) -> bool { - self.open_count == 0 - } - /// Some open route woke and has not been taken. - pub(crate) fn any_ready(&self) -> bool { + #[cfg(test)] + fn any_ready(&self) -> bool { self.shared .ready .iter() @@ -224,172 +285,413 @@ mod tests { use super::*; use alloc::collections::VecDeque; use alloc::vec::Vec; - use core::sync::atomic::AtomicBool; + use core::sync::atomic::AtomicUsize; + + /// Counts the transport task's wake-ups. + #[derive(Default)] + struct Count(AtomicUsize); + + impl Wake for Count { + fn wake(self: Arc) { + self.wake_by_ref() + } - /// Drives a [`ReadyRoutes`] the way `poll_stage` does, with a queue per - /// route standing in for its reader. + fn wake_by_ref(self: &Arc) { + self.0.fetch_add(1, Ordering::Relaxed); + } + } + + enum Value { + Good(u32), + /// Fails to serialize. + Bad, + } + + /// A route's reader, modelled on Tokio's: it keeps the waker only when + /// it returns `Pending`, and a write wakes that waker and drops it. + #[derive(Default)] + struct Route { + queue: VecDeque, + waker: Option, + closed: bool, + /// Good values accepted, in order. + written: Vec, + polls: usize, + } + + impl Route { + fn write(&mut self, value: Value) { + if self.closed { + return; + } + if let Value::Good(v) = value { + self.written.push(v); + } + self.queue.push_back(value); + if let Some(w) = self.waker.take() { + w.wake(); + } + } + + fn close(&mut self) { + self.closed = true; + if let Some(w) = self.waker.take() { + w.wake(); + } + } + } + + /// Drives `poll_ready` the way `OutboundRoutes::poll_stage` does. struct Sim { ready: ReadyRoutes, - queues: Vec>, + routes: Vec, + task: Arc, + /// Values staged, in order. + sent: Vec<(RouteId, u32)>, + /// The next call's readers return `Pending` and wake themselves. + budget_spent: bool, + /// When route `.0` returns `Pending`, write `.2` into route `.1` + /// before the poll returns: a producer racing the transport task. + inject: Option<(RouteId, RouteId, u32)>, } impl Sim { fn new(len: usize) -> Self { Self { ready: ReadyRoutes::new(len), - queues: (0..len).map(|_| VecDeque::new()).collect(), + routes: (0..len).map(|_| Route::default()).collect(), + task: Arc::default(), + sent: Vec::new(), + budget_spent: false, + inject: None, } } - fn produce(&mut self, id: RouteId, v: u32) { - self.queues[id].push_back(v); - self.ready.waker(id).unwrap().wake_by_ref(); + fn write(&mut self, id: RouteId, v: u32) { + self.routes[id].write(Value::Good(v)); } - /// One call: the first route in the pass with a value, or `None`. - fn next(&mut self) -> Option { - let mut pass = self.ready.pass(); - while let Some(id) = self.ready.take_next(&mut pass) { - if self.queues[id].pop_front().is_some() { - self.ready.served(id); - return Some(id); + fn wakes(&self) -> usize { + self.task.0.load(Ordering::Relaxed) + } + + fn take_wakes(&self) -> usize { + self.task.0.swap(0, Ordering::Relaxed) + } + + fn poll(&mut self) -> Poll> { + let waker = Waker::from(self.task.clone()); + let mut cx = Context::from_waker(&waker); + let budget_spent = core::mem::take(&mut self.budget_spent); + // Each route once per pass, plus one poll per value taken, plus + // the value a racing producer may add: fail instead of spinning. + let limit = self.routes.len() + self.queued() + 2; + let mut polls = 0; + let (routes, sent, inject) = (&mut self.routes, &mut self.sent, &mut self.inject); + self.ready.poll_ready(&mut cx, |id, route_cx| { + polls += 1; + assert!(polls <= limit, "one call polled {polls} times"); + let route = &mut routes[id]; + route.polls += 1; + if budget_spent { + route_cx.waker().wake_by_ref(); + return Polled::Pending; + } + match route.queue.pop_front() { + Some(Value::Good(v)) => { + sent.push((id, v)); + Polled::Staged + } + Some(Value::Bad) => Polled::Skipped, + None if route.closed => Polled::Closed, + None => { + route.waker = Some(route_cx.waker().clone()); + if let Some((at, to, v)) = *inject { + if at == id { + *inject = None; + routes[to].write(Value::Good(v)); + } + } + Polled::Pending + } } + }) + } + + /// The next route served, or `None` once a call does not serve one. + fn next(&mut self) -> Option { + match self.poll() { + Poll::Ready(id) => id, + Poll::Pending => None, } - None } - } - fn drain(ready: &ReadyRoutes) -> Vec { - let mut pass = ready.pass(); - core::iter::from_fn(|| ready.take_next(&mut pass)).collect() - } + fn drain(&mut self) -> Vec { + core::iter::from_fn(|| self.next()).collect() + } - #[derive(Default)] - struct Flag(AtomicBool); + fn polls(&self) -> Vec { + self.routes.iter().map(|r| r.polls).collect() + } - impl Wake for Flag { - fn wake(self: Arc) { - self.0.store(true, Ordering::Release); + fn queued(&self) -> usize { + self.routes.iter().map(|r| r.queue.len()).sum() } } + /// Every route in one pass, without polling any reader. + fn take_all(ready: &ReadyRoutes) -> Vec { + let mut pass = ready.pass(); + core::iter::from_fn(|| ready.take_next(&mut pass)).collect() + } + #[test] fn serves_ready_routes_round_robin() { let mut sim = Sim::new(3); for v in 0..3 { for id in 0..3 { - sim.produce(id, v); + sim.write(id, v); } } - let order: Vec<_> = core::iter::from_fn(|| sim.next()).collect(); - assert_eq!(order, [0, 1, 2, 0, 1, 2, 0, 1, 2]); + assert_eq!(sim.drain(), [0, 1, 2, 0, 1, 2, 0, 1, 2]); } #[test] fn a_hot_route_does_not_starve_a_quiet_one() { let mut sim = Sim::new(2); for v in 0..60 { - sim.produce(0, v); + sim.write(0, v); } - sim.produce(1, 0); + sim.write(1, 0); assert_eq!((sim.next(), sim.next()), (Some(0), Some(1))); // Mid-stream: the quiet route is served right after the hot one. for _ in 0..5 { assert_eq!(sim.next(), Some(0)); } - sim.produce(1, 1); + sim.write(1, 1); assert_eq!(sim.next(), Some(1)); } #[test] - fn a_route_woken_by_a_producer_wakes_the_task() { - let ready = ReadyRoutes::new(3); - assert_eq!(drain(&ready), [0, 1, 2]); - let flag = Arc::new(Flag::default()); - ready.register(&Waker::from(flag.clone())); + fn a_write_wakes_the_task_and_is_served() { + let mut sim = Sim::new(3); + assert!(sim.drain().is_empty()); + let wakes = sim.wakes(); - ready.waker(2).unwrap().wake_by_ref(); - assert!(flag.0.load(Ordering::Acquire)); - assert_eq!(drain(&ready), [2]); + sim.write(2, 7); + assert_eq!(sim.wakes(), wakes + 1); + assert_eq!(sim.drain(), [2]); + assert_eq!(sim.sent, [(2, 7)]); } #[test] fn a_wake_during_the_poll_is_not_lost() { - let ready = ReadyRoutes::new(1); - let mut pass = ready.pass(); - assert_eq!(ready.take_next(&mut pass), Some(0)); - // The reader registers its waker and a producer fires it before the - // poll returns `Pending`. - ready.waker(0).unwrap().wake_by_ref(); - assert!(ready.any_ready()); - assert_eq!(drain(&ready), [0]); + let mut sim = Sim::new(2); + assert!(sim.drain().is_empty()); + + // A spurious wake gets route 1 polled. Its reader stores its waker + // and a producer fires it before the poll returns `Pending`. + sim.ready.wakers[1].wake_by_ref(); + sim.take_wakes(); + sim.inject = Some((1, 1, 5)); + assert!(sim.poll().is_pending()); + assert!(sim.take_wakes() > 0); + assert_eq!(sim.drain(), [1]); + assert_eq!(sim.sent, [(1, 5)]); } #[test] - fn a_pass_takes_each_route_at_most_once() { - let ready = ReadyRoutes::new(3); - let mut pass = ready.pass(); - assert_eq!(ready.take_next(&mut pass), Some(0)); - ready.waker(0).unwrap().wake_by_ref(); - let rest: Vec<_> = core::iter::from_fn(|| ready.take_next(&mut pass)).collect(); - assert_eq!(rest, [1, 2]); - assert!(ready.any_ready(), "route 0 waits for the next pass"); + fn a_skipped_value_is_polled_past() { + let mut sim = Sim::new(2); + assert!(sim.drain().is_empty()); + + sim.routes[1].write(Value::Bad); + sim.write(1, 3); + assert_eq!(sim.drain(), [1]); + assert_eq!(sim.polls()[1], 4, "registered, skipped, staged, empty"); + + // A skip with nothing behind it leaves the reader holding its waker. + sim.routes[1].write(Value::Bad); + assert!(sim.drain().is_empty()); + sim.write(1, 4); + assert_eq!(sim.drain(), [1]); + assert_eq!(sim.sent, [(1, 3), (1, 4)]); } #[test] - fn closed_routes_are_never_taken() { - let mut ready = ReadyRoutes::new(3); - ready.close(1); - ready.close(1); - ready.waker(1).unwrap().wake_by_ref(); - assert_eq!(drain(&ready), [0, 2]); - assert!(!ready.is_done()); - ready.close(0); - ready.close(2); - assert!(ready.is_done()); - ready.waker(1).unwrap().wake_by_ref(); - assert!(!ready.any_ready()); + fn a_spent_budget_ends_the_call() { + let mut sim = Sim::new(3); + assert!(sim.drain().is_empty()); + for id in 0..3 { + sim.write(id, id as u32); + } + let before = sim.polls(); + sim.take_wakes(); + + // Every reader wakes itself while it is polled: one poll each, then + // `Pending`, with the task woken to try again. + sim.budget_spent = true; + assert!(sim.poll().is_pending()); + let after: Vec<_> = sim + .polls() + .iter() + .zip(&before) + .map(|(a, b)| a - b) + .collect(); + assert_eq!(after, [1, 1, 1]); + assert!(sim.take_wakes() > 0); + assert_eq!(sim.drain(), [0, 1, 2]); + } + + #[test] + fn closed_routes_are_never_polled_again() { + let mut sim = Sim::new(3); + sim.write(1, 9); + sim.routes[1].close(); + assert_eq!(sim.drain(), [1]); + assert_eq!(sim.sent, [(1, 9)], "values before the close still go out"); + let polls = sim.polls()[1]; + + sim.ready.wakers[1].wake_by_ref(); + assert!(sim.drain().is_empty()); + assert_eq!(sim.polls()[1], polls); + + sim.routes[0].close(); + sim.routes[2].close(); + assert_eq!(sim.poll(), Poll::Ready(None)); + assert_eq!(sim.poll(), Poll::Ready(None), "final"); } #[test] fn no_routes_is_done_at_once() { - let ready = ReadyRoutes::new(0); - assert!(ready.is_done()); - assert!(drain(&ready).is_empty()); - assert!(ready.waker(0).is_none()); + let mut sim = Sim::new(0); + assert_eq!(sim.poll(), Poll::Ready(None)); + assert!(take_all(&sim.ready).is_empty()); } #[test] fn scans_across_word_boundaries() { for len in [1, 31, 32, 33, 64, 256] { let mut ready = ReadyRoutes::new(len); - assert_eq!(drain(&ready), (0..len).collect::>(), "len {len}"); + assert_eq!(take_all(&ready), (0..len).collect::>(), "len {len}"); assert!(!ready.any_ready()); - let woken: Vec<_> = [0, 31, 32, len - 1] + let mut woken: Vec<_> = [0, 31, 32, len - 1] .into_iter() .filter(|&id| id < len) .collect(); + woken.dedup(); for &id in &woken { - ready.waker(id).unwrap().wake_by_ref(); + ready.wakers[id].wake_by_ref(); } - let mut expected = woken.clone(); - expected.dedup(); - assert_eq!(drain(&ready), expected, "len {len}"); + assert_eq!(take_all(&ready), woken, "len {len}"); // From the middle: wraps past the end back to the cursor. if len > 33 { ready.served(31); - ready.waker(0).unwrap().wake_by_ref(); - ready.waker(len - 1).unwrap().wake_by_ref(); - assert_eq!(drain(&ready), [len - 1, 0, 31], "len {len}"); + ready.wakers[0].wake_by_ref(); + ready.wakers[len - 1].wake_by_ref(); + assert_eq!(take_all(&ready), [len - 1, 0, 31], "len {len}"); + } + } + } + + /// xorshift64, so the randomized test needs no dependency and replays. + struct Rng(u64); + + impl Rng { + fn below(&mut self, n: usize) -> usize { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 % n as u64) as usize + } + } + + /// Random writes, skipped values, closes, spent budgets and producers + /// racing the poll, with the task polled only when it was woken or its + /// last call served a value. Once it stops, every value written has gone + /// out, in order per route: a lost wake-up leaves one behind. + #[test] + fn every_value_written_is_sent() { + for seed in 1..=300u64 { + let mut rng = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + let len = [1, 2, 5, 33, 64][rng.below(5)]; + let mut sim = Sim::new(len); + let mut runnable = true; + let mut v = 0; + for _ in 0..400 { + v += 1; + match rng.below(12) { + 0..=3 => sim.write(rng.below(len), v), + 4 => sim.routes[rng.below(len)].write(Value::Bad), + 5 if rng.below(10) == 0 => sim.routes[rng.below(len)].close(), + 6 => sim.budget_spent = true, + 7 => sim.inject = Some((rng.below(len), rng.below(len), v)), + _ => { + if runnable || sim.take_wakes() > 0 { + runnable = matches!(sim.poll(), Poll::Ready(Some(_))); + } + } + } + } + let mut calls = 0; + while runnable || sim.take_wakes() > 0 { + runnable = matches!(sim.poll(), Poll::Ready(Some(_))); + calls += 1; + assert!(calls < 10_000, "seed {seed}: never parks"); + } + for (id, route) in sim.routes.iter().enumerate() { + let sent: Vec = sim + .sent + .iter() + .filter(|&&(r, _)| r == id) + .map(|&(_, v)| v) + .collect(); + assert_eq!(sent, route.written, "seed {seed}, route {id} of {len}"); } } } + /// Clearing one route's bit never erases a neighbour's bit set at the + /// same time: 32 routes share a word. + #[cfg(feature = "std")] + #[test] + fn clearing_a_bit_keeps_its_neighbours() { + use std::sync::atomic::AtomicBool; + use std::thread; + + let shared = ReadyRoutes::new(2).shared; + let stop = Arc::new(AtomicBool::new(false)); + let clearer = { + let (shared, stop) = (shared.clone(), stop.clone()); + thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + shared.clear(0); + } + }) + }; + // Only this thread touches bit 1, so it must read back as set. + let mut erased = 0; + for _ in 0..200_000 { + shared.set(1); + for _ in 0..16 { + core::hint::spin_loop(); + } + if shared.ready[0].load(Ordering::Acquire) & bit(1) == 0 { + erased += 1; + } + shared.ready[0].fetch_and(!bit(1), Ordering::Relaxed); + } + stop.store(true, Ordering::Relaxed); + clearer.join().unwrap(); + assert_eq!(erased, 0); + } + /// Producers on other threads wake routes while the transport task - /// takes them: nothing is lost and the task never stalls. + /// polls them: nothing is lost, and the task never sleeps through a + /// wake-up. #[cfg(feature = "std")] #[test] fn concurrent_wakes_lose_nothing() { @@ -406,17 +708,18 @@ mod tests { const ROUTES: usize = 64; const PER_PRODUCER: u64 = 50_000; + const TOTAL: u64 = 4 * PER_PRODUCER; + const PARK: Duration = Duration::from_secs(5); let mut ready = ReadyRoutes::new(ROUTES); let pending: Arc> = Arc::new((0..ROUTES).map(|_| AtomicU64::new(0)).collect()); let task = Waker::from(Arc::new(Unpark(thread::current()))); + let mut cx = Context::from_waker(&task); let producers: Vec<_> = (0..4) .map(|t| { let pending = pending.clone(); - let wakers: Vec = (0..ROUTES) - .map(|id| ready.waker(id).unwrap().clone()) - .collect(); + let wakers = ready.wakers.to_vec(); thread::spawn(move || { for i in 0..PER_PRODUCER as usize { let id = (i * 7 + t * 13) % ROUTES; @@ -427,28 +730,32 @@ mod tests { }) .collect(); - let deadline = Instant::now() + Duration::from_secs(30); let mut total = 0; - while total < 4 * PER_PRODUCER { - ready.register(&task); - let mut pass = ready.pass(); - let mut got = false; - while let Some(id) = ready.take_next(&mut pass) { + while total < TOTAL { + let polled = ready.poll_ready(&mut cx, |id, _| { let n = pending[id].swap(0, Ordering::Acquire); + total += n; if n > 0 { - total += n; - ready.served(id); - got = true; + Polled::Staged + } else { + Polled::Pending } - } - if !got { - assert!(Instant::now() < deadline, "stalled at {total}"); - thread::park_timeout(Duration::from_millis(100)); + }); + if polled.is_pending() && total < TOTAL { + // Every write after the scan unparks this thread, so a park + // that runs out with work outstanding slept through a wake. + let parked = Instant::now(); + thread::park_timeout(PARK); + let stranded = pending.iter().any(|p| p.load(Ordering::Acquire) > 0); + assert!( + parked.elapsed() < PARK || !stranded, + "slept through a wake-up at {total}" + ); } } for p in producers { p.join().unwrap(); } - assert_eq!(total, 4 * PER_PRODUCER); + assert_eq!(total, TOTAL); } } diff --git a/docs/design/054-zero-alloc-connector-boundary.md b/docs/design/054-zero-alloc-connector-boundary.md index 89fdda0d..83045b54 100644 --- a/docs/design/054-zero-alloc-connector-boundary.md +++ b/docs/design/054-zero-alloc-connector-boundary.md @@ -333,9 +333,19 @@ impl Wake for RouteWake { - The route wakers are built once, in `OutboundRoutes::new`; polling a route uses `Context::from_waker(&route_waker)`. Every bit starts set, since no reader has registered a waker yet. -- `poll_stage` registers the task's waker before it reads the bitmap, so a +- **One loop, owned by the ready set.** `ReadyRoutes::poll_ready(cx, + poll_route)` runs the whole scan below. `poll_stage` passes a closure that + polls route `id`'s reader with the context it is handed and reports + `Pending`, `Staged`, `Skipped` (skip or lag) or `Closed`. The orderings + in this list are then kept in one tested function instead of by every + caller; getting one wrong stalls a route for good, since a reader that + returned a value keeps no waker. A skip or lag polls the same route + again, since moving on would leave it with a clear bit and no waker. +- `poll_ready` registers the task's waker before it reads the bitmap, so a route that wakes after the bitmap reads empty still wakes the task. -- **Clear, then poll.** `poll_stage` clears a route's bit + Every wake takes the stored waker out, so registering after the scan + would lose such a wake-up, not just risk a stale waker. +- **Clear, then poll.** `poll_ready` clears a route's bit (`fetch_and(!bit, Acquire)`) before polling its reader. A wake that lands during the poll sets the bit again, which costs at most one spurious re-poll and never loses a wake-up. @@ -990,6 +1000,18 @@ The Zenoh connector (053) is not implemented yet; it is written against `KnxConnector::new` unchanged, but brings back the per-route task and the queue between the record buffers and the transport that this design removes everywhere else (§4.9). +13. **`futures-util`'s `SelectAll` or `FuturesUnordered` as the ready + set.** Both give each child its own waker and queue the ones that woke, + which is what §4.2 needs. But `SelectAll` puts a stream back after + every item, and each insertion allocates a task node: one allocation + per message (10,000 for 10,000 items with futures-util 0.3.33), which + the `outbound_next_*` rows (§5) forbid. `FuturesUnordered` alone holds + futures that finish once, so a route would be re-inserted after every + value too. Its queue can also be seen half-updated by a task that + preempted a producer mid-insertion; it then wakes itself and returns + `Pending`, which on one core starves that producer if the transport + task outranks it. And routes read different value types into one + scratch buffer passed in at poll time, which a `Stream` cannot take. ## 8. Open questions From a9bf6465a62b3a1ab1b5d5e9eb9da1181936ee3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sat, 3 Oct 2026 21:04:47 +0000 Subject: [PATCH 06/28] docs(core): drop the design reference and correct the Tokio wake note in the ready set Co-Authored-By: Claude Opus 5.5 --- aimdb-core/src/outbound/ready.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/aimdb-core/src/outbound/ready.rs b/aimdb-core/src/outbound/ready.rs index d7632a38..fb13283e 100644 --- a/aimdb-core/src/outbound/ready.rs +++ b/aimdb-core/src/outbound/ready.rs @@ -10,8 +10,7 @@ //! wrong stalls a route for good: a reader that returned a value keeps no //! waker, so nothing would set its bit again. //! -//! `futures_util`'s `SelectAll` does the same job but allocates per message -//! (design 054 §7). +//! `futures_util`'s `SelectAll` does the same job but allocates per message. use alloc::boxed::Box; use alloc::sync::Arc; @@ -98,9 +97,11 @@ pub(crate) struct ReadyRoutes { /// One round-robin pass: every route at most once, starting after the /// cursor. /// -/// The bound is what ends a call. A Tokio reader whose task has spent its -/// budget returns `Pending` and wakes its own waker, setting its bit again -/// while it is polled; a scan that went back for set bits would never stop. +/// The bound is what ends a call. A reader may wake its own waker before it +/// returns `Pending` (spurious wakes are allowed), setting its bit again +/// while it is polled; a scan that went back for set bits could then never +/// stop. Tokio's readers, once the task's budget is spent, defer that wake +/// until the task yields. struct Pass { start: RouteId, /// The next position to scan, counted from `start` without wrapping: From d25172367fce51c23cde72a068f95a2fcad56ac8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 07:22:34 +0000 Subject: [PATCH 07/28] fix(core): bound skips in the ready set and document its priority rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A route whose values all fail to stage kept poll_ready polling it for as long as its producer kept writing. Only Tokio's coop budget bounded that loop, and the Tokio mailbox reader and every Embassy reader do not use it, so one route's topic overflow could stall every route of the connector and the rest of its loop. - poll_ready polls a skipping route at most SKIP_BUDGET (32) times in a row; then the route's own waker sets its bit and wakes the task, and it is polled again in a later pass. No route is left with a clear bit and no waker. - The transport task must not outrank its producers (module doc, design 054 §4.2). AtomicWaker::register answers a wake in progress by waking the task again, so a transport that preempted that wake livelocks on one core; reproduced with both threads pinned to one CPU under SCHED_FIFO. Design 049's deployment already satisfies the rule. - Module doc: a buffer that keeps a route waker after its reader is gone frees the set, and wakes the finished task, from the producer's context on its next write. - Tests: a route that keeps skipping does not hold the call; a pass that starts at bit 31 of a word takes that route once. An off-by-one in that word's mask passed every existing test. - Design 054: the §4.2 sketch uses the code's names; §4.2 and §4.6 state the skip bound; alternative 13 no longer counts the half-updated queue against FuturesUnordered, since AtomicWaker shares it; alternative 14 covers futures-concurrency's Merge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KTnKDhY6tvu8bnscus1TZk --- aimdb-core/src/outbound/ready.rs | 84 ++++++++++++++++++- .../054-zero-alloc-connector-boundary.md | 65 +++++++++----- 2 files changed, 125 insertions(+), 24 deletions(-) diff --git a/aimdb-core/src/outbound/ready.rs b/aimdb-core/src/outbound/ready.rs index fb13283e..d7c5aea6 100644 --- a/aimdb-core/src/outbound/ready.rs +++ b/aimdb-core/src/outbound/ready.rs @@ -10,6 +10,18 @@ //! wrong stalls a route for good: a reader that returned a value keeps no //! waker, so nothing would set its bit again. //! +//! The transport task must not outrank its producers: run it at the same or +//! a lower priority than every task or interrupt that writes its routes' +//! records. `poll_ready` registers the task with an `AtomicWaker`, and a +//! registration that finds a wake still in progress wakes the task again +//! instead of waiting. A transport that preempted that wake is then polled +//! again and again, and on one core the producer never gets to finish it. +//! +//! A buffer may keep a route's waker after its reader is gone (embassy-sync's +//! `PubSubChannel` does). The producer's next write then drops the last +//! clone, which frees this set and wakes the finished task from the +//! producer's context. +//! //! `futures_util`'s `SelectAll` does the same job but allocates per message. use alloc::boxed::Box; @@ -23,6 +35,10 @@ use portable_atomic::AtomicU32; use super::RouteId; +/// Skips in a row one route may take before [`ReadyRoutes::poll_ready`] +/// moves on. +const SKIP_BUDGET: usize = 32; + /// What polling one route's reader did, as reported to /// [`ReadyRoutes::poll_ready`]. pub(crate) enum Polled { @@ -33,7 +49,7 @@ pub(crate) enum Polled { Staged, /// A value was taken but not staged (topic overflow, serializer error), /// or the reader lagged. The reader kept no waker, so the route is - /// polled again at once. + /// polled again at once, up to [`SKIP_BUDGET`] times in a row. Skipped, /// The reader's buffer is closed; the route is never polled again. Closed, @@ -154,7 +170,9 @@ impl ReadyRoutes { /// task's waker wakes the task without marking its route. /// /// - `Ready(Some(id))`: route `id` staged a value. Every other route that - /// is ready is served before `id` is served again. + /// is ready is served before `id` is served again. A route that keeps + /// skipping is polled at most [`SKIP_BUDGET`] times in a row, then + /// again in a later pass. /// - `Ready(None)`: every route is closed, or there were none. Final. /// - `Pending`: no woken route had a value. The task is woken when one /// does. @@ -173,6 +191,7 @@ impl ReadyRoutes { let mut pass = self.pass(); while let Some(id) = self.take_next(&mut pass) { let mut route_cx = Context::from_waker(&self.wakers[id]); + let mut skips = 0; loop { match poll_route(id, &mut route_cx) { Polled::Pending => break, @@ -183,8 +202,17 @@ impl ReadyRoutes { return Poll::Ready(Some(id)); } // Moving on would leave the route with a clear bit and a - // reader that kept no waker. - Polled::Skipped => {} + // reader that kept no waker, so it is polled again. After + // `SKIP_BUDGET` skips in a row its own waker sets its bit + // and wakes the task instead: a producer that keeps up + // with the skips cannot hold the call. + Polled::Skipped => { + skips += 1; + if skips == SKIP_BUDGET { + route_cx.waker().wake_by_ref(); + break; + } + } Polled::Closed => { self.close(id); break; @@ -520,6 +548,54 @@ mod tests { assert_eq!(sim.sent, [(1, 3), (1, 4)]); } + #[test] + fn a_route_that_keeps_skipping_does_not_hold_the_call() { + // Route 0's values all fail to stage, and a producer that preempts + // the transport refills it as fast as they are skipped. Route 1 has + // a value waiting. + let mut ready = ReadyRoutes::new(2); + let task = Arc::new(Count::default()); + let waker = Waker::from(task.clone()); + let mut cx = Context::from_waker(&waker); + let mut skips = 0; + let polled = ready.poll_ready(&mut cx, |id, _| match id { + 0 => { + skips += 1; + assert!(skips <= SKIP_BUDGET, "one call skipped {skips} times"); + Polled::Skipped + } + _ => Polled::Staged, + }); + assert_eq!(polled, Poll::Ready(Some(1))); + assert_eq!(skips, SKIP_BUDGET); + + // Route 0 is marked and the task woken, so the next call polls it. + assert!(task.0.load(Ordering::Relaxed) > 0); + let mut polled_again = false; + let _ = ready.poll_ready(&mut cx, |id, _| { + polled_again |= id == 0; + Polled::Pending + }); + assert!(polled_again); + } + + #[test] + fn a_pass_starting_at_bit_31_takes_that_route_once() { + // The wrap-around part of a pass stops below `start`. When `start` is + // the top bit of a word, only that word's mask keeps the scan from + // taking `start` again after its reader woke itself. + let mut sim = Sim::new(64); + assert!(sim.drain().is_empty()); + sim.ready.cursor = 30; + sim.write(31, 1); + let before = sim.polls()[31]; + + sim.budget_spent = true; + assert!(sim.poll().is_pending()); + assert_eq!(sim.polls()[31] - before, 1); + assert_eq!(sim.drain(), [31]); + } + #[test] fn a_spent_budget_ends_the_call() { let mut sim = Sim::new(3); diff --git a/docs/design/054-zero-alloc-connector-boundary.md b/docs/design/054-zero-alloc-connector-boundary.md index 83045b54..140c0b8f 100644 --- a/docs/design/054-zero-alloc-connector-boundary.md +++ b/docs/design/054-zero-alloc-connector-boundary.md @@ -274,8 +274,11 @@ places; the message is lent once the poll has returned. nothing. - After a value that is skipped (topic overflow, serializer error) or a `BufferLagged`, the same route is polled again until it returns `Pending` - or a usable value. Moving on instead would leave that reader without a - registered waker, and its next value would not wake the task. + or a usable value, at most 32 times in a row. Moving on instead would + leave that reader without a registered waker, and its next value would + not wake the task. After 32 skips the route's own waker marks it for a + later pass and wakes the task, so a route whose values all fail cannot + hold the call while its producer keeps writing. - Skips and lag are logged and counted per route (`RouteStats`); a closed buffer closes the route. None of these end the other routes. - `Ready(None)` is final, and a connector with no outbound links gets it on @@ -305,23 +308,24 @@ polled: use portable_atomic::AtomicU32; // already a core dependency use futures_util::task::AtomicWaker; // already a core dependency, no_std -struct ReadySet { +struct Shared { ready: Box<[AtomicU32]>, // one bit per route, ceil(routes / 32) words task: AtomicWaker, // the transport task } -struct RouteWake { id: RouteId, set: Arc } +struct RouteWake { id: RouteId, shared: Arc } impl Wake for RouteWake { fn wake_by_ref(self: &Arc) { - self.set.ready[self.id / 32].fetch_or(1 << (self.id % 32), Release); - self.set.task.wake(); + self.shared.ready[self.id / 32].fetch_or(1 << (self.id % 32), Release); + self.shared.task.wake(); } fn wake(self: Arc) { self.wake_by_ref() } } -// OutboundRoutes also keeps `cursor: RouteId` (plain field, `&mut self`) -// and `open: Box<[u32]>` (bitmap of routes that are not closed). +// `ReadyRoutes` owns the `Arc`, the route wakers, `cursor: RouteId` +// (plain field, `&mut self`) and `open: Box<[u32]>` (bitmap of routes that +// are not closed). `OutboundRoutes` holds one `ReadyRoutes`. ``` - **Lock-free, so a waker may fire from any context.** A route's waker is @@ -329,7 +333,17 @@ impl Wake for RouteWake { task on an `InterruptExecutor` (049) that preempts the transport task. A lock held by the transport task while the waker runs would deadlock a single core, which rules out the spin-locked FIFO the prototype used - (§7, alternative 11). `fetch_or` and `AtomicWaker` take no lock. + (§7, alternative 11). `fetch_or` and `AtomicWaker::wake` take no lock. +- **The transport task does not outrank its producers.** It runs at the + same or a lower priority than every task or interrupt that writes its + routes' records. `AtomicWaker::register`, called on every poll, answers + a wake still in progress by waking the task again instead of waiting + ("we simply schedule to come back later"). A transport that preempted + that wake is polled again and again before the producer can finish it, + and on one core the producer never does. Reproduced with the transport + and a producer pinned to one CPU under `SCHED_FIFO`; the reverse order + is fine. 049's deployment already satisfies the rule: cadences run above + connector I/O. - The route wakers are built once, in `OutboundRoutes::new`; polling a route uses `Context::from_waker(&route_waker)`. Every bit starts set, since no reader has registered a waker yet. @@ -340,7 +354,9 @@ impl Wake for RouteWake { in this list are then kept in one tested function instead of by every caller; getting one wrong stalls a route for good, since a reader that returned a value keeps no waker. A skip or lag polls the same route - again, since moving on would leave it with a clear bit and no waker. + again, since moving on would leave it with a clear bit and no waker, up + to 32 times in a row; then the route's own waker sets its bit and wakes + the task. - `poll_ready` registers the task's waker before it reads the bitmap, so a route that wakes after the bitmap reads empty still wakes the task. Every wake takes the stored waker out, so registering after the scan @@ -521,11 +537,12 @@ Document this next to the buffer types; no API change. from its loop. - **Fairness.** Round-robin over routes that woke (§4.2); after each message from a busy route every other ready route is served once before - it, so it cannot starve the others. Measured on the prototype's FIFO, and - required of the bitmap by the same tests: three routes with three values - each are pulled 0, 1, 2, 0, 1, 2, …; with 200 values queued on one route - and one on another, the second is served first or second. Priorities are - not in scope. + it, so it cannot starve the others. A route whose values keep failing to + stage gives up its turn after 32 skips in a row, so it cannot either. + Measured on the prototype's FIFO, and required of the bitmap by the same + tests: three routes with three values each are pulled 0, 1, 2, 0, 1, 2, + …; with 200 values queued on one route and one on another, the second is + served first or second. Priorities are not in scope. - **Embedded MQTT: outbound PUBLISH size is bounded.** Today `encode` allocates each packet at its exact length, so any size goes out. With the write ring (§4.7) a PUBLISH frame is at most `capacity / 2 − @@ -1007,11 +1024,19 @@ The Zenoh connector (053) is not implemented yet; it is written against per message (10,000 for 10,000 items with futures-util 0.3.33), which the `outbound_next_*` rows (§5) forbid. `FuturesUnordered` alone holds futures that finish once, so a route would be re-inserted after every - value too. Its queue can also be seen half-updated by a task that - preempted a producer mid-insertion; it then wakes itself and returns - `Pending`, which on one core starves that producer if the transport - task outranks it. And routes read different value types into one - scratch buffer passed in at poll time, which a `Stream` cannot take. + value too. And routes read different value types into one scratch + buffer passed in at poll time, which a `Stream` cannot take. (Its + queue can be seen half-updated by a task that preempted a producer + mid-insertion, which then wakes itself; `AtomicWaker::register` does + the same, which is why §4.2 keeps the transport from outranking its + producers. It is not a reason to prefer one over the other.) +14. **`futures-concurrency`'s `Merge` over a `Vec` of streams.** The + closest match: a waker per child, a readiness bitset, a parent waker, + and no allocation per item. But with `std` the readiness sits behind a + `std::sync::Mutex` that every wake takes, and the `no_std` build gives + every child the parent waker and polls all of them: 384 child polls + per item with one busy stream among 256 (futures-concurrency 7.7.1), + which is alternative 10. The scratch-buffer limit of 13 applies too. ## 8. Open questions From 6c1bd9bf45d7e974c2a768ebea6f1ce2570d30c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 07:51:02 +0000 Subject: [PATCH 08/28] refactor(core): build the ready set's open mask one route at a time ReadyRoutes::new computed each word of the open mask from the number of routes left, with a special case for a partly filled last word. It now sets each route's bit with the same bit() the wakers and the scan use, so the mask is right by construction. The result is identical for every route count from 0 to 1,024. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KTnKDhY6tvu8bnscus1TZk --- aimdb-core/src/outbound/ready.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/aimdb-core/src/outbound/ready.rs b/aimdb-core/src/outbound/ready.rs index d7c5aea6..e163463e 100644 --- a/aimdb-core/src/outbound/ready.rs +++ b/aimdb-core/src/outbound/ready.rs @@ -130,16 +130,11 @@ impl ReadyRoutes { /// `len` routes, all open and all ready: no reader has registered a waker /// yet. pub(crate) fn new(len: usize) -> Self { - let open: Box<[u32]> = (0..len.div_ceil(32)) - .map(|w| { - let rest = len - w * 32; - if rest < 32 { - (1 << rest) - 1 - } else { - u32::MAX - } - }) - .collect(); + // One bit per route, set with the same `bit()` the wakers use. + let mut open = alloc::vec![0u32; len.div_ceil(32)].into_boxed_slice(); + for id in 0..len { + open[id / 32] |= bit(id); + } let shared = Arc::new(Shared { ready: open.iter().map(|&w| AtomicU32::new(w)).collect(), task: AtomicWaker::new(), @@ -157,6 +152,7 @@ impl ReadyRoutes { wakers, open, open_count: len, + // The first pass starts after the cursor, at route 0. cursor: len.saturating_sub(1), } } From 0b81fb7bc5d61dc5772f6e5a31486341cb5d5359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 4 Oct 2026 09:07:49 +0000 Subject: [PATCH 09/28] =?UTF-8?q?feat(core):=20add=20OutboundRoutes=20(des?= =?UTF-8?q?ign=20054=20=C2=A74.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- aimdb-core/src/builder.rs | 19 + aimdb-core/src/connector.rs | 4 + aimdb-core/src/lib.rs | 7 +- aimdb-core/src/outbound/mod.rs | 9 +- aimdb-core/src/outbound/routes.rs | 316 ++++++++++++ aimdb-core/src/typed_api.rs | 147 +++++- aimdb-tokio-adapter/tests/outbound_routes.rs | 479 +++++++++++++++++++ 7 files changed, 961 insertions(+), 20 deletions(-) create mode 100644 aimdb-core/src/outbound/routes.rs create mode 100644 aimdb-tokio-adapter/tests/outbound_routes.rs diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index 127fc89d..a3a69ea7 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -1256,6 +1256,25 @@ impl AimDb { .name(key) } + /// Every outbound link of `scheme`, with its record's index and key. + pub(crate) fn outbound_links<'a>( + &'a self, + scheme: &'a str, + ) -> impl Iterator + 'a { + self.inner + .storages + .iter() + .enumerate() + .flat_map(move |(i, entry)| { + entry + .record + .outbound_connectors() + .iter() + .filter(move |link| link.url.scheme() == scheme) + .map(move |link| (i, entry.key.as_str(), link)) + }) + } + /// Collects outbound routes for a specific protocol scheme /// /// Mirrors [`inbound_router`](Self::inbound_router). Iterates all records, diff --git a/aimdb-core/src/connector.rs b/aimdb-core/src/connector.rs index 6002edd1..aae97753 100644 --- a/aimdb-core/src/connector.rs +++ b/aimdb-core/src/connector.rs @@ -595,6 +595,9 @@ pub struct ConnectorLink { /// /// Available in both `std` and `no_std + alloc` environments. pub source_factory: SourceFactoryFn, + + /// Builds the link's route for `OutboundRoutes`; set by `link_to`. + pub(crate) route_factory: Option, } impl Debug for ConnectorLink { @@ -614,6 +617,7 @@ impl ConnectorLink { url, config: Vec::new(), source_factory, + route_factory: None, } } diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index dbfaa13d..7be4a774 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -87,10 +87,6 @@ pub mod extensions; pub mod graph; mod inbound_dispatch; mod inbound_key; -#[cfg_attr( - not(test), - expect(dead_code, reason = "OutboundRoutes uses it from the next commit on") -)] mod outbound; #[cfg(feature = "observability")] pub mod profiling; @@ -174,6 +170,9 @@ pub use connector::{TopicBuf, TopicOverflow, TopicProvider, TopicWriter}; // Router exports for connector implementations pub use inbound_dispatch::InboundDispatch; +pub use outbound::{ + OutboundMessage, OutboundPayload, OutboundRoutes, RouteId, RouteInfo, RouteStats, +}; pub use router::Router; // Topic grammar for connectors with wildcard subscriptions diff --git a/aimdb-core/src/outbound/mod.rs b/aimdb-core/src/outbound/mod.rs index 34b2ef27..3407edf2 100644 --- a/aimdb-core/src/outbound/mod.rs +++ b/aimdb-core/src/outbound/mod.rs @@ -1,6 +1,11 @@ //! Outbound path: connectors pull serialized messages from their routes. mod ready; +mod routes; -/// Dense route index, `0..len`. -pub(crate) type RouteId = usize; +pub use routes::{OutboundMessage, OutboundPayload, OutboundRoutes, RouteInfo, RouteStats}; +pub(crate) use routes::{PollRoute, RouteFactoryFn, RouteOutcome, RouteParts, StagedPayload}; + +/// Dense route index, `0..routes().len()`. A plain `usize`, so a connector +/// indexes its own per-route tables with it. +pub type RouteId = usize; diff --git a/aimdb-core/src/outbound/routes.rs b/aimdb-core/src/outbound/routes.rs new file mode 100644 index 00000000..a70b3e8a --- /dev/null +++ b/aimdb-core/src/outbound/routes.rs @@ -0,0 +1,316 @@ +//! [`OutboundRoutes`]: every outbound link of one scheme, pulled by the +//! connector's transport task. + +use alloc::boxed::Box; +use alloc::string::ToString; +use alloc::sync::Arc; +use alloc::vec::Vec; +use core::future::poll_fn; +use core::task::{Context, Poll}; + +use super::ready::{Polled, ReadyRoutes}; +use super::RouteId; +use crate::transport::ConnectorConfig; +use crate::{AimDb, ConfigError, DbError, DbResult, RuntimeContext}; + +/// One outbound route, for parsing per-route configuration once at build. +#[derive(Debug, Clone)] +pub struct RouteInfo { + /// Dense index, `0..routes().len()`. + pub id: RouteId, + /// The topic from the link URL, used when no topic is written. + pub default_topic: Arc, + /// The link's configuration, with `record_index` set. + pub config: ConnectorConfig, + /// Longest topic the link's writer produces; 0 without a writer. + pub topic_capacity: usize, + /// Scratch for `with_serializer_into`; 0 with an owned serializer only. + pub payload_capacity: usize, +} + +/// A message pulled from [`OutboundRoutes`]. +#[derive(Debug)] +pub struct OutboundMessage<'a> { + /// The route it came from. + pub route: &'a RouteInfo, + /// The written topic, or the route's default. + pub topic: &'a str, + /// The serialized value. + pub payload: OutboundPayload<'a>, +} + +/// Where a pulled message's bytes are. +#[derive(Debug, PartialEq, Eq)] +pub enum OutboundPayload<'a> { + /// Serialized into `OutboundRoutes`' scratch (`with_serializer_into`). + Borrowed(&'a [u8]), + /// An owned serializer's bytes (`with_serializer`). Transports that take + /// ownership move it in; others borrow it. + Owned(Vec), +} + +/// Values taken from one route's buffer, by outcome. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct RouteStats { + /// Staged for the connector. + pub sent: u64, + /// Missed because the reader fell behind. + pub lagged: u64, + /// Skipped: the written topic did not fit. + pub topic_overflow: u64, + /// Skipped: the serializer failed or reported an invalid length. + pub serialize_failed: u64, +} + +/// What polling one route did. +pub(crate) enum RouteOutcome { + /// A value was serialized. `topic_len`: bytes written into the topic + /// scratch, `None` for the default topic. + Staged { + topic_len: Option, + payload: StagedPayload, + }, + /// The reader lagged by this many values. + Lagged(u64), + /// A value was taken; its topic did not fit. + TopicOverflow, + /// A value was taken; serializing it failed (already logged). + SerializeFailed, + /// The buffer is gone. + Closed, +} + +/// Where a staged payload is. +pub(crate) enum StagedPayload { + /// The first `len` bytes of the payload scratch. + Scratch(usize), + Owned(Vec), +} + +/// A route's typed reader, topic writer and serializers, polled through one +/// erased call. +pub(crate) trait PollRoute: Send { + /// Poll the reader once with `cx`. On a value, write its topic into + /// `topic` and its payload into `payload` (or an owned `Vec`). + fn poll_route( + &mut self, + cx: &mut Context<'_>, + ctx: &RuntimeContext, + topic: &mut [u8], + payload: &mut [u8], + ) -> Poll; +} + +/// What a link's route factory builds. +pub(crate) struct RouteParts { + pub(crate) route: Box, + pub(crate) topic_capacity: usize, + pub(crate) payload_capacity: usize, + /// The link uses `with_topic_provider`, which this path does not support. + pub(crate) topic_provider: bool, +} + +/// Builds a link's [`RouteParts`], subscribing to its record. +pub(crate) type RouteFactoryFn = Arc RouteParts + Send + Sync>; + +struct Staged { + id: RouteId, + topic_len: Option, + payload: StagedPayload, +} + +/// Every outbound link of one scheme, read by the connector's transport task. +/// +/// Built in the connector's `build()`: cursors start then, so an SPMC ring +/// keeps what is produced before the transport first polls. Only routes +/// whose readers woke are polled, in round-robin order. +pub struct OutboundRoutes { + routes: Box<[RouteInfo]>, + states: Box<[Box]>, + stats: Box<[RouteStats]>, + ready: ReadyRoutes, + /// Topic region (`topic_region` bytes), then payload region. + scratch: Box<[u8]>, + topic_region: usize, + staged: Option, + ctx: RuntimeContext, +} + +impl OutboundRoutes { + /// Subscribes every outbound link of `scheme` and allocates the scratch + /// once: the largest topic capacity plus the largest payload capacity. + /// + /// Rejects every link that uses `with_topic_provider`. + pub fn new(db: &AimDb, scheme: &str) -> DbResult { + let mut routes = Vec::new(); + let mut states = Vec::new(); + let mut errors = Vec::new(); + + for (record_index, record_key, link) in db.outbound_links(scheme) { + let Some(factory) = &link.route_factory else { + errors.push(ConfigError::new( + record_key, + Some(link.url.to_string()), + "link was not registered through `link_to`", + )); + continue; + }; + let parts = factory(db); + if parts.topic_provider { + errors.push(ConfigError::new( + record_key, + Some(link.url.to_string()), + "`with_topic_provider` is not supported here; use `with_topic_writer` or `with_topic_fn`", + )); + continue; + } + let mut query = link.config.clone(); + query.push(("record_index".to_string(), record_index.to_string())); + routes.push(RouteInfo { + id: routes.len(), + default_topic: Arc::from(link.url.resource_id()), + config: ConnectorConfig::from_query(&query), + topic_capacity: parts.topic_capacity, + payload_capacity: parts.payload_capacity, + }); + states.push(parts.route); + } + + if !errors.is_empty() { + return Err(DbError::InvalidConfiguration { errors }); + } + + let topic_region = routes.iter().map(|r| r.topic_capacity).max().unwrap_or(0); + let payload_region = routes.iter().map(|r| r.payload_capacity).max().unwrap_or(0); + Ok(Self { + stats: alloc::vec![RouteStats::default(); routes.len()].into_boxed_slice(), + ready: ReadyRoutes::new(routes.len()), + routes: routes.into_boxed_slice(), + states: states.into_boxed_slice(), + scratch: alloc::vec![0; topic_region + payload_region].into_boxed_slice(), + topic_region, + staged: None, + ctx: db.runtime_ctx(), + }) + } + + /// Routes, for parsing per-route configuration once at build. + pub fn routes(&self) -> &[RouteInfo] { + &self.routes + } + + /// Values taken from route `id`'s buffer so far, by outcome. + pub fn stats(&self, id: RouteId) -> Option { + self.stats.get(id).copied() + } + + /// Take the next ready value from a route that woke and serialize it into + /// the scratch. Lends nothing. + /// + /// - `Ready(Some(id))`: a message from route `id` is staged; take it with + /// [`take_staged`](Self::take_staged). A staged message that was never + /// taken is returned again, not replaced. + /// - `Ready(None)`: every route is closed, or there were none. Final: a + /// `select` arm must be disarmed after it. + /// - `Pending`: no woken route had a value. On Tokio this can also mean + /// the task's budget is spent; the task is woken to try again. + /// + /// A value leaves its buffer only in a call that returns `Ready`, so this + /// is safe as a `select` arm. Skipped values (topic overflow, serializer + /// error) and lag are logged and counted in [`stats`](Self::stats). + pub fn poll_stage(&mut self, cx: &mut Context<'_>) -> Poll> { + if let Some(staged) = &self.staged { + return Poll::Ready(Some(staged.id)); + } + let Self { + routes, + states, + stats, + ready, + scratch, + topic_region, + staged, + ctx, + } = self; + let (topic_buf, payload_buf) = scratch.split_at_mut(*topic_region); + ready.poll_ready(cx, |id, route_cx| { + let info = &routes[id]; + let topic = &mut topic_buf[..info.topic_capacity]; + let payload = &mut payload_buf[..info.payload_capacity]; + let stats = &mut stats[id]; + match states[id].poll_route(route_cx, ctx, topic, payload) { + Poll::Pending => Polled::Pending, + Poll::Ready(RouteOutcome::Staged { topic_len, payload }) => { + stats.sent += 1; + *staged = Some(Staged { + id, + topic_len, + payload, + }); + Polled::Staged + } + Poll::Ready(RouteOutcome::Lagged(n)) => { + stats.lagged += n; + log_warn!("outbound route '{}' lagged by {}", info.default_topic, n); + Polled::Skipped + } + Poll::Ready(RouteOutcome::TopicOverflow) => { + stats.topic_overflow += 1; + log_warn!( + "outbound route '{}': topic does not fit in {} bytes, value skipped", + info.default_topic, + info.topic_capacity + ); + Polled::Skipped + } + Poll::Ready(RouteOutcome::SerializeFailed) => { + stats.serialize_failed += 1; + Polled::Skipped + } + Poll::Ready(RouteOutcome::Closed) => { + log_debug!("outbound route '{}' closed", info.default_topic); + Polled::Closed + } + } + }) + } + + /// Lend the staged message and clear it. `None` if nothing is staged. + pub fn take_staged(&mut self) -> Option> { + let staged = self.staged.take()?; + let route = &self.routes[staged.id]; + let (topic_buf, payload_buf) = self.scratch.split_at(self.topic_region); + let topic = match staged.topic_len { + // Written through `TopicBuf`, which only ever holds whole `&str`s. + Some(len) => core::str::from_utf8(&topic_buf[..len]).unwrap_or_default(), + None => &route.default_topic, + }; + let payload = match staged.payload { + StagedPayload::Scratch(len) => OutboundPayload::Borrowed(&payload_buf[..len]), + StagedPayload::Owned(bytes) => OutboundPayload::Owned(bytes), + }; + Some(OutboundMessage { + route, + topic, + payload, + }) + } + + /// [`poll_stage`](Self::poll_stage), then [`take_staged`](Self::take_staged), + /// for hand-written `poll` code. + pub fn poll_next(&mut self, cx: &mut Context<'_>) -> Poll>> { + match self.poll_stage(cx) { + Poll::Ready(Some(_)) => Poll::Ready(self.take_staged()), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } + + /// The next message, or `None` once every route is closed. + /// + /// Cancel-safe: dropping the future before it completes takes nothing. + pub async fn next(&mut self) -> Option> { + poll_fn(|cx| self.poll_stage(cx)).await?; + self.take_staged() + } +} diff --git a/aimdb-core/src/typed_api.rs b/aimdb-core/src/typed_api.rs index c6bcf72e..f0fbf68e 100644 --- a/aimdb-core/src/typed_api.rs +++ b/aimdb-core/src/typed_api.rs @@ -297,6 +297,9 @@ type FusedSerializeFn = Arc< + Sync, >; +/// Builds a link's `Consumer` from the live database, once per route. +type ConsumerFactoryFn = Arc Consumer + Send + Sync>; + /// Optional allocation-free serializer captured beside [`FusedSerializeFn`]. /// /// The callback writes into one pump-owned bounded scratch buffer. Returning @@ -525,6 +528,90 @@ impl crate::connector::SerializedReader for FusedRead } } +/// One outbound link's per-route state inside +/// [`OutboundRoutes`](crate::OutboundRoutes): reader, topic writer and +/// serializers, all typed. +struct TypedRoute { + reader: crate::buffer::Reader, + writer: Option>>, + serialize: FusedSerializeFn, + serialize_into: Option>, +} + +impl crate::outbound::PollRoute for TypedRoute { + fn poll_route( + &mut self, + cx: &mut core::task::Context<'_>, + ctx: &crate::RuntimeContext, + topic: &mut [u8], + payload: &mut [u8], + ) -> core::task::Poll { + use crate::outbound::{RouteOutcome, StagedPayload}; + use core::task::Poll; + + let value = match self.reader.poll_recv(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Ok(value)) => value, + Poll::Ready(Err(crate::DbError::BufferLagged { lag_count, .. })) => { + return Poll::Ready(RouteOutcome::Lagged(lag_count)); + } + Poll::Ready(Err(_)) => return Poll::Ready(RouteOutcome::Closed), + }; + + let topic_len = match &self.writer { + None => None, + Some(writer) => { + let mut out = crate::connector::TopicBuf::new(topic); + match writer.write_topic(&value, &mut out) { + Ok(true) if !out.overflowed() => Some(out.len()), + Ok(false) if !out.overflowed() => None, + _ => return Poll::Ready(RouteOutcome::TopicOverflow), + } + } + }; + + let owned = |value: &T| match (self.serialize)(ctx, value) { + Ok(bytes) => Some(StagedPayload::Owned(bytes)), + Err(_e) => { + log_error!( + "outbound link: failed to serialize {}: {:?}", + core::any::type_name::(), + _e + ); + None + } + }; + let staged = match &self.serialize_into { + None => owned(&value), + Some(serialize_into) => match serialize_into(ctx, &value, payload) { + Ok(len) if len <= payload.len() => Some(StagedPayload::Scratch(len)), + Ok(_len) => { + log_error!( + "outbound link: serializer for {} returned invalid length {} for {}-byte scratch", + core::any::type_name::(), + _len, + payload.len() + ); + None + } + Err(crate::connector::SerializeError::BufferTooSmall) => owned(&value), + Err(_e) => { + log_error!( + "outbound link: failed to serialize {} into scratch: {:?}", + core::any::type_name::(), + _e + ); + None + } + }, + }; + Poll::Ready(match staged { + Some(payload) => RouteOutcome::Staged { topic_len, payload }, + None => RouteOutcome::SerializeFailed, + }) + } +} + // ============================================================================ // RecordRegistrar - Fluent registration API // ============================================================================ @@ -1070,25 +1157,19 @@ where self.registrar.last_stage = Some((StageKind::Link, 0)); } - // Fused source factory that captures type T and record key. + // Resolves the record and builds a `Consumer` bound to its buffer + // handle, once per route (not per message) — same pattern as the + // build-time path in `TypedRecord::collect_consumer_futures`. // - // Resolves the record at route-collection time (not per-message) and - // constructs a `Consumer` bound to a pre-resolved buffer handle — - // same pattern as the build-time path in - // `TypedRecord::collect_consumer_futures`. The serializer - // and topic provider ride along typed, so the readers handed to the - // pumps yield destination + payload with no erasure crossing. - // - // The factory runs during build() after every record is registered and - // validated (including the linked-records-need-a-buffer check), so - // failures here are aimdb bugs, not user mistakes. + // The factories run during build() after every record is registered + // and validated (including the linked-records-need-a-buffer check), + // so failures here are aimdb bugs, not user mistakes. #[allow( clippy::panic, reason = "the factory returns no Result and these lookups were validated at build() time" )] - let source_factory: crate::connector::SourceFactoryFn = { + let make_consumer: ConsumerFactoryFn = { let record_key = self.registrar.record_key.clone(); - let topic = self.topic; Arc::new(move |db: &AimDb| { let typed_rec = db .inner() @@ -1110,8 +1191,23 @@ where let mut consumer = Consumer::::new(buffer); #[cfg(feature = "observability")] consumer.set_profiling(link_metrics.clone(), db.profiling_clock().clone()); + consumer + }) + }; + + // Fused source for the pumps: the serializer and topic selector ride + // along typed, so its readers yield destination + payload with no + // erasure crossing. + let source_factory: crate::connector::SourceFactoryFn = { + let make_consumer = make_consumer.clone(); + let (serialize, serialize_into, topic) = ( + serialize.clone(), + serialize_into.clone(), + self.topic.clone(), + ); + Arc::new(move |db: &AimDb| { Box::new(FusedSource { - consumer, + consumer: make_consumer(db), serialize: serialize.clone(), serialize_into: serialize_into.clone(), topic: topic.clone(), @@ -1119,8 +1215,31 @@ where }) }; + // The same parts for `OutboundRoutes`, subscribed when it is built. + let route_factory: crate::outbound::RouteFactoryFn = { + let topic = self.topic; + Arc::new(move |db: &AimDb| { + let (writer, topic_capacity) = match &topic { + TopicSelector::Writer { capacity, writer } => (Some(writer.clone()), *capacity), + _ => (None, 0), + }; + crate::outbound::RouteParts { + route: Box::new(TypedRoute { + reader: make_consumer(db).subscribe(), + writer, + serialize: serialize.clone(), + serialize_into: serialize_into.as_ref().map(|(_, f)| f.clone()), + }), + topic_capacity, + payload_capacity: serialize_into.as_ref().map_or(0, |(capacity, _)| *capacity), + topic_provider: matches!(topic, TopicSelector::Provider(_)), + } + }) + }; + let mut link = ConnectorLink::new(url, source_factory); link.config = self.config; + link.route_factory = Some(route_factory); // Store the connector link - sources will be created later in build() // after connectors are actually built diff --git a/aimdb-tokio-adapter/tests/outbound_routes.rs b/aimdb-tokio-adapter/tests/outbound_routes.rs new file mode 100644 index 00000000..9e895512 --- /dev/null +++ b/aimdb-tokio-adapter/tests/outbound_routes.rs @@ -0,0 +1,479 @@ +//! `OutboundRoutes` on real Tokio buffers: round-robin, topics, serializers, +//! lag, outage semantics per buffer type, cancellation and shutdown. + +use core::fmt::Write as _; +use std::future::{poll_fn, Future}; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll, Waker}; +use std::time::Duration; + +use aimdb_core::buffer::BufferCfg; +use aimdb_core::connector::{ConnectorBuilder, SerializeError, TopicProvider}; +use aimdb_core::{AimDb, AimDbBuilder, DbResult, OutboundPayload, OutboundRoutes, RecordRegistrar}; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + +#[derive(Clone, Debug)] +struct V(u32); + +type Futures = Vec + Send + 'static>>>; + +/// Lets `link_to("test://…")` register; drives nothing. +struct TestConnector; + +impl ConnectorBuilder for TestConnector { + fn build<'a>( + &'a self, + _db: &'a AimDb, + ) -> Pin> + Send + 'a>> { + Box::pin(async { Ok(Vec::new()) }) + } + fn scheme(&self) -> &str { + "test" + } +} + +fn le(_ctx: aimdb_core::RuntimeContext, v: &V) -> Result, SerializeError> { + Ok(v.0.to_le_bytes().to_vec()) +} + +type Configure = Box) + Send>; + +const KEYS: [&str; 4] = ["r0", "r1", "r2", "r3"]; + +/// One record per entry, keyed `r0`, `r1`, …, in route order. +async fn db(records: Vec) -> AimDb { + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter::new().unwrap())) + .with_connector(TestConnector); + for (i, configure) in records.into_iter().enumerate() { + builder.configure::(KEYS[i], move |reg| configure(reg)); + } + let (db, runner) = builder.build().await.expect("build"); + tokio::spawn(runner.run()); + db +} + +/// A record with `cfg` linked to `test://r{i}` with the owned serializer. +fn plain(i: usize, cfg: BufferCfg) -> Configure { + Box::new(move |reg| { + reg.buffer(cfg) + .link_to(&format!("test://r{i}")) + .with_serializer(le) + .finish(); + }) +} + +fn spmc(i: usize, capacity: usize) -> Configure { + plain(i, BufferCfg::SpmcRing { capacity }) +} + +fn produce(db: &AimDb, i: usize, v: u32) { + db.produce::(KEYS[i], V(v)).unwrap(); +} + +/// Route, topic and payload of the next message. +async fn pull(o: &mut OutboundRoutes) -> Option<(usize, String, Vec)> { + let m = o.next().await?; + let payload = match m.payload { + OutboundPayload::Borrowed(b) => b.to_vec(), + OutboundPayload::Owned(v) => v, + }; + Some((m.route.id, m.topic.to_string(), payload)) +} + +/// The next message, or `None` if none comes within 100 ms. +async fn try_pull(o: &mut OutboundRoutes) -> Option<(usize, String, Vec)> { + tokio::time::timeout(Duration::from_millis(100), pull(o)) + .await + .ok() + .flatten() +} + +fn value(payload: &[u8]) -> u32 { + u32::from_le_bytes(payload.try_into().unwrap()) +} + +/// Values pulled until none comes within 100 ms. +async fn drain(o: &mut OutboundRoutes) -> Vec<(usize, u32)> { + let mut out = Vec::new(); + while let Some((id, _, payload)) = try_pull(o).await { + out.push((id, value(&payload))); + } + out +} + +#[tokio::test] +async fn no_outbound_links_is_done_on_the_first_poll() { + let db = db(vec![Box::new(|reg| { + reg.buffer(BufferCfg::SingleLatest); + })]) + .await; + let mut o = OutboundRoutes::new(&db, "test").unwrap(); + assert!(o.routes().is_empty()); + let mut cx = Context::from_waker(Waker::noop()); + assert_eq!(o.poll_stage(&mut cx), Poll::Ready(None)); +} + +#[tokio::test] +async fn routes_are_served_round_robin() { + let db = db((0..3).map(|i| spmc(i, 16)).collect()).await; + let mut o = OutboundRoutes::new(&db, "test").unwrap(); + for v in 0..3 { + for i in 0..3 { + produce(&db, i, v); + } + } + let order: Vec = drain(&mut o).await.into_iter().map(|(id, _)| id).collect(); + assert_eq!(order, [0, 1, 2, 0, 1, 2, 0, 1, 2]); +} + +#[tokio::test] +async fn a_hot_route_does_not_starve_a_quiet_one() { + let db = db(vec![spmc(0, 256), spmc(1, 16)]).await; + let mut o = OutboundRoutes::new(&db, "test").unwrap(); + for v in 0..200 { + produce(&db, 0, v); + } + produce(&db, 1, 0); + let first_two = [pull(&mut o).await.unwrap().0, pull(&mut o).await.unwrap().0]; + assert!(first_two.contains(&1), "{first_two:?}"); +} + +#[tokio::test] +async fn written_and_default_topics_and_overflow() { + let db = db(vec![ + Box::new(|reg| { + reg.buffer(BufferCfg::SpmcRing { capacity: 16 }) + .link_to("test://default") + .with_topic_fn(8, |v, out| { + if v.0 == 0 { + return Ok(false); + } + write!(out, "t/{}", v.0)?; + Ok(true) + }) + .with_serializer(le) + .finish(); + }), + Box::new(|reg| { + reg.buffer(BufferCfg::SpmcRing { capacity: 16 }) + .link_to("test://ignored") + .with_topic_fn(4, |v, out| { + let _ = write!(out, "t/{}", v.0); + Ok(true) + }) + .with_serializer(le) + .finish(); + }), + ]) + .await; + let mut o = OutboundRoutes::new(&db, "test").unwrap(); + assert_eq!(o.routes()[0].topic_capacity, 8); + + // "t/1234567" is 9 bytes. + for v in [5, 0, 1_234_567, 6] { + produce(&db, 0, v); + } + let mut got = Vec::new(); + while let Some((_, topic, payload)) = try_pull(&mut o).await { + got.push((topic, value(&payload))); + } + assert_eq!( + got, + [ + ("t/5".to_string(), 5), + ("default".to_string(), 0), + ("t/6".to_string(), 6) + ] + ); + assert_eq!(o.stats(0).unwrap().topic_overflow, 1); + + // A writer that ignores the overflow still has its value skipped. + produce(&db, 1, 123); + produce(&db, 1, 7); + assert_eq!(drain(&mut o).await, [(1, 7)]); + assert_eq!(o.stats(1).unwrap().topic_overflow, 1); +} + +#[tokio::test] +async fn scratch_owned_fallback_and_serializer_failures() { + let db = db(vec![Box::new(|reg| { + reg.buffer(BufferCfg::SpmcRing { capacity: 16 }) + .link_to("test://r0") + .with_serializer(|_ctx, v: &V| { + if v.0 == 4 { + return Err(SerializeError::InvalidData); + } + Ok(v.0.to_le_bytes().to_vec()) + }) + .with_serializer_into(4, |_ctx, v: &V, out| match v.0 { + 2 | 4 => Err(SerializeError::BufferTooSmall), + 3 => Ok(99), + _ => { + out[..4].copy_from_slice(&v.0.to_le_bytes()); + Ok(4) + } + }) + .finish(); + })]) + .await; + let mut o = OutboundRoutes::new(&db, "test").unwrap(); + assert_eq!(o.routes()[0].payload_capacity, 4); + for v in 1..=5 { + produce(&db, 0, v); + } + + let m = o.next().await.unwrap(); + assert_eq!(m.payload, OutboundPayload::Borrowed(&1u32.to_le_bytes())); + let m = o.next().await.unwrap(); + assert_eq!( + m.payload, + OutboundPayload::Owned(2u32.to_le_bytes().to_vec()) + ); + // 3: invalid length, 4: fallback fails; both skipped. + let m = o.next().await.unwrap(); + assert_eq!(m.payload, OutboundPayload::Borrowed(&5u32.to_le_bytes())); + let stats = o.stats(0).unwrap(); + assert_eq!((stats.sent, stats.serialize_failed), (3, 2)); +} + +#[tokio::test] +async fn lag_is_reported_then_recovered() { + let db = db(vec![spmc(0, 4)]).await; + let mut o = OutboundRoutes::new(&db, "test").unwrap(); + for v in 0..10 { + produce(&db, 0, v); + } + assert_eq!(drain(&mut o).await, [(0, 6), (0, 7), (0, 8), (0, 9)]); + assert_eq!(o.stats(0).unwrap().lagged, 6); +} + +#[tokio::test] +async fn outage_semantics_per_buffer_type() { + let db = db(vec![ + plain(0, BufferCfg::SingleLatest), + plain(1, BufferCfg::Mailbox), + spmc(2, 16), + ]) + .await; + let mut o = OutboundRoutes::new(&db, "test").unwrap(); + for v in 0..5 { + for i in 0..3 { + produce(&db, i, v); + } + } + let got = drain(&mut o).await; + let of = |route| -> Vec { + got.iter() + .filter(|(id, _)| *id == route) + .map(|(_, v)| *v) + .collect() + }; + assert_eq!(of(0), [4], "single-latest"); + assert_eq!(of(1), [4], "mailbox"); + assert_eq!(of(2), [0, 1, 2, 3, 4], "spmc ring"); +} + +#[tokio::test] +async fn a_dropped_pending_next_loses_nothing() { + let db = db(vec![spmc(0, 16)]).await; + let mut o = OutboundRoutes::new(&db, "test").unwrap(); + assert!(try_pull(&mut o).await.is_none(), "dropped while pending"); + produce(&db, 0, 1); + assert_eq!(drain(&mut o).await, [(0, 1)]); +} + +#[tokio::test] +async fn a_staged_value_that_was_not_taken_is_returned_again() { + let db = db(vec![spmc(0, 16), spmc(1, 16)]).await; + let mut o = OutboundRoutes::new(&db, "test").unwrap(); + produce(&db, 1, 7); + produce(&db, 1, 8); + let id = poll_fn(|cx| o.poll_stage(cx)).await; + assert_eq!(id, Some(1)); + assert_eq!( + poll_fn(|cx| o.poll_stage(cx)).await, + Some(1), + "not replaced" + ); + assert_eq!(drain(&mut o).await, [(1, 7), (1, 8)]); +} + +#[tokio::test] +async fn values_survive_a_select_that_loses_every_third_poll() { + let db = db(vec![spmc(0, 512)]).await; + let mut o = OutboundRoutes::new(&db, "test").unwrap(); + let producer = { + let db = db.clone(); + tokio::spawn(async move { + for v in 0..500 { + produce(&db, 0, v); + tokio::task::yield_now().await; + } + }) + }; + let mut got = Vec::new(); + let mut i = 0u32; + while got.len() < 500 { + i += 1; + // Unbiased: on every third iteration the stage arm may be polled, + // return `Pending`, and be dropped when the yield arm wins. + tokio::select! { + _ = tokio::task::yield_now(), if i.is_multiple_of(3) => {} + id = poll_fn(|cx| o.poll_stage(cx)) => { + assert_eq!(id, Some(0)); + let m = o.take_staged().unwrap(); + let OutboundPayload::Owned(bytes) = m.payload else { panic!("owned") }; + got.push(value(&bytes)); + } + } + } + producer.await.unwrap(); + assert_eq!(got, (0..500).collect::>()); +} + +#[tokio::test] +async fn a_skipped_value_does_not_lose_the_next_wake_up() { + let db = db(vec![Box::new(|reg| { + reg.buffer(BufferCfg::SpmcRing { capacity: 16 }) + .link_to("test://r0") + .with_serializer(|_ctx, v: &V| { + if v.0 == 13 { + return Err(SerializeError::InvalidData); + } + Ok(v.0.to_le_bytes().to_vec()) + }) + .finish(); + })]) + .await; + let mut o = OutboundRoutes::new(&db, "test").unwrap(); + let producer = { + let db = db.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + produce(&db, 0, 13); + tokio::time::sleep(Duration::from_millis(20)).await; + produce(&db, 0, 14); + }) + }; + let got = tokio::time::timeout(Duration::from_secs(5), pull(&mut o)) + .await + .expect("woken after the skipped value") + .unwrap(); + assert_eq!(value(&got.2), 14); + producer.await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn wakes_from_other_threads_lose_nothing() { + const ROUTES: usize = 4; + const PER_ROUTE: u32 = 2_500; + let db = db((0..ROUTES).map(|i| spmc(i, 4096)).collect()).await; + let mut o = OutboundRoutes::new(&db, "test").unwrap(); + let producers: Vec<_> = (0..ROUTES) + .map(|i| { + let db = db.clone(); + tokio::spawn(async move { + for v in 0..PER_ROUTE { + produce(&db, i, v); + if v.is_multiple_of(64) { + tokio::task::yield_now().await; + } + } + }) + }) + .collect(); + let mut next = [0u32; ROUTES]; + for _ in 0..ROUTES as u32 * PER_ROUTE { + let (id, _, payload) = tokio::time::timeout(Duration::from_secs(10), pull(&mut o)) + .await + .expect("no stall") + .unwrap(); + assert_eq!(value(&payload), next[id], "route {id} in order"); + next[id] += 1; + } + for p in producers { + p.await.unwrap(); + } + assert!(o + .routes() + .iter() + .all(|r| o.stats(r.id).unwrap().lagged == 0)); +} + +#[tokio::test] +async fn a_topic_provider_link_is_rejected() { + struct Fixed; + impl TopicProvider for Fixed { + fn topic(&self, _: &V) -> Option { + Some("x".into()) + } + } + let db = db(vec![Box::new(|reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_to("test://r0") + .with_topic_provider(Fixed) + .with_serializer(le) + .finish(); + })]) + .await; + let Err(aimdb_core::DbError::InvalidConfiguration { errors }) = + OutboundRoutes::new(&db, "test") + else { + panic!("expected a configuration error"); + }; + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].record_key, "r0"); + assert!( + errors[0].message.contains("with_topic_writer"), + "{}", + errors[0].message + ); +} + +#[tokio::test] +async fn route_info_matches_collect_outbound_routes() { + let db = db(vec![ + Box::new(|reg| { + reg.buffer(BufferCfg::SingleLatest); + }), + spmc(1, 16), + Box::new(|reg| { + reg.buffer(BufferCfg::Mailbox) + .link_to("test://two?qos=1") + .with_serializer(le) + .finish(); + }), + ]) + .await; + let o = OutboundRoutes::new(&db, "test").unwrap(); + let old = db.collect_outbound_routes("test"); + assert_eq!(o.routes().len(), old.len()); + for (info, route) in o.routes().iter().zip(&old) { + let config = aimdb_core::transport::ConnectorConfig::from_query(&route.config); + assert!(info.config.record_index.is_some()); + assert_eq!(info.config.record_index, config.record_index); + assert_eq!(&*info.default_topic, route.topic); + assert_eq!(info.config.protocol_options, config.protocol_options); + } + assert_eq!(o.routes()[0].config.record_index, Some(1)); + assert_eq!(o.routes()[1].config.record_index, Some(2)); +} + +#[tokio::test] +async fn every_route_closes_when_the_database_is_dropped() { + let db = db(vec![spmc(0, 16), plain(1, BufferCfg::SingleLatest)]).await; + let mut o = OutboundRoutes::new(&db, "test").unwrap(); + produce(&db, 0, 1); + drop(db); + assert_eq!( + pull(&mut o).await.map(|m| m.0), + Some(0), + "values before the close" + ); + let end = tokio::time::timeout(Duration::from_secs(5), pull(&mut o)) + .await + .expect("closes"); + assert!(end.is_none()); +} From 601c0cf80a5f9ca15272c76360b5bb82d8ef7096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 4 Oct 2026 09:39:25 +0000 Subject: [PATCH 10/28] feat(core): add OutboundPayload::as_slice and into_vec Co-Authored-By: Claude Opus 5.5 --- aimdb-core/src/outbound/routes.rs | 38 ++++++++++++++++++++ aimdb-tokio-adapter/tests/outbound_routes.rs | 9 ++--- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/aimdb-core/src/outbound/routes.rs b/aimdb-core/src/outbound/routes.rs index a70b3e8a..97d359df 100644 --- a/aimdb-core/src/outbound/routes.rs +++ b/aimdb-core/src/outbound/routes.rs @@ -49,6 +49,25 @@ pub enum OutboundPayload<'a> { Owned(Vec), } +impl OutboundPayload<'_> { + /// The bytes, whichever variant holds them. + pub fn as_slice(&self) -> &[u8] { + match self { + Self::Borrowed(bytes) => bytes, + Self::Owned(bytes) => bytes, + } + } + + /// The bytes as an owned `Vec`: moved out of `Owned`, copied from + /// `Borrowed`. + pub fn into_vec(self) -> Vec { + match self { + Self::Borrowed(bytes) => bytes.to_vec(), + Self::Owned(bytes) => bytes, + } + } +} + /// Values taken from one route's buffer, by outcome. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct RouteStats { @@ -314,3 +333,22 @@ impl OutboundRoutes { self.take_staged() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payload_helpers_cover_both_variants() { + let scratch = [1u8, 2, 3]; + assert_eq!(OutboundPayload::Borrowed(&scratch).as_slice(), [1, 2, 3]); + assert_eq!(OutboundPayload::Borrowed(&scratch).into_vec(), [1, 2, 3]); + + let owned = alloc::vec![4u8, 5]; + let ptr = owned.as_ptr(); + let payload = OutboundPayload::Owned(owned); + assert_eq!(payload.as_slice(), [4, 5]); + let moved = payload.into_vec(); + assert_eq!(moved.as_ptr(), ptr, "moved, not copied"); + } +} diff --git a/aimdb-tokio-adapter/tests/outbound_routes.rs b/aimdb-tokio-adapter/tests/outbound_routes.rs index 9e895512..e7f63eae 100644 --- a/aimdb-tokio-adapter/tests/outbound_routes.rs +++ b/aimdb-tokio-adapter/tests/outbound_routes.rs @@ -75,11 +75,7 @@ fn produce(db: &AimDb, i: usize, v: u32) { /// Route, topic and payload of the next message. async fn pull(o: &mut OutboundRoutes) -> Option<(usize, String, Vec)> { let m = o.next().await?; - let payload = match m.payload { - OutboundPayload::Borrowed(b) => b.to_vec(), - OutboundPayload::Owned(v) => v, - }; - Some((m.route.id, m.topic.to_string(), payload)) + Some((m.route.id, m.topic.to_string(), m.payload.into_vec())) } /// The next message, or `None` if none comes within 100 ms. @@ -324,8 +320,7 @@ async fn values_survive_a_select_that_loses_every_third_poll() { id = poll_fn(|cx| o.poll_stage(cx)) => { assert_eq!(id, Some(0)); let m = o.take_staged().unwrap(); - let OutboundPayload::Owned(bytes) = m.payload else { panic!("owned") }; - got.push(value(&bytes)); + got.push(value(m.payload.as_slice())); } } } From 3345a41253ec4b0288b5555aedcfe0ec77d453fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:45:12 +0000 Subject: [PATCH 11/28] fix(core): log OutboundRoutes skips with their route; share serializer rules - poll_stage logs every skip and close with the route's default topic. A serializer failure used to name only the value's type, and a closed route was logged at debug without its error; the pumps log a stop at info with the error, and OutboundRoutes now does too. - The topic-overflow rule and the scratch, fallback and invalid-length rules live in write_topic and serialize_outbound, which FusedReader and TypedRoute both call, so the pumps and OutboundRoutes cannot drift before stage 16. - AimDb::outbound_links holds the storage-index debug_assert, and collect_outbound_routes is built on it. - record_index is set on the parsed config instead of round-tripping through the query string. - OutboundRoutes: Send is asserted at compile time; a test pulls with poll_next from a spawned task. - Docs: a link's profiling interval under OutboundRoutes also covers serving the other ready routes; route_factory is set in finish(); the shared consumer factory's panics no longer say "source factory". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VJdtLXS2xKnn3hY3huLtjX --- aimdb-core/src/builder.rs | 36 ++-- aimdb-core/src/connector.rs | 3 +- aimdb-core/src/outbound/mod.rs | 4 +- aimdb-core/src/outbound/routes.rs | 61 +++++- aimdb-core/src/profiling/mod.rs | 6 +- aimdb-core/src/typed_api.rs | 197 ++++++++----------- aimdb-tokio-adapter/tests/outbound_routes.rs | 24 +++ 7 files changed, 187 insertions(+), 144 deletions(-) diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index a3a69ea7..89c83283 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -1266,6 +1266,13 @@ impl AimDb { .iter() .enumerate() .flat_map(move |(i, entry)| { + // i and RecordId must match: connectors get i as `record_index` + debug_assert_eq!( + self.inner.by_key.get(&entry.key).map(|id| id.index()), + Some(i), + "record storage order diverges from RecordId for key {}", + entry.key.as_str() + ); entry .record .outbound_connectors() @@ -1290,36 +1297,21 @@ impl AimDb { /// # Arguments /// * `scheme` - URL scheme to filter by (e.g., "mqtt", "kafka") pub fn collect_outbound_routes(&self, scheme: &str) -> Vec { - let mut routes = Vec::new(); - - for (i, entry) in self.inner.storages.iter().enumerate() { - // i and RecordId must match - debug_assert_eq!( - self.inner.by_key.get(&entry.key).map(|id| id.index()), - Some(i), - "record storage order diverges from RecordId for key {}", - entry.key.as_str() - ); - let outbound_links = entry.record.outbound_connectors(); - - for link in outbound_links { - // Filter by scheme - if link.url.scheme() != scheme { - continue; - } - + let routes: Vec = self + .outbound_links(scheme) + .map(|(i, _, link)| { // config must carry the record index let mut config = link.config.clone(); config.push(("record_index".to_string(), i.to_string())); // Create the fused source using the stored factory - routes.push(OutboundRoute { + OutboundRoute { topic: link.url.resource_id().to_string(), source: link.create_source(self), config, - }); - } - } + } + }) + .collect(); if !routes.is_empty() { log_debug!( diff --git a/aimdb-core/src/connector.rs b/aimdb-core/src/connector.rs index aae97753..4202a514 100644 --- a/aimdb-core/src/connector.rs +++ b/aimdb-core/src/connector.rs @@ -596,7 +596,8 @@ pub struct ConnectorLink { /// Available in both `std` and `no_std + alloc` environments. pub source_factory: SourceFactoryFn, - /// Builds the link's route for `OutboundRoutes`; set by `link_to`. + /// Builds the link's route for `OutboundRoutes`; set by + /// `OutboundConnectorBuilder::finish`. pub(crate) route_factory: Option, } diff --git a/aimdb-core/src/outbound/mod.rs b/aimdb-core/src/outbound/mod.rs index 3407edf2..4274b90e 100644 --- a/aimdb-core/src/outbound/mod.rs +++ b/aimdb-core/src/outbound/mod.rs @@ -4,7 +4,9 @@ mod ready; mod routes; pub use routes::{OutboundMessage, OutboundPayload, OutboundRoutes, RouteInfo, RouteStats}; -pub(crate) use routes::{PollRoute, RouteFactoryFn, RouteOutcome, RouteParts, StagedPayload}; +pub(crate) use routes::{ + PollRoute, RouteFactoryFn, RouteOutcome, RouteParts, SerializeFailure, StagedPayload, +}; /// Dense route index, `0..routes().len()`. A plain `usize`, so a connector /// indexes its own per-route tables with it. diff --git a/aimdb-core/src/outbound/routes.rs b/aimdb-core/src/outbound/routes.rs index 97d359df..2dd8fe3b 100644 --- a/aimdb-core/src/outbound/routes.rs +++ b/aimdb-core/src/outbound/routes.rs @@ -10,6 +10,7 @@ use core::task::{Context, Poll}; use super::ready::{Polled, ReadyRoutes}; use super::RouteId; +use crate::connector::SerializeError; use crate::transport::ConnectorConfig; use crate::{AimDb, ConfigError, DbError, DbResult, RuntimeContext}; @@ -93,10 +94,10 @@ pub(crate) enum RouteOutcome { Lagged(u64), /// A value was taken; its topic did not fit. TopicOverflow, - /// A value was taken; serializing it failed (already logged). - SerializeFailed, + /// A value was taken; serializing it failed. + SerializeFailed(SerializeFailure), /// The buffer is gone. - Closed, + Closed(DbError), } /// Where a staged payload is. @@ -106,6 +107,37 @@ pub(crate) enum StagedPayload { Owned(Vec), } +/// Why a value that was taken could not be serialized. The caller logs it +/// with its route. +pub(crate) enum SerializeFailure { + /// The owned serializer (`with_serializer`) failed. + Owned(SerializeError), + /// `with_serializer_into` failed. + Into(SerializeError), + /// The value did not fit `with_serializer_into`'s scratch, and the owned + /// serializer it fell back to failed. + Fallback(SerializeError), + /// `with_serializer_into` reported more bytes than its scratch holds. + InvalidLength { len: usize, capacity: usize }, +} + +impl core::fmt::Display for SerializeFailure { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Owned(e) => write!(f, "serializer failed: {e:?}"), + Self::Into(e) => write!(f, "serializer failed into scratch: {e:?}"), + Self::Fallback(e) => write!( + f, + "value did not fit the scratch and the owned fallback failed: {e:?}" + ), + Self::InvalidLength { len, capacity } => write!( + f, + "serializer returned invalid length {len} for {capacity}-byte scratch" + ), + } + } +} + /// A route's typed reader, topic writer and serializers, polled through one /// erased call. pub(crate) trait PollRoute: Send { @@ -155,6 +187,12 @@ pub struct OutboundRoutes { ctx: RuntimeContext, } +// Moved into the connector's `Send` transport task (design 054 §4.2). +const _: fn() = || { + fn assert_send() {} + assert_send::(); +}; + impl OutboundRoutes { /// Subscribes every outbound link of `scheme` and allocates the scratch /// once: the largest topic capacity plus the largest payload capacity. @@ -183,12 +221,12 @@ impl OutboundRoutes { )); continue; } - let mut query = link.config.clone(); - query.push(("record_index".to_string(), record_index.to_string())); + let mut config = ConnectorConfig::from_query(&link.config); + config.record_index = Some(record_index); routes.push(RouteInfo { id: routes.len(), default_topic: Arc::from(link.url.resource_id()), - config: ConnectorConfig::from_query(&query), + config, topic_capacity: parts.topic_capacity, payload_capacity: parts.payload_capacity, }); @@ -282,12 +320,17 @@ impl OutboundRoutes { ); Polled::Skipped } - Poll::Ready(RouteOutcome::SerializeFailed) => { + Poll::Ready(RouteOutcome::SerializeFailed(_failure)) => { stats.serialize_failed += 1; + log_error!( + "outbound route '{}': {}, value skipped", + info.default_topic, + _failure + ); Polled::Skipped } - Poll::Ready(RouteOutcome::Closed) => { - log_debug!("outbound route '{}' closed", info.default_topic); + Poll::Ready(RouteOutcome::Closed(_e)) => { + log_info!("outbound route '{}' closed: {:?}", info.default_topic, _e); Polled::Closed } } diff --git a/aimdb-core/src/profiling/mod.rs b/aimdb-core/src/profiling/mod.rs index 2f0b9fb5..39a57f96 100644 --- a/aimdb-core/src/profiling/mod.rs +++ b/aimdb-core/src/profiling/mod.rs @@ -6,7 +6,11 @@ //! //! * **source** — wall-clock interval between successive `Producer::produce()` calls. //! * **tap / link** — wall-clock interval from a buffer read yielding a value to -//! the next read (≈ the user's per-value processing time). +//! the next read (≈ the user's per-value processing time). A link read +//! through [`OutboundRoutes`](crate::OutboundRoutes) shares its connector's +//! transport task with the scheme's other routes, served round robin, so +//! its interval also includes serving the other ready routes before it is +//! read again. //! //! Timing uses the runtime's own clock ([`crate::executor::RuntimeOps`]), so the //! feature works on `no_std` targets too (it only needs heap + a clock). diff --git a/aimdb-core/src/typed_api.rs b/aimdb-core/src/typed_api.rs index f0fbf68e..3a0e784d 100644 --- a/aimdb-core/src/typed_api.rs +++ b/aimdb-core/src/typed_api.rs @@ -388,6 +388,55 @@ struct FusedReader { topic_buf: Box<[u8]>, } +/// Runs `writer` for `value` into `out`. `Ok(true)`: publish to what `out` +/// holds; `Ok(false)`: to the link's default topic; `Err`: the topic did not +/// fit and the value is skipped, whatever the writer returned. +/// +/// Shared by [`FusedReader`] and [`TypedRoute`]. +fn write_topic + ?Sized>( + writer: &W, + value: &T, + out: &mut crate::connector::TopicBuf<'_>, +) -> Result { + match writer.write_topic(value, out) { + Ok(written) if !out.overflowed() => Ok(written), + _ => Err(crate::connector::TopicOverflow), + } +} + +/// Serializes one outbound value: into `scratch` through +/// `with_serializer_into` when the link has one, else (or when the value does +/// not fit) through the owned serializer. +/// +/// Shared by [`FusedReader`] and [`TypedRoute`]. +fn serialize_outbound( + serialize: &FusedSerializeFn, + serialize_into: Option<&FusedSerializeIntoFn>, + ctx: &crate::RuntimeContext, + value: &T, + scratch: &mut [u8], +) -> Result { + use crate::connector::SerializeError; + use crate::outbound::{SerializeFailure, StagedPayload}; + + let Some(serialize_into) = serialize_into else { + return serialize(ctx, value) + .map(StagedPayload::Owned) + .map_err(SerializeFailure::Owned); + }; + match serialize_into(ctx, value, scratch) { + Ok(len) if len <= scratch.len() => Ok(StagedPayload::Scratch(len)), + Ok(len) => Err(SerializeFailure::InvalidLength { + len, + capacity: scratch.len(), + }), + Err(SerializeError::BufferTooSmall) => serialize(ctx, value) + .map(StagedPayload::Owned) + .map_err(SerializeFailure::Fallback), + Err(e) => Err(SerializeFailure::Into(e)), + } +} + impl FusedReader { /// The value's destination (`None`: the route's default), or `Err` when /// its written topic overflowed and the value must be skipped. @@ -397,10 +446,10 @@ impl FusedReader { TopicSelector::Provider(p) => Ok(p.topic(value)), TopicSelector::Writer { writer, .. } => { let mut out = crate::connector::TopicBuf::new(&mut self.topic_buf); - match writer.write_topic(value, &mut out) { - Ok(true) if !out.overflowed() => Ok(Some(out.as_str().to_string())), - Ok(false) if !out.overflowed() => Ok(None), - _ => { + match write_topic(&**writer, value, &mut out) { + Ok(true) => Ok(Some(out.as_str().to_string())), + Ok(false) => Ok(None), + Err(_) => { log_warn!( "outbound link: topic for {} does not fit in {} bytes, value skipped", core::any::type_name::(), @@ -451,78 +500,35 @@ impl crate::connector::SerializedReader for FusedRead ctx: &'a crate::RuntimeContext, scratch: &'a mut [u8], ) -> crate::connector::RecvSerializedIntoFuture<'a> { + use crate::connector::SerializedPayload; + use crate::outbound::StagedPayload; + Box::pin(async move { loop { let value = self.inner.recv().await?; let Ok(dest) = self.resolve_dest(&value) else { continue; }; - - let Some(serialize_into) = &self.serialize_into else { - match (self.serialize)(ctx, &value) { - Ok(payload) => { - return Ok(crate::connector::SerializedValueInto { - dest, - payload: crate::connector::SerializedPayload::Owned(payload), - }); - } - Err(_e) => { - log_error!( - "outbound link: failed to serialize {} (dest {:?}): {:?}", - core::any::type_name::(), - dest, - _e - ); - continue; - } - } - }; - - match serialize_into(ctx, &value, scratch) { - Ok(len) => { - if scratch.get(..len).is_none() { - log_error!( - "outbound link: serializer for {} returned invalid length {} for {}-byte scratch buffer", - core::any::type_name::(), - len, - scratch.len() - ); - continue; - } - return Ok(crate::connector::SerializedValueInto { - dest, - payload: crate::connector::SerializedPayload::Scratch { len }, - }); - } - Err(crate::connector::SerializeError::BufferTooSmall) => { - match (self.serialize)(ctx, &value) { - Ok(payload) => { - return Ok(crate::connector::SerializedValueInto { - dest, - payload: crate::connector::SerializedPayload::Owned(payload), - }); - } - Err(_e) => { - log_error!( - "outbound link: fallback serialization failed for {} (dest {:?}): {:?}", - core::any::type_name::(), - dest, - _e - ); - continue; - } - } - } - Err(_e) => { + let payload = match serialize_outbound( + &self.serialize, + self.serialize_into.as_ref(), + ctx, + &value, + scratch, + ) { + Ok(StagedPayload::Scratch(len)) => SerializedPayload::Scratch { len }, + Ok(StagedPayload::Owned(bytes)) => SerializedPayload::Owned(bytes), + Err(_failure) => { log_error!( - "outbound link: failed to serialize {} into scratch buffer (dest {:?}): {:?}", + "outbound link: {} (dest {:?}): {}, value skipped", core::any::type_name::(), dest, - _e + _failure ); continue; } - } + }; + return Ok(crate::connector::SerializedValueInto { dest, payload }); } }) } @@ -546,7 +552,7 @@ impl crate::outbound::PollRoute for TypedRoute { topic: &mut [u8], payload: &mut [u8], ) -> core::task::Poll { - use crate::outbound::{RouteOutcome, StagedPayload}; + use crate::outbound::RouteOutcome; use core::task::Poll; let value = match self.reader.poll_recv(cx) { @@ -555,59 +561,30 @@ impl crate::outbound::PollRoute for TypedRoute { Poll::Ready(Err(crate::DbError::BufferLagged { lag_count, .. })) => { return Poll::Ready(RouteOutcome::Lagged(lag_count)); } - Poll::Ready(Err(_)) => return Poll::Ready(RouteOutcome::Closed), + Poll::Ready(Err(e)) => return Poll::Ready(RouteOutcome::Closed(e)), }; let topic_len = match &self.writer { None => None, Some(writer) => { let mut out = crate::connector::TopicBuf::new(topic); - match writer.write_topic(&value, &mut out) { - Ok(true) if !out.overflowed() => Some(out.len()), - Ok(false) if !out.overflowed() => None, - _ => return Poll::Ready(RouteOutcome::TopicOverflow), + match write_topic(&**writer, &value, &mut out) { + Ok(written) => written.then_some(out.len()), + Err(_) => return Poll::Ready(RouteOutcome::TopicOverflow), } } }; - let owned = |value: &T| match (self.serialize)(ctx, value) { - Ok(bytes) => Some(StagedPayload::Owned(bytes)), - Err(_e) => { - log_error!( - "outbound link: failed to serialize {}: {:?}", - core::any::type_name::(), - _e - ); - None - } - }; - let staged = match &self.serialize_into { - None => owned(&value), - Some(serialize_into) => match serialize_into(ctx, &value, payload) { - Ok(len) if len <= payload.len() => Some(StagedPayload::Scratch(len)), - Ok(_len) => { - log_error!( - "outbound link: serializer for {} returned invalid length {} for {}-byte scratch", - core::any::type_name::(), - _len, - payload.len() - ); - None - } - Err(crate::connector::SerializeError::BufferTooSmall) => owned(&value), - Err(_e) => { - log_error!( - "outbound link: failed to serialize {} into scratch: {:?}", - core::any::type_name::(), - _e - ); - None - } - }, - }; - Poll::Ready(match staged { - Some(payload) => RouteOutcome::Staged { topic_len, payload }, - None => RouteOutcome::SerializeFailed, + let serialized = serialize_outbound( + &self.serialize, + self.serialize_into.as_ref(), + ctx, + &value, + payload, + ); + Poll::Ready(match serialized { + Ok(payload) => RouteOutcome::Staged { topic_len, payload }, + Err(failure) => RouteOutcome::SerializeFailed(failure), }) } } @@ -1176,13 +1153,13 @@ where .get_typed_record_by_key::(&record_key) .unwrap_or_else(|e| { panic!( - "source factory: record '{record_key}' lookup failed ({e:?}) — \ + "outbound link: record '{record_key}' lookup failed ({e:?}) — \ this is a bug in aimdb-core" ) }); let buffer = typed_rec.buffer_handle().unwrap_or_else(|| { panic!( - "source factory: record '{record_key}' has no buffer despite \ + "outbound link: record '{record_key}' has no buffer despite \ build()-time validation — this is a bug in aimdb-core" ) }); diff --git a/aimdb-tokio-adapter/tests/outbound_routes.rs b/aimdb-tokio-adapter/tests/outbound_routes.rs index e7f63eae..471de39a 100644 --- a/aimdb-tokio-adapter/tests/outbound_routes.rs +++ b/aimdb-tokio-adapter/tests/outbound_routes.rs @@ -296,6 +296,30 @@ async fn a_staged_value_that_was_not_taken_is_returned_again() { assert_eq!(drain(&mut o).await, [(1, 7), (1, 8)]); } +#[tokio::test] +async fn poll_next_pulls_in_a_spawned_task() { + let db = db(vec![spmc(0, 16), spmc(1, 16)]).await; + let mut o = OutboundRoutes::new(&db, "test").unwrap(); + produce(&db, 0, 1); + produce(&db, 1, 2); + produce(&db, 0, 3); + // Moved into a spawned task, as a connector's transport task holds it. + let got = tokio::spawn(async move { + let mut got = Vec::new(); + while got.len() < 3 { + let next = poll_fn(|cx| { + o.poll_next(cx) + .map(|m| m.map(|m| (m.route.id, value(m.payload.as_slice())))) + }); + got.push(next.await.expect("routes still open")); + } + got + }) + .await + .unwrap(); + assert_eq!(got, [(0, 1), (1, 2), (0, 3)]); +} + #[tokio::test] async fn values_survive_a_select_that_loses_every_third_poll() { let db = db(vec![spmc(0, 512)]).await; From 53ea68306d6859013a0a72c4a40e9085c6037d07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 4 Oct 2026 12:00:51 +0000 Subject: [PATCH 12/28] docs(core): drop the design reference from the OutboundRoutes Send check Co-Authored-By: Claude Opus 5.5 --- aimdb-core/src/outbound/routes.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aimdb-core/src/outbound/routes.rs b/aimdb-core/src/outbound/routes.rs index 2dd8fe3b..9f06061d 100644 --- a/aimdb-core/src/outbound/routes.rs +++ b/aimdb-core/src/outbound/routes.rs @@ -187,7 +187,7 @@ pub struct OutboundRoutes { ctx: RuntimeContext, } -// Moved into the connector's `Send` transport task (design 054 §4.2). +// Moved into the connector's `Send` transport task. const _: fn() = || { fn assert_send() {} assert_send::(); From 8232f1dea3196c4d1f959dea56775d3c07080487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 4 Oct 2026 16:41:44 +0000 Subject: [PATCH 13/28] =?UTF-8?q?bench:=20measure=20InboundDispatch=20and?= =?UTF-8?q?=20OutboundRoutes=20(design=20054=20=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- aimdb-bench/Cargo.toml | 6 + aimdb-bench/benches/b0_alloc_connector.rs | 264 +++++++++++++++++- aimdb-bench/benches/b1_outbound_wakeup.rs | 240 ++++++++++++++++ .../data/baselines/b0_alloc_connector.json | 81 ++++++ 4 files changed, 590 insertions(+), 1 deletion(-) create mode 100644 aimdb-bench/benches/b1_outbound_wakeup.rs diff --git a/aimdb-bench/Cargo.toml b/aimdb-bench/Cargo.toml index 06136f8d..1025a4f9 100644 --- a/aimdb-bench/Cargo.toml +++ b/aimdb-bench/Cargo.toml @@ -67,6 +67,12 @@ harness = false name = "b0_alloc_connector" harness = false +# Outbound wake-up time per message, 1 to 256 routes. +# Informational: not in `bench-gate`. +[[bench]] +name = "b1_outbound_wakeup" +harness = false + [features] default = ["std"] # Gates `profiles`/`reports`/`harness` and their criterion/serde_json/ diff --git a/aimdb-bench/benches/b0_alloc_connector.rs b/aimdb-bench/benches/b0_alloc_connector.rs index 45778fcd..b79df06a 100644 --- a/aimdb-bench/benches/b0_alloc_connector.rs +++ b/aimdb-bench/benches/b0_alloc_connector.rs @@ -13,6 +13,11 @@ //! `SerializedReader::recv_into` followed by `Connector::publish` on a no-op //! connector — for the scratch and owned serializers, with a static and a //! dynamic (`TopicProvider`) topic. +//! - **`InboundDispatch` and `OutboundRoutes`:** the same inbound cases through +//! `InboundDispatch::dispatch`, and `OutboundRoutes::next` with a static +//! topic, a written topic and the owned serializer, plus eight routes that +//! are all ready (round-robin) and one pull that parks before every value +//! (the waker path). //! //! Buffers, ingest and routing allocate nothing (design 037, and the `route` //! row here); every non-zero row is a cost of the connector interface. The @@ -37,7 +42,10 @@ use aimdb_core::connector::{ }; use aimdb_core::session::{pump_source, Payload, Source}; use aimdb_core::transport::{Connector, ConnectorConfig, PublishError}; -use aimdb_core::{AimDb, AimDbBuilder, BoxFut, DbResult, ExactGrammar, RuntimeContext, StringKey}; +use aimdb_core::{ + AimDb, AimDbBuilder, BoxFut, DbResult, ExactGrammar, InboundDispatch, OutboundRoutes, + RuntimeContext, StringKey, +}; use aimdb_mqtt_connector::MqttGrammar; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; @@ -63,8 +71,20 @@ const EXPECTED: &[(&str, u64)] = &[ ("outbound_scratch_static_topic", 2), ("outbound_scratch_dynamic_topic", 3), ("outbound_owned_static_topic", 3), + ("inbound_dispatch", 0), + ("inbound_dispatch_pattern", 0), + ("inbound_dispatch_keyed_known", 0), + ("inbound_dispatch_keyed_new", 1), + ("outbound_next_static_topic", 0), + ("outbound_next_written_topic", 0), + ("outbound_next_owned", 1), + ("outbound_next_round_robin", 0), + ("outbound_next_parked", 0), ]; +/// Routes in the `outbound_next_round_robin` row. +const ROUND_ROBIN_ROUTES: usize = 8; + #[derive(Clone, Copy, Debug)] struct Reading { id: u32, @@ -242,6 +262,40 @@ async fn measure_route() -> (u64, u64) { snapshot() } +/// `measure_route` through `InboundDispatch`. +async fn measure_dispatch() -> (u64, u64) { + let db = inbound_db().await; + let inbound = InboundDispatch::new(&db, SCHEME, &ExactGrammar).unwrap(); + let payload = [1u8; 8]; + for _ in 0..WARMUP_ITERS { + inbound.dispatch("in/target", &payload); + } + reset(); + for _ in 0..MEASURE_ITERS { + inbound.dispatch("in/target", black_box(&payload)); + } + snapshot() +} + +/// `measure_pattern_route` through `InboundDispatch`. +async fn measure_pattern_dispatch( + keyed: bool, + warmup: &[String], + measured: &[String], +) -> (u64, u64) { + let db = pattern_db(keyed).await; + let inbound = InboundDispatch::new(&db, SCHEME, &MqttGrammar).unwrap(); + let payload = [1u8; 8]; + for topic in warmup { + inbound.dispatch(topic, &payload); + } + reset(); + for topic in measured { + inbound.dispatch(black_box(topic), black_box(&payload)); + } + snapshot() +} + /// Yields `remaining` copies of one message, then ends. struct MinimalSource { topic: String, @@ -372,6 +426,158 @@ async fn measure_outbound(serializer: Serializer, dynamic_topic: bool) -> (u64, snapshot() } +// --- OutboundRoutes --------------------------------------------------------- + +#[derive(Clone, Copy)] +enum Topic { + Static, + Written, +} + +/// One outbound link on `record` with the given serializer and topic. +fn outbound_link( + reg: &mut aimdb_core::RecordRegistrar<'_, Reading>, + serializer: Serializer, + topic: Topic, +) { + reg.buffer(BufferCfg::SpmcRing { capacity: 64 }); + let mut link = reg + .link_to("bench://out/default") + .with_serializer(|_ctx, r: &Reading| Ok(encode(r).to_vec())); + if let Serializer::Scratch = serializer { + link = link.with_serializer_into(SCRATCH_CAPACITY, |_ctx, r: &Reading, buf| { + let bytes = encode(r); + let dst = buf + .get_mut(..bytes.len()) + .ok_or(SerializeError::BufferTooSmall)?; + dst.copy_from_slice(&bytes); + Ok(bytes.len()) + }); + } + if let Topic::Written = topic { + link = link.with_topic_fn(16, |r, out| { + use std::fmt::Write as _; + write!(out, "out/{}", r.id)?; + Ok(true) + }); + } + link.finish(); +} + +/// Pulls one message and hands its topic and bytes to `black_box`. +async fn pull_one(outbound: &mut OutboundRoutes) { + let msg = outbound.next().await.expect("route open"); + black_box((msg.topic, msg.payload.as_slice())); +} + +async fn measure_next(serializer: Serializer, topic: Topic) -> (u64, u64) { + let db = build_db(|b| { + b.configure::("out.record", move |reg| { + outbound_link(reg, serializer, topic) + }); + }) + .await; + let mut outbound = OutboundRoutes::new(&db, SCHEME).unwrap(); + let producer = db.producer::("out.record").expect("producer"); + + for i in 0..WARMUP_ITERS { + producer.produce(reading(i)); + pull_one(&mut outbound).await; + } + reset(); + for i in 0..MEASURE_ITERS { + producer.produce(reading(i)); + pull_one(&mut outbound).await; + } + snapshot() +} + +/// One value on each of `ROUND_ROBIN_ROUTES` routes, then as many pulls. +async fn measure_next_round_robin() -> (u64, u64) { + let db = build_db(|b| { + for i in 0..ROUND_ROBIN_ROUTES { + b.configure::(StringKey::intern(format!("out.rr{i}")), |reg| { + outbound_link(reg, Serializer::Scratch, Topic::Static) + }); + } + }) + .await; + let mut outbound = OutboundRoutes::new(&db, SCHEME).unwrap(); + let producers: Vec<_> = (0..ROUND_ROBIN_ROUTES) + .map(|i| { + db.producer::(format!("out.rr{i}")) + .expect("producer") + }) + .collect(); + for i in 0..WARMUP_ITERS / ROUND_ROBIN_ROUTES { + round_robin(&mut outbound, &producers, i).await; + } + reset(); + for i in 0..MEASURE_ITERS / ROUND_ROBIN_ROUTES { + round_robin(&mut outbound, &producers, i).await; + } + snapshot() +} + +async fn round_robin( + outbound: &mut OutboundRoutes, + producers: &[aimdb_core::Producer], + i: usize, +) { + for p in producers { + p.produce(reading(i)); + } + for _ in 0..producers.len() { + pull_one(outbound).await; + } +} + +/// The transport pulls in its own task and parks before every value; the +/// producer writes one value, then yields until it was pulled. +async fn measure_next_parked() -> (u64, u64) { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let db = build_db(|b| { + b.configure::("out.record", |reg| { + outbound_link(reg, Serializer::Scratch, Topic::Static) + }); + }) + .await; + let mut outbound = OutboundRoutes::new(&db, SCHEME).unwrap(); + let producer = db.producer::("out.record").expect("producer"); + let pulled = Arc::new(AtomicUsize::new(0)); + let total = WARMUP_ITERS + MEASURE_ITERS; + let transport = { + let pulled = pulled.clone(); + tokio::spawn(async move { + for _ in 0..total { + pull_one(&mut outbound).await; + pulled.fetch_add(1, Ordering::Release); + } + }) + }; + let send = |i: usize| { + producer.produce(reading(i)); + let pulled = pulled.clone(); + async move { + while pulled.load(Ordering::Acquire) <= i { + tokio::task::yield_now().await; + } + } + }; + + for i in 0..WARMUP_ITERS { + send(i).await; + } + reset(); + for i in WARMUP_ITERS..total { + send(i).await; + } + let counted = snapshot(); + transport.await.expect("transport"); + counted +} + // --- Driver ----------------------------------------------------------------- fn main() { @@ -432,6 +638,62 @@ fn main() { "SpmcRing", measure_outbound(Serializer::Owned, false).await, ), + ("inbound_dispatch", "SpmcRing", measure_dispatch().await), + ( + "inbound_dispatch_pattern", + "SpmcRing", + measure_pattern_dispatch( + false, + &pattern_topics(WARMUP_ITERS, 0, false), + &pattern_topics(MEASURE_ITERS, 0, false), + ) + .await, + ), + ( + "inbound_dispatch_keyed_known", + "SpmcRing", + measure_pattern_dispatch( + true, + &pattern_topics(WARMUP_ITERS, 0, false), + &pattern_topics(MEASURE_ITERS, 0, false), + ) + .await, + ), + ( + "inbound_dispatch_keyed_new", + "SpmcRing", + measure_pattern_dispatch( + true, + &pattern_topics(WARMUP_ITERS, 0, true), + &pattern_topics(MEASURE_ITERS, WARMUP_ITERS, true), + ) + .await, + ), + ( + "outbound_next_static_topic", + "SpmcRing", + measure_next(Serializer::Scratch, Topic::Static).await, + ), + ( + "outbound_next_written_topic", + "SpmcRing", + measure_next(Serializer::Scratch, Topic::Written).await, + ), + ( + "outbound_next_owned", + "SpmcRing", + measure_next(Serializer::Owned, Topic::Static).await, + ), + ( + "outbound_next_round_robin", + "SpmcRing", + measure_next_round_robin().await, + ), + ( + "outbound_next_parked", + "SpmcRing", + measure_next_parked().await, + ), ] }); diff --git a/aimdb-bench/benches/b1_outbound_wakeup.rs b/aimdb-bench/benches/b1_outbound_wakeup.rs new file mode 100644 index 00000000..bc769a36 --- /dev/null +++ b/aimdb-bench/benches/b1_outbound_wakeup.rs @@ -0,0 +1,240 @@ +//! B1-Outbound — time per message when the transport parks before every +//! value. Informational: not part of `bench-gate`, since timings need a quiet +//! host. +//! +//! One busy route among N (1, 8, 64, 256), the producer and the transport in +//! separate tasks on a current-thread Tokio runtime. The producer writes one +//! value and waits until it was pulled, so every message pays one park and +//! one wake-up of the transport. Two columns: +//! +//! - **task per route:** one task per route awaiting `Reader::recv`, the +//! shape of the per-route pumps. +//! - **OutboundRoutes:** one task pulling with `OutboundRoutes::next`, which +//! polls only the routes that woke. +//! +//! A per-route scan would show as `OutboundRoutes` growing with N. Compare +//! columns within one run, not across hosts. +//! +//! Run `cargo bench -p aimdb-bench --bench b1_outbound_wakeup`. + +use std::future::poll_fn; +use std::hint::black_box; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::task::Poll; +use std::time::Instant; + +use aimdb_bench::alloc::{reset, snapshot}; +use aimdb_core::buffer::BufferCfg; +use aimdb_core::connector::{ConnectorBuilder, SerializeError}; +use aimdb_core::{AimDb, AimDbBuilder, DbResult, OutboundRoutes, Producer, StringKey}; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use futures::task::AtomicWaker; + +#[global_allocator] +static GLOBAL: aimdb_bench::alloc::CountingAllocator = + aimdb_bench::alloc::CountingAllocator(std::alloc::System); + +const ROUTE_COUNTS: &[usize] = &[1, 8, 64, 256]; +const WARMUP: u64 = 2_000; +const MEASURE: u64 = 20_000; +const RUNS: usize = 5; +const RING: usize = 64; +const SCHEME: &str = "bench"; + +/// Registers the scheme so `link_to` succeeds; drives nothing. +struct NoopConnectorBuilder; + +impl ConnectorBuilder for NoopConnectorBuilder { + #[allow(clippy::type_complexity)] + fn build<'a>( + &'a self, + _db: &'a AimDb, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = DbResult< + Vec + Send>>>, + >, + > + Send + + 'a, + >, + > { + Box::pin(async { Ok(Vec::new()) }) + } + + fn scheme(&self) -> &str { + SCHEME + } +} + +/// `n` SPMC records `r0..r{n}`, each linked to `bench://r{i}` with a scratch +/// serializer. +async fn build_db(n: usize) -> AimDb { + let runtime = Arc::new(TokioAdapter::new().expect("tokio adapter")); + let mut builder = AimDbBuilder::new() + .runtime(runtime) + .with_connector(NoopConnectorBuilder); + for i in 0..n { + builder.configure::(StringKey::intern(format!("r{i}")), move |reg| { + reg.buffer(BufferCfg::SpmcRing { capacity: RING }) + .link_to(&format!("{SCHEME}://r{i}")) + .with_serializer(|_ctx, v: &u64| Ok(v.to_le_bytes().to_vec())) + .with_serializer_into(8, |_ctx, v: &u64, out| { + out.get_mut(..8) + .ok_or(SerializeError::BufferTooSmall)? + .copy_from_slice(&v.to_le_bytes()); + Ok(8) + }) + .finish(); + }); + } + let (db, runner) = builder.build().await.expect("build"); + // Nothing in the runner is needed; keep it alive for the process. + std::mem::forget(runner); + db +} + +/// Values pulled so far, and the producer's waker. +struct Ack { + count: AtomicU64, + waker: AtomicWaker, +} + +impl Ack { + fn new() -> Arc { + Arc::new(Self { + count: AtomicU64::new(0), + waker: AtomicWaker::new(), + }) + } + + fn bump(&self) { + self.count.fetch_add(1, Ordering::Release); + self.waker.wake(); + } + + async fn wait_for(&self, target: u64) { + poll_fn(|cx| { + self.waker.register(cx.waker()); + if self.count.load(Ordering::Acquire) >= target { + Poll::Ready(()) + } else { + Poll::Pending + } + }) + .await + } +} + +/// Writes one value, waits until it was pulled. Returns ns and allocations +/// per message over the measured window. +async fn drive_producer(producer: Producer, ack: Arc) -> (f64, f64) { + for i in 0..WARMUP { + producer.produce(i); + ack.wait_for(i + 1).await; + } + reset(); + let start = Instant::now(); + for i in WARMUP..WARMUP + MEASURE { + producer.produce(i); + ack.wait_for(i + 1).await; + } + let ns = start.elapsed().as_nanos() as f64 / MEASURE as f64; + let (allocs, _) = snapshot(); + (ns, allocs as f64 / MEASURE as f64) +} + +async fn task_per_route(n: usize) -> (f64, f64) { + let db = build_db(n).await; + let busy = n - 1; + let producer = db.producer::(format!("r{busy}")).expect("producer"); + let ack = Ack::new(); + let mut tasks = Vec::new(); + for i in 0..n { + let mut reader = db.subscribe::(format!("r{i}")).expect("subscribe"); + let ack = ack.clone(); + tasks.push(tokio::spawn(async move { + loop { + let v = reader.recv().await.expect("recv"); + black_box((i, v)); + ack.bump(); + if v + 1 == WARMUP + MEASURE { + break; + } + } + })); + } + let result = drive_producer(producer, ack).await; + tasks.pop().expect("busy task").await.expect("busy task"); + for task in tasks { + task.abort(); + } + result +} + +async fn outbound_routes(n: usize) -> (f64, f64) { + let db = build_db(n).await; + let busy = n - 1; + let producer = db.producer::(format!("r{busy}")).expect("producer"); + let mut outbound = OutboundRoutes::new(&db, SCHEME).expect("routes"); + let ack = Ack::new(); + let transport = { + let ack = ack.clone(); + tokio::spawn(async move { + loop { + let msg = outbound.next().await.expect("route open"); + let v = u64::from_le_bytes(msg.payload.as_slice().try_into().expect("8 bytes")); + black_box((msg.route.id, msg.topic, v)); + ack.bump(); + if v + 1 == WARMUP + MEASURE { + break; + } + } + }) + }; + let result = drive_producer(producer, ack).await; + transport.await.expect("transport"); + result +} + +fn median(mut v: Vec) -> f64 { + v.sort_by(|a, b| a.total_cmp(b)); + v[v.len() / 2] +} + +/// Median of `RUNS` runs, each on a fresh database. +fn row(runtime: &tokio::runtime::Runtime, name: &str, n: usize, run: F) -> f64 +where + F: Fn(usize) -> Fut, + Fut: std::future::Future, +{ + let mut ns = Vec::with_capacity(RUNS); + let mut allocs = 0.0; + for _ in 0..RUNS { + let (t, a) = runtime.block_on(run(n)); + ns.push(t); + allocs = a; + } + let lo = ns.iter().copied().fold(f64::MAX, f64::min); + let hi = ns.iter().copied().fold(0.0, f64::max); + let m = median(ns); + println!( + "{name:<16} {n:>4} routes {m:>7.0} ns/msg ({lo:.0}–{hi:.0}) {allocs:.3} allocs/msg" + ); + m +} + +fn main() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + println!("=== B1 outbound wake-up: one busy route among N, current-thread runtime ==="); + for &n in ROUTE_COUNTS { + row(&runtime, "task per route", n, task_per_route); + row(&runtime, "OutboundRoutes", n, outbound_routes); + println!(); + } +} diff --git a/aimdb-bench/data/baselines/b0_alloc_connector.json b/aimdb-bench/data/baselines/b0_alloc_connector.json index fbd67fcf..ae351427 100644 --- a/aimdb-bench/data/baselines/b0_alloc_connector.json +++ b/aimdb-bench/data/baselines/b0_alloc_connector.json @@ -70,5 +70,86 @@ "batch_size": 2000, "allocs_per_msg": 3.0, "bytes_per_msg": 81.0 + }, + { + "profile": "inbound_dispatch", + "buffer_type": "SpmcRing", + "total_allocs": 0, + "total_bytes": 0, + "batch_size": 2000, + "allocs_per_msg": 0.0, + "bytes_per_msg": 0.0 + }, + { + "profile": "inbound_dispatch_pattern", + "buffer_type": "SpmcRing", + "total_allocs": 0, + "total_bytes": 0, + "batch_size": 2000, + "allocs_per_msg": 0.0, + "bytes_per_msg": 0.0 + }, + { + "profile": "inbound_dispatch_keyed_known", + "buffer_type": "SpmcRing", + "total_allocs": 0, + "total_bytes": 0, + "batch_size": 2000, + "allocs_per_msg": 0.0, + "bytes_per_msg": 0.0 + }, + { + "profile": "inbound_dispatch_keyed_new", + "buffer_type": "SpmcRing", + "total_allocs": 2010, + "total_bytes": 373456, + "batch_size": 2000, + "allocs_per_msg": 1.005, + "bytes_per_msg": 186.728 + }, + { + "profile": "outbound_next_static_topic", + "buffer_type": "SpmcRing", + "total_allocs": 0, + "total_bytes": 0, + "batch_size": 2000, + "allocs_per_msg": 0.0, + "bytes_per_msg": 0.0 + }, + { + "profile": "outbound_next_written_topic", + "buffer_type": "SpmcRing", + "total_allocs": 0, + "total_bytes": 0, + "batch_size": 2000, + "allocs_per_msg": 0.0, + "bytes_per_msg": 0.0 + }, + { + "profile": "outbound_next_owned", + "buffer_type": "SpmcRing", + "total_allocs": 2000, + "total_bytes": 16000, + "batch_size": 2000, + "allocs_per_msg": 1.0, + "bytes_per_msg": 8.0 + }, + { + "profile": "outbound_next_round_robin", + "buffer_type": "SpmcRing", + "total_allocs": 0, + "total_bytes": 0, + "batch_size": 2000, + "allocs_per_msg": 0.0, + "bytes_per_msg": 0.0 + }, + { + "profile": "outbound_next_parked", + "buffer_type": "SpmcRing", + "total_allocs": 0, + "total_bytes": 0, + "batch_size": 2000, + "allocs_per_msg": 0.0, + "bytes_per_msg": 0.0 } ] \ No newline at end of file From 34bc7a3b83dd5ec266922f556f61a9f907ccda1d Mon Sep 17 00:00:00 2001 From: "sounds.like.lx" <147444674+lxsaah@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:10:38 +0200 Subject: [PATCH 14/28] =?UTF-8?q?feat(mqtt):=20encode=20embedded=20packets?= =?UTF-8?q?=20into=20one=20bbqueue=20write=20ring=20(design=20054=20=C2=A7?= =?UTF-8?q?4.7)=20(#283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 16 + Makefile | 4 + aimdb-mqtt-connector/Cargo.toml | 5 + aimdb-mqtt-connector/src/embedded/mod.rs | 1 + aimdb-mqtt-connector/src/embedded/session.rs | 5 + .../src/embedded/session_loop.rs | 337 ++++++++----- aimdb-mqtt-connector/src/embedded/tls.rs | 5 + .../src/embedded/write_ring.rs | 453 ++++++++++++++++++ .../tests/write_ring_proofs.rs | 168 +++++++ 9 files changed, 886 insertions(+), 108 deletions(-) create mode 100644 aimdb-mqtt-connector/src/embedded/write_ring.rs create mode 100644 aimdb-mqtt-connector/tests/write_ring_proofs.rs diff --git a/Cargo.lock b/Cargo.lock index 265f74df..46f2d412 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -264,6 +264,7 @@ dependencies = [ "aimdb-embassy-adapter", "aimdb-mountain-mqtt", "aimdb-tokio-adapter", + "bbqueue", "critical-section", "defmt 1.1.1", "embassy-futures 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", @@ -684,6 +685,15 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bbqueue" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68917624e17aad88607cb5a5936f6da9b607c48c711e4e9ed101e7189aed28c2" +dependencies = [ + "const-init", +] + [[package]] name = "bit_field" version = "0.10.3" @@ -939,6 +949,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" +[[package]] +name = "const-init" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd422bfb4f24a97243f60b6a4443e63d810c925d8da4bb2d8fde26a7c1d57ec" + [[package]] name = "const-oid" version = "0.9.6" diff --git a/Makefile b/Makefile index fe75335b..15881391 100644 --- a/Makefile +++ b/Makefile @@ -253,6 +253,8 @@ test: cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-tls-broker" --test tls_broker @printf "$(YELLOW) → Testing MQTT connector (event-driven session: wake cadence, partial packets, QoS 1)$(NC)\n" cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test session_loop + @printf "$(YELLOW) → Testing MQTT connector (embedded packet-size limits against a fake broker)$(NC)\n" + cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test write_ring_proofs @printf "$(YELLOW) → Testing MQTT connector (the same criteria over mqtts://)$(NC)\n" cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-tls-broker" --test tls_session @printf "$(YELLOW) → Testing MQTT connector (no_std unit tests: framing, deadlines, TLS duplex)$(NC)\n" @@ -402,6 +404,8 @@ clippy: cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tls-broker" --test tls_broker -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (event-driven session criteria)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test session_loop -- -D warnings + @printf "$(YELLOW) → Clippy on MQTT connector (embedded packet-size limits)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test write_ring_proofs -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (the same criteria over mqtts://)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tls-broker" --test tls_session -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (no_std unit tests)$(NC)\n" diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 187c709d..8680be7f 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -49,6 +49,8 @@ embedded = [ # `mountain-mqtt`'s packet types take `heapless::Vec`, so the session names # it to build them. Same 0.8 the dependency itself resolves. "dep:heapless", + # The session's write ring. + "dep:bbqueue", ] # Convenience bundle: `embedded` plus the Embassy transport and clock. The @@ -157,6 +159,9 @@ embassy-time = { version = "0.5.1", optional = true } embassy-sync = { version = "0.8.0", path = "../_external/embassy/embassy-sync", optional = true } embassy-futures = { workspace = true, optional = true } heapless = { workspace = true, optional = true } +# Byte ring the embedded session encodes outbound packets into. `AtomicCoord` +# needs atomic CAS, which every supported embedded target has. +bbqueue = { version = "0.7", default-features = false, features = ["alloc"], optional = true } embassy-net = { version = "0.9.0", optional = true, features = [ "tcp", "dhcpv4", diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index 1201b07d..7fe77910 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -13,6 +13,7 @@ pub(crate) mod session; // replace the polled loop. pub(crate) mod packet_reader; pub(crate) mod session_loop; +pub(crate) mod write_ring; // TLS transport + SNTP time source. #[cfg(feature = "embassy-tls")] diff --git a/aimdb-mqtt-connector/src/embedded/session.rs b/aimdb-mqtt-connector/src/embedded/session.rs index 2adc9f63..1d2b1c8d 100644 --- a/aimdb-mqtt-connector/src/embedded/session.rs +++ b/aimdb-mqtt-connector/src/embedded/session.rs @@ -64,6 +64,7 @@ where use mountain_mqtt::data::quality_of_service::QualityOfService; use crate::embedded::session_loop::run_session; + use crate::embedded::write_ring::{WriteRing, DEFAULT_WRITE_BUFFER}; // Built once and borrowed for the loop; re-sent on every connection. let subscribe_topics: alloc::vec::Vec<(&str, QualityOfService)> = topics @@ -71,6 +72,9 @@ where .map(|topic| (topic.as_str(), QualityOfService::Qos1)) .collect(); + // Allocated once and reused by every session. + let ring = WriteRing::new(DEFAULT_WRITE_BUFFER); + loop { let mut stream = match dialer.connect(&host, port).await { Ok(stream) => stream, @@ -92,6 +96,7 @@ where &subscribe_topics, &events, &actions, + &ring, &settings, &dialer, runtime.as_ref(), diff --git a/aimdb-mqtt-connector/src/embedded/session_loop.rs b/aimdb-mqtt-connector/src/embedded/session_loop.rs index ec8975aa..9bf39b2f 100644 --- a/aimdb-mqtt-connector/src/embedded/session_loop.rs +++ b/aimdb-mqtt-connector/src/embedded/session_loop.rs @@ -5,25 +5,25 @@ //! partially-read packet is ever discarded — which is what lets the TLS path //! share this loop. //! -//! [`read_into`] and [`write_out`] know no MQTT; [`client_loop`] owns all -//! client state and wakes only on data, an action or a deadline. Raw chunks -//! cross the inbound channel rather than whole packets, so framing needs no -//! second packet-sized buffer. +//! [`read_into`] and [`WriteRing::write_out`] know no MQTT; [`client_loop`] +//! owns all client state and wakes only on data, an action or a deadline. Raw +//! chunks cross the inbound channel rather than whole packets, so framing +//! needs no second packet-sized buffer. Outbound packets are encoded straight +//! into the connector's [`WriteRing`]. use core::convert::Infallible; +use core::future::poll_fn; +use core::task::Poll; use core::time::Duration; use aimdb_core::session::{ByteRead, ByteWrite, Delay}; use aimdb_core::RuntimeOps; -use alloc::vec::Vec; use embassy_futures::select::{select3, Either3}; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::channel::Channel; use mountain_mqtt::client::{ClientError, ConnectionSettings}; use mountain_mqtt::client_state::{ClientState, ClientStateNoQueue, ClientStateReceiveEvent}; -use mountain_mqtt::codec::mqtt_writer::{MqttBufWriter, MqttLenWriter, MqttWriter}; -use mountain_mqtt::codec::write::Write; use mountain_mqtt::data::property::ConnectProperty; use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::error::{PacketReadError, PacketWriteError}; @@ -32,6 +32,7 @@ use mountain_mqtt::packets::packet_generic::PacketGeneric; use crate::embedded::manager::{now_ms, Error, FromApplicationMessage, Settings}; use crate::embedded::packet_reader::PacketReader; +use crate::embedded::write_ring::{encoded_len, WriteRing, CONTROL_RESERVE}; use crate::embedded::{ ActionChannel, AimdbMqttAction, AimdbMqttEvent, EventChannel, BUFFER_SIZE, MAX_PROPERTIES, }; @@ -41,11 +42,15 @@ const RX_CHUNK: usize = 256; /// The largest MQTT packet the session can receive. /// -/// Reassembly, the inbound slots and the encode buffer all come out of one -/// `BUFFER_SIZE`; outbound packets are encoded to exactly-sized `Vec`s -/// rather than a fixed buffer. +/// Reassembly and the inbound slots come out of one `BUFFER_SIZE`; outbound +/// packets go into the connector's write ring instead. const PACKET_BUFFER_SIZE: usize = BUFFER_SIZE - 2 * RX_CHUNK; +/// Ring space waited for before each received packet is parsed: enough for +/// the PUBACK a QoS 1 publish needs (6 bytes; mountain-mqtt adds no +/// properties). +const PUBACK_ROOM: usize = 16; + /// One chunk of freshly read bytes, in flight from the read half to the loop. type Chunk = heapless::Vec; @@ -54,6 +59,10 @@ type Chunk = heapless::Vec; /// Connects, subscribes `subscribe_topics`, then dispatches actions and /// forwards events. Returns only on failure — the caller reconnects. /// +/// `ring` is the connector's, reused across sessions; whatever an old session +/// left in it is discarded first, so nothing reaches the new socket ahead of +/// its CONNECT. +/// /// **At most once**: an action is taken off `actions` before it is performed, /// so the one in flight when a session ends is lost. Everything still queued /// survives. @@ -65,6 +74,7 @@ pub(crate) async fn run_session( subscribe_topics: &[(&str, QualityOfService)], events: &EventChannel, actions: &ActionChannel, + ring: &WriteRing, settings: &Settings, delay: &D, runtime: &dyn RuntimeOps, @@ -75,13 +85,11 @@ where D: Delay, { let inbound: Channel = Channel::new(); - // Four slots of a pointer each: enough that a burst of small packets does - // not park the loop, and cheap because the bytes live on the heap. - let outbound: Channel, 4> = Channel::new(); + ring.drain(); let session = client_loop( &inbound, - &outbound, + ring, connection_settings, subscribe_topics, events, @@ -91,7 +99,7 @@ where runtime, ); - match select3(read_into(rx, &inbound), write_out(tx, &outbound), session).await { + match select3(read_into(rx, &inbound), ring.write_out(tx), session).await { Either3::First(error) => error, Either3::Second(error) => error, Either3::Third(Err(error)) => error, @@ -122,20 +130,6 @@ async fn read_into( } } -/// Drain encoded packets to the socket. Never cancelled, which is what keeps -/// the non-cancel-safe `write_all` out of a `select` arm. -async fn write_out( - mut tx: W, - outbound: &Channel, 4>, -) -> Error { - loop { - let packet = outbound.receive().await; - if tx.write_all(&packet).await.is_err() || tx.flush().await.is_err() { - return Error::Client(ClientError::PacketWrite(PacketWriteError::ConnectionSend)); - } - } -} - fn receive_failed() -> Error { Error::Client(ClientError::PacketRead(PacketReadError::ConnectionReceive)) } @@ -144,7 +138,7 @@ fn receive_failed() -> Error { #[allow(clippy::too_many_arguments)] async fn client_loop( inbound: &Channel, - outbound: &Channel, 4>, + ring: &WriteRing, connection_settings: &ConnectionSettings<'static>, subscribe_topics: &[(&str, QualityOfService)], events: &EventChannel, @@ -189,7 +183,7 @@ async fn client_loop( properties, ); state.connect(&connect).map_err(client_error)?; - queue(outbound, encode(&connect)?).await; + ring.put(&connect, 0).await?; } loop { @@ -212,9 +206,16 @@ async fn client_loop( if connected && now.saturating_sub(last_ping_ms) >= ping_interval { last_ping_ms = now; let ping = state.send_ping().map_err(client_error)?; - // The one packet worth dropping rather than waiting for — see - // `queue_lossy`. - queue_lossy(outbound, encode(&ping)?); + // The one packet worth dropping rather than waiting for. A ping + // carries no state — `send_ping` bumps a counter but arms no + // response deadline — so a dropped one costs nothing and the next + // deadline tries again; if the link really is gone, the liveness + // window closes the session. Parking on a ping would leave the + // loop that has to notice a dead link stuck. + if !ring.try_put(&ping)? { + #[cfg(feature = "defmt")] + defmt::warn!("MQTT: write ring full, ping dropped"); + } } // Subscriptions go out one at a time: `ClientStateNoQueue` tracks a @@ -222,7 +223,7 @@ async fn client_loop( if connected && !state.waiting_for_responses() && next_topic < subscribe_topics.len() { let (topic, qos) = subscribe_topics[next_topic]; let packet = state.subscribe_packet(topic, qos).map_err(client_error)?; - queue(outbound, encode(&packet)?).await; + ring.put(&packet, 0).await?; state.subscribe_update(&packet).map_err(client_error)?; next_topic += 1; // `continue` skips the bottom-of-loop bookkeeping, so arm the @@ -236,18 +237,26 @@ async fn client_loop( // The action arm is armed only when a publish can actually be sent: // connected, nothing awaiting acknowledgement (the client state holds - // one in-flight slot), every subscription placed, and room to queue the - // bytes. This - // is what replaces the old inline wait for a PUBACK — the ping and - // liveness deadlines keep running while it is parked. - let action_ready = connected - && !state.waiting_for_responses() - && next_topic >= subscribe_topics.len() - && !outbound.is_full(); + // one in-flight slot), every subscription placed, and room in the ring + // for the largest PUBLISH plus its reserve. This is what replaces the + // old inline wait for a PUBACK — the ping and liveness deadlines keep + // running while it is parked. Room is checked on every poll, not once + // here: PUBACKs and pings can take ring space while the arm is parked. + let action_ready = + connected && !state.waiting_for_responses() && next_topic >= subscribe_topics.len(); + let publish_room = ring.max_publish() + CONTROL_RESERVE; let action_arm = async { if !action_ready { core::future::pending::<()>().await; } + poll_fn(|cx| { + if ring.poll_room(publish_room, cx.waker()) { + Poll::Ready(()) + } else { + Poll::Pending + } + }) + .await; actions.receive().await }; @@ -265,7 +274,7 @@ async fn client_loop( drain_packets( &mut reader, &mut state, - outbound, + ring, events, runtime, &mut last_ack_ms, @@ -274,7 +283,7 @@ async fn client_loop( .await?; } Either3::Second(action) => { - perform(action, &mut state, outbound).await?; + perform(action, &mut state, ring).await?; } // The timer fired: the top of the loop re-evaluates every deadline. Either3::Third(()) => {} @@ -293,39 +302,44 @@ async fn client_loop( async fn drain_packets( reader: &mut PacketReader, state: &mut ClientStateNoQueue, - outbound: &Channel, 4>, + ring: &WriteRing, events: &EventChannel, runtime: &dyn RuntimeOps, last_ack_ms: &mut u64, connected: &mut bool, ) -> Result<(), Error> { while let Some(total) = reader.framed_len().map_err(client_error)? { + // A burst of QoS 1 publishes needs a PUBACK each, so room for one is + // waited for here, before parsing: the parsed packet is too large to + // hold across an await in the session future. + ring.wait_room(PUBACK_ROOM).await; + // The packet borrows the reader's buffer, so everything that outlives - // it — the response bytes, the application event — is made owned inside - // this scope. `consume` can then take `&mut`. - let (response, received) = { + // it — the application event — is made owned inside this scope. + // `consume` can then take `&mut`. + let received = { let packet: PacketGeneric<'_, MAX_PROPERTIES, 0, 0> = reader.parse(total).map_err(client_error)?; // Produce the PUBACK before the state update, as upstream does, so - // the two cannot disagree about what was acknowledged. - let response = match state + // the two cannot disagree about what was acknowledged. It borrows + // the client state, so it goes into the ring before + // `state.receive` takes `&mut`. The room was waited for above and + // nothing else writes the ring in between. + if let Some(puback) = state .receive_produce_response(&packet) .map_err(client_error)? { - Some(puback) => Some(encode(&puback)?), - None => None, - }; + if !ring.try_put(&puback)? { + return Err(client_error(PacketWriteError::Overflow)); + } + } let event = state.receive(packet).map_err(client_error)?; - (response, Received::of(event)?) + Received::of(event)? }; reader.consume(total); - if let Some(bytes) = response { - queue(outbound, bytes).await; - } - // Every packet the state accepted proves the broker is alive. *last_ack_ms = now_ms(runtime); @@ -395,7 +409,7 @@ impl Received { async fn perform( action: AimdbMqttAction, state: &mut ClientStateNoQueue, - outbound: &Channel, 4>, + ring: &WriteRing, ) -> Result<(), Error> { match action { AimdbMqttAction::Publish { @@ -425,7 +439,20 @@ async fn perform( ); }) .map_err(client_error)?; - queue(outbound, encode(&packet)?).await; + // A frame larger than the ring can always grant would wait + // forever; skip it before the state commits to it. + let len = encoded_len(&packet)?; + if !ring.fits(len, CONTROL_RESERVE) { + #[cfg(feature = "defmt")] + defmt::warn!( + "MQTT: dropping publish to {}: {} bytes exceed the {}-byte write ring limit", + topic.as_str(), + len, + ring.max_publish() + ); + return Ok(()); + } + ring.put_sized(&packet, len, CONTROL_RESERVE).await?; state.publish_update(&packet).map_err(client_error)?; } AimdbMqttAction::Subscribe { topic, qos } => { @@ -438,55 +465,13 @@ async fn perform( defmt::warn!("MQTT: dropping subscribe to {}: {}", topic.as_str(), _e); }) .map_err(client_error)?; - queue(outbound, encode(&packet)?).await; + ring.put(&packet, 0).await?; state.subscribe_update(&packet).map_err(client_error)?; } } Ok(()) } -/// Encode a packet to exactly its own length: a counting pass, then a real -/// one, so no fixed buffer is sized for the largest packet anyone might send. -fn encode(packet: &P) -> Result, Error> { - let mut len_writer = MqttLenWriter::new(); - len_writer.put(packet).map_err(write_error)?; - - let mut bytes = alloc::vec![0u8; len_writer.position()]; - let mut writer = MqttBufWriter::new(&mut bytes); - writer.put(packet).map_err(write_error)?; - Ok(bytes) -} - -/// Queue encoded bytes for the write half, waiting for a slot. -/// -/// Everything the protocol obliges us to send goes through here: CONNECT, -/// SUBSCRIBE, PUBLISH and the PUBACKs answering QoS 1 delivery. None of those -/// can be dropped — the state machine has already committed to them, so a -/// discarded packet leaves our state and the wire disagreeing, with nothing to -/// resync on. -/// -/// Waiting cannot deadlock: [`write_out`] is this channel's only consumer and -/// is a sibling arm of the same `select`, so parking here is what lets it run. -/// It is also the backpressure — a peer that stops reading stops us encoding. -async fn queue(outbound: &Channel, 4>, bytes: Vec) { - outbound.send(bytes).await; -} - -/// Queue encoded bytes only if the write half is keeping up, dropping them if -/// it is not. -/// -/// For pings alone. A ping carries no state — `send_ping` bumps a counter but -/// arms no response deadline — so a dropped one costs nothing and the next -/// ping deadline tries again; if the link really is gone, the liveness window -/// closes the session. Parking on a ping would be worse than skipping it: the -/// loop that has to notice the link is gone would be the thing stuck. -fn queue_lossy(outbound: &Channel, 4>, bytes: Vec) { - if outbound.try_send(bytes).is_err() { - #[cfg(feature = "defmt")] - defmt::warn!("MQTT: write queue full, ping dropped"); - } -} - /// Milliseconds to sleep before the earliest armed deadline. fn next_deadline( now: u64, @@ -509,13 +494,10 @@ fn client_error(error: impl Into) -> Error { Error::Client(error.into()) } -fn write_error(error: PacketWriteError) -> Error { - Error::Client(ClientError::PacketWrite(error)) -} - #[cfg(test)] mod tests { use super::*; + use alloc::vec::Vec; /// Halves that never do anything: enough to build the session future and /// measure it without polling it. @@ -572,6 +554,7 @@ mod tests { let settings = Settings::default(); let connection_settings = ConnectionSettings::unauthenticated("size-probe"); let runtime = aimdb_core::executor::test_support::NoopRuntimeOps; + let ring = WriteRing::new(64); // Built, never polled: `size_of_val` on the future is the whole point. let session = run_session( @@ -581,6 +564,7 @@ mod tests { &[], &events, &actions, + &ring, &settings, &NullDelay, &runtime, @@ -664,9 +648,146 @@ mod tests { ); } + #[test] + fn puback_room_covers_the_puback_the_client_state_produces() { + use mountain_mqtt::data::packet_identifier::PacketIdentifier; + use mountain_mqtt::data::reason_code::PublishReasonCode; + use mountain_mqtt::packets::puback::Puback; + + let puback: Puback<'_, MAX_PROPERTIES> = Puback::new( + PacketIdentifier(u16::MAX), + PublishReasonCode::Success, + heapless::Vec::new(), + ); + assert!(encoded_len(&puback).unwrap() <= PUBACK_ROOM); + } + #[test] fn a_deadline_in_the_past_still_sleeps_a_tick() { // Never zero: a zero-length sleep would spin the loop. assert_eq!(next_deadline(1_000, true, 100, 500, None), 1); } } + +#[cfg(test)] +mod proofs { + //! Where the embedded backend's size limits sit today: the largest + //! PUBLISH it sends, what it does with a larger one (skipped without an + //! error and without a count), and the largest packet it receives. + use super::*; + use crate::embedded::write_ring::DEFAULT_WRITE_BUFFER; + use alloc::vec::Vec; + use core::future::Future; + use core::pin::pin; + use core::task::{Context, Waker}; + use mountain_mqtt::data::reason_code::ConnectReasonCode; + use mountain_mqtt::packets::connack::Connack; + + fn connected() -> ClientStateNoQueue { + let mut state = ClientStateNoQueue::new(); + let connect: Connect<'_, 1, 0> = + Connect::new(60, None, None, "proof", true, None, heapless::Vec::new()); + state.connect(&connect).unwrap(); + let connack: Connack<'_, MAX_PROPERTIES> = + Connack::new(false, ConnectReasonCode::Success, heapless::Vec::new()); + state + .receive(PacketGeneric::<'_, MAX_PROPERTIES, 0, 0>::Connack(connack)) + .unwrap(); + state + } + + /// Payload length whose QoS 1 PUBLISH to `t` encodes to exactly `frame`. + fn payload_for(frame: usize) -> usize { + let mut probe = connected(); + (0..frame) + .rev() + .find(|&n| { + let payload = alloc::vec![0u8; n]; + let packet = probe + .publish_packet("t", &payload, QualityOfService::Qos1, false) + .unwrap(); + encoded_len(&packet).unwrap() == frame + }) + .unwrap() + } + + fn publish(n: usize) -> AimdbMqttAction { + AimdbMqttAction::Publish { + topic: "t".into(), + payload: alloc::vec![b'x'; n], + qos: QualityOfService::Qos1, + retain: false, + } + } + + fn run( + action: AimdbMqttAction, + state: &mut ClientStateNoQueue, + ring: &WriteRing, + ) -> Poll> { + let f = pin!(perform(action, state, ring)); + f.poll(&mut Context::from_waker(Waker::noop())) + } + + #[test] + fn proof_a_1984_byte_publish_goes_out() { + let ring = WriteRing::new(DEFAULT_WRITE_BUFFER); + assert_eq!(ring.max_publish(), 1984); + let mut state = connected(); + let n = payload_for(1984); + assert!(matches!( + run(publish(n), &mut state, &ring), + Poll::Ready(Ok(())) + )); + assert!(!ring.has_room(DEFAULT_WRITE_BUFFER), "bytes were queued"); + assert!(state.waiting_for_responses(), "QoS 1 publish in flight"); + } + + #[test] + fn proof_a_1985_byte_publish_vanishes_without_an_error() { + let ring = WriteRing::new(DEFAULT_WRITE_BUFFER); + let mut state = connected(); + let n = payload_for(1985); + assert!(matches!( + run(publish(n), &mut state, &ring), + Poll::Ready(Ok(())) + )); + assert!(ring.has_room(DEFAULT_WRITE_BUFFER), "nothing was queued"); + assert!(!state.waiting_for_responses(), "nothing in flight"); + // No counter exists to assert on: the only trace is a defmt warn. + } + + /// A QoS 0 PUBLISH to `t` that is `total` bytes on the wire. + fn inbound_publish(total: usize) -> Vec { + let remaining = total - 3; // header byte + 2-byte varint + let mut bytes = alloc::vec![ + 0x30, + (remaining % 128) as u8 | 0x80, + (remaining / 128) as u8, + 0x00, + 0x01, + b't', + 0x00, + ]; + bytes.resize(total, b'x'); + bytes + } + + fn receive(total: usize) -> Result { + let mut reader = PacketReader::::new(); + for chunk in inbound_publish(total).chunks(RX_CHUNK) { + reader.feed(chunk)?; + if let Some(n) = reader.framed_len()? { + return Ok(n); + } + } + unreachable!("the whole packet was fed") + } + + #[test] + fn proof_the_receive_side_takes_3584_and_refuses_3585() { + assert_eq!(PACKET_BUFFER_SIZE, 3584); + assert_eq!(receive(3584), Ok(3584)); + assert_eq!(receive(3585), Err(PacketReadError::PacketTooLargeForBuffer)); + } +} diff --git a/aimdb-mqtt-connector/src/embedded/tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs index 4b088d2e..65571de7 100644 --- a/aimdb-mqtt-connector/src/embedded/tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -24,6 +24,7 @@ use embedded_tls::{ use crate::embedded::manager::Settings; use crate::embedded::session_loop::run_session; +use crate::embedded::write_ring::{WriteRing, DEFAULT_WRITE_BUFFER}; use mountain_mqtt::client::ConnectionSettings; use mountain_mqtt::data::quality_of_service::QualityOfService; @@ -343,6 +344,9 @@ where .map(|topic| (topic.as_str(), QualityOfService::Qos1)) .collect(); + // Allocated once and reused by every session. + let ring = WriteRing::new(DEFAULT_WRITE_BUFFER); + loop { // Certificate validity needs real time. Take it from the runtime when // it has a wall clock; otherwise wait for whatever feeds `WallClock` @@ -410,6 +414,7 @@ where &subscribe_topics, &events, &actions, + &ring, &settings, &delay, runtime.as_ref(), diff --git a/aimdb-mqtt-connector/src/embedded/write_ring.rs b/aimdb-mqtt-connector/src/embedded/write_ring.rs new file mode 100644 index 00000000..36eb0003 --- /dev/null +++ b/aimdb-mqtt-connector/src/embedded/write_ring.rs @@ -0,0 +1,453 @@ +//! The session's write queue: one byte ring, allocated once per connector. +//! +//! The session loop encodes every packet straight into a grant of the ring; +//! [`WriteRing::write_out`] sends committed bytes to the socket and releases +//! them. Both ends run in the same task under the session's `select3`, so the +//! ring needs no lock. Two wakers carry the signals the ring's own notifier +//! does not: `data` from a commit to `write_out`, and `room` from a release to +//! whatever waits for space. + +use core::future::poll_fn; +use core::task::Poll; + +use aimdb_core::session::ByteWrite; +use bbqueue::traits::coordination::cas::AtomicCoord; +use bbqueue::traits::notifier::polling::Polling; +use bbqueue::traits::storage::BoxedSlice; +use bbqueue::BBQueue; +use embassy_sync::waitqueue::AtomicWaker; +use mountain_mqtt::client::ClientError; +use mountain_mqtt::codec::mqtt_writer::{MqttBufWriter, MqttLenWriter, MqttWriter}; +use mountain_mqtt::codec::write::Write; +use mountain_mqtt::error::PacketWriteError; + +use crate::embedded::manager::Error; + +/// Default ring size, the same as the inbound side's `BUFFER_SIZE`. +pub(crate) const DEFAULT_WRITE_BUFFER: usize = 4096; + +/// Bytes kept free behind a PUBLISH, so the control packet after it (a +/// PUBACK, a ping) still finds room. +pub(crate) const CONTROL_RESERVE: usize = 64; + +/// One connector's write ring. +pub(crate) struct WriteRing { + queue: BBQueue, + data: AtomicWaker, + room: AtomicWaker, +} + +impl WriteRing { + pub(crate) fn new(capacity: usize) -> Self { + Self { + queue: BBQueue::new_with_storage(BoxedSlice::new(capacity)), + data: AtomicWaker::new(), + room: AtomicWaker::new(), + } + } + + /// The largest PUBLISH frame the ring always has room for eventually. + pub(crate) fn max_publish(&self) -> usize { + (self.queue.capacity() / 2).saturating_sub(CONTROL_RESERVE) + } + + /// Whether a `len`-byte frame plus `reserve` always fits eventually. + /// + /// A bipbuffer grants only contiguous space: once its pointers have both + /// moved to `k`, an empty ring grants at most `max(capacity − k, k − 1)` + /// bytes, about half its capacity in the worst case. A larger grant could + /// wait forever, even with nothing queued. + pub(crate) fn fits(&self, len: usize, reserve: usize) -> bool { + len + reserve <= self.queue.capacity() / 2 + } + + /// Whether a contiguous grant of `n` bytes exists right now. + /// + /// bbqueue has no free-space query, so this takes the grant and drops it + /// uncommitted. A probe that wraps commits an early wraparound (the + /// unused tail is skipped until the reader passes it); no data changes. + pub(crate) fn has_room(&self, n: usize) -> bool { + self.queue.stream_producer().grant_exact(n).is_ok() + } + + /// [`has_room`](Self::has_room), registering `waker` for the next release + /// when there is none. + pub(crate) fn poll_room(&self, n: usize, waker: &core::task::Waker) -> bool { + if self.has_room(n) { + return true; + } + self.room.register(waker); + // A release between the probe and the registration woke nobody. + self.has_room(n) + } + + /// Wait until a contiguous grant of `n` bytes exists. + pub(crate) async fn wait_room(&self, n: usize) { + poll_fn(|cx| { + if self.poll_room(n, cx.waker()) { + Poll::Ready(()) + } else { + Poll::Pending + } + }) + .await + } + + /// Encode `packet` into the ring, waiting for room. `reserve` more bytes + /// are granted behind it and left free. A packet that does not + /// [`fit`](Self::fits) fails with `Overflow` instead of waiting forever. + /// + /// Waiting cannot deadlock: [`write_out`](Self::write_out) is the ring's + /// only consumer and a sibling arm of the session's `select3`, so parking + /// here is what lets it run. It is also the backpressure: a peer that + /// stops reading stops the session encoding. + pub(crate) async fn put(&self, packet: &P, reserve: usize) -> Result<(), Error> { + let len = encoded_len(packet)?; + if !self.fits(len, reserve) { + return Err(write_error(PacketWriteError::Overflow)); + } + self.put_sized(packet, len, reserve).await + } + + /// [`put`](Self::put) for a packet whose encoded length is already known + /// and [fits](Self::fits). + pub(crate) async fn put_sized( + &self, + packet: &P, + len: usize, + reserve: usize, + ) -> Result<(), Error> { + debug_assert!(self.fits(len, reserve)); + let producer = self.queue.stream_producer(); + let mut grant = poll_fn(|cx| match producer.grant_exact(len + reserve) { + Ok(grant) => Poll::Ready(grant), + Err(_) => { + self.room.register(cx.waker()); + match producer.grant_exact(len + reserve) { + Ok(grant) => Poll::Ready(grant), + Err(_) => Poll::Pending, + } + } + }) + .await; + let mut writer = MqttBufWriter::new(&mut grant[..len]); + writer.put(packet).map_err(write_error)?; + grant.commit(len); + self.data.wake(); + Ok(()) + } + + /// Encode `packet` only if there is room now. `Ok(false)`: dropped. + pub(crate) fn try_put(&self, packet: &P) -> Result { + let len = encoded_len(packet)?; + let Ok(mut grant) = self.queue.stream_producer().grant_exact(len) else { + return Ok(false); + }; + let mut writer = MqttBufWriter::new(&mut grant[..len]); + writer.put(packet).map_err(write_error)?; + grant.commit(len); + self.data.wake(); + Ok(true) + } + + /// Discard every byte still queued, so nothing from an old session + /// reaches a new socket ahead of its CONNECT. + pub(crate) fn drain(&self) { + let consumer = self.queue.stream_consumer(); + // Two reads at most: the tail before a wraparound, then the head. + while let Ok(grant) = consumer.read() { + let n = grant.len(); + grant.release(n); + } + self.room.wake(); + } + + /// Send committed bytes to the socket, releasing them once written and + /// flushed. Returns only on a write error. Never cancelled mid-write: + /// it is a `select3` arm that ends the session when it returns. + pub(crate) async fn write_out(&self, mut tx: W) -> Error { + let consumer = self.queue.stream_consumer(); + loop { + let grant = poll_fn(|cx| match consumer.read() { + Ok(grant) => Poll::Ready(grant), + Err(_) => { + self.data.register(cx.waker()); + match consumer.read() { + Ok(grant) => Poll::Ready(grant), + Err(_) => Poll::Pending, + } + } + }) + .await; + if tx.write_all(&grant).await.is_err() || tx.flush().await.is_err() { + return Error::Client(ClientError::PacketWrite(PacketWriteError::ConnectionSend)); + } + let n = grant.len(); + grant.release(n); + self.room.wake(); + } + } +} + +/// Bytes `packet` encodes to. +pub(crate) fn encoded_len(packet: &P) -> Result { + let mut len_writer = MqttLenWriter::new(); + len_writer.put(packet).map_err(write_error)?; + Ok(len_writer.position()) +} + +fn write_error(error: PacketWriteError) -> Error { + Error::Client(ClientError::PacketWrite(error)) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::sync::Arc; + use alloc::task::Wake; + use alloc::vec::Vec; + use core::cell::RefCell; + + use core::future::Future; + use core::pin::pin; + use core::sync::atomic::{AtomicUsize, Ordering}; + use core::task::{Context, Waker}; + use embassy_sync::blocking_mutex::CriticalSectionMutex; + + use mountain_mqtt::codec::mqtt_writer::MqttWriter; + + /// A "packet" of `n` copies of one byte. + struct Bytes(usize, u8); + + impl Write for Bytes { + fn write<'a, W: MqttWriter<'a>>(&self, writer: &mut W) -> Result<(), PacketWriteError> { + writer.put_slice(&alloc::vec![self.1; self.0]) + } + } + + /// Records everything written. + #[derive(Clone)] + struct Sink(Arc>>>); + + impl Sink { + fn new() -> Self { + Self(Arc::new(CriticalSectionMutex::new( + RefCell::new(Vec::new()), + ))) + } + + fn written(&self) -> Vec { + self.0.lock(|w| w.borrow().clone()) + } + } + + impl ByteWrite for Sink { + async fn write_all(&mut self, buf: &[u8]) -> aimdb_core::session::TransportResult<()> { + self.0.lock(|w| w.borrow_mut().extend_from_slice(buf)); + Ok(()) + } + + async fn flush(&mut self) -> aimdb_core::session::TransportResult<()> { + Ok(()) + } + } + + #[derive(Default)] + struct Count(AtomicUsize); + + impl Wake for Count { + fn wake(self: Arc) { + self.0.fetch_add(1, Ordering::Relaxed); + } + } + + /// Poll a future once with `waker`. + fn poll_once(f: core::pin::Pin<&mut F>, waker: &Waker) -> Poll { + f.poll(&mut Context::from_waker(waker)) + } + + /// Everything committed, in read order, released as it is read. + fn read_all(ring: &WriteRing) -> Vec { + let consumer = ring.queue.stream_consumer(); + let mut out = Vec::new(); + while let Ok(grant) = consumer.read() { + out.extend_from_slice(&grant); + let n = grant.len(); + grant.release(n); + } + out + } + + /// Write `n` bytes and read them back, moving both pointers to `n`. + fn advance(ring: &WriteRing, n: usize) { + if n > 0 { + assert!(ring.try_put(&Bytes(n, 0)).unwrap()); + assert_eq!(read_all(ring).len(), n); + } + } + + fn ready(f: impl Future>) { + let f = pin!(f); + assert!(matches!(poll_once(f, Waker::noop()), Poll::Ready(Ok(())))); + } + + #[test] + fn a_probe_that_wraps_changes_no_data() { + let ring = WriteRing::new(16); + advance(&ring, 6); + ready(ring.put(&Bytes(6, b'b'), 0)); + // 12..16 is too short for 5 bytes; the probe wraps to the start. + assert!(ring.has_room(5)); + ready(ring.put(&Bytes(3, b'c'), 0)); + assert_eq!(read_all(&ring), b"bbbbbbccc"); + } + + #[test] + fn an_empty_ring_always_grants_half_its_capacity() { + const CAPACITY: usize = 64; + for offset in 0..CAPACITY { + let ring = WriteRing::new(CAPACITY); + advance(&ring, offset); + assert!(ring.has_room(CAPACITY / 2), "offset {offset}"); + } + // Drained at the middle, one byte more than half never fits. + let ring = WriteRing::new(CAPACITY); + advance(&ring, CAPACITY / 2); + assert!(!ring.has_room(CAPACITY / 2 + 1)); + } + + #[test] + fn max_publish_leaves_room_for_the_reserve_in_half_the_ring() { + let ring = WriteRing::new(DEFAULT_WRITE_BUFFER); + assert_eq!(ring.max_publish(), 1984); + assert_eq!( + ring.max_publish() + CONTROL_RESERVE, + DEFAULT_WRITE_BUFFER / 2 + ); + } + + #[test] + fn drain_discards_an_old_sessions_bytes() { + let ring = WriteRing::new(64); + ready(ring.put(&Bytes(10, b'a'), 0)); + ring.drain(); + ready(ring.put(&Bytes(4, b'b'), 0)); + assert_eq!(read_all(&ring), b"bbbb"); + } + + #[test] + fn a_puback_waits_for_room_and_goes_out_in_order() { + let ring = WriteRing::new(256); + // Filled with `try_put`: 200 bytes is more than `put` accepts. + assert!(ring.try_put(&Bytes(200, b'p')).unwrap()); + assert!(ring.try_put(&Bytes(40, b'q')).unwrap()); + + // 16 bytes left, and nothing has been read: the PUBACK waits. + let woken = Arc::new(Count::default()); + let waker = Waker::from(woken.clone()); + let mut puback = pin!(ring.put(&Bytes(20, b'r'), 0)); + assert!(poll_once(puback.as_mut(), &waker).is_pending()); + + // `write_out` sends what is queued and releases it. + let sink = Sink::new(); + let mut write_out = pin!(ring.write_out(sink.clone())); + assert!(poll_once(write_out.as_mut(), Waker::noop()).is_pending()); + assert_eq!(sink.written().len(), 240); + assert!( + woken.0.load(Ordering::Relaxed) > 0, + "the release woke the PUBACK" + ); + + assert!(matches!( + poll_once(puback.as_mut(), &waker), + Poll::Ready(Ok(())) + )); + assert!(poll_once(write_out.as_mut(), Waker::noop()).is_pending()); + let written = sink.written(); + assert_eq!(written.len(), 260); + assert!(written[..200].iter().all(|&b| b == b'p')); + assert!(written[200..240].iter().all(|&b| b == b'q')); + assert!(written[240..].iter().all(|&b| b == b'r')); + } + + #[test] + fn try_put_drops_rather_than_waits() { + let ring = WriteRing::new(16); + assert!(ring.try_put(&Bytes(14, b'a')).unwrap()); + assert!(!ring.try_put(&Bytes(4, b'p')).unwrap()); + assert_eq!(read_all(&ring).len(), 14); + } +} + +#[cfg(test)] +mod proofs { + //! A control packet larger than the ring can always grant fails instead + //! of parking forever; one that fits goes out from any offset. + use super::*; + use core::future::Future; + use core::pin::pin; + use core::task::{Context, Waker}; + use mountain_mqtt::packets::connect::Connect; + + fn connect_with_password(password: &[u8]) -> Connect<'_, 1, 0> { + Connect::new( + 60, + Some("user"), + Some(password), + "proof", + true, + None, + heapless::Vec::new(), + ) + } + + /// Move both pointers to `n` with nothing left queued, as a session that + /// sent `n` bytes and then dropped leaves the ring after `drain`. + fn leave_at(ring: &WriteRing, n: usize) { + struct Filler(usize); + impl Write for Filler { + fn write<'a, W: MqttWriter<'a>>(&self, w: &mut W) -> Result<(), PacketWriteError> { + w.put_slice(&alloc::vec![0u8; self.0]) + } + } + assert!(ring.try_put(&Filler(n)).unwrap()); + ring.drain(); + } + + fn poll(f: core::pin::Pin<&mut F>) -> Poll { + f.poll(&mut Context::from_waker(Waker::noop())) + } + + fn is_overflow(r: &Poll>) -> bool { + matches!( + r, + Poll::Ready(Err(Error::Client(ClientError::PacketWrite( + PacketWriteError::Overflow + )))) + ) + } + + #[test] + fn a_connect_over_half_the_ring_fails_at_any_offset() { + let password = alloc::vec![b'p'; 2100]; + let connect = connect_with_password(&password); + for offset in [0, DEFAULT_WRITE_BUFFER / 2] { + let ring = WriteRing::new(DEFAULT_WRITE_BUFFER); + leave_at(&ring, offset); + let mut put = pin!(ring.put(&connect, 0)); + assert!(is_overflow(&poll(put.as_mut())), "offset {offset}"); + } + } + + #[test] + fn a_connect_that_fits_goes_out_from_offset_2048() { + // Just under half the ring once the CONNECT's own fields are added. + let password = alloc::vec![b'p'; 2000]; + let connect = connect_with_password(&password); + assert!(encoded_len(&connect).unwrap() <= DEFAULT_WRITE_BUFFER / 2); + let ring = WriteRing::new(DEFAULT_WRITE_BUFFER); + leave_at(&ring, DEFAULT_WRITE_BUFFER / 2); + let mut put = pin!(ring.put(&connect, 0)); + assert!(matches!(poll(put.as_mut()), Poll::Ready(Ok(())))); + } +} diff --git a/aimdb-mqtt-connector/tests/write_ring_proofs.rs b/aimdb-mqtt-connector/tests/write_ring_proofs.rs new file mode 100644 index 00000000..ad352078 --- /dev/null +++ b/aimdb-mqtt-connector/tests/write_ring_proofs.rs @@ -0,0 +1,168 @@ +//! The embedded backend's packet-size behaviour against a fake broker +//! (`_test-tokio-broker`). +#![cfg(feature = "_test-tokio-broker")] + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tokio::net::TcpListener; + +mod common; +use common::{fake_broker, Seen}; + +// Each test binary defines these exactly once. +#[defmt::global_logger] +struct HostTestLogger; +unsafe impl defmt::Logger for HostTestLogger { + fn acquire() {} + unsafe fn flush() {} + unsafe fn release() {} + unsafe fn write(_bytes: &[u8]) {} +} +#[defmt::panic_handler] +fn defmt_panic() -> ! { + core::panic!("defmt panic in host test") +} +// This binary must define `_defmt_timestamp` itself. `embassy-time` would — +// `defmt-timestamp-uptime` is enabled here, as it is for `embassy_broker` — +// but nothing in this test references `embassy-time`, so its object never +// reaches the link and the symbol would be undefined. `embassy_broker` pulls +// it in through `embassy-net` and therefore must *not* define one. +defmt::timestamp!("{=u64:us}", 0); + +/// Real wall-clock time; the session loop's delays are `embassy_time`'s until +/// it takes core's `Delay`. +struct HostClock; +impl embassy_time_driver::Driver for HostClock { + fn now(&self) -> u64 { + use std::sync::OnceLock; + use std::time::Instant; + static START: OnceLock = OnceLock::new(); + let start = START.get_or_init(Instant::now); + (start.elapsed().as_micros() * u128::from(embassy_time_driver::TICK_HZ) / 1_000_000) as u64 + } + fn schedule_wake(&self, _at: u64, waker: &core::task::Waker) { + waker.wake_by_ref(); + } +} +embassy_time_driver::time_driver_impl!(static HOST_CLOCK: HostClock = HostClock); + +use aimdb_core::buffer::BufferCfg; +use aimdb_core::AimDbBuilder; +use aimdb_mqtt_connector::MqttConnector; +use aimdb_tokio_adapter::net::TokioNet; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + +/// A 3,000-byte publish never reaches the broker, the session never errors, +/// and the small publishes around it keep flowing. Nothing counts the drop. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn proof_an_oversize_publish_is_dropped_silently() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let seen = Arc::new(Mutex::new(Seen::default())); + + let connector = MqttConnector::new(format!("mqtt://127.0.0.1:{port}")) + .transport(TokioNet::tcp()) + .with_client_id("proof-drop"); + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + builder.configure::("blob", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .source(|_ctx, producer| async move { + loop { + producer.produce(1u64); // 3,000 bytes + tokio::time::sleep(Duration::from_millis(100)).await; + producer.produce(2u64); // 1 byte + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .link_to("mqtt://sensors/blob") + .with_serializer(|_ctx, v: &u64| { + Ok(if *v == 1 { + vec![b'x'; 3000] + } else { + b"2".to_vec() + }) + }) + .finish(); + }); + let (_db, runner) = builder.build().await.expect("build db"); + let broker = fake_broker(listener, seen.clone(), 0, None); + + let until_small_ones = async { + while seen.lock().unwrap().published.len() < 5 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }; + tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = broker => panic!("the broker returned"), + _ = until_small_ones => {} + _ = tokio::time::sleep(Duration::from_secs(30)) => panic!("watchdog"), + } + + let seen = seen.lock().unwrap(); + assert!( + seen.published.iter().all(|(_, p)| p == b"2"), + "only the 1-byte payloads reached the broker" + ); + assert_eq!(seen.connects, 1, "the session never errored"); +} + +/// Build a client subscribed to `sensors/temperature` against a broker that +/// pushes `payload_len` bytes after every SUBACK, as it would a retained +/// message, and count connections after `wait`. +async fn connects_with_push(payload_len: usize, wait: Duration) -> usize { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let seen = Arc::new(Mutex::new(Seen::default())); + + let connector = MqttConnector::new(format!("mqtt://127.0.0.1:{port}")) + .transport(TokioNet::tcp()) + .with_client_id("proof-retained"); + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + builder.configure::("temperature", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from("mqtt://sensors/temperature") + .with_deserializer(|_ctx, data: &[u8]| Ok::(data.len() as u64)) + .finish(); + }); + let (_db, runner) = builder.build().await.expect("build db"); + + let payload = vec![b'x'; payload_len]; + let broker = fake_broker( + listener, + seen.clone(), + 0, + Some(("sensors/temperature", payload.as_slice())), + ); + tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = broker => panic!("the broker returned"), + _ = tokio::time::sleep(wait) => {} + } + let connects = seen.lock().unwrap().connects; + connects +} + +/// A 3,000-byte message fits the receive buffer. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn proof_a_3000_byte_retained_message_is_received_once() { + assert_eq!(connects_with_push(3000, Duration::from_secs(5)).await, 1); +} + +/// A 4,000-byte message ends every session it reaches, so a retained one +/// reconnects the client forever (one cycle per 2 s reconnection delay): the +/// CONNECT does not tell the broker the largest packet the client accepts. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn proof_a_4000_byte_retained_message_reconnects_forever() { + let connects = connects_with_push(4000, Duration::from_secs(5)).await; + eprintln!("connects in 5 s: {connects}"); + assert!( + connects >= 3, + "expected a reconnect loop, saw {connects} connects" + ); +} From a11813a84cb23a326698eb7a4cc336a54d5f29dc Mon Sep 17 00:00:00 2001 From: "sounds.like.lx" <147444674+lxsaah@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:21:21 +0200 Subject: [PATCH 15/28] Merge main into feat/054-connector-boundary (#285) --- CHANGELOG.md | 7 ++ aimdb-mqtt-connector/CHANGELOG.md | 10 +++ .../src/embedded/session_loop.rs | 69 +++++++++++++++++- aimdb-mqtt-connector/tests/common/mod.rs | 48 +++++++++++-- aimdb-mqtt-connector/tests/tokio_broker.rs | 72 +++++++++++++++++++ .../tests/write_ring_proofs.rs | 60 +--------------- 6 files changed, 202 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 620b1899..cd7465e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,13 @@ and `pump_client` take the router. KNX, WebSocket, TCP, UDS and serial use `ExactGrammar`: a `{…}` link on them fails the build. The user-facing link API is unchanged. ([aimdb-core](aimdb-core/CHANGELOG.md)) +### Fixed + +- **The embedded MQTT backend advertises the largest packet it receives** + (`Maximum Packet Size` 3,328 in CONNECT), so an oversized retained message is + withheld by the broker instead of reconnecting the client forever. + ([aimdb-mqtt-connector](aimdb-mqtt-connector/CHANGELOG.md)) + ## [2.0.0] - 2026-09-18 ### Added diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index 4f0f62e0..5d499e8d 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -23,6 +23,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **The `with_qos` doc no longer claims an inbound subscribe QoS.** Inbound subscriptions stay at QoS 1, as before. +### Fixed + +- **An oversized retained message no longer reconnects the `Embedded` backend + forever.** The session receives packets of up to 3,328 bytes (its 3,584-byte + buffer minus one read), but its MQTT 5 CONNECT did not say so. A broker + could send a larger packet, which ends the session, and a retained one is + replayed after every SUBSCRIBE, so the client reconnected once per + reconnection delay. CONNECT now advertises `Maximum Packet Size` 3,328, and + the broker withholds anything larger instead of sending it. + ## [0.7.0] - 2026-09-18 ### Changed (breaking) diff --git a/aimdb-mqtt-connector/src/embedded/session_loop.rs b/aimdb-mqtt-connector/src/embedded/session_loop.rs index 9bf39b2f..0c737fb6 100644 --- a/aimdb-mqtt-connector/src/embedded/session_loop.rs +++ b/aimdb-mqtt-connector/src/embedded/session_loop.rs @@ -51,6 +51,13 @@ const PACKET_BUFFER_SIZE: usize = BUFFER_SIZE - 2 * RX_CHUNK; /// properties). const PUBACK_ROOM: usize = 16; +/// The largest packet the reader takes whatever arrives before it: the +/// buffer minus one feed chunk (see [`PacketReader`]). Advertised as the +/// CONNECT's Maximum Packet Size, so a broker never sends a larger packet — +/// one that would end the session, and a retained one would end every +/// session it is replayed into. +const MAX_INBOUND_PACKET: usize = PACKET_BUFFER_SIZE - RX_CHUNK; + /// One chunk of freshly read bytes, in flight from the read half to the loop. type Chunk = heapless::Vec; @@ -170,10 +177,13 @@ async fn client_loop( // Topic aliases are declined: honouring them would mean storing the // server's topic names for the life of the connection. let _ = properties.push(ConnectProperty::TopicAliasMaximum(0.into())); + let _ = properties.push(ConnectProperty::MaximumPacketSize( + (MAX_INBOUND_PACKET as u32).into(), + )); // Ours, not `connection_settings.keep_alive()`: that field has no // setter, so it is always mountain-mqtt's own 60 s constant. The // cadence below is derived from the value we actually send. - let connect: Connect<'_, 1, 0> = Connect::new( + let connect: Connect<'_, 2, 0> = Connect::new( settings.keep_alive_secs, *connection_settings.username(), *connection_settings.password(), @@ -648,6 +658,63 @@ mod tests { ); } + /// A QoS 0 PUBLISH to `t` that is exactly `total` bytes on the wire. + fn publish_of(total: usize) -> Vec { + let varint_len = if total - 2 < 128 { 1 } else { 2 }; + let remaining = total - 1 - varint_len; + let mut bytes = alloc::vec![0x30u8]; + if varint_len == 1 { + bytes.push(remaining as u8); + } else { + bytes.push((remaining % 128) as u8 | 0x80); + bytes.push((remaining / 128) as u8); + } + bytes.extend_from_slice(&[0x00, 0x01, b't', 0x00]); + bytes.resize(total, b'x'); + bytes + } + + /// Feeds a `first`-byte packet, a `second`-byte one and a trailing one in + /// `RX_CHUNK` reads, consuming packets as they complete, as the session + /// does. The trailing packet makes the read that completes `second` a full + /// one that also carries the head of the next packet: the worst case. + fn receive_after(first: usize, second: usize) -> Result<(), PacketReadError> { + let mut stream = publish_of(first); + stream.extend_from_slice(&publish_of(second)); + stream.extend_from_slice(&publish_of(RX_CHUNK)); + let mut reader = PacketReader::::new(); + let mut received = 0; + for chunk in stream.chunks(RX_CHUNK) { + reader.feed(chunk)?; + while let Some(total) = reader.framed_len()? { + reader.consume(total); + received += 1; + } + } + assert!(received >= 2); + Ok(()) + } + + /// The advertised Maximum Packet Size is one the reader takes wherever the + /// packet starts inside a read. The reader's stated limit (buffer minus + /// one read) is conservative by one byte; two bytes more fail at some + /// offset. + #[test] + fn the_advertised_maximum_packet_size_is_always_received() { + assert_eq!(MAX_INBOUND_PACKET, 3328); + let offsets = 8..8 + RX_CHUNK; + for first in offsets.clone() { + assert_eq!( + receive_after(first, MAX_INBOUND_PACKET), + Ok(()), + "after {first} bytes" + ); + } + assert!(offsets + .map(|first| receive_after(first, MAX_INBOUND_PACKET + 2)) + .any(|r| r == Err(PacketReadError::PacketTooLargeForBuffer))); + } + #[test] fn puback_room_covers_the_puback_the_client_state_produces() { use mountain_mqtt::data::packet_identifier::PacketIdentifier; diff --git a/aimdb-mqtt-connector/tests/common/mod.rs b/aimdb-mqtt-connector/tests/common/mod.rs index e2b35ce3..b6a28d5f 100644 --- a/aimdb-mqtt-connector/tests/common/mod.rs +++ b/aimdb-mqtt-connector/tests/common/mod.rs @@ -26,6 +26,10 @@ pub struct Seen { pub keep_alives: Vec, pub subscribes: Vec>, pub published: Vec<(String, Vec)>, + /// The Maximum Packet Size each MQTT 5 CONNECT advertised, when it did. + pub max_packet_sizes: Vec, + /// Pushes withheld because they exceeded the client's Maximum Packet Size. + pub withheld: usize, } impl Seen { @@ -127,6 +131,30 @@ fn connect_keep_alive(body: &[u8]) -> Option { Some(u16::from_be_bytes([*body.get(8)?, *body.get(9)?])) } +/// The Maximum Packet Size property (0x27) of an MQTT 5 CONNECT, if present. +/// Knows the fixed-size properties a client sends; anything else ends the +/// scan with `None`. +fn connect_max_packet_size(body: &[u8]) -> Option { + let mut i = 10; + let len = take_varint(body, &mut i)?; + let end = i + len; + while i < end { + let id = *body.get(i)?; + i += 1; + match id { + 0x27 => { + let b = body.get(i..i + 4)?; + return Some(u32::from_be_bytes([b[0], b[1], b[2], b[3]])); + } + 0x11 => i += 4, // session expiry interval + 0x21 | 0x22 => i += 2, // receive maximum, topic alias maximum + 0x17 | 0x19 => i += 1, // request problem / response information + _ => return None, + } + } + None +} + /// The identity a CONNECT carries: client id, then the credentials its flags /// advertise. Nothing here sets a will, so the payload fields are contiguous. fn connect_identity(body: &[u8], v5: bool) -> Option<(String, Option<(String, String)>)> { @@ -261,6 +289,9 @@ where { let mut buf = Vec::new(); let mut v5 = true; + // The client's Maximum Packet Size: a broker must not send it anything + // larger, so a push over it is withheld. + let mut client_max: Option = None; loop { let Some((first, body)) = read_packet(socket, &mut buf).await else { @@ -280,6 +311,14 @@ where if let Some(keep_alive) = connect_keep_alive(&body) { seen.keep_alives.push(keep_alive); } + client_max = if v5 { + connect_max_packet_size(&body) + } else { + None + }; + if let Some(max) = client_max { + seen.max_packet_sizes.push(max); + } } let ack: &[u8] = if v5 { &[0x20, 0x03, 0x00, 0x00, 0x00] @@ -299,11 +338,10 @@ where return; } if let Some((topic, payload)) = after.push { - if socket - .write_all(&publish(topic, payload, v5)) - .await - .is_err() - { + let packet = publish(topic, payload, v5); + if client_max.is_some_and(|max| packet.len() > max as usize) { + seen.lock().unwrap().withheld += 1; + } else if socket.write_all(&packet).await.is_err() { return; } } diff --git a/aimdb-mqtt-connector/tests/tokio_broker.rs b/aimdb-mqtt-connector/tests/tokio_broker.rs index 0a452a7d..3acbfccb 100644 --- a/aimdb-mqtt-connector/tests/tokio_broker.rs +++ b/aimdb-mqtt-connector/tests/tokio_broker.rs @@ -277,3 +277,75 @@ async fn two_connectors_in_one_process_keep_their_own_client_ids() { ids.sort(); assert_eq!(ids, vec!["first-node", "second-node"]); } + +/// Connects a client subscribed to `sensors/temperature` against a broker that +/// pushes `payload_len` bytes after every SUBACK, as it would a retained +/// message, and returns what the broker saw after `wait` plus the length the +/// record last received. +async fn with_retained_push(payload_len: usize, wait: Duration) -> (Seen, Option) { + use aimdb_core::buffer::BufferCfg; + use aimdb_core::AimDbBuilder; + use aimdb_mqtt_connector::MqttConnector; + use aimdb_tokio_adapter::net::TokioNet; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let seen = Arc::new(Mutex::new(Seen::default())); + + let connector = MqttConnector::new(format!("mqtt://127.0.0.1:{port}")) + .transport(TokioNet::tcp()) + .with_client_id("max-packet-size"); + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + builder.configure::("temperature", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from("mqtt://sensors/temperature") + .with_deserializer(|_ctx, data: &[u8]| Ok::(data.len() as u64)) + .finish(); + }); + let (db, runner) = builder.build().await.expect("build db"); + let mut reader = db.subscribe::("temperature").expect("subscribe"); + + let payload = vec![b'x'; payload_len]; + let broker = fake_broker( + listener, + seen.clone(), + 0, + Some(("sensors/temperature", payload.as_slice())), + ); + let mut received = None; + let observe = async { + loop { + received = Some(reader.recv().await.expect("record open")); + } + }; + tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = broker => panic!("the broker returned"), + _ = observe => unreachable!(), + _ = tokio::time::sleep(wait) => {} + } + let seen = std::mem::take(&mut *seen.lock().unwrap()); + (seen, received) +} + +/// The CONNECT advertises the largest packet the session always receives, so +/// a broker withholds a larger retained message instead of sending one that +/// would end every session it is replayed into. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_retained_message_over_the_maximum_packet_size_is_withheld() { + let wait = Duration::from_secs(3); + + let (seen, received) = with_retained_push(3000, wait).await; + assert_eq!(seen.max_packet_sizes, [3328]); + assert_eq!((seen.connects, seen.withheld), (1, 0)); + assert_eq!(received, Some(3000), "a message within the limit arrives"); + + let (seen, received) = with_retained_push(4000, wait).await; + assert_eq!(seen.max_packet_sizes, [3328]); + assert_eq!(seen.connects, 1, "no reconnect loop"); + assert_eq!(seen.withheld, 1); + assert_eq!(received, None); +} diff --git a/aimdb-mqtt-connector/tests/write_ring_proofs.rs b/aimdb-mqtt-connector/tests/write_ring_proofs.rs index ad352078..a2574298 100644 --- a/aimdb-mqtt-connector/tests/write_ring_proofs.rs +++ b/aimdb-mqtt-connector/tests/write_ring_proofs.rs @@ -1,5 +1,6 @@ //! The embedded backend's packet-size behaviour against a fake broker -//! (`_test-tokio-broker`). +//! (`_test-tokio-broker`). A retained message over the client's Maximum +//! Packet Size is covered in `tokio_broker`. #![cfg(feature = "_test-tokio-broker")] use std::sync::{Arc, Mutex}; @@ -109,60 +110,3 @@ async fn proof_an_oversize_publish_is_dropped_silently() { ); assert_eq!(seen.connects, 1, "the session never errored"); } - -/// Build a client subscribed to `sensors/temperature` against a broker that -/// pushes `payload_len` bytes after every SUBACK, as it would a retained -/// message, and count connections after `wait`. -async fn connects_with_push(payload_len: usize, wait: Duration) -> usize { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let seen = Arc::new(Mutex::new(Seen::default())); - - let connector = MqttConnector::new(format!("mqtt://127.0.0.1:{port}")) - .transport(TokioNet::tcp()) - .with_client_id("proof-retained"); - let mut builder = AimDbBuilder::new() - .runtime(Arc::new(TokioAdapter)) - .with_connector(connector); - builder.configure::("temperature", |reg| { - reg.buffer(BufferCfg::SingleLatest) - .link_from("mqtt://sensors/temperature") - .with_deserializer(|_ctx, data: &[u8]| Ok::(data.len() as u64)) - .finish(); - }); - let (_db, runner) = builder.build().await.expect("build db"); - - let payload = vec![b'x'; payload_len]; - let broker = fake_broker( - listener, - seen.clone(), - 0, - Some(("sensors/temperature", payload.as_slice())), - ); - tokio::select! { - _ = runner.run() => panic!("the session loop returned"), - _ = broker => panic!("the broker returned"), - _ = tokio::time::sleep(wait) => {} - } - let connects = seen.lock().unwrap().connects; - connects -} - -/// A 3,000-byte message fits the receive buffer. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn proof_a_3000_byte_retained_message_is_received_once() { - assert_eq!(connects_with_push(3000, Duration::from_secs(5)).await, 1); -} - -/// A 4,000-byte message ends every session it reaches, so a retained one -/// reconnects the client forever (one cycle per 2 s reconnection delay): the -/// CONNECT does not tell the broker the largest packet the client accepts. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn proof_a_4000_byte_retained_message_reconnects_forever() { - let connects = connects_with_push(4000, Duration::from_secs(5)).await; - eprintln!("connects in 5 s: {connects}"); - assert!( - connects >= 3, - "expected a reconnect loop, saw {connects} connects" - ); -} From 588f682c3bfa224c919dcabac3fffe393af32aeb Mon Sep 17 00:00:00 2001 From: "sounds.like.lx" <147444674+lxsaah@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:40:37 +0200 Subject: [PATCH 16/28] =?UTF-8?q?feat(mqtt):=20dispatch=20embedded=20inbou?= =?UTF-8?q?nd=20publishes=20from=20the=20session=20loop=20(design=20054=20?= =?UTF-8?q?=C2=A74.7)=20(#286)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aimdb-mqtt-connector/src/connector.rs | 5 + aimdb-mqtt-connector/src/embedded/manager.rs | 19 +-- aimdb-mqtt-connector/src/embedded/mod.rs | 115 ++++---------- .../src/embedded/packet_reader.rs | 25 ++++ aimdb-mqtt-connector/src/embedded/session.rs | 4 +- .../src/embedded/session_loop.rs | 141 +++++++++--------- aimdb-mqtt-connector/src/embedded/tls.rs | 4 +- aimdb-mqtt-connector/tests/tokio_broker.rs | 28 ++++ 8 files changed, 170 insertions(+), 171 deletions(-) diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index ccf9ac59..c410350b 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -39,6 +39,11 @@ type BuildFuture<'a> = Pin>> + S pub struct Native; /// The `mountain-mqtt` backend over a caller-supplied transport. +/// +/// Inbound publishes are delivered into their records by the session task +/// itself. At QoS 1 the PUBACK is sent before delivery, so it means the +/// message reached AimDB, not that every record kept it: a record whose +/// buffer is full drops it, and the broker does not resend. #[cfg(feature = "embedded")] pub struct Embedded { pub(crate) dialer: D, diff --git a/aimdb-mqtt-connector/src/embedded/manager.rs b/aimdb-mqtt-connector/src/embedded/manager.rs index 206b7425..12fa5852 100644 --- a/aimdb-mqtt-connector/src/embedded/manager.rs +++ b/aimdb-mqtt-connector/src/embedded/manager.rs @@ -1,7 +1,7 @@ -//! Session cadence and the channels a session talks over. +//! Session cadence and the channel a session takes actions from. //! -//! Channels use `CriticalSectionRawMutex`, so they are `Sync` and the sink and -//! source need no force-`Send` wrapper. Time comes from core's +//! The channel uses `CriticalSectionRawMutex`, so it is `Sync` and the sink +//! needs no force-`Send` wrapper. Time comes from core's //! [`aimdb_core::session::Delay`], so nothing here names an executor. use core::time::Duration; @@ -9,11 +9,7 @@ use core::time::Duration; use aimdb_core::RuntimeOps; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::channel::Channel; -use mountain_mqtt::client::{ClientError, EventHandlerError}; -use mountain_mqtt::packets::publish::ApplicationMessage; - -/// The event channel: broker session to `pump_source`. -pub(crate) type EventChannel = Channel; +use mountain_mqtt::client::ClientError; /// The action channel: `pump_sink` to broker session. pub(crate) type ActionChannel = Channel; @@ -23,13 +19,6 @@ pub(crate) fn now_ms(runtime: &dyn RuntimeOps) -> u64 { runtime.now_nanos() / 1_000_000 } -/// Convert a received [`ApplicationMessage`] into an application event. -pub trait FromApplicationMessage: Sized { - /// Build the event, or reject the message. - fn from_application_message(message: &ApplicationMessage

) - -> Result; -} - /// Why a session ended. #[derive(Debug, PartialEq, Clone, Copy)] pub enum Error { diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index 7fe77910..5933aa9e 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -1,8 +1,9 @@ //! The `mountain-mqtt` backend: broker session plus the data-plane bridges. //! -//! Outbound publishes and inbound routing ride core's [`pump_sink`] / -//! [`pump_source`] directly — the session channels are `Sync`, so nothing -//! force-`Send` stands between them and the runner. +//! Outbound publishes ride core's [`pump_sink`] — the action channel is +//! `Sync`, so nothing force-`Send` stands between it and the runner. Inbound +//! publishes are dispatched into their records by the session loop itself, +//! through an [`InboundDispatch`](aimdb_core::InboundDispatch). //! //! See the crate docs for a usage example. @@ -24,7 +25,7 @@ pub mod tls; extern crate alloc; use aimdb_core::connector::ConnectorUrl; -use aimdb_core::session::{pump_sink, pump_source, Payload}; +use aimdb_core::session::pump_sink; use aimdb_core::transport::{ConnectorConfig, PublishError}; use alloc::boxed::Box; use alloc::format; @@ -47,7 +48,7 @@ pub use crate::embedded::tls::TlsOptions; #[cfg(feature = "embedded-tls")] use crate::embedded::tls::{host_ip_literal, READ_BUF_MIN, WRITE_BUF_MIN}; -/// Maximum number of pending MQTT actions and events +/// Maximum number of pending MQTT actions pub(crate) const CHANNEL_SIZE: usize = 32; /// Buffer size for MQTT packets (4KB) @@ -61,15 +62,13 @@ pub(crate) const MAX_PROPERTIES: usize = 32; /// The runner's collected future type. type EmbassyBoxFuture = Pin + Send + 'static>>; -/// What a transport's setup hands back: the two channel ends the pumps ride, -/// plus the tasks that serve them. -type ManagerSetup = (Arc, Arc, Vec); +/// What a transport's setup hands back: the action channel `pump_sink` rides, +/// plus the tasks that serve it. +type ManagerSetup = (Arc, Vec); /// Outbound publishes and subscriptions: pumps to broker session. pub(crate) type ActionChannel = crate::embedded::manager::ActionChannel; -/// Inbound messages: broker session to pumps. -pub(crate) type EventChannel = crate::embedded::manager::EventChannel; /// What the pumps ask the session to put on the wire. /// @@ -91,42 +90,10 @@ pub enum AimdbMqttAction { }, } -/// What the session hands back for `pump_source` to route. -#[derive(Clone)] -pub enum AimdbMqttEvent { - /// A message was received from a subscribed topic - MessageReceived { - /// The topic the message was received on - topic: String, - /// The message payload, built once from the wire bytes. - payload: Payload, - }, -} - -impl crate::embedded::manager::FromApplicationMessage for AimdbMqttEvent { - fn from_application_message( - message: &mountain_mqtt::packets::publish::ApplicationMessage, - ) -> Result { - #[cfg(feature = "defmt")] - defmt::debug!( - "Received message on topic '{}', {} bytes", - message.topic_name, - message.payload.len() - ); - - Ok(Self::MessageReceived { - topic: message.topic_name.to_string(), - // Straight to `Payload` — one allocation and one copy, where a - // `Vec` here would be converted again on the way out. - payload: Payload::from(message.payload), - }) - } -} - // =========================================================================== -// Data-plane bridges — core's pumps drive these directly. The channels are -// `Sync` (their mutex is `CriticalSectionRawMutex`), so no force-`Send` -// wrapper stands between them and the runner. +// Data-plane bridge — `pump_sink` drives it directly. The action channel is +// `Sync` (its mutex is `CriticalSectionRawMutex`), so no force-`Send` wrapper +// stands between it and the runner. // =========================================================================== /// Turns a `pump_sink` publish into an `AimdbMqttAction::Publish` on the @@ -167,20 +134,6 @@ impl aimdb_core::transport::Connector for MqttSink { } } -/// Drains the session's event channel as `(topic, payload)` for `pump_source`. -struct MqttSource { - events: Arc, -} - -impl aimdb_core::session::Source for MqttSource { - fn next(&mut self) -> aimdb_core::BoxFut<'_, Option<(String, Payload)>> { - Box::pin(async move { - let AimdbMqttEvent::MessageReceived { topic, payload } = self.events.receive().await; - Some((topic, payload)) - }) - } -} - /// Force-`Send + Sync` slot for the TLS materials: [`TlsOptions`] holds /// `&'static mut` exclusive resources, so it is neither `Sync` nor takeable /// through the `&self` that [`ConnectorBuilder::build`] receives. @@ -205,8 +158,8 @@ where + 'static, { Box::pin(async move { - let router = db.inbound_router("mqtt", &crate::MqttGrammar)?; - let topics = inbound_topics(&router); + let inbound = aimdb_core::InboundDispatch::new(db, "mqtt", &crate::MqttGrammar)?; + let topics = inbound_topics(&inbound); warn_unsupported_qos(db); let broker = parse_broker_url(broker_url)?; if broker.tls { @@ -215,15 +168,16 @@ where let connection_settings = static_connection_settings(client_id, credentials, broker.credentials.as_ref()); - let (actions, events, manager_tasks) = setup_manager( + let (actions, manager_tasks) = setup_manager( &broker, connection_settings, dialer.clone(), topics, + inbound, Settings::from_keep_alive_secs(keep_alive_secs), db.runtime_ops(), )?; - Ok(collect_pumps(db, router, actions, events, manager_tasks)) + Ok(collect_pumps(db, actions, manager_tasks)) }) } @@ -246,8 +200,8 @@ where + 'static, { Box::pin(async move { - let router = db.inbound_router("mqtt", &crate::MqttGrammar)?; - let topics = inbound_topics(&router); + let inbound = aimdb_core::InboundDispatch::new(db, "mqtt", &crate::MqttGrammar)?; + let topics = inbound_topics(&inbound); warn_unsupported_qos(db); let broker = parse_broker_url(broker_url)?; if !broker.tls { @@ -260,22 +214,23 @@ where let connection_settings = static_connection_settings(client_id, credentials, broker.credentials.as_ref()); - let (actions, events, manager_tasks) = setup_tls_manager( + let (actions, manager_tasks) = setup_tls_manager( &broker, options, connection_settings, backend.dialer.clone(), topics, + inbound, Settings::from_keep_alive_secs(keep_alive_secs), db.runtime_ops(), )?; - Ok(collect_pumps(db, router, actions, events, manager_tasks)) + Ok(collect_pumps(db, actions, manager_tasks)) }) } /// The inbound topics the session must subscribe on every connection. -fn inbound_topics(router: &aimdb_core::Router) -> Vec { - let topics: Vec = router +fn inbound_topics(inbound: &aimdb_core::InboundDispatch) -> Vec { + let topics: Vec = inbound .subscriptions() .iter() .map(|t| t.to_string()) @@ -287,17 +242,14 @@ fn inbound_topics(router: &aimdb_core::Router) -> Vec { topics } -/// Outbound publishes and inbound routing ride core's pumps; the session tasks -/// join them. +/// Outbound publishes ride core's `pump_sink`; the session tasks, which also +/// dispatch inbound publishes, join them. fn collect_pumps( db: &aimdb_core::builder::AimDb, - router: aimdb_core::Router, actions: Arc, - events: Arc, manager_tasks: Vec, ) -> Vec { let mut futures = pump_sink(db, "mqtt", Arc::new(MqttSink { actions })); - futures.extend(pump_source(db, router, MqttSource { events })); futures.extend(manager_tasks); futures } @@ -391,6 +343,7 @@ fn setup_manager( connection_settings: ConnectionSettings<'static>, dialer: D, topics: Vec, + inbound: aimdb_core::InboundDispatch, settings: Settings, runtime: Arc, ) -> Result @@ -403,7 +356,6 @@ where + 'static, { let actions: Arc = Arc::new(ActionChannel::new()); - let events: Arc = Arc::new(EventChannel::new()); // The dialer is both the transport and the clock the session runs on. let host = broker.host.clone(); @@ -415,7 +367,6 @@ where let manager_task: EmbassyBoxFuture = Box::pin(unsafe { crate::embedded::session::SendSession::new({ let actions = actions.clone(); - let events = events.clone(); async move { #[cfg(feature = "defmt")] defmt::info!("MQTT background task starting"); @@ -427,7 +378,7 @@ where topics, connection_settings, settings, - events, + inbound, actions, runtime, ) @@ -436,18 +387,20 @@ where }) }); - Ok((actions, events, alloc::vec![manager_task])) + Ok((actions, alloc::vec![manager_task])) } /// Set up the TLS broker manager ([`run_tls`]) plus the SNTP time-source task. /// Synchronous — no `.await` — so the caller's `build` future stays `Send`. #[cfg(feature = "embedded-tls")] +#[allow(clippy::too_many_arguments)] fn setup_tls_manager( broker: &BrokerUrl, options: TlsOptions, connection_settings: ConnectionSettings<'static>, dialer: D, topics: Vec, + inbound: aimdb_core::InboundDispatch, settings: Settings, runtime: Arc, ) -> Result @@ -487,7 +440,6 @@ where } let actions: Arc = Arc::new(ActionChannel::new()); - let events: Arc = Arc::new(EventChannel::new()); let host = broker.host.clone(); let port = broker.port; @@ -502,7 +454,6 @@ where let mut tasks: Vec = alloc::vec![Box::pin(unsafe { crate::embedded::session::SendSession::new({ let actions = actions.clone(); - let events = events.clone(); async move { #[cfg(feature = "defmt")] defmt::info!("MQTT-TLS background task starting"); @@ -517,7 +468,7 @@ where topics, connection_settings, settings, - events, + inbound, actions, delay, runtime, @@ -539,7 +490,7 @@ where })); } - Ok((actions, events, tasks)) + Ok((actions, tasks)) } /// Map a QoS level to mountain-mqtt's `QualityOfService`. diff --git a/aimdb-mqtt-connector/src/embedded/packet_reader.rs b/aimdb-mqtt-connector/src/embedded/packet_reader.rs index ce376f3d..d77273f6 100644 --- a/aimdb-mqtt-connector/src/embedded/packet_reader.rs +++ b/aimdb-mqtt-connector/src/embedded/packet_reader.rs @@ -89,6 +89,12 @@ impl PacketReader { Ok(Some(total)) } + /// Whether the packet at the head is a PUBLISH above QoS 0, which the + /// client answers with a PUBACK. Reads only the first header byte. + pub(crate) fn head_needs_ack(&self) -> bool { + self.len > 0 && self.buf[0] >> 4 == 3 && (self.buf[0] >> 1) & 0b11 != 0 + } + /// Parse the complete packet at the head of the buffer. /// /// `total` must come from [`framed_len`](Self::framed_len). Takes `&self`, @@ -162,6 +168,25 @@ mod tests { got } + #[test] + fn only_a_publish_above_qos_0_needs_an_ack() { + let mut reader = PacketReader::<64>::new(); + assert!(!reader.head_needs_ack(), "empty"); + + reader.feed(&publish_bytes("t", b"x")).unwrap(); + assert!(!reader.head_needs_ack(), "QoS 0 publish"); + reader.consume(reader.framed_len().unwrap().unwrap()); + + let mut qos1 = publish_bytes("t", b"x"); + qos1[0] |= 0b0010; + reader.feed(&qos1).unwrap(); + assert!(reader.head_needs_ack(), "QoS 1 publish"); + reader.consume(qos1.len()); + + reader.feed(CONNACK).unwrap(); + assert!(!reader.head_needs_ack(), "CONNACK"); + } + #[test] fn one_byte_at_a_time_both_packets_parse() { let mut wire = Vec::new(); diff --git a/aimdb-mqtt-connector/src/embedded/session.rs b/aimdb-mqtt-connector/src/embedded/session.rs index 1d2b1c8d..12c8c0b0 100644 --- a/aimdb-mqtt-connector/src/embedded/session.rs +++ b/aimdb-mqtt-connector/src/embedded/session.rs @@ -53,7 +53,7 @@ pub(crate) async fn run_sessions( topics: alloc::vec::Vec, connection_settings: mountain_mqtt::client::ConnectionSettings<'static>, settings: crate::embedded::manager::Settings, - events: alloc::sync::Arc, + inbound: aimdb_core::InboundDispatch, actions: alloc::sync::Arc, runtime: alloc::sync::Arc, ) -> ! @@ -94,7 +94,7 @@ where tx, &connection_settings, &subscribe_topics, - &events, + &inbound, &actions, &ring, &settings, diff --git a/aimdb-mqtt-connector/src/embedded/session_loop.rs b/aimdb-mqtt-connector/src/embedded/session_loop.rs index 0c737fb6..acf4c1a4 100644 --- a/aimdb-mqtt-connector/src/embedded/session_loop.rs +++ b/aimdb-mqtt-connector/src/embedded/session_loop.rs @@ -17,7 +17,7 @@ use core::task::Poll; use core::time::Duration; use aimdb_core::session::{ByteRead, ByteWrite, Delay}; -use aimdb_core::RuntimeOps; +use aimdb_core::{InboundDispatch, RuntimeOps}; use embassy_futures::select::{select3, Either3}; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::channel::Channel; @@ -30,12 +30,10 @@ use mountain_mqtt::error::{PacketReadError, PacketWriteError}; use mountain_mqtt::packets::connect::Connect; use mountain_mqtt::packets::packet_generic::PacketGeneric; -use crate::embedded::manager::{now_ms, Error, FromApplicationMessage, Settings}; +use crate::embedded::manager::{now_ms, Error, Settings}; use crate::embedded::packet_reader::PacketReader; use crate::embedded::write_ring::{encoded_len, WriteRing, CONTROL_RESERVE}; -use crate::embedded::{ - ActionChannel, AimdbMqttAction, AimdbMqttEvent, EventChannel, BUFFER_SIZE, MAX_PROPERTIES, -}; +use crate::embedded::{ActionChannel, AimdbMqttAction, BUFFER_SIZE, MAX_PROPERTIES}; /// Bytes lifted off the socket at a time, and the size of one `inbound` slot. const RX_CHUNK: usize = 256; @@ -46,9 +44,8 @@ const RX_CHUNK: usize = 256; /// packets go into the connector's write ring instead. const PACKET_BUFFER_SIZE: usize = BUFFER_SIZE - 2 * RX_CHUNK; -/// Ring space waited for before each received packet is parsed: enough for -/// the PUBACK a QoS 1 publish needs (6 bytes; mountain-mqtt adds no -/// properties). +/// Ring space waited for before a QoS 1 publish is parsed: enough for its +/// PUBACK (6 bytes; mountain-mqtt adds no properties). const PUBACK_ROOM: usize = 16; /// The largest packet the reader takes whatever arrives before it: the @@ -63,8 +60,9 @@ type Chunk = heapless::Vec; /// Drive one MQTT session over a split stream until an error ends it. /// -/// Connects, subscribes `subscribe_topics`, then dispatches actions and -/// forwards events. Returns only on failure — the caller reconnects. +/// Connects, subscribes `subscribe_topics`, then performs actions and +/// dispatches every inbound publish into its records through `dispatch`. +/// Returns only on failure — the caller reconnects. /// /// `ring` is the connector's, reused across sessions; whatever an old session /// left in it is discarded first, so nothing reaches the new socket ahead of @@ -79,7 +77,7 @@ pub(crate) async fn run_session( tx: W, connection_settings: &ConnectionSettings<'static>, subscribe_topics: &[(&str, QualityOfService)], - events: &EventChannel, + dispatch: &InboundDispatch, actions: &ActionChannel, ring: &WriteRing, settings: &Settings, @@ -99,7 +97,7 @@ where ring, connection_settings, subscribe_topics, - events, + dispatch, actions, settings, delay, @@ -148,7 +146,7 @@ async fn client_loop( ring: &WriteRing, connection_settings: &ConnectionSettings<'static>, subscribe_topics: &[(&str, QualityOfService)], - events: &EventChannel, + dispatch: &InboundDispatch, actions: &ActionChannel, settings: &Settings, delay: &D, @@ -285,7 +283,7 @@ async fn client_loop( &mut reader, &mut state, ring, - events, + dispatch, runtime, &mut last_ack_ms, &mut connected, @@ -307,13 +305,14 @@ async fn client_loop( } } -/// Parse and dispatch every whole packet the reader now holds. +/// Parse every whole packet the reader now holds, dispatching publishes into +/// their records. #[allow(clippy::too_many_arguments)] async fn drain_packets( reader: &mut PacketReader, state: &mut ClientStateNoQueue, ring: &WriteRing, - events: &EventChannel, + dispatch: &InboundDispatch, runtime: &dyn RuntimeOps, last_ack_ms: &mut u64, connected: &mut bool, @@ -322,12 +321,13 @@ async fn drain_packets( // A burst of QoS 1 publishes needs a PUBACK each, so room for one is // waited for here, before parsing: the parsed packet is too large to // hold across an await in the session future. - ring.wait_room(PUBACK_ROOM).await; + if reader.head_needs_ack() { + ring.wait_room(PUBACK_ROOM).await; + } - // The packet borrows the reader's buffer, so everything that outlives - // it — the application event — is made owned inside this scope. - // `consume` can then take `&mut`. - let received = { + // The packet borrows the reader's buffer, so it is handled entirely + // inside this scope; `consume` can then take `&mut`. + { let packet: PacketGeneric<'_, MAX_PROPERTIES, 0, 0> = reader.parse(total).map_err(client_error)?; @@ -346,8 +346,8 @@ async fn drain_packets( } let event = state.receive(packet).map_err(client_error)?; - Received::of(event)? - }; + deliver(event, dispatch)?; + } reader.consume(total); // Every packet the state accepted proves the broker is alive. @@ -358,58 +358,53 @@ async fn drain_packets( if !*connected && matches!(state, ClientStateNoQueue::Connected(_)) { *connected = true; } - - if let Received::Event(event) = received { - events.send(event).await; - } } Ok(()) } -/// What a received packet leaves for the loop to do, owned so the reader's -/// buffer can be compacted first. -enum Received { - /// An acknowledgement: liveness only, nothing to forward. - Ack, - /// A message for `pump_source` to route. - Event(AimdbMqttEvent), -} - -impl Received { - fn of(event: ClientStateReceiveEvent<'_, '_, MAX_PROPERTIES>) -> Result { - Ok(match event { - ClientStateReceiveEvent::Ack => Self::Ack, - - ClientStateReceiveEvent::Publish { publish } - | ClientStateReceiveEvent::PublishAndPuback { publish, .. } => { - if publish.topic_name().is_empty() { - return Err(Error::Client( - ClientError::EmptyTopicNameWithAliasesDisabled, - )); - } - let message = publish.into(); - let event = AimdbMqttEvent::from_application_message(&message) - .map_err(|e| Error::Client(ClientError::EventHandler(e)))?; - Self::Event(event) +/// Hand a received publish to its records; everything else only proves +/// liveness. +/// +/// The PUBACK for a QoS 1 publish is already queued, so a record buffer that +/// is full drops a message the broker considers delivered. +fn deliver( + event: ClientStateReceiveEvent<'_, '_, MAX_PROPERTIES>, + dispatch: &InboundDispatch, +) -> Result<(), Error> { + match event { + ClientStateReceiveEvent::Ack => {} + + ClientStateReceiveEvent::Publish { publish } + | ClientStateReceiveEvent::PublishAndPuback { publish, .. } => { + if publish.topic_name().is_empty() { + return Err(Error::Client( + ClientError::EmptyTopicNameWithAliasesDisabled, + )); } + #[cfg(feature = "defmt")] + defmt::debug!( + "Received message on topic '{}', {} bytes", + publish.topic_name(), + publish.payload().len() + ); + dispatch.dispatch(publish.topic_name(), publish.payload()); + } - // Liveness, and nothing else. The broker is telling us a - // subscription was granted below the QoS asked for, that a publish - // matched no subscriber, or that an unsubscribe named a - // subscription it did not hold. AimDB has nowhere to deliver any of - // that: `pump_source` owns the channel an application would have - // read it from, and a record has no connection-state callback. - ClientStateReceiveEvent::SubscriptionGrantedBelowMaximumQos { .. } - | ClientStateReceiveEvent::PublishedMessageHadNoMatchingSubscribers - | ClientStateReceiveEvent::NoSubscriptionExisted => Self::Ack, - - ClientStateReceiveEvent::Disconnect { disconnect } => { - return Err(Error::Client(ClientError::Disconnected( - *disconnect.reason_code(), - ))) - } - }) + // Liveness, and nothing else. The broker is telling us a subscription + // was granted below the QoS asked for, that a publish matched no + // subscriber, or that an unsubscribe named a subscription it did not + // hold. A record has no connection-state callback to deliver it to. + ClientStateReceiveEvent::SubscriptionGrantedBelowMaximumQos { .. } + | ClientStateReceiveEvent::PublishedMessageHadNoMatchingSubscribers + | ClientStateReceiveEvent::NoSubscriptionExisted => {} + + ClientStateReceiveEvent::Disconnect { disconnect } => { + return Err(Error::Client(ClientError::Disconnected( + *disconnect.reason_code(), + ))) + } } + Ok(()) } /// Turn one queued action into a packet on the wire. @@ -559,12 +554,18 @@ mod tests { /// absorb codegen drift, but not loose enough to fit another buffer. #[test] fn the_session_future_has_not_outgrown_the_loop_it_replaced() { - let events = EventChannel::new(); let actions = ActionChannel::new(); let settings = Settings::default(); let connection_settings = ConnectionSettings::unauthenticated("size-probe"); let runtime = aimdb_core::executor::test_support::NoopRuntimeOps; let ring = WriteRing::new(64); + let (db, _runner) = futures::executor::block_on( + aimdb_core::AimDbBuilder::new() + .runtime(alloc::sync::Arc::new(runtime)) + .build(), + ) + .expect("empty database"); + let dispatch = InboundDispatch::new(&db, "mqtt", &crate::MqttGrammar).expect("no links"); // Built, never polled: `size_of_val` on the future is the whole point. let session = run_session( @@ -572,7 +573,7 @@ mod tests { NullWrite, &connection_settings, &[], - &events, + &dispatch, &actions, &ring, &settings, diff --git a/aimdb-mqtt-connector/src/embedded/tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs index 65571de7..c98f77f3 100644 --- a/aimdb-mqtt-connector/src/embedded/tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -321,7 +321,7 @@ pub(crate) async fn run_tls( topics: Vec, connection_settings: ConnectionSettings<'static>, settings: Settings, - events: Arc, + inbound: aimdb_core::InboundDispatch, actions: Arc, delay: D, runtime: Arc, @@ -412,7 +412,7 @@ where TlsWrite(tls_tx), &connection_settings, &subscribe_topics, - &events, + &inbound, &actions, &ring, &settings, diff --git a/aimdb-mqtt-connector/tests/tokio_broker.rs b/aimdb-mqtt-connector/tests/tokio_broker.rs index 3acbfccb..32e7c303 100644 --- a/aimdb-mqtt-connector/tests/tokio_broker.rs +++ b/aimdb-mqtt-connector/tests/tokio_broker.rs @@ -349,3 +349,31 @@ async fn a_retained_message_over_the_maximum_packet_size_is_withheld() { assert_eq!(seen.withheld, 1); assert_eq!(received, None); } + +/// Inbound publishes are dispatched by the session task itself: with inbound +/// links and no outbound ones, the connector contributes one future. +#[tokio::test] +async fn the_embedded_backend_dispatches_inbound_on_its_session_task() { + use aimdb_core::buffer::BufferCfg; + use aimdb_core::connector::ConnectorBuilder; + use aimdb_core::AimDbBuilder; + use aimdb_mqtt_connector::MqttConnector; + use aimdb_tokio_adapter::net::TokioNet; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + + let connector = || MqttConnector::new("mqtt://127.0.0.1:1").transport(TokioNet::tcp()); + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector()); + builder.configure::("temperature", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from("mqtt://sensors/temperature") + .with_deserializer(|_ctx, data: &[u8]| Ok::(data.len() as u64)) + .finish(); + }); + let (db, _runner) = builder.build().await.expect("build db"); + + // Built, never polled: nothing dials. + let futures = connector().build(&db).await.expect("build connector"); + assert_eq!(futures.len(), 1, "the session task, and no inbound pump"); +} From 625589624ab0b49bf2f38bac20198c5947cb4580 Mon Sep 17 00:00:00 2001 From: "sounds.like.lx" <147444674+lxsaah@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:24:22 +0200 Subject: [PATCH 17/28] =?UTF-8?q?feat(mqtt):=20pull=20embedded=20outbound?= =?UTF-8?q?=20publishes=20from=20OutboundRoutes=20(design=20054=20=C2=A74.?= =?UTF-8?q?7)=20(#287)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aimdb-core/src/outbound/routes.rs | 28 +- aimdb-mqtt-connector/src/connector.rs | 41 +- aimdb-mqtt-connector/src/embedded/manager.rs | 10 +- aimdb-mqtt-connector/src/embedded/mod.rs | 327 ++++++-------- aimdb-mqtt-connector/src/embedded/session.rs | 11 +- .../src/embedded/session_loop.rs | 422 ++++++++++++------ aimdb-mqtt-connector/src/embedded/tls.rs | 11 +- .../src/embedded/write_ring.rs | 8 +- aimdb-mqtt-connector/src/lib.rs | 3 + aimdb-mqtt-connector/src/publish_opts.rs | 116 +++++ aimdb-mqtt-connector/tests/session_loop.rs | 97 +++- aimdb-mqtt-connector/tests/tokio_broker.rs | 131 ++++++ .../tests/write_ring_proofs.rs | 6 +- aimdb-tokio-adapter/tests/outbound_routes.rs | 11 + 14 files changed, 844 insertions(+), 378 deletions(-) create mode 100644 aimdb-mqtt-connector/src/publish_opts.rs diff --git a/aimdb-core/src/outbound/routes.rs b/aimdb-core/src/outbound/routes.rs index 9f06061d..871e7cef 100644 --- a/aimdb-core/src/outbound/routes.rs +++ b/aimdb-core/src/outbound/routes.rs @@ -72,8 +72,12 @@ impl OutboundPayload<'_> { /// Values taken from one route's buffer, by outcome. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct RouteStats { - /// Staged for the connector. + /// Staged and handed to the connector, including any it then rejected. pub sent: u64, + /// Handed to the connector, which could not send them (for example, + /// larger than its transport accepts). Reported with + /// [`OutboundRoutes::reject`]. + pub rejected: u64, /// Missed because the reader fell behind. pub lagged: u64, /// Skipped: the written topic did not fit. @@ -358,6 +362,15 @@ impl OutboundRoutes { }) } + /// Count a message from route `id` that the connector took but could not + /// send. The connector logs why; this keeps the count beside the route's + /// other outcomes. + pub fn reject(&mut self, id: RouteId) { + if let Some(stats) = self.stats.get_mut(id) { + stats.rejected += 1; + } + } + /// [`poll_stage`](Self::poll_stage), then [`take_staged`](Self::take_staged), /// for hand-written `poll` code. pub fn poll_next(&mut self, cx: &mut Context<'_>) -> Poll>> { @@ -394,4 +407,17 @@ mod tests { let moved = payload.into_vec(); assert_eq!(moved.as_ptr(), ptr, "moved, not copied"); } + + #[tokio::test] + async fn reject_counts_beside_the_routes_other_outcomes() { + let (db, _runner) = crate::AimDbBuilder::new() + .runtime(Arc::new(crate::executor::test_support::NoopRuntimeOps)) + .build() + .await + .expect("empty database"); + let mut routes = OutboundRoutes::new(&db, "mqtt").unwrap(); + // No routes: an unknown id is ignored rather than a panic. + routes.reject(0); + assert_eq!(routes.stats(0), None); + } } diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index c410350b..a6b56fde 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -47,6 +47,7 @@ pub struct Native; #[cfg(feature = "embedded")] pub struct Embedded { pub(crate) dialer: D, + pub(crate) write_buffer: usize, } /// The `mountain-mqtt` backend over `embedded-tls`, on the same @@ -55,6 +56,7 @@ pub struct Embedded { pub struct EmbeddedTls { pub(crate) dialer: D, pub(crate) options: crate::embedded::TlsSlot, + pub(crate) write_buffer: usize, } /// An MQTT connector over the backend `B`. @@ -90,7 +92,10 @@ impl MqttConnector { client_id: self.client_id, credentials: self.credentials, keep_alive: self.keep_alive, - backend: Embedded { dialer }, + backend: Embedded { + dialer, + write_buffer: crate::embedded::DEFAULT_WRITE_BUFFER, + }, } } @@ -110,6 +115,7 @@ impl MqttConnector { backend: EmbeddedTls { dialer, options: crate::embedded::TlsSlot::new(options), + write_buffer: crate::embedded::DEFAULT_WRITE_BUFFER, }, } } @@ -143,6 +149,38 @@ impl MqttConnector { } } +/// The write buffer's documentation, shared by both embedded backends. +#[cfg(feature = "embedded")] +macro_rules! write_buffer_doc { + () => { + "Size the session's write ring, in bytes (default 4,096). Allocated once \ + and reused across reconnects.\n\n\ + An outbound PUBLISH frame plus a 64-byte reserve must fit in half the \ + ring (1,984 bytes of frame at the default). `build()` fails for a \ + route whose largest frame does not fit, and for a CONNECT or \ + SUBSCRIBE that does not; an owned payload over the limit at runtime \ + is skipped and counted as rejected in the route's `RouteStats`." + }; +} + +#[cfg(feature = "embedded")] +impl MqttConnector> { + #[doc = write_buffer_doc!()] + pub fn with_write_buffer(mut self, bytes: usize) -> Self { + self.backend.write_buffer = bytes; + self + } +} + +#[cfg(feature = "embedded-tls")] +impl MqttConnector> { + #[doc = write_buffer_doc!()] + pub fn with_write_buffer(mut self, bytes: usize) -> Self { + self.backend.write_buffer = bytes; + self + } +} + /// Whole seconds for the wire, or the reason this keep-alive cannot be used. fn keep_alive_secs(keep_alive: Duration) -> DbResult { let secs = keep_alive.as_secs(); @@ -228,6 +266,7 @@ where credentials, keep_alive_secs, &self.dialer, + self.write_buffer, ) } } diff --git a/aimdb-mqtt-connector/src/embedded/manager.rs b/aimdb-mqtt-connector/src/embedded/manager.rs index 12fa5852..ec0259f7 100644 --- a/aimdb-mqtt-connector/src/embedded/manager.rs +++ b/aimdb-mqtt-connector/src/embedded/manager.rs @@ -1,19 +1,13 @@ -//! Session cadence and the channel a session takes actions from. +//! Session cadence and the reasons a session ends. //! -//! The channel uses `CriticalSectionRawMutex`, so it is `Sync` and the sink -//! needs no force-`Send` wrapper. Time comes from core's +//! Time comes from core's //! [`aimdb_core::session::Delay`], so nothing here names an executor. use core::time::Duration; use aimdb_core::RuntimeOps; -use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; -use embassy_sync::channel::Channel; use mountain_mqtt::client::ClientError; -/// The action channel: `pump_sink` to broker session. -pub(crate) type ActionChannel = Channel; - /// Monotonic milliseconds. Only differences are meaningful. pub(crate) fn now_ms(runtime: &dyn RuntimeOps) -> u64 { runtime.now_nanos() / 1_000_000 diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index 5933aa9e..ef958e89 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -1,9 +1,9 @@ //! The `mountain-mqtt` backend: broker session plus the data-plane bridges. //! -//! Outbound publishes ride core's [`pump_sink`] — the action channel is -//! `Sync`, so nothing force-`Send` stands between it and the runner. Inbound -//! publishes are dispatched into their records by the session loop itself, -//! through an [`InboundDispatch`](aimdb_core::InboundDispatch). +//! The session task drives both directions itself: it pulls outbound messages +//! from an [`OutboundRoutes`](aimdb_core::OutboundRoutes) when it can send, +//! and dispatches inbound publishes into their records through an +//! [`InboundDispatch`](aimdb_core::InboundDispatch). Core runs no pump for it. //! //! See the crate docs for a usage example. @@ -25,8 +25,6 @@ pub mod tls; extern crate alloc; use aimdb_core::connector::ConnectorUrl; -use aimdb_core::session::pump_sink; -use aimdb_core::transport::{ConnectorConfig, PublishError}; use alloc::boxed::Box; use alloc::format; use alloc::string::{String, ToString}; @@ -39,18 +37,19 @@ use core::pin::Pin; use aimdb_embassy_adapter::connectors::into_box_future; use mountain_mqtt::client::ConnectionSettings; -use mountain_mqtt::data::quality_of_service::QualityOfService; use crate::embedded::manager::Settings; +use crate::embedded::session_loop::{connect_packet, publish_frame_len, subscribe_len}; +use crate::embedded::write_ring::{encoded_len, fits_ring, CONTROL_RESERVE}; +use crate::publish_opts::PublishOpts; + +pub(crate) use crate::embedded::write_ring::DEFAULT_WRITE_BUFFER; #[cfg(feature = "embedded-tls")] pub use crate::embedded::tls::TlsOptions; #[cfg(feature = "embedded-tls")] use crate::embedded::tls::{host_ip_literal, READ_BUF_MIN, WRITE_BUF_MIN}; -/// Maximum number of pending MQTT actions -pub(crate) const CHANNEL_SIZE: usize = 32; - /// Buffer size for MQTT packets (4KB) pub(crate) const BUFFER_SIZE: usize = 4096; @@ -62,78 +61,6 @@ pub(crate) const MAX_PROPERTIES: usize = 32; /// The runner's collected future type. type EmbassyBoxFuture = Pin + Send + 'static>>; -/// What a transport's setup hands back: the action channel `pump_sink` rides, -/// plus the tasks that serve it. -type ManagerSetup = (Arc, Vec); - -/// Outbound publishes and subscriptions: pumps to broker session. -pub(crate) type ActionChannel = - crate::embedded::manager::ActionChannel; - -/// What the pumps ask the session to put on the wire. -/// -/// The session encodes each of these itself against the MQTT client state, so -/// an action is data rather than a call (`session_loop::perform`). -#[derive(Clone)] -pub enum AimdbMqttAction { - /// Publish a message to a topic - Publish { - topic: String, - payload: Vec, - qos: QualityOfService, - retain: bool, - }, - /// Subscribe to a topic - Subscribe { - topic: String, - qos: QualityOfService, - }, -} - -// =========================================================================== -// Data-plane bridge — `pump_sink` drives it directly. The action channel is -// `Sync` (its mutex is `CriticalSectionRawMutex`), so no force-`Send` wrapper -// stands between it and the runner. -// =========================================================================== - -/// Turns a `pump_sink` publish into an `AimdbMqttAction::Publish` on the -/// session's action channel. -struct MqttSink { - actions: Arc, -} - -impl aimdb_core::transport::Connector for MqttSink { - fn publish( - &self, - destination: &str, - config: &ConnectorConfig, - payload: &[u8], - ) -> Pin> + Send + '_>> { - // `qos`/`retain` arrive via the URL query (passed through in - // `protocol_options`); default to QoS 1 (legacy behaviour), no retain. - let qos = match opt_u8(config, "qos").map(map_qos) { - Some(Ok(qos)) => qos, - Some(Err(e)) => return Box::pin(async move { Err(e) }), - None => QualityOfService::Qos1, - }; - let retain = opt_bool(config, "retain").unwrap_or(false); - let topic = destination.to_string(); - let payload = payload.to_vec(); - - Box::pin(async move { - self.actions - .send(AimdbMqttAction::Publish { - topic, - payload, - qos, - retain, - }) - .await; - Ok(()) - }) - } -} - /// Force-`Send + Sync` slot for the TLS materials: [`TlsOptions`] holds /// `&'static mut` exclusive resources, so it is neither `Sync` nor takeable /// through the `&self` that [`ConnectorBuilder::build`] receives. @@ -148,6 +75,7 @@ pub(crate) fn build_plain<'a, D>( credentials: Option<&'a (String, String)>, keep_alive_secs: u16, dialer: &'a D, + write_buffer: usize, ) -> Pin>> + Send + 'a>> where D: aimdb_core::session::StreamDialer @@ -160,24 +88,28 @@ where Box::pin(async move { let inbound = aimdb_core::InboundDispatch::new(db, "mqtt", &crate::MqttGrammar)?; let topics = inbound_topics(&inbound); - warn_unsupported_qos(db); let broker = parse_broker_url(broker_url)?; if broker.tls { return Err(build_err("mqtts:// broker URLs require .tls(...)")); } let connection_settings = static_connection_settings(client_id, credentials, broker.credentials.as_ref()); + let settings = Settings::from_keep_alive_secs(keep_alive_secs); + let (outbound, opts) = + prepare_outbound(db, write_buffer, &settings, &connection_settings, &topics)?; - let (actions, manager_tasks) = setup_manager( + setup_manager( &broker, connection_settings, dialer.clone(), topics, inbound, - Settings::from_keep_alive_secs(keep_alive_secs), + outbound, + opts, + write_buffer, + settings, db.runtime_ops(), - )?; - Ok(collect_pumps(db, actions, manager_tasks)) + ) }) } @@ -202,7 +134,6 @@ where Box::pin(async move { let inbound = aimdb_core::InboundDispatch::new(db, "mqtt", &crate::MqttGrammar)?; let topics = inbound_topics(&inbound); - warn_unsupported_qos(db); let broker = parse_broker_url(broker_url)?; if !broker.tls { return Err(build_err(".tls(...) requires an mqtts:// broker URL")); @@ -213,18 +144,28 @@ where .ok_or_else(|| build_err("TLS materials already taken; build() ran twice"))?; let connection_settings = static_connection_settings(client_id, credentials, broker.credentials.as_ref()); + let settings = Settings::from_keep_alive_secs(keep_alive_secs); + let (outbound, opts) = prepare_outbound( + db, + backend.write_buffer, + &settings, + &connection_settings, + &topics, + )?; - let (actions, manager_tasks) = setup_tls_manager( + setup_tls_manager( &broker, options, connection_settings, backend.dialer.clone(), topics, inbound, - Settings::from_keep_alive_secs(keep_alive_secs), + outbound, + opts, + backend.write_buffer, + settings, db.runtime_ops(), - )?; - Ok(collect_pumps(db, actions, manager_tasks)) + ) }) } @@ -242,16 +183,76 @@ fn inbound_topics(inbound: &aimdb_core::InboundDispatch) -> Vec { topics } -/// Outbound publishes ride core's `pump_sink`; the session tasks, which also -/// dispatch inbound publishes, join them. -fn collect_pumps( +/// Build the outbound routes and parse each route's options, and check that +/// every packet the session must be able to send fits the write ring: the +/// largest PUBLISH of every route, the CONNECT and each SUBSCRIBE. One that +/// does not would fail every session, and the connector would reconnect +/// forever. Warns once per route asking for `qos=2`, which this client sends +/// at QoS 1. +fn prepare_outbound( db: &aimdb_core::builder::AimDb, - actions: Arc, - manager_tasks: Vec, -) -> Vec { - let mut futures = pump_sink(db, "mqtt", Arc::new(MqttSink { actions })); - futures.extend(manager_tasks); - futures + write_buffer: usize, + settings: &Settings, + connection_settings: &ConnectionSettings<'static>, + topics: &[String], +) -> Result<(aimdb_core::OutboundRoutes, Vec), aimdb_core::DbError> { + let outbound = aimdb_core::OutboundRoutes::new(db, "mqtt")?; + let size = |len: Result| { + len.map_err(|_| build_err("a packet could not be sized")) + }; + let mut problems: Vec = Vec::new(); + let mut opts = Vec::with_capacity(outbound.routes().len()); + + for route in outbound.routes() { + match PublishOpts::parse(route) { + Ok(opt) => { + if opt.qos == 2 { + aimdb_core::log_warn!( + "MQTT: route '{}' asks for qos=2; this backend publishes it at QoS 1 (at-least-once). The std backend honours qos=2 on the same URL.", + route.default_topic + ); + #[cfg(feature = "defmt")] + defmt::warn!( + "MQTT: route '{}' asks qos=2; publishing at QoS 1 (at-least-once)", + &*route.default_topic + ); + } + opts.push(opt); + } + Err(e) => problems.push(e), + } + let topic_len = route.default_topic.len().max(route.topic_capacity); + let frame = size(publish_frame_len(topic_len, route.payload_capacity))?; + if !fits_ring(write_buffer, frame, CONTROL_RESERVE) { + problems.push(format!( + "route '{}': its largest PUBLISH is {frame} bytes, which needs a write buffer of at least {} bytes; it is {write_buffer}", + route.default_topic, + 2 * (frame + CONTROL_RESERVE) + )); + } + } + + let connect = size(encoded_len(&connect_packet(settings, connection_settings)))?; + if !fits_ring(write_buffer, connect, 0) { + problems.push(format!( + "the CONNECT (client id and credentials) is {connect} bytes, which needs a write buffer of at least {} bytes; it is {write_buffer}", + 2 * connect + )); + } + for topic in topics { + let subscribe = size(subscribe_len(topic))?; + if !fits_ring(write_buffer, subscribe, 0) { + problems.push(format!( + "the SUBSCRIBE to '{topic}' is {subscribe} bytes, which needs a write buffer of at least {} bytes; it is {write_buffer}", + 2 * subscribe + )); + } + } + + if !problems.is_empty() { + return Err(build_err(&problems.join("; "))); + } + Ok((outbound, opts)) } /// Parsed broker endpoint: transport + authority. @@ -335,18 +336,21 @@ fn static_connection_settings( } } -/// Set up the plain-TCP broker session loop, returning the action channel -/// (outbound), the event channel (inbound), and the task future. Synchronous — -/// no `.await` — so the caller's `build` future stays `Send`. +/// Set up the plain-TCP broker session task, which drives both directions. +/// Synchronous — no `.await` — so the caller's `build` future stays `Send`. +#[allow(clippy::too_many_arguments)] fn setup_manager( broker: &BrokerUrl, connection_settings: ConnectionSettings<'static>, dialer: D, topics: Vec, inbound: aimdb_core::InboundDispatch, + outbound: aimdb_core::OutboundRoutes, + opts: Vec, + write_buffer: usize, settings: Settings, runtime: Arc, -) -> Result +) -> aimdb_core::DbResult> where D: aimdb_core::session::StreamDialer + aimdb_core::session::Delay @@ -355,18 +359,16 @@ where + Sync + 'static, { - let actions: Arc = Arc::new(ActionChannel::new()); - // The dialer is both the transport and the clock the session runs on. let host = broker.host.clone(); let port = broker.port; // SAFETY: every value the session holds is `Send` — `StreamDialer` - // guarantees `Stream: Send`, the channels are `CriticalSectionRawMutex` - // and the state cell is a blocking mutex. See `SendSession`. + // guarantees `Stream: Send`, `InboundDispatch` and `OutboundRoutes` are + // `Send`, and the session's channel is `CriticalSectionRawMutex`. See + // `SendSession`. let manager_task: EmbassyBoxFuture = Box::pin(unsafe { crate::embedded::session::SendSession::new({ - let actions = actions.clone(); async move { #[cfg(feature = "defmt")] defmt::info!("MQTT background task starting"); @@ -379,7 +381,9 @@ where connection_settings, settings, inbound, - actions, + outbound, + opts, + write_buffer, runtime, ) .await @@ -387,7 +391,7 @@ where }) }); - Ok((actions, alloc::vec![manager_task])) + Ok(alloc::vec![manager_task]) } /// Set up the TLS broker manager ([`run_tls`]) plus the SNTP time-source task. @@ -401,9 +405,12 @@ fn setup_tls_manager( dialer: D, topics: Vec, inbound: aimdb_core::InboundDispatch, + outbound: aimdb_core::OutboundRoutes, + opts: Vec, + write_buffer: usize, settings: Settings, runtime: Arc, -) -> Result +) -> aimdb_core::DbResult> where D: aimdb_core::session::StreamDialer + aimdb_core::session::Delay @@ -439,8 +446,6 @@ where )); } - let actions: Arc = Arc::new(ActionChannel::new()); - let host = broker.host.clone(); let port = broker.port; #[cfg(feature = "embassy-tls")] @@ -448,12 +453,11 @@ where let delay = dialer.clone(); // SAFETY: as for the plain path — `StreamDialer` guarantees `Stream: Send`, - // the channels are `CriticalSectionRawMutex`, and `TlsOptions` is `Send` - // (its RNG carries the bound). See `session::SendSession`. + // `InboundDispatch` and `OutboundRoutes` are `Send`, and `TlsOptions` is + // `Send` (its RNG carries the bound). See `session::SendSession`. #[cfg_attr(not(feature = "embassy-tls"), allow(unused_mut))] let mut tasks: Vec = alloc::vec![Box::pin(unsafe { crate::embedded::session::SendSession::new({ - let actions = actions.clone(); async move { #[cfg(feature = "defmt")] defmt::info!("MQTT-TLS background task starting"); @@ -469,7 +473,9 @@ where connection_settings, settings, inbound, - actions, + outbound, + opts, + write_buffer, delay, runtime, ) @@ -490,86 +496,5 @@ where })); } - Ok((actions, tasks)) -} - -/// Map a QoS level to mountain-mqtt's `QualityOfService`. -/// -/// `2` downgrades to 1 — this client implements no exactly-once handshake, -/// where [`Native`](crate::connector::Native) honours the same route URL -/// exactly. [`warn_unsupported_qos`] is what says so, once per route at build. -/// Anything above 2 is rejected, as `Native` rejects it. -fn map_qos(qos: u8) -> Result { - match qos { - 0 => Ok(QualityOfService::Qos0), - 1 => Ok(QualityOfService::Qos1), - 2 => Ok(QualityOfService::Qos1), - _ => Err(PublishError::UnsupportedQoS), - } -} - -/// Name, at build, every outbound route asking for a QoS this backend cannot -/// give. -/// -/// Checked here rather than in [`map_qos`] because `map_qos` runs per publish: -/// warning there would repeat at the route's own rate for the life of the -/// process, and latching it to fire once would hide the message whenever the -/// first publish beats the logger into place. The route set is fixed at build, -/// so once per offending route — naming the route, while the caller is still -/// reading startup output — is both quieter and more use than either. -/// -/// Both facades fire: they are independent, and neither covers the other. -/// `log_warn!` reaches `tracing`/`log` when this backend runs on a host, -/// `defmt` reaches an MCU. -fn warn_unsupported_qos(db: &aimdb_core::builder::AimDb) { - for route in db.collect_outbound_routes("mqtt") { - let asked = route - .config - .iter() - .find(|(k, _)| k == "qos") - .and_then(|(_, v)| v.parse::().ok()); - - if asked == Some(2) { - aimdb_core::log_warn!( - "MQTT: route '{}' asks for qos=2; this backend publishes it at QoS 1 (at-least-once). The std backend honours qos=2 on the same URL.", - route.topic - ); - #[cfg(feature = "defmt")] - defmt::warn!( - "MQTT: route '{}' asks qos=2; publishing at QoS 1 (at-least-once)", - route.topic.as_str() - ); - } - } -} - -/// Read a `u8` option from the per-route `protocol_options` (URL query). -fn opt_u8(config: &ConnectorConfig, key: &str) -> Option { - config - .protocol_options - .iter() - .find(|(k, _)| k == key) - .and_then(|(_, v)| v.parse::().ok()) -} - -/// Read a `bool` option from the per-route `protocol_options` (URL query). -fn opt_bool(config: &ConnectorConfig, key: &str) -> Option { - config - .protocol_options - .iter() - .find(|(k, _)| k == key) - .and_then(|(_, v)| v.parse::().ok()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_qos_mapping() { - assert!(matches!(map_qos(0), Ok(QualityOfService::Qos0))); - assert!(matches!(map_qos(1), Ok(QualityOfService::Qos1))); - assert!(matches!(map_qos(2), Ok(QualityOfService::Qos1))); // Downgrades to QoS 1 - assert!(matches!(map_qos(99), Err(PublishError::UnsupportedQoS))); // Not a QoS level - } + Ok(tasks) } diff --git a/aimdb-mqtt-connector/src/embedded/session.rs b/aimdb-mqtt-connector/src/embedded/session.rs index 12c8c0b0..b41bec41 100644 --- a/aimdb-mqtt-connector/src/embedded/session.rs +++ b/aimdb-mqtt-connector/src/embedded/session.rs @@ -54,7 +54,9 @@ pub(crate) async fn run_sessions( connection_settings: mountain_mqtt::client::ConnectionSettings<'static>, settings: crate::embedded::manager::Settings, inbound: aimdb_core::InboundDispatch, - actions: alloc::sync::Arc, + mut outbound: aimdb_core::OutboundRoutes, + opts: alloc::vec::Vec, + write_buffer: usize, runtime: alloc::sync::Arc, ) -> ! where @@ -64,7 +66,7 @@ where use mountain_mqtt::data::quality_of_service::QualityOfService; use crate::embedded::session_loop::run_session; - use crate::embedded::write_ring::{WriteRing, DEFAULT_WRITE_BUFFER}; + use crate::embedded::write_ring::WriteRing; // Built once and borrowed for the loop; re-sent on every connection. let subscribe_topics: alloc::vec::Vec<(&str, QualityOfService)> = topics @@ -73,7 +75,7 @@ where .collect(); // Allocated once and reused by every session. - let ring = WriteRing::new(DEFAULT_WRITE_BUFFER); + let ring = WriteRing::new(write_buffer); loop { let mut stream = match dialer.connect(&host, port).await { @@ -95,7 +97,8 @@ where &connection_settings, &subscribe_topics, &inbound, - &actions, + &mut outbound, + &opts, &ring, &settings, &dialer, diff --git a/aimdb-mqtt-connector/src/embedded/session_loop.rs b/aimdb-mqtt-connector/src/embedded/session_loop.rs index acf4c1a4..b8708a9c 100644 --- a/aimdb-mqtt-connector/src/embedded/session_loop.rs +++ b/aimdb-mqtt-connector/src/embedded/session_loop.rs @@ -17,23 +17,27 @@ use core::task::Poll; use core::time::Duration; use aimdb_core::session::{ByteRead, ByteWrite, Delay}; -use aimdb_core::{InboundDispatch, RuntimeOps}; +use aimdb_core::{InboundDispatch, OutboundRoutes, RouteId, RuntimeOps}; use embassy_futures::select::{select3, Either3}; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::channel::Channel; use mountain_mqtt::client::{ClientError, ConnectionSettings}; use mountain_mqtt::client_state::{ClientState, ClientStateNoQueue, ClientStateReceiveEvent}; -use mountain_mqtt::data::property::ConnectProperty; +use mountain_mqtt::data::packet_identifier::{PacketIdentifier, PublishPacketIdentifier}; +use mountain_mqtt::data::property::{ConnackProperty, ConnectProperty, Property}; use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::error::{PacketReadError, PacketWriteError}; use mountain_mqtt::packets::connect::Connect; use mountain_mqtt::packets::packet_generic::PacketGeneric; +use mountain_mqtt::packets::publish::Publish; +use mountain_mqtt::packets::subscribe::{Subscribe, SubscriptionRequest}; use crate::embedded::manager::{now_ms, Error, Settings}; use crate::embedded::packet_reader::PacketReader; use crate::embedded::write_ring::{encoded_len, WriteRing, CONTROL_RESERVE}; -use crate::embedded::{ActionChannel, AimdbMqttAction, BUFFER_SIZE, MAX_PROPERTIES}; +use crate::embedded::{BUFFER_SIZE, MAX_PROPERTIES}; +use crate::publish_opts::PublishOpts; /// Bytes lifted off the socket at a time, and the size of one `inbound` slot. const RX_CHUNK: usize = 256; @@ -60,17 +64,18 @@ type Chunk = heapless::Vec; /// Drive one MQTT session over a split stream until an error ends it. /// -/// Connects, subscribes `subscribe_topics`, then performs actions and -/// dispatches every inbound publish into its records through `dispatch`. -/// Returns only on failure — the caller reconnects. +/// Connects, subscribes `subscribe_topics`, then publishes what `outbound` +/// stages and dispatches every inbound publish into its records through +/// `dispatch`. Returns only on failure — the caller reconnects. /// /// `ring` is the connector's, reused across sessions; whatever an old session /// left in it is discarded first, so nothing reaches the new socket ahead of /// its CONNECT. /// -/// **At most once**: an action is taken off `actions` before it is performed, -/// so the one in flight when a session ends is lost. Everything still queued -/// survives. +/// **At most once**: a message is taken from its record buffer before it is +/// written, so the one in flight when a session ends is lost. Everything still +/// in the record buffers survives (what survives depends on each buffer's +/// type). #[allow(clippy::too_many_arguments)] pub(crate) async fn run_session( rx: R, @@ -78,7 +83,8 @@ pub(crate) async fn run_session( connection_settings: &ConnectionSettings<'static>, subscribe_topics: &[(&str, QualityOfService)], dispatch: &InboundDispatch, - actions: &ActionChannel, + outbound: &mut OutboundRoutes, + opts: &[PublishOpts], ring: &WriteRing, settings: &Settings, delay: &D, @@ -98,7 +104,8 @@ where connection_settings, subscribe_topics, dispatch, - actions, + outbound, + opts, settings, delay, runtime, @@ -147,7 +154,8 @@ async fn client_loop( connection_settings: &ConnectionSettings<'static>, subscribe_topics: &[(&str, QualityOfService)], dispatch: &InboundDispatch, - actions: &ActionChannel, + outbound: &mut OutboundRoutes, + opts: &[PublishOpts], settings: &Settings, delay: &D, runtime: &dyn RuntimeOps, @@ -168,28 +176,17 @@ async fn client_loop( let mut waiting_since: Option = Some(start); let mut connected = false; let mut next_topic = 0usize; + // The broker's Maximum Packet Size, from its CONNACK. Sending a larger + // packet is a protocol error that ends the session. + let mut broker_max: Option = None; + // Latched on `Ready(None)`: every route is closed, or there were none. + // Passing it through would resolve the arm on every iteration and the + // session would never yield. + let mut outbound_done = false; // CONNECT goes out first; its CONNACK is what flips `connected`. { - let mut properties = heapless::Vec::new(); - // Topic aliases are declined: honouring them would mean storing the - // server's topic names for the life of the connection. - let _ = properties.push(ConnectProperty::TopicAliasMaximum(0.into())); - let _ = properties.push(ConnectProperty::MaximumPacketSize( - (MAX_INBOUND_PACKET as u32).into(), - )); - // Ours, not `connection_settings.keep_alive()`: that field has no - // setter, so it is always mountain-mqtt's own 60 s constant. The - // cadence below is derived from the value we actually send. - let connect: Connect<'_, 2, 0> = Connect::new( - settings.keep_alive_secs, - *connection_settings.username(), - *connection_settings.password(), - connection_settings.client_id(), - true, - None, - properties, - ); + let connect = connect_packet(settings, connection_settings); state.connect(&connect).map_err(client_error)?; ring.put(&connect, 0).await?; } @@ -243,30 +240,32 @@ async fn client_loop( // --- park until something happens ---------------------------------- - // The action arm is armed only when a publish can actually be sent: + // The publish arm is armed only when a publish can actually be sent: // connected, nothing awaiting acknowledgement (the client state holds // one in-flight slot), every subscription placed, and room in the ring - // for the largest PUBLISH plus its reserve. This is what replaces the - // old inline wait for a PUBACK — the ping and liveness deadlines keep - // running while it is parked. Room is checked on every poll, not once - // here: PUBACKs and pings can take ring space while the arm is parked. - let action_ready = - connected && !state.waiting_for_responses() && next_topic >= subscribe_topics.len(); + // for the largest PUBLISH plus its reserve. The ping and liveness + // deadlines keep running while it is parked. Room is checked on every + // poll, not once here: PUBACKs and pings can take ring space while the + // arm is parked. A value leaves its record buffer only when the arm + // resolves, so a losing arm takes nothing. + let publish_ready = connected + && !outbound_done + && !state.waiting_for_responses() + && next_topic >= subscribe_topics.len(); let publish_room = ring.max_publish() + CONTROL_RESERVE; - let action_arm = async { - if !action_ready { - core::future::pending::<()>().await; + let publish_arm = poll_fn(|cx| { + if !publish_ready || !ring.poll_room(publish_room, cx.waker()) { + return Poll::Pending; } - poll_fn(|cx| { - if ring.poll_room(publish_room, cx.waker()) { - Poll::Ready(()) - } else { + match outbound.poll_stage(cx) { + Poll::Ready(Some(id)) => Poll::Ready(id), + Poll::Ready(None) => { + outbound_done = true; Poll::Pending } - }) - .await; - actions.receive().await - }; + Poll::Pending => Poll::Pending, + } + }); let sleep_for = Duration::from_millis(next_deadline( now, @@ -276,7 +275,7 @@ async fn client_loop( waiting_since.map(|since| since + response_timeout), )); - match select3(inbound.receive(), action_arm, delay.sleep(sleep_for)).await { + match select3(inbound.receive(), publish_arm, delay.sleep(sleep_for)).await { Either3::First(chunk) => { reader.feed(&chunk).map_err(client_error)?; drain_packets( @@ -287,11 +286,12 @@ async fn client_loop( runtime, &mut last_ack_ms, &mut connected, + &mut broker_max, ) .await?; } - Either3::Second(action) => { - perform(action, &mut state, ring).await?; + Either3::Second(id) => { + publish_staged(outbound, id, opts, &mut state, ring, broker_max).await?; } // The timer fired: the top of the loop re-evaluates every deadline. Either3::Third(()) => {} @@ -316,6 +316,7 @@ async fn drain_packets( runtime: &dyn RuntimeOps, last_ack_ms: &mut u64, connected: &mut bool, + broker_max: &mut Option, ) -> Result<(), Error> { while let Some(total) = reader.framed_len().map_err(client_error)? { // A burst of QoS 1 publishes needs a PUBACK each, so room for one is @@ -331,6 +332,13 @@ async fn drain_packets( let packet: PacketGeneric<'_, MAX_PROPERTIES, 0, 0> = reader.parse(total).map_err(client_error)?; + if let PacketGeneric::Connack(connack) = &packet { + *broker_max = connack.properties().iter().find_map(|p| match p { + ConnackProperty::MaximumPacketSize(max) => Some(max.value() as usize), + _ => None, + }); + } + // Produce the PUBACK before the state update, as upstream does, so // the two cannot disagree about what was acknowledged. It borrows // the client state, so it goes into the ring before @@ -407,76 +415,129 @@ fn deliver( Ok(()) } -/// Turn one queued action into a packet on the wire. +/// Publish the message `outbound` staged for route `id`. /// /// Sent before the state update, as upstream does: a state that believes a -/// publish is in flight when it is not parks the action arm forever. -async fn perform( - action: AimdbMqttAction, +/// publish is in flight when it is not parks the publish arm forever. A frame +/// the ring can never grant, or larger than the broker accepts, is skipped +/// before the state commits to it, logged, and counted as rejected in the +/// route's `RouteStats`. +async fn publish_staged( + outbound: &mut OutboundRoutes, + id: RouteId, + opts: &[PublishOpts], state: &mut ClientStateNoQueue, ring: &WriteRing, + broker_max: Option, ) -> Result<(), Error> { - match action { - AimdbMqttAction::Publish { - topic, - payload, - qos, - retain, - } => { + let opt = opts.get(id).copied().unwrap_or(PublishOpts { + qos: 1, + retain: false, + }); + // QoS 2 is not supported by this client; build() warned once per route. + let qos = if opt.qos == 0 { + QualityOfService::Qos0 + } else { + QualityOfService::Qos1 + }; + let rejected = { + let Some(msg) = outbound.take_staged() else { + return Ok(()); + }; + let packet = state + .publish_packet(msg.topic, msg.payload.as_slice(), qos, opt.retain) + .map_err(client_error)?; + let len = encoded_len(&packet)?; + let over_broker = broker_max.is_some_and(|max| len > max); + if !ring.fits(len, CONTROL_RESERVE) || over_broker { + aimdb_core::log_warn!( + "MQTT: skipping publish to '{}': {} bytes exceed the {} limit", + msg.topic, + len, + if over_broker { + "broker's" + } else { + "write ring's" + } + ); #[cfg(feature = "defmt")] - defmt::debug!( - "Publishing {} bytes to {} (QoS={:?})", - payload.len(), - topic.as_str(), - qos + defmt::warn!( + "MQTT: skipping publish to {}: {} bytes exceed the {} limit", + msg.topic, + len, + if over_broker { + "broker's" + } else { + "write ring's" + } ); - let packet = state - .publish_packet(&topic, &payload, qos, retain) - .inspect_err(|_e| { - // The action is already off the channel, so a failure here - // loses this message and ends the session — say which. - #[cfg(feature = "defmt")] - defmt::warn!( - "MQTT: dropping publish of {} bytes to {}: {}", - payload.len(), - topic.as_str(), - _e - ); - }) - .map_err(client_error)?; - // A frame larger than the ring can always grant would wait - // forever; skip it before the state commits to it. - let len = encoded_len(&packet)?; - if !ring.fits(len, CONTROL_RESERVE) { - #[cfg(feature = "defmt")] - defmt::warn!( - "MQTT: dropping publish to {}: {} bytes exceed the {}-byte write ring limit", - topic.as_str(), - len, - ring.max_publish() - ); - return Ok(()); - } + true + } else { ring.put_sized(&packet, len, CONTROL_RESERVE).await?; state.publish_update(&packet).map_err(client_error)?; + false } - AimdbMqttAction::Subscribe { topic, qos } => { - #[cfg(feature = "defmt")] - defmt::info!("Subscribing to {} (QoS={:?})", topic.as_str(), qos); - let packet = state - .subscribe_packet(&topic, qos) - .inspect_err(|_e| { - #[cfg(feature = "defmt")] - defmt::warn!("MQTT: dropping subscribe to {}: {}", topic.as_str(), _e); - }) - .map_err(client_error)?; - ring.put(&packet, 0).await?; - state.subscribe_update(&packet).map_err(client_error)?; - } + }; + if rejected { + outbound.reject(id); } Ok(()) } +/// The CONNECT this session sends, also sized at build. +pub(crate) fn connect_packet<'a>( + settings: &Settings, + connection_settings: &'a ConnectionSettings<'static>, +) -> Connect<'a, 2, 0> { + let mut properties = heapless::Vec::new(); + // Topic aliases are declined: honouring them would mean storing the + // server's topic names for the life of the connection. + let _ = properties.push(ConnectProperty::TopicAliasMaximum(0.into())); + let _ = properties.push(ConnectProperty::MaximumPacketSize( + (MAX_INBOUND_PACKET as u32).into(), + )); + // Ours, not `connection_settings.keep_alive()`: that field has no setter, + // so it is always mountain-mqtt's own 60 s constant. The cadence is + // derived from the value we actually send. + Connect::new( + settings.keep_alive_secs, + *connection_settings.username(), + *connection_settings.password(), + connection_settings.client_id(), + true, + None, + properties, + ) +} + +/// Bytes the SUBSCRIBE for `topic` encodes to. +pub(crate) fn subscribe_len(topic: &str) -> Result { + let packet: Subscribe<'_, 0, 0> = Subscribe::new( + PacketIdentifier(1), + SubscriptionRequest::new(topic, QualityOfService::Qos1), + heapless::Vec::new(), + heapless::Vec::new(), + ); + encoded_len(&packet) +} + +/// Bytes a QoS 1 PUBLISH with a `topic_len`-byte topic and a +/// `payload_len`-byte payload encodes to: the largest frame a route with +/// those capacities produces. +pub(crate) fn publish_frame_len(topic_len: usize, payload_len: usize) -> Result { + let topic = "x".repeat(topic_len); + let payload = alloc::vec![0u8; payload_len]; + let packet: Publish<'_, 0> = Publish::new( + false, + false, + &topic, + PublishPacketIdentifier::Qos1(PacketIdentifier(1)), + &payload, + heapless::Vec::new(), + ); + encoded_len(&packet) +} + /// Milliseconds to sleep before the earliest armed deadline. fn next_deadline( now: u64, @@ -554,7 +615,6 @@ mod tests { /// absorb codegen drift, but not loose enough to fit another buffer. #[test] fn the_session_future_has_not_outgrown_the_loop_it_replaced() { - let actions = ActionChannel::new(); let settings = Settings::default(); let connection_settings = ConnectionSettings::unauthenticated("size-probe"); let runtime = aimdb_core::executor::test_support::NoopRuntimeOps; @@ -566,6 +626,7 @@ mod tests { ) .expect("empty database"); let dispatch = InboundDispatch::new(&db, "mqtt", &crate::MqttGrammar).expect("no links"); + let mut outbound = OutboundRoutes::new(&db, "mqtt").expect("no links"); // Built, never polled: `size_of_val` on the future is the whole point. let session = run_session( @@ -574,7 +635,8 @@ mod tests { &connection_settings, &[], &dispatch, - &actions, + &mut outbound, + &[], &ring, &settings, &NullDelay, @@ -739,15 +801,14 @@ mod tests { #[cfg(test)] mod proofs { - //! Where the embedded backend's size limits sit today: the largest - //! PUBLISH it sends, what it does with a larger one (skipped without an - //! error and without a count), and the largest packet it receives. + //! Where the embedded backend's size limits sit: the largest PUBLISH it + //! sends, what it does with a larger one (skipped and counted as rejected + //! in the route's `RouteStats`), and the largest packet it receives. use super::*; use crate::embedded::write_ring::DEFAULT_WRITE_BUFFER; + use alloc::boxed::Box; + use alloc::sync::Arc; use alloc::vec::Vec; - use core::future::Future; - use core::pin::pin; - use core::task::{Context, Waker}; use mountain_mqtt::data::reason_code::ConnectReasonCode; use mountain_mqtt::packets::connack::Connack; @@ -779,50 +840,123 @@ mod proofs { .unwrap() } - fn publish(n: usize) -> AimdbMqttAction { - AimdbMqttAction::Publish { - topic: "t".into(), - payload: alloc::vec![b'x'; n], - qos: QualityOfService::Qos1, - retain: false, + /// Lets `link_to("mqtt://…")` register; drives nothing. + struct NoTransport; + + impl aimdb_core::connector::ConnectorBuilder for NoTransport { + #[allow(clippy::type_complexity)] + fn build<'a>( + &'a self, + _db: &'a aimdb_core::AimDb, + ) -> core::pin::Pin< + Box< + dyn core::future::Future< + Output = aimdb_core::DbResult< + Vec + Send>>>, + >, + > + Send + + 'a, + >, + > { + Box::pin(async { Ok(Vec::new()) }) + } + fn scheme(&self) -> &str { + "mqtt" } } - fn run( - action: AimdbMqttAction, - state: &mut ClientStateNoQueue, - ring: &WriteRing, - ) -> Poll> { - let f = pin!(perform(action, state, ring)); - f.poll(&mut Context::from_waker(Waker::noop())) + /// One route to `t` whose owned serializer emits as many bytes as the + /// value says, with a message of `payload_len` bytes staged. + async fn staged(payload_len: usize) -> (aimdb_core::AimDb, OutboundRoutes, RouteId) { + use aimdb_core::buffer::BufferCfg; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + + let mut builder = aimdb_core::AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(NoTransport); + builder.configure::("blob", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_to("mqtt://t") + .with_serializer(|_ctx, n: &usize| Ok(alloc::vec![b'x'; *n])) + .finish(); + }); + let (db, _runner) = builder.build().await.expect("build"); + let mut outbound = OutboundRoutes::new(&db, "mqtt").expect("routes"); + db.produce("blob", payload_len).expect("produce"); + let id = poll_fn(|cx| outbound.poll_stage(cx)) + .await + .expect("route open"); + (db, outbound, id) } - #[test] - fn proof_a_1984_byte_publish_goes_out() { + const QOS1: [PublishOpts; 1] = [PublishOpts { + qos: 1, + retain: false, + }]; + + #[tokio::test] + async fn a_1984_byte_publish_goes_out() { let ring = WriteRing::new(DEFAULT_WRITE_BUFFER); assert_eq!(ring.max_publish(), 1984); let mut state = connected(); - let n = payload_for(1984); - assert!(matches!( - run(publish(n), &mut state, &ring), - Poll::Ready(Ok(())) - )); + let (_db, mut outbound, id) = staged(payload_for(1984)).await; + publish_staged(&mut outbound, id, &QOS1, &mut state, &ring, None) + .await + .unwrap(); assert!(!ring.has_room(DEFAULT_WRITE_BUFFER), "bytes were queued"); assert!(state.waiting_for_responses(), "QoS 1 publish in flight"); + assert_eq!(outbound.stats(id).unwrap().rejected, 0); } - #[test] - fn proof_a_1985_byte_publish_vanishes_without_an_error() { + #[tokio::test] + async fn a_1985_byte_publish_is_skipped_and_counted() { let ring = WriteRing::new(DEFAULT_WRITE_BUFFER); let mut state = connected(); - let n = payload_for(1985); - assert!(matches!( - run(publish(n), &mut state, &ring), - Poll::Ready(Ok(())) - )); + let (_db, mut outbound, id) = staged(payload_for(1985)).await; + publish_staged(&mut outbound, id, &QOS1, &mut state, &ring, None) + .await + .unwrap(); assert!(ring.has_room(DEFAULT_WRITE_BUFFER), "nothing was queued"); assert!(!state.waiting_for_responses(), "nothing in flight"); - // No counter exists to assert on: the only trace is a defmt warn. + let stats = outbound.stats(id).unwrap(); + assert_eq!((stats.sent, stats.rejected), (1, 1)); + } + + #[tokio::test] + async fn a_publish_over_the_brokers_maximum_packet_size_is_skipped_and_counted() { + let ring = WriteRing::new(DEFAULT_WRITE_BUFFER); + let mut state = connected(); + let (_db, mut outbound, id) = staged(payload_for(200)).await; + publish_staged(&mut outbound, id, &QOS1, &mut state, &ring, Some(199)) + .await + .unwrap(); + assert!(ring.has_room(DEFAULT_WRITE_BUFFER), "nothing was queued"); + assert_eq!(outbound.stats(id).unwrap().rejected, 1); + + // At the limit it goes out. + let (_db, mut outbound, id) = staged(payload_for(200)).await; + publish_staged(&mut outbound, id, &QOS1, &mut state, &ring, Some(200)) + .await + .unwrap(); + assert_eq!(outbound.stats(id).unwrap().rejected, 0); + assert!(state.waiting_for_responses()); + } + + #[test] + fn publish_frame_len_matches_what_the_client_state_encodes() { + let mut state = connected(); + for (topic_len, payload_len) in [(1, 0), (1, 100), (30, 1900), (200, 3000)] { + let topic = "t".repeat(topic_len); + let payload = alloc::vec![0u8; payload_len]; + let packet = state + .publish_packet(&topic, &payload, QualityOfService::Qos1, true) + .unwrap(); + assert_eq!( + publish_frame_len(topic_len, payload_len).unwrap(), + encoded_len(&packet).unwrap(), + "{topic_len}-byte topic, {payload_len}-byte payload" + ); + } } /// A QoS 0 PUBLISH to `t` that is `total` bytes on the wire. diff --git a/aimdb-mqtt-connector/src/embedded/tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs index c98f77f3..c2c58f3e 100644 --- a/aimdb-mqtt-connector/src/embedded/tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -24,7 +24,7 @@ use embedded_tls::{ use crate::embedded::manager::Settings; use crate::embedded::session_loop::run_session; -use crate::embedded::write_ring::{WriteRing, DEFAULT_WRITE_BUFFER}; +use crate::embedded::write_ring::WriteRing; use mountain_mqtt::client::ConnectionSettings; use mountain_mqtt::data::quality_of_service::QualityOfService; @@ -322,7 +322,9 @@ pub(crate) async fn run_tls( connection_settings: ConnectionSettings<'static>, settings: Settings, inbound: aimdb_core::InboundDispatch, - actions: Arc, + mut outbound: aimdb_core::OutboundRoutes, + opts: Vec, + write_buffer: usize, delay: D, runtime: Arc, ) -> ! @@ -345,7 +347,7 @@ where .collect(); // Allocated once and reused by every session. - let ring = WriteRing::new(DEFAULT_WRITE_BUFFER); + let ring = WriteRing::new(write_buffer); loop { // Certificate validity needs real time. Take it from the runtime when @@ -413,7 +415,8 @@ where &connection_settings, &subscribe_topics, &inbound, - &actions, + &mut outbound, + &opts, &ring, &settings, &delay, diff --git a/aimdb-mqtt-connector/src/embedded/write_ring.rs b/aimdb-mqtt-connector/src/embedded/write_ring.rs index 36eb0003..d94fe078 100644 --- a/aimdb-mqtt-connector/src/embedded/write_ring.rs +++ b/aimdb-mqtt-connector/src/embedded/write_ring.rs @@ -58,7 +58,7 @@ impl WriteRing { /// bytes, about half its capacity in the worst case. A larger grant could /// wait forever, even with nothing queued. pub(crate) fn fits(&self, len: usize, reserve: usize) -> bool { - len + reserve <= self.queue.capacity() / 2 + fits_ring(self.queue.capacity(), len, reserve) } /// Whether a contiguous grant of `n` bytes exists right now. @@ -189,6 +189,12 @@ impl WriteRing { } } +/// Whether a `len`-byte frame plus `reserve` always fits eventually in a +/// `capacity`-byte ring; see [`WriteRing::fits`]. +pub(crate) fn fits_ring(capacity: usize, len: usize, reserve: usize) -> bool { + len + reserve <= capacity / 2 +} + /// Bytes `packet` encodes to. pub(crate) fn encoded_len(packet: &P) -> Result { let mut len_writer = MqttLenWriter::new(); diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index b5d1389f..dbe41722 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -127,6 +127,9 @@ pub mod native; #[cfg(feature = "embedded")] pub mod embedded; +#[cfg(feature = "embedded")] +pub(crate) mod publish_opts; + // SNTP wire codec — pure and feature-independent so it is unit-tested on the // host; only the TLS I/O task consumes it. #[cfg_attr(not(feature = "embassy-tls"), allow(dead_code))] diff --git a/aimdb-mqtt-connector/src/publish_opts.rs b/aimdb-mqtt-connector/src/publish_opts.rs new file mode 100644 index 00000000..4c992c04 --- /dev/null +++ b/aimdb-mqtt-connector/src/publish_opts.rs @@ -0,0 +1,116 @@ +//! Per-route publish options, parsed once when the connector builds. + +use alloc::format; +use alloc::string::String; + +use aimdb_core::RouteInfo; + +/// How every message of one outbound route is published. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PublishOpts { + /// 0, 1 or 2, from `?qos=`. Defaults to 1. + pub qos: u8, + /// From `?retain=`. Defaults to `false`. + pub retain: bool, +} + +impl PublishOpts { + /// Parse `route`'s `qos` and `retain`. A value that does not parse is an + /// error naming the route, not a silent fallback to the default. + pub(crate) fn parse(route: &RouteInfo) -> Result { + let option = |key: &str| { + route + .config + .protocol_options + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + }; + let qos = match option("qos") { + None => 1, + Some("0") => 0, + Some("1") => 1, + Some("2") => 2, + Some(other) => { + return Err(format!( + "route '{}': qos must be 0, 1 or 2, got '{other}'", + route.default_topic + )) + } + }; + let retain = match option("retain") { + None | Some("false") => false, + Some("true") => true, + Some(other) => { + return Err(format!( + "route '{}': retain must be true or false, got '{other}'", + route.default_topic + )) + } + }; + Ok(Self { qos, retain }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aimdb_core::transport::ConnectorConfig; + use alloc::string::ToString; + use alloc::sync::Arc; + use alloc::vec::Vec; + + fn route(query: &[(&str, &str)]) -> RouteInfo { + let query: Vec<(String, String)> = query + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + RouteInfo { + id: 0, + default_topic: Arc::from("sensors/t"), + config: ConnectorConfig::from_query(&query), + topic_capacity: 0, + payload_capacity: 0, + } + } + + #[test] + fn defaults_are_qos_1_without_retain() { + assert_eq!( + PublishOpts::parse(&route(&[])), + Ok(PublishOpts { + qos: 1, + retain: false + }) + ); + } + + #[test] + fn every_valid_value_parses() { + for (qos, expected) in [("0", 0), ("1", 1), ("2", 2)] { + assert_eq!( + PublishOpts::parse(&route(&[("qos", qos)])).unwrap().qos, + expected + ); + } + assert!( + PublishOpts::parse(&route(&[("retain", "true")])) + .unwrap() + .retain + ); + assert!( + !PublishOpts::parse(&route(&[("retain", "false")])) + .unwrap() + .retain + ); + } + + #[test] + fn a_value_that_does_not_parse_names_the_route() { + for query in [[("qos", "3")], [("qos", "abc")], [("retain", "yes")]] { + let err = PublishOpts::parse(&route(&query)).unwrap_err(); + assert!(err.contains("route 'sensors/t'"), "{err}"); + assert!(err.contains(query[0].1), "{err}"); + } + } +} diff --git a/aimdb-mqtt-connector/tests/session_loop.rs b/aimdb-mqtt-connector/tests/session_loop.rs index e0d256e5..82c778fa 100644 --- a/aimdb-mqtt-connector/tests/session_loop.rs +++ b/aimdb-mqtt-connector/tests/session_loop.rs @@ -403,11 +403,10 @@ async fn every_qos1_push_is_acknowledged() { // The build-time QoS warning can actually see what it warns about. // --------------------------------------------------------------------------- -/// `warn_unsupported_qos` scans `collect_outbound_routes("mqtt")` for a `qos` -/// entry in each route's query config. That scan is the part that can silently -/// find nothing — a scheme filter that does not match, or a config key that -/// never lands — leaving a warning that compiles and never fires. This asserts -/// the shape it depends on, mirroring the private function exactly. +/// `build()` warns once per route whose `RouteInfo` carries `qos=2`. That +/// lookup is the part that can silently find nothing — a scheme that does not +/// match, or a config key that never lands — leaving a warning that compiles +/// and never fires. This asserts the shape it depends on. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_qos2_route_is_visible_to_the_build_time_scan() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -416,20 +415,17 @@ async fn a_qos2_route_is_visible_to_the_build_time_scan() { let dialer = CountingDialer::new(); let (db, _runner) = build_db(port, dialer, Some((Duration::from_secs(60), 2))).await; - let routes = db.collect_outbound_routes("mqtt"); - assert!( - !routes.is_empty(), - "the mqtt scheme must match, or the scan sees no routes at all" - ); - + let routes = aimdb_core::OutboundRoutes::new(&db, "mqtt").expect("routes"); let flagged: Vec<(&str, &str)> = routes + .routes() .iter() .filter_map(|route| { route .config + .protocol_options .iter() .find(|(k, _)| k == "qos") - .map(|(_, v)| (route.topic.as_str(), v.as_str())) + .map(|(_, v)| (&*route.default_topic, v.as_str())) }) .collect(); @@ -439,3 +435,80 @@ async fn a_qos2_route_is_visible_to_the_build_time_scan() { "the scan must see the route's topic and its qos option; got {flagged:?}" ); } + +// --------------------------------------------------------------------------- +// Outbound values wait in their record buffers, not in a connector queue. +// --------------------------------------------------------------------------- + +/// While the broker holds a PUBACK, the session cannot publish, and values +/// produced meanwhile stay in their record buffer. A single-latest record +/// keeps only the newest, so the publish after the stall carries `9`, not the +/// nine values a connector queue would have held. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn after_a_stall_a_single_latest_record_sends_only_its_newest_value() { + use aimdb_core::buffer::BufferCfg; + use aimdb_core::AimDbBuilder; + use aimdb_mqtt_connector::{MqttConnector, MqttLinkExt}; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + + const ACK_DELAY: Duration = Duration::from_millis(1500); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let log = Arc::new(Mutex::new(Log::default())); + + let connector = MqttConnector::new(format!("mqtt://127.0.0.1:{port}")) + .transport(CountingDialer::new()) + .with_client_id("outage") + .with_keep_alive(TEST_KEEP_ALIVE); + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + builder.configure::("uptime", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_to("mqtt://sensors/uptime") + .with_qos(1) + .with_serializer(|_ctx, value: &u64| Ok(value.to_string().into_bytes())) + .finish(); + }); + let (db, runner) = builder.build().await.expect("build db"); + let producer = db.producer::("uptime").expect("producer"); + + let published = |log: &Mutex| -> Vec { + log.lock() + .unwrap() + .publishes + .iter() + .map(|(_, p)| String::from_utf8_lossy(p).into_owned()) + .collect() + }; + let drive = async { + producer.produce(0); + while published(&log).is_empty() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + // The PUBACK for `0` is held: produce the rest during the stall, + // spaced so that anything reading the buffer meanwhile sees each one. + for n in 1..=9 { + producer.produce(n); + tokio::time::sleep(Duration::from_millis(20)).await; + } + while published(&log).len() < 2 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + // Nothing else may follow. + tokio::time::sleep(ACK_DELAY + Duration::from_millis(500)).await; + }; + + tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = serve_one(listener, log.clone(), Script::SlowPuback { delay: ACK_DELAY }) => { + panic!("the broker returned") + } + _ = drive => {} + _ = tokio::time::sleep(Duration::from_secs(30)) => { + panic!("watchdog: published {:?}", published(&log)) + } + } + + assert_eq!(published(&log), ["0", "9"]); +} diff --git a/aimdb-mqtt-connector/tests/tokio_broker.rs b/aimdb-mqtt-connector/tests/tokio_broker.rs index 32e7c303..1295ad27 100644 --- a/aimdb-mqtt-connector/tests/tokio_broker.rs +++ b/aimdb-mqtt-connector/tests/tokio_broker.rs @@ -377,3 +377,134 @@ async fn the_embedded_backend_dispatches_inbound_on_its_session_task() { let futures = connector().build(&db).await.expect("build connector"); assert_eq!(futures.len(), 1, "the session task, and no inbound pump"); } + +// --------------------------------------------------------------------------- +// Build-time checks: per-route options and what the write ring can carry. +// --------------------------------------------------------------------------- + +/// An embedded-backend connector that never dials. +macro_rules! embedded { + () => { + aimdb_mqtt_connector::MqttConnector::new("mqtt://127.0.0.1:1") + .transport(aimdb_tokio_adapter::net::TokioNet::tcp()) + }; +} + +/// Builds a database with `connector` and the records `configure` adds. +async fn build( + connector: impl aimdb_core::connector::ConnectorBuilder + 'static, + configure: impl FnOnce(&mut aimdb_core::AimDbBuilder), +) -> Result<(), String> { + let mut builder = aimdb_core::AimDbBuilder::new() + .runtime(Arc::new(aimdb_tokio_adapter::TokioAdapter)) + .with_connector(connector); + configure(&mut builder); + builder.build().await.map(|_| ()).map_err(|e| e.to_string()) +} + +/// One outbound record on `sensors/out` with `config` on its link and, given +/// a capacity, a scratch serializer. +fn outbound( + config: &'static [(&'static str, &'static str)], + scratch: Option, +) -> impl FnOnce(&mut aimdb_core::AimDbBuilder) { + move |builder| { + use aimdb_tokio_adapter::TokioRecordRegistrarExt; + builder.configure::("out", move |reg| { + let mut link = reg + .buffer(aimdb_core::buffer::BufferCfg::SingleLatest) + .link_to("mqtt://sensors/out") + .with_serializer(|_ctx, v: &u64| Ok(v.to_le_bytes().to_vec())); + for (k, v) in config { + link = link.with_config(k, v); + } + if let Some(capacity) = scratch { + link = link.with_serializer_into(capacity, |_ctx, v: &u64, out| { + out[..8].copy_from_slice(&v.to_le_bytes()); + Ok(8) + }); + } + link.finish(); + }); + } +} + +#[tokio::test] +async fn an_invalid_qos_or_retain_fails_the_build() { + for (key, value, expected) in [ + ("qos", "3", "qos must be 0, 1 or 2, got '3'"), + ("qos", "abc", "qos must be 0, 1 or 2, got 'abc'"), + ("retain", "yes", "retain must be true or false, got 'yes'"), + ] { + let config: &'static [(&str, &str)] = Box::leak(Box::new([(key, value)])); + let err = build(embedded!(), outbound(config, None)) + .await + .unwrap_err(); + assert!(err.contains("route 'sensors/out'"), "{err}"); + assert!(err.contains(expected), "{err}"); + } +} + +#[tokio::test] +async fn a_route_too_large_for_the_write_ring_fails_the_build() { + let err = build(embedded!(), outbound(&[], Some(3000))) + .await + .unwrap_err(); + assert!( + err.contains("route 'sensors/out': its largest PUBLISH is"), + "{err}" + ); + assert!(err.contains("it is 4096"), "{err}"); + + build( + embedded!().with_write_buffer(8192), + outbound(&[], Some(3000)), + ) + .await + .expect("a larger ring carries it"); +} + +#[tokio::test] +async fn a_connect_too_large_for_the_write_ring_fails_the_build() { + let password = "p".repeat(2100); + let err = build(embedded!().with_credentials("user", &password), |_| {}) + .await + .unwrap_err(); + assert!( + err.contains("the CONNECT (client id and credentials) is"), + "{err}" + ); + + build( + embedded!() + .with_credentials("user", &password) + .with_write_buffer(8192), + |_| {}, + ) + .await + .expect("a larger ring carries it"); +} + +#[tokio::test] +async fn a_subscribe_too_large_for_the_write_ring_fails_the_build() { + let topic = format!("sensors/{}", "t".repeat(2100)); + let inbound = |topic: String| { + move |builder: &mut aimdb_core::AimDbBuilder| { + use aimdb_tokio_adapter::TokioRecordRegistrarExt; + builder.configure::("in", move |reg| { + reg.buffer(aimdb_core::buffer::BufferCfg::SingleLatest) + .link_from(&format!("mqtt://{topic}")) + .with_deserializer(|_ctx, data: &[u8]| Ok::(data.len() as u64)) + .finish(); + }); + } + }; + let err = build(embedded!(), inbound(topic.clone())) + .await + .unwrap_err(); + assert!(err.contains("the SUBSCRIBE to 'sensors/"), "{err}"); + + build(embedded!().with_write_buffer(8192), inbound(topic)) + .await + .expect("a larger ring carries it"); +} diff --git a/aimdb-mqtt-connector/tests/write_ring_proofs.rs b/aimdb-mqtt-connector/tests/write_ring_proofs.rs index a2574298..ea9ced11 100644 --- a/aimdb-mqtt-connector/tests/write_ring_proofs.rs +++ b/aimdb-mqtt-connector/tests/write_ring_proofs.rs @@ -55,9 +55,11 @@ use aimdb_tokio_adapter::net::TokioNet; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; /// A 3,000-byte publish never reaches the broker, the session never errors, -/// and the small publishes around it keep flowing. Nothing counts the drop. +/// and the small publishes around it keep flowing. The skip is counted as +/// rejected in the route's `RouteStats` (asserted in the session loop's unit +/// tests, which hold the `OutboundRoutes`). #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn proof_an_oversize_publish_is_dropped_silently() { +async fn an_oversize_publish_is_skipped_and_the_session_stays_up() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); let seen = Arc::new(Mutex::new(Seen::default())); diff --git a/aimdb-tokio-adapter/tests/outbound_routes.rs b/aimdb-tokio-adapter/tests/outbound_routes.rs index 471de39a..a713225d 100644 --- a/aimdb-tokio-adapter/tests/outbound_routes.rs +++ b/aimdb-tokio-adapter/tests/outbound_routes.rs @@ -496,3 +496,14 @@ async fn every_route_closes_when_the_database_is_dropped() { .expect("closes"); assert!(end.is_none()); } + +#[tokio::test] +async fn a_rejected_message_is_counted_beside_sent() { + let db = db(vec![spmc(0, 16)]).await; + let mut o = OutboundRoutes::new(&db, "test").unwrap(); + produce(&db, 0, 1); + let id = pull(&mut o).await.unwrap().0; + o.reject(id); + let stats = o.stats(0).unwrap(); + assert_eq!((stats.sent, stats.rejected), (1, 1)); +} From ab62c67debd67609f53b38c78ddb4208cb548646 Mon Sep 17 00:00:00 2001 From: "sounds.like.lx" <147444674+lxsaah@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:19:38 +0200 Subject: [PATCH 18/28] =?UTF-8?q?feat(mqtt):=20native=20backend=20dispatch?= =?UTF-8?q?es=20inbound=20and=20pulls=20outbound=20(design=20054=20=C2=A74?= =?UTF-8?q?.7)=20(#289)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aimdb-mqtt-connector/src/lib.rs | 2 +- aimdb-mqtt-connector/src/native.rs | 285 ++++++++++-------- ...rovider_tests.rs => topic_writer_tests.rs} | 221 ++++++++------ 3 files changed, 287 insertions(+), 221 deletions(-) rename aimdb-mqtt-connector/tests/{topic_provider_tests.rs => topic_writer_tests.rs} (72%) diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index dbe41722..e9291287 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -127,7 +127,7 @@ pub mod native; #[cfg(feature = "embedded")] pub mod embedded; -#[cfg(feature = "embedded")] +#[cfg(any(feature = "std", feature = "embedded"))] pub(crate) mod publish_opts; // SNTP wire codec — pure and feature-independent so it is unit-tested on the diff --git a/aimdb-mqtt-connector/src/native.rs b/aimdb-mqtt-connector/src/native.rs index f45ad04f..d06298b9 100644 --- a/aimdb-mqtt-connector/src/native.rs +++ b/aimdb-mqtt-connector/src/native.rs @@ -1,19 +1,21 @@ //! The `rumqttc` backend: one broker connection, QoS 0–2, platform trust roots. //! //! `rumqttc` owns its socket, TLS and reconnect, so this module contributes -//! only the connect-and-subscribe step and the `MqttSink`/`MqttEventLoopSource` -//! adapters that core's pumps drive. +//! the connect-and-subscribe step and two tasks: the event loop, which +//! dispatches inbound publishes into their records, and the publish loop, +//! which pulls outbound messages and hands them to `rumqttc`. use aimdb_core::connector::ConnectorUrl; -use aimdb_core::transport::{Connector, ConnectorConfig, PublishError}; use aimdb_core::{log_debug, log_error, log_info}; -use aimdb_core::{pump_sink, pump_source, BoxFut, Payload, Source}; +use aimdb_core::{InboundDispatch, OutboundRoutes}; use rumqttc::{AsyncClient, Event, EventLoop, MqttOptions, Packet}; use std::future::Future; use std::pin::Pin; use std::sync::Arc; use std::time::Duration; +use crate::publish_opts::PublishOpts; + type BoxFuture = Pin + Send + 'static>>; /// Connect, subscribe, and collect the data-plane futures for the `rumqttc` @@ -26,9 +28,21 @@ pub(crate) fn build<'a>( keep_alive_secs: u16, ) -> Pin>> + Send + 'a>> { Box::pin(async move { - // One router both subscribes (here) and routes (`pump_source`). - let router = db.inbound_router("mqtt", &crate::MqttGrammar)?; - let topics = router.subscriptions(); + // One dispatcher both subscribes (here) and routes (the event loop). + let inbound = InboundDispatch::new(db, "mqtt", &crate::MqttGrammar)?; + let topics = inbound.subscriptions(); + + // Routes are subscribed now, so nothing produced before the publish + // loop first runs is missed; options are parsed once, here. + let outbound = OutboundRoutes::new(db, "mqtt")?; + let opts = outbound + .routes() + .iter() + .map(PublishOpts::parse) + .collect::, _>>() + .map_err(|e| { + aimdb_core::DbError::runtime_error(format!("Failed to build MQTT connector: {e}")) + })?; log_info!("MQTT subscribing to {} topics", topics.len()); @@ -45,35 +59,22 @@ pub(crate) fn build<'a>( aimdb_core::DbError::runtime_error(format!("Failed to build MQTT connector: {}", e)) })?; - let mut futures: Vec = Vec::new(); - - // Inbound: one multiplexed reader future fanning publishes out to producers. - futures.extend(pump_source( - db, - router, - MqttEventLoopSource { - event_loop, - broker_key: broker_url.to_string(), - }, - )); - - // Outbound: one publisher future per outbound route. - futures.extend(pump_sink(db, "mqtt", Arc::new(MqttSink { client }))); - + let futures: Vec = vec![ + Box::pin(run_event_loop(event_loop, inbound, broker_url.to_string())), + Box::pin(run_publish_loop(client, outbound, opts)), + ]; Ok(futures) }) } -/// The broker-connection setup invoked from `build`; the data-plane loops -/// themselves are core's `pump_sink` / `pump_source`. +/// The broker-connection setup invoked from `build`. pub struct MqttConnectorImpl; impl MqttConnectorImpl { /// Connect to the broker and subscribe to `topics`, sizing the send /// channel from their count. /// - /// Returns the shared client (for the outbound `pump_sink`) plus the raw - /// event loop (for [`MqttEventLoopSource`] and the inbound `pump_source`). + /// Returns the client (for the publish loop) plus the raw event loop. /// A `None` `client_id` generates a UUID-based one. async fn build_internal( broker_url: &str, @@ -81,7 +82,7 @@ impl MqttConnectorImpl { credentials: Option<&(String, String)>, keep_alive_secs: u16, topics: &[Arc], - ) -> Result<(Arc, EventLoop), String> { + ) -> Result<(AsyncClient, EventLoop), String> { // Parse the broker URL - we accept it with or without a topic let mut url = broker_url.to_string(); @@ -169,14 +170,13 @@ impl MqttConnectorImpl { // Create client and event loop with dynamic capacity let (client, event_loop) = AsyncClient::new(mqtt_opts, channel_capacity); - let client_arc = Arc::new(client); log_info!("Subscribing to {} MQTT topics...", topics.len()); for topic in topics { log_debug!("Subscribing to MQTT topic: {}", topic); - client_arc + client .subscribe(topic.as_ref(), rumqttc::QoS::AtLeastOnce) .await .map_err(|e| format!("Failed to subscribe to topic '{}': {}", topic, e))?; @@ -184,114 +184,74 @@ impl MqttConnectorImpl { log_info!("MQTT subscriptions complete"); - Ok((client_arc, event_loop)) + Ok((client, event_loop)) } } -/// Pure outbound publish adapter driven by `pump_sink`. +/// Drive `rumqttc`'s event loop, dispatching every inbound publish into its +/// records. Other packets (PUBACK, PINGRESP, …) only keep the protocol going; +/// a connection error backs off 5 s before `rumqttc` reconnects. Never +/// returns: the loop runs for the lifetime of the connector. /// -/// Wraps the shared rumqttc client. `qos`/`retain` come from the route's -/// protocol options, defaulting to **QoS 1 (`AtLeastOnce`)** and no retain. -struct MqttSink { - client: Arc, -} - -impl MqttSink { - /// Look up a protocol option by key and parse it. - fn opt(config: &ConnectorConfig, key: &str) -> Option { - config - .protocol_options - .iter() - .find(|(k, _)| k == key) - .and_then(|(_, v)| v.parse().ok()) - } -} - -impl Connector for MqttSink { - fn publish( - &self, - destination: &str, - config: &ConnectorConfig, - payload: &[u8], - ) -> Pin> + Send + '_>> { - // Legacy defaults: QoS 1 when no `qos` query option, no retain. - let qos = Self::opt::(config, "qos").unwrap_or(1); - let retain = Self::opt::(config, "retain").unwrap_or(false); - - // Destination is already the MQTT topic (from ConnectorUrl::resource_id()). - let topic = destination.to_string(); - let payload_owned = payload.to_vec(); - let client = self.client.clone(); - - Box::pin(async move { - let qos_level = match qos { - 0 => rumqttc::QoS::AtMostOnce, - 1 => rumqttc::QoS::AtLeastOnce, - 2 => rumqttc::QoS::ExactlyOnce, - _ => return Err(PublishError::UnsupportedQoS), - }; - - // Borrowed before `topic` is moved into `publish`, which is why this - // reads "Publishing" and sits above the call: the alternative was a - // `String` clone on every publish just to name the topic afterwards. - // A failed publish is reported by the `map_err` below. - log_debug!("Publishing to topic: {}", topic); - - client - .publish(topic, qos_level, retain, payload_owned) - .await - .map_err(|_e| { - log_error!("MQTT publish failed: {}", _e); - - PublishError::ConnectionFailed - })?; - - Ok(()) - }) +/// `_broker_key` only names the broker in an error line; the logging facade +/// decides whether that line exists. +async fn run_event_loop(mut event_loop: EventLoop, inbound: InboundDispatch, _broker_key: String) { + loop { + match event_loop.poll().await { + Ok(Event::Incoming(Packet::Publish(publish))) => { + log_debug!( + "Received MQTT message on topic '{}' ({} bytes)", + publish.topic, + publish.payload.len() + ); + inbound.dispatch(&publish.topic, &publish.payload); + } + Ok(_) => {} + Err(_e) => { + log_error!("MQTT event loop error for {}: {:?}", _broker_key, _e); + tokio::time::sleep(Duration::from_secs(5)).await; + } + } } } -/// Inbound frame source driven by `pump_source`. -/// -/// Yields `(topic, payload)` for each incoming MQTT publish, discarding other -/// packets and backing off 5s on a connection error. Never yields `None`: the -/// reader runs for the lifetime of the connector. -struct MqttEventLoopSource { - event_loop: EventLoop, - /// Only ever used to name the broker in an error line. Ungated, because the - /// logging facade decides its own gating. - broker_key: String, -} - -impl Source for MqttEventLoopSource { - fn next(&mut self) -> BoxFut<'_, Option<(String, Payload)>> { - Box::pin(async move { - loop { - match self.event_loop.poll().await { - Ok(Event::Incoming(Packet::Publish(publish))) => { - let payload: Payload = Arc::from(publish.payload.as_ref()); - let topic = publish.topic; - - log_debug!( - "Received MQTT message on topic '{}' ({} bytes)", - topic, - payload.len() - ); - - return Some((topic, payload)); - } - // Non-publish packets (PUBACK/PINGRESP/…) keep driving the protocol. - Ok(_) => continue, - Err(_e) => { - log_error!("MQTT event loop error for {}: {:?}", self.broker_key, _e); - - // Wait before reconnecting. - tokio::time::sleep(Duration::from_secs(5)).await; - } - } - } - }) +/// Pull outbound messages and hand each to `rumqttc` with its route's QoS and +/// retain flag. `publish` takes an owned topic and payload, so the topic is +/// copied and a borrowed payload copied (an owned one is moved). It waits for +/// room in `rumqttc`'s request channel, which is the backpressure. A failed +/// publish is logged with its topic and counted as rejected in the route's +/// `RouteStats`. Returns once every outbound route has closed. +async fn run_publish_loop( + client: AsyncClient, + mut outbound: OutboundRoutes, + opts: Vec, +) { + while let Some(msg) = outbound.next().await { + let id = msg.route.id; + let opt = opts.get(id).copied().unwrap_or(PublishOpts { + qos: 1, + retain: false, + }); + let qos = match opt.qos { + 0 => rumqttc::QoS::AtMostOnce, + 2 => rumqttc::QoS::ExactlyOnce, + _ => rumqttc::QoS::AtLeastOnce, + }; + let topic = msg.topic.to_string(); + let payload = msg.payload.into_vec(); + + log_debug!("Publishing to topic: {}", topic); + if let Err(_e) = client.publish(topic, qos, opt.retain, payload).await { + // The topic moved into `publish`; the route's default names it. + log_error!( + "MQTT publish on route '{}' failed: {}", + outbound.routes()[id].default_topic, + _e + ); + outbound.reject(id); + } } + log_info!("MQTT publish loop: every outbound route has closed"); } /// The TLS configuration for `mqtts://`, from whichever backend this build @@ -393,6 +353,73 @@ mod tests { } } + /// A database with `routes` outbound records on `mqtt://out/{i}`, each + /// link carrying `config`, built with the native connector. + async fn native_db( + routes: usize, + config: &'static [(&'static str, &'static str)], + ) -> aimdb_core::DbResult { + use aimdb_core::buffer::BufferCfg; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + + let mut builder = aimdb_core::AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(crate::MqttConnector::new("mqtt://127.0.0.1:1")); + for i in 0..routes { + let key = aimdb_core::StringKey::intern(format!("out{i}")); + builder.configure::(key, move |reg| { + let mut link = reg + .buffer(BufferCfg::SingleLatest) + .link_to(&format!("mqtt://out/{i}")) + .with_serializer(|_ctx, v: &u64| Ok(v.to_le_bytes().to_vec())); + for (k, v) in config { + link = link.with_config(k, v); + } + link.finish(); + }); + } + builder.build().await.map(|(db, _runner)| db) + } + + #[tokio::test] + async fn an_invalid_qos_or_retain_fails_the_build() { + for (config, expected) in [ + (&[("qos", "3")][..], "qos must be 0, 1 or 2, got '3'"), + (&[("qos", "abc")][..], "qos must be 0, 1 or 2, got 'abc'"), + ( + &[("retain", "yes")][..], + "retain must be true or false, got 'yes'", + ), + ] { + let Err(err) = native_db(1, config).await else { + panic!("{config:?} must fail the build"); + }; + let err = err.to_string(); + assert!(err.contains("route 'out/0'"), "{err}"); + assert!(err.contains(expected), "{err}"); + } + // qos=2 is honoured natively, not refused. + assert!(native_db(1, &[("qos", "2"), ("retain", "true")]) + .await + .is_ok()); + } + + /// One event loop and one publish loop, however many routes: no + /// per-route pump. + #[tokio::test] + async fn the_native_backend_runs_two_tasks_whatever_the_route_count() { + use aimdb_core::connector::ConnectorBuilder; + + for routes in [0, 1, 5] { + let db = native_db(routes, &[]).await.expect("build"); + let futures = crate::MqttConnector::new("mqtt://127.0.0.1:1") + .build(&db) + .await + .expect("build connector"); + assert_eq!(futures.len(), 2, "{routes} routes"); + } + } + /// The plain scheme is unaffected by which backend, if any, is selected. #[tokio::test] async fn test_connector_mqtt_url_needs_no_tls_backend() { diff --git a/aimdb-mqtt-connector/tests/topic_provider_tests.rs b/aimdb-mqtt-connector/tests/topic_writer_tests.rs similarity index 72% rename from aimdb-mqtt-connector/tests/topic_provider_tests.rs rename to aimdb-mqtt-connector/tests/topic_writer_tests.rs index 03602d41..13e38657 100644 --- a/aimdb-mqtt-connector/tests/topic_provider_tests.rs +++ b/aimdb-mqtt-connector/tests/topic_writer_tests.rs @@ -1,7 +1,7 @@ -//! Integration tests for MQTT TopicProvider and TopicResolver functionality +//! Integration tests for MQTT TopicWriter and TopicResolver functionality //! //! These tests verify the dynamic topic routing features: -//! - **TopicProvider**: Outbound (AimDB → MQTT) dynamic topic selection per-value +//! - **TopicWriter**: Outbound (AimDB → MQTT) dynamic topic selection per-value //! - **TopicResolver**: Inbound (MQTT → AimDB) late-binding topic resolution at startup //! //! The tests use mock data and don't require a running MQTT broker. @@ -9,9 +9,10 @@ #![cfg(feature = "std")] use aimdb_core::buffer::BufferCfg; -use aimdb_core::connector::TopicProvider; +use aimdb_core::connector::{TopicBuf, TopicOverflow, TopicWriter}; use aimdb_core::{AimDbBuilder, Producer, RuntimeContext}; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use std::fmt::Write as _; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; @@ -67,107 +68,134 @@ impl Command { } // ============================================================================ -// TopicProvider Implementations +// TopicWriter Implementations // ============================================================================ -/// Dynamic topic provider that routes based on sensor_id +/// Dynamic topic writer that routes based on sensor_id /// /// This demonstrates the core use case: routing to different MQTT topics /// based on data content (e.g., multi-tenant, device-specific topics). -struct SensorIdTopicProvider; - -impl TopicProvider for SensorIdTopicProvider { - fn topic(&self, value: &Temperature) -> Option { - // Route to topic based on sensor_id - Some(format!("sensors/temp/{}", value.sensor_id)) +struct SensorIdTopic; + +impl TopicWriter for SensorIdTopic { + fn write_topic( + &self, + value: &Temperature, + out: &mut TopicBuf<'_>, + ) -> Result { + write!(out, "sensors/temp/{}", value.sensor_id)?; + Ok(true) } } -/// Topic provider that returns None for fallback testing +/// Topic writer that falls back to the link's default topic /// -/// When the provider returns None, the connector should use the default -/// topic from the link_to() URL. -struct FallbackTopicProvider; - -impl TopicProvider for FallbackTopicProvider { - fn topic(&self, value: &Temperature) -> Option { - // Return None for specific sensor_ids to test fallback +/// When the writer returns `Ok(false)`, the connector uses the default topic +/// from the link_to() URL. +struct FallbackTopic; + +impl TopicWriter for FallbackTopic { + fn write_topic( + &self, + value: &Temperature, + out: &mut TopicBuf<'_>, + ) -> Result { if value.sensor_id == "use-default" { - None - } else { - Some(format!("sensors/custom/{}", value.sensor_id)) + return Ok(false); } + write!(out, "sensors/custom/{}", value.sensor_id)?; + Ok(true) } } -/// Topic provider that uses temperature thresholds +/// Topic writer that uses temperature thresholds /// /// Demonstrates conditional routing based on data values. -struct ThresholdTopicProvider { +struct ThresholdTopic { threshold: f32, } -impl TopicProvider for ThresholdTopicProvider { - fn topic(&self, value: &Temperature) -> Option { +impl TopicWriter for ThresholdTopic { + fn write_topic( + &self, + value: &Temperature, + out: &mut TopicBuf<'_>, + ) -> Result { if value.celsius > self.threshold { - Some("alerts/high-temp".into()) + out.push_str("alerts/high-temp")?; } else if value.celsius < 0.0 { - Some("alerts/freezing".into()) + out.push_str("alerts/freezing")?; } else { - None // Use default topic for normal temps + return Ok(false); // Use default topic for normal temps } + Ok(true) + } +} + +/// What `writer` writes for `value` into 64 bytes: `Some(topic)`, or `None` +/// for the link's default. +fn written(writer: &dyn TopicWriter, value: &Temperature) -> Option { + let mut storage = [0u8; 64]; + let mut out = TopicBuf::new(&mut storage); + match writer.write_topic(value, &mut out) { + Ok(true) => Some(out.as_str().to_string()), + Ok(false) => None, + Err(TopicOverflow) => panic!("topic overflowed 64 bytes"), } } // ============================================================================ -// Unit Tests for TopicProvider +// Unit Tests for TopicWriter // ============================================================================ #[test] -fn test_sensor_id_topic_provider() { - let provider = SensorIdTopicProvider; +fn test_sensor_id_topic_writer() { + let writer = SensorIdTopic; let temp_indoor = Temperature::new("indoor-001", 22.0); assert_eq!( - provider.topic(&temp_indoor), + written(&writer, &temp_indoor), Some("sensors/temp/indoor-001".into()) ); let temp_outdoor = Temperature::new("outdoor-garden", 5.5); assert_eq!( - provider.topic(&temp_outdoor), + written(&writer, &temp_outdoor), Some("sensors/temp/outdoor-garden".into()) ); } #[test] -fn test_fallback_topic_provider() { - let provider = FallbackTopicProvider; +fn test_fallback_topic_writer() { + let writer = FallbackTopic; // Normal case: returns custom topic let temp = Temperature::new("kitchen", 21.0); - assert_eq!(provider.topic(&temp), Some("sensors/custom/kitchen".into())); + assert_eq!( + written(&writer, &temp), + Some("sensors/custom/kitchen".into()) + ); // Fallback case: returns None let temp_default = Temperature::new("use-default", 20.0); - assert_eq!(provider.topic(&temp_default), None); + assert_eq!(written(&writer, &temp_default), None); } #[test] -fn test_threshold_topic_provider() { - let provider = ThresholdTopicProvider { threshold: 30.0 }; +fn test_threshold_topic_writer() { + let writer = ThresholdTopic { threshold: 30.0 }; // Normal temperature: fallback to default let normal = Temperature::new("room", 22.0); - assert_eq!(provider.topic(&normal), None); + assert_eq!(written(&writer, &normal), None); // High temperature: route to alert topic let hot = Temperature::new("server-room", 35.0); - assert_eq!(provider.topic(&hot), Some("alerts/high-temp".into())); + assert_eq!(written(&writer, &hot), Some("alerts/high-temp".into())); // Freezing: route to freezing alert let cold = Temperature::new("outdoor", -5.0); - assert_eq!(provider.topic(&cold), Some("alerts/freezing".into())); + assert_eq!(written(&writer, &cold), Some("alerts/freezing".into())); } // ============================================================================ @@ -215,15 +243,15 @@ fn test_topic_resolver_with_config() { } // ============================================================================ -// Integration Test: TopicProvider with AimDbBuilder (No Connector) +// Integration Test: TopicWriter with AimDbBuilder (No Connector) // ============================================================================ -/// Test that TopicProvider can be configured without connector (verifies API) +/// Test that a record can be configured without connector (verifies API) /// /// Note: These tests verify the configuration API compiles and works, /// but don't test actual MQTT connectivity (that requires a broker). #[tokio::test] -async fn test_topic_provider_registration_api() { +async fn test_topic_writer_registration_api() { let runtime = Arc::new(TokioAdapter::new().unwrap()); let produced_count = Arc::new(AtomicU32::new(0)); let produced_count_clone = produced_count.clone(); @@ -250,20 +278,20 @@ async fn test_topic_provider_registration_api() { assert!(db.is_ok()); } -/// Test TopicProvider with MqttConnector registration +/// Test TopicWriter with MqttConnector registration /// -/// This test verifies the full configuration API including link_to + with_topic_provider -/// works correctly at compile time. Runtime requires actual MQTT broker. +/// This test verifies the full configuration API including link_to + with_topic_writer +/// works correctly. Runtime requires actual MQTT broker. #[tokio::test] -async fn test_topic_provider_with_connector_registration() { +async fn test_topic_writer_with_connector_registration() { let runtime = Arc::new(TokioAdapter::new().unwrap()); let mut builder = AimDbBuilder::new().runtime(runtime).with_connector( aimdb_mqtt_connector::MqttConnector::new("mqtt://localhost:1883") - .with_client_id("test-topic-provider"), + .with_client_id("test-topic-writer"), ); - // Register with dynamic topic provider - validates compile-time API + // Register with a dynamic topic writer builder.configure::("test.sensor.dynamic", |reg| { reg.buffer(BufferCfg::SingleLatest) .source( @@ -273,7 +301,7 @@ async fn test_topic_provider_with_connector_registration() { }, ) .link_to("mqtt://sensors/temp/default") // Fallback topic - .with_topic_provider(SensorIdTopicProvider) // Dynamic routing! + .with_topic_writer(64, SensorIdTopic) // Dynamic routing! .with_serializer(|_ctx, temp: &Temperature| Ok(temp.to_json_vec())) .finish(); }); @@ -345,7 +373,10 @@ async fn test_mixed_static_and_dynamic_topics() { }, ) .link_to("mqtt://sensors/temp/fallback") - .with_topic_provider(SensorIdTopicProvider) + .with_topic_fn(64, |temp, out| { + write!(out, "sensors/temp/{}", temp.sensor_id)?; + Ok(true) + }) .with_serializer(|_ctx, temp: &Temperature| Ok(temp.to_json_vec())) .finish(); }); @@ -355,110 +386,118 @@ async fn test_mixed_static_and_dynamic_topics() { } // ============================================================================ -// Test TopicProvider as a typed trait object +// Test TopicWriter as a typed trait object // ============================================================================ #[test] -fn test_topic_provider_as_trait_object() { - use aimdb_core::connector::TopicProvider; - use std::sync::Arc; - - // Providers are stored as Arc> and stay typed +fn test_topic_writer_as_trait_object() { + // Writers are stored as Arc> and stay typed // end-to-end — a wrong-type call is unrepresentable. - let provider: Arc> = Arc::new(SensorIdTopicProvider); + let writer: Arc> = Arc::new(SensorIdTopic); let temp = Temperature::new("kitchen", 22.0); - assert_eq!(provider.topic(&temp), Some("sensors/temp/kitchen".into())); + assert_eq!( + written(&*writer, &temp), + Some("sensors/temp/kitchen".into()) + ); +} + +#[test] +fn test_a_topic_over_the_capacity_overflows() { + let mut storage = [0u8; 8]; + let mut out = TopicBuf::new(&mut storage); + let temp = Temperature::new("kitchen", 22.0); + // "sensors/temp/kitchen" is 20 bytes. + assert_eq!( + SensorIdTopic.write_topic(&temp, &mut out), + Err(TopicOverflow) + ); } // ============================================================================ // Test: Simulate Connector Topic Resolution Logic // ============================================================================ // -// These tests simulate EXACTLY what the fused outbound reader does internally -// while it still holds the typed value: -// ```rust -// let dest = topic.as_ref().and_then(|p| p.topic(&value)); -// // ...later, in the pump: -// let dest = msg.dest.unwrap_or_else(|| default_topic.clone()); -// ``` +// These tests simulate what `OutboundRoutes` does while it still holds the +// typed value: the written topic, or the route's default on `Ok(false)` or +// without a writer. /// Simulates the fused reader's topic resolution for outbound messages fn resolve_topic_like_connector( default_topic: &str, - topic_provider: Option<&dyn aimdb_core::connector::TopicProvider>, + topic_writer: Option<&dyn TopicWriter>, value: &Temperature, ) -> String { - topic_provider - .and_then(|provider| provider.topic(value)) + topic_writer + .and_then(|writer| written(writer, value)) .unwrap_or_else(|| default_topic.to_string()) } #[test] -fn test_connector_topic_resolution_with_dynamic_provider() { - let provider = SensorIdTopicProvider; +fn test_connector_topic_resolution_with_dynamic_writer() { + let writer = SensorIdTopic; let default_topic = "sensors/temp/default"; - // Test 1: Dynamic topic is returned when provider returns Some + // Test 1: The written topic is returned when the writer returns Ok(true) let temp_kitchen = Temperature::new("kitchen", 22.0); - let resolved = resolve_topic_like_connector(default_topic, Some(&provider), &temp_kitchen); + let resolved = resolve_topic_like_connector(default_topic, Some(&writer), &temp_kitchen); assert_eq!(resolved, "sensors/temp/kitchen"); // Test 2: Different sensor_id → different topic let temp_bedroom = Temperature::new("bedroom", 19.5); - let resolved = resolve_topic_like_connector(default_topic, Some(&provider), &temp_bedroom); + let resolved = resolve_topic_like_connector(default_topic, Some(&writer), &temp_bedroom); assert_eq!(resolved, "sensors/temp/bedroom"); // Test 3: Multi-tenant scenario let temp_tenant_a = Temperature::new("tenant-a/room-1", 21.0); - let resolved = resolve_topic_like_connector(default_topic, Some(&provider), &temp_tenant_a); + let resolved = resolve_topic_like_connector(default_topic, Some(&writer), &temp_tenant_a); assert_eq!(resolved, "sensors/temp/tenant-a/room-1"); } #[test] fn test_connector_topic_resolution_fallback_to_default() { - let provider = FallbackTopicProvider; + let writer = FallbackTopic; let default_topic = "sensors/temp/default"; - // Test 1: Provider returns Some → use dynamic topic + // Test 1: Writer returns Ok(true) → use the written topic let temp_kitchen = Temperature::new("kitchen", 22.0); - let resolved = resolve_topic_like_connector(default_topic, Some(&provider), &temp_kitchen); + let resolved = resolve_topic_like_connector(default_topic, Some(&writer), &temp_kitchen); assert_eq!(resolved, "sensors/custom/kitchen"); - // Test 2: Provider returns None → fallback to default + // Test 2: Writer returns Ok(false) → fallback to default let temp_use_default = Temperature::new("use-default", 20.0); - let resolved = resolve_topic_like_connector(default_topic, Some(&provider), &temp_use_default); + let resolved = resolve_topic_like_connector(default_topic, Some(&writer), &temp_use_default); assert_eq!(resolved, "sensors/temp/default"); // Fallback! } #[test] -fn test_connector_topic_resolution_no_provider() { +fn test_connector_topic_resolution_no_writer() { let default_topic = "sensors/temp/static"; - // No provider configured → always use default + // No writer configured → always use default let temp = Temperature::new("kitchen", 22.0); let resolved = resolve_topic_like_connector(default_topic, None, &temp); assert_eq!(resolved, "sensors/temp/static"); } #[test] -fn test_connector_topic_resolution_with_threshold_provider() { - let provider = ThresholdTopicProvider { threshold: 30.0 }; +fn test_connector_topic_resolution_with_threshold_writer() { + let writer = ThresholdTopic { threshold: 30.0 }; let default_topic = "sensors/temp/normal"; - // Normal temperature → fallback (provider returns None) + // Normal temperature → fallback (writer returns Ok(false)) let normal_temp = Temperature::new("room", 22.0); - let resolved = resolve_topic_like_connector(default_topic, Some(&provider), &normal_temp); + let resolved = resolve_topic_like_connector(default_topic, Some(&writer), &normal_temp); assert_eq!(resolved, "sensors/temp/normal"); // Default // High temperature → alert topic let hot_temp = Temperature::new("server-room", 35.0); - let resolved = resolve_topic_like_connector(default_topic, Some(&provider), &hot_temp); + let resolved = resolve_topic_like_connector(default_topic, Some(&writer), &hot_temp); assert_eq!(resolved, "alerts/high-temp"); // Dynamic // Freezing → freezing alert let cold_temp = Temperature::new("outdoor", -5.0); - let resolved = resolve_topic_like_connector(default_topic, Some(&provider), &cold_temp); + let resolved = resolve_topic_like_connector(default_topic, Some(&writer), &cold_temp); assert_eq!(resolved, "alerts/freezing"); // Dynamic } From decce4ae76290a56d1c6fbfa893967127c03e8ea Mon Sep 17 00:00:00 2001 From: "sounds.like.lx" <147444674+lxsaah@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:55:56 +0200 Subject: [PATCH 19/28] =?UTF-8?q?Stage=2012:=20round-trip=20allocation=20g?= =?UTF-8?q?ate=20and=20Embassy=20outage=20semantics=20(design=20054=20?= =?UTF-8?q?=C2=A75,=20=C2=A78)=20(#290)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 4 + .../tests/outbound_routes.rs | 104 +++++++ .../tests/alloc_round_trip.rs | 264 ++++++++++++++++++ aimdb-mqtt-connector/tests/common/mod.rs | 78 ++++++ 4 files changed, 450 insertions(+) create mode 100644 aimdb-embassy-adapter/tests/outbound_routes.rs create mode 100644 aimdb-mqtt-connector/tests/alloc_round_trip.rs diff --git a/Makefile b/Makefile index 15881391..dc085e25 100644 --- a/Makefile +++ b/Makefile @@ -249,6 +249,8 @@ test: cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test tokio_broker @printf "$(YELLOW) → Testing MQTT connector (both backends, one broker, one process)$(NC)\n" cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-backend-parity" --test backend_parity + @printf "$(YELLOW) → Testing MQTT connector (allocations per round trip, both backends)$(NC)\n" + cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-backend-parity" --test alloc_round_trip @printf "$(YELLOW) → Testing MQTT connector (mqtts:// against a pinned self-signed root)$(NC)\n" cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-tls-broker" --test tls_broker @printf "$(YELLOW) → Testing MQTT connector (event-driven session: wake cadence, partial packets, QoS 1)$(NC)\n" @@ -400,6 +402,8 @@ clippy: cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test tokio_broker -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (backend parity)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-backend-parity" --test backend_parity -- -D warnings + @printf "$(YELLOW) → Clippy on MQTT connector (allocations per round trip)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-backend-parity" --test alloc_round_trip -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (mqtts:// host smoke)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tls-broker" --test tls_broker -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (event-driven session criteria)$(NC)\n" diff --git a/aimdb-embassy-adapter/tests/outbound_routes.rs b/aimdb-embassy-adapter/tests/outbound_routes.rs new file mode 100644 index 00000000..0b82ed87 --- /dev/null +++ b/aimdb-embassy-adapter/tests/outbound_routes.rs @@ -0,0 +1,104 @@ +//! What a connector pulling from `OutboundRoutes` sees after an outage, on the +//! Embassy buffers: the same cases as the Tokio adapter's +//! `outage_semantics_per_buffer_type`, driven on the host with a no-op waker. +#![cfg(all(feature = "embassy-sync", feature = "embassy-time"))] + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll, Waker}; + +use aimdb_core::buffer::DynBuffer; +use aimdb_core::connector::ConnectorBuilder; +use aimdb_core::executor::test_support::NoopRuntimeOps; +use aimdb_core::{AimDb, AimDbBuilder, DbResult, OutboundRoutes}; +use aimdb_embassy_adapter::EmbassyBuffer; +use futures::executor::block_on; + +// No-op defmt logger + host time driver, so the binary links. +aimdb_embassy_adapter::host_test_stubs!(); + +type Futures = Vec + Send + 'static>>>; + +/// Lets `link_to("test://…")` register; drives nothing. +struct TestConnector; + +impl ConnectorBuilder for TestConnector { + fn build<'a>( + &'a self, + _db: &'a AimDb, + ) -> Pin> + Send + 'a>> { + Box::pin(async { Ok(Vec::new()) }) + } + fn scheme(&self) -> &str { + "test" + } +} + +const KEYS: [&str; 3] = ["r0", "r1", "r2"]; + +/// One record per buffer, keyed `r0`, `r1`, …, each linked to `test://r{i}`. +fn db(buffers: Vec>>) -> AimDb { + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(NoopRuntimeOps)) + .with_connector(TestConnector); + for (i, buffer) in buffers.into_iter().enumerate() { + builder.configure::(KEYS[i], move |reg| { + reg.buffer_raw(buffer) + .link_to(&format!("test://r{i}")) + .with_serializer(|_ctx, v: &u32| Ok(v.to_le_bytes().to_vec())) + .finish(); + }); + } + block_on(builder.build()).expect("build").0 +} + +/// Every message available now, as (route, value). +fn drain(o: &mut OutboundRoutes) -> Vec<(usize, u32)> { + let mut cx = Context::from_waker(Waker::noop()); + let mut out = Vec::new(); + while let Poll::Ready(Some(m)) = o.poll_next(&mut cx) { + out.push(( + m.route.id, + u32::from_le_bytes(m.payload.as_slice().try_into().unwrap()), + )); + } + out +} + +fn values(got: &[(usize, u32)], route: usize) -> Vec { + got.iter() + .filter(|(id, _)| *id == route) + .map(|(_, v)| *v) + .collect() +} + +#[test] +fn outage_semantics_per_buffer_type() { + let db = db(vec![ + Box::new(EmbassyBuffer::::new_watch()), + Box::new(EmbassyBuffer::::new_mailbox()), + Box::new(EmbassyBuffer::::new_spmc()), + ]); + let mut o = OutboundRoutes::new(&db, "test").unwrap(); + for v in 0..5 { + for key in KEYS { + db.produce::(key, v).unwrap(); + } + } + let got = drain(&mut o); + assert_eq!(values(&got, 0), [4], "single-latest"); + assert_eq!(values(&got, 1), [4], "mailbox"); + assert_eq!(values(&got, 2), [0, 1, 2, 3, 4], "spmc ring"); +} + +#[test] +fn an_spmc_ring_that_overflows_reports_lag_then_recovers() { + let db = db(vec![Box::new(EmbassyBuffer::::new_spmc())]); + let mut o = OutboundRoutes::new(&db, "test").unwrap(); + for v in 0..10 { + db.produce::("r0", v).unwrap(); + } + assert_eq!(values(&drain(&mut o), 0), [6, 7, 8, 9]); + assert_eq!(o.stats(0).unwrap().lagged, 6); +} diff --git a/aimdb-mqtt-connector/tests/alloc_round_trip.rs b/aimdb-mqtt-connector/tests/alloc_round_trip.rs new file mode 100644 index 00000000..fa3a6381 --- /dev/null +++ b/aimdb-mqtt-connector/tests/alloc_round_trip.rs @@ -0,0 +1,264 @@ +//! Allocations per MQTT round trip, per backend (`_test-backend-parity`). +//! +//! One round trip: produce → PUBLISH QoS 1 → the broker's PUBACK and echo → +//! inbound dispatch → the client's PUBACK → reader `recv`. The database, its +//! connector and the produce/recv loop run on one thread with a current-thread +//! runtime; a counting allocator counts only on that thread, so the broker on +//! another thread is not measured. +//! +//! The embedded backend's one remaining copy per message is topic and payload +//! from `OutboundRoutes`' scratch into the encoded frame; it allocates nothing. +//! The native backend's count is `rumqttc`'s: `AsyncClient::publish` takes an +//! owned topic and payload, and builds its own request. +#![cfg(feature = "_test-backend-parity")] + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio::net::TcpListener; + +use aimdb_core::buffer::BufferCfg; +use aimdb_core::connector::{ConnectorBuilder, SerializeError}; +use aimdb_core::AimDbBuilder; +use aimdb_mqtt_connector::MqttConnector; +use aimdb_tokio_adapter::net::TokioNet; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + +mod common; +use common::echo_broker; + +// Each test binary defines these exactly once. +#[defmt::global_logger] +struct HostTestLogger; +unsafe impl defmt::Logger for HostTestLogger { + fn acquire() {} + unsafe fn flush() {} + unsafe fn release() {} + unsafe fn write(_bytes: &[u8]) {} +} +#[defmt::panic_handler] +fn defmt_panic() -> ! { + core::panic!("defmt panic in host test") +} +defmt::timestamp!("{=u64:us}", 0); + +struct HostClock; +impl embassy_time_driver::Driver for HostClock { + fn now(&self) -> u64 { + use std::sync::OnceLock; + static START: OnceLock = OnceLock::new(); + let start = START.get_or_init(Instant::now); + (start.elapsed().as_micros() * u128::from(embassy_time_driver::TICK_HZ) / 1_000_000) as u64 + } + fn schedule_wake(&self, _at: u64, waker: &core::task::Waker) { + waker.wake_by_ref(); + } +} +embassy_time_driver::time_driver_impl!(static HOST_CLOCK: HostClock = HostClock); + +// --------------------------------------------------------------------------- +// Counting allocator: per-thread counters, so tests may run in parallel. +// --------------------------------------------------------------------------- + +struct Counting; + +thread_local! { + /// Set on the database thread; nothing else is counted. + static COUNT_HERE: Cell = const { Cell::new(false) }; + /// Set only during the measured round trips. + static WINDOW: Cell = const { Cell::new(false) }; + static ALLOCS: Cell = const { Cell::new(0) }; + static BYTES: Cell = const { Cell::new(0) }; + /// Bytes this thread allocated and has not freed. + static LIVE: Cell = const { Cell::new(0) }; +} + +fn add(cell: &'static std::thread::LocalKey>, n: usize) { + cell.with(|c| c.set(c.get() + n)); +} + +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + if COUNT_HERE.with(Cell::get) { + add(&LIVE, layout.size()); + if WINDOW.with(Cell::get) { + add(&ALLOCS, 1); + add(&BYTES, layout.size()); + } + } + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + if COUNT_HERE.with(Cell::get) { + LIVE.with(|c| c.set(c.get().saturating_sub(layout.size()))); + } + unsafe { System.dealloc(ptr, layout) } + } +} + +#[global_allocator] +static GLOBAL: Counting = Counting; + +// --------------------------------------------------------------------------- +// The round trip. +// --------------------------------------------------------------------------- + +const WARMUP: u64 = 100; +const MEASURED: u64 = 300; +const TOPIC: &str = "mqtt://rt/ping"; + +struct Report { + allocs: usize, + bytes: usize, + live_before: usize, + median: Duration, + min: Duration, + max: Duration, +} + +/// Round trips through `connector` on a fresh thread; `COUNT_HERE` is on for +/// that thread only. +fn measure(connector: impl ConnectorBuilder + 'static) -> Report { + std::thread::spawn(move || { + COUNT_HERE.with(|c| c.set(true)); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + runtime.block_on(round_trips(connector)) + }) + .join() + .expect("database thread") +} + +async fn round_trips(connector: impl ConnectorBuilder + 'static) -> Report { + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + builder.configure::("ping", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_to(TOPIC) + .with_serializer(|_ctx, v: &u64| Ok(v.to_le_bytes().to_vec())) + .with_serializer_into(8, |_ctx, v: &u64, out| { + out.get_mut(..8) + .ok_or(SerializeError::BufferTooSmall)? + .copy_from_slice(&v.to_le_bytes()); + Ok(8) + }) + .finish(); + }); + builder.configure::("pong", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from(TOPIC) + .with_deserializer(|_ctx, data: &[u8]| { + data.try_into() + .map(u64::from_le_bytes) + .map_err(|_| String::from("not 8 bytes")) + }) + .finish(); + }); + let (db, runner) = builder.build().await.expect("build db"); + tokio::spawn(runner.run()); + let producer = db.producer::("ping").expect("producer"); + let mut pong = db.subscribe::("pong").expect("subscribe"); + + // Warm-up also waits out the connect and subscribe. + tokio::time::timeout(Duration::from_secs(60), async { + for n in 0..WARMUP { + round_trip(&producer, &mut pong, n).await; + } + }) + .await + .expect("warm-up round trips"); + + let mut latencies = Vec::with_capacity(MEASURED as usize); + let live_before = LIVE.with(Cell::get); + ALLOCS.with(|c| c.set(0)); + BYTES.with(|c| c.set(0)); + WINDOW.with(|w| w.set(true)); + for n in WARMUP..WARMUP + MEASURED { + let start = Instant::now(); + round_trip(&producer, &mut pong, n).await; + latencies.push(start.elapsed()); + } + WINDOW.with(|w| w.set(false)); + + latencies.sort(); + Report { + allocs: ALLOCS.with(Cell::get), + bytes: BYTES.with(Cell::get), + live_before, + median: latencies[latencies.len() / 2], + min: latencies[0], + max: latencies[latencies.len() - 1], + } +} + +/// Produce `n` and wait until it comes back through the broker. +async fn round_trip( + producer: &aimdb_core::Producer, + pong: &mut aimdb_core::buffer::Reader, + n: u64, +) { + producer.produce(n); + while pong.recv().await.expect("pong open") != n {} +} + +/// An echo broker on its own thread; returns its port. +fn broker() -> u16 { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + runtime.block_on(async move { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + tx.send(listener.local_addr().unwrap().port()).unwrap(); + echo_broker(listener).await; + }); + }); + rx.recv().expect("broker port") +} + +fn print(backend: &str, r: &Report) { + println!( + "{backend:<8} {:>6.2} allocs/round trip {:>8.1} bytes/round trip live heap before: {} B latency median {:?} ({:?}–{:?})", + r.allocs as f64 / MEASURED as f64, + r.bytes as f64 / MEASURED as f64, + r.live_before, + r.median, + r.min, + r.max + ); +} + +/// The embedded backend allocates nothing per round trip. +#[test] +fn the_embedded_backend_allocates_nothing_per_round_trip() { + let port = broker(); + let report = + measure(MqttConnector::new(format!("mqtt://127.0.0.1:{port}")).transport(TokioNet::tcp())); + print("embedded", &report); + assert_eq!( + report.allocs, 0, + "{} allocations in {MEASURED} round trips", + report.allocs + ); +} + +/// The native backend's allocations are `rumqttc`'s; the bound is what the +/// prototype measured. +#[test] +fn the_native_backend_stays_within_rumqttcs_allocations() { + let port = broker(); + let report = measure(MqttConnector::new(format!("mqtt://127.0.0.1:{port}"))); + print("native", &report); + let per_round_trip = report.allocs as f64 / MEASURED as f64; + assert!( + per_round_trip <= 11.0, + "{per_round_trip:.2} allocations per round trip" + ); +} diff --git a/aimdb-mqtt-connector/tests/common/mod.rs b/aimdb-mqtt-connector/tests/common/mod.rs index b6a28d5f..eb4c12fc 100644 --- a/aimdb-mqtt-connector/tests/common/mod.rs +++ b/aimdb-mqtt-connector/tests/common/mod.rs @@ -716,3 +716,81 @@ impl Delay for CountingDialer { Delay::sleep(&self.inner, d) } } + +// =========================================================================== +// The echo broker: relays a client's publish back to it when it subscribed to +// that topic, as a real broker would. +// =========================================================================== + +/// A QoS 1 PUBLISH in the protocol version the client connected with. +fn publish_qos1_for(topic: &str, payload: &[u8], packet_id: u16, v5: bool) -> Vec { + if v5 { + return publish_qos1(topic, payload, packet_id); + } + let mut rest = Vec::new(); + rest.extend_from_slice(&(topic.len() as u16).to_be_bytes()); + rest.extend_from_slice(topic.as_bytes()); + rest.extend_from_slice(&packet_id.to_be_bytes()); + rest.extend_from_slice(payload); + let mut packet = vec![0x32]; + varint(rest.len(), &mut packet); + packet.extend_from_slice(&rest); + packet +} + +/// Serve one connection: CONNACK, SUBACK (recording the topics), PUBACK every +/// QoS 1 publish, and send a publish on a subscribed topic back at QoS 1. +async fn serve_echo(mut socket: TcpStream) { + let _ = socket.set_nodelay(true); + let mut buf = Vec::new(); + let mut v5 = true; + let mut subscribed: Vec = Vec::new(); + let mut next_id: u16 = 0; + loop { + let Some((first, body)) = read_packet(&mut socket, &mut buf).await else { + return; + }; + let reply: Vec = match first >> 4 { + 1 => { + v5 = is_v5(&body); + if v5 { + vec![0x20, 0x03, 0x00, 0x00, 0x00] + } else { + vec![0x20, 0x02, 0x00, 0x00] + } + } + 8 => suback(&body, v5, &mut subscribed), + 3 => { + let Some((topic, payload, packet_id)) = parse_publish(first, &body, v5) else { + return; + }; + let mut reply = Vec::new(); + if let Some(id) = packet_id { + reply.extend_from_slice(&[0x40, 0x02, id[0], id[1]]); + } + if subscribed.iter().any(|t| t == &topic) { + next_id = next_id.wrapping_add(1).max(1); + reply.extend_from_slice(&publish_qos1_for(&topic, &payload, next_id, v5)); + } + reply + } + 12 => vec![0xD0, 0x00], + 14 => return, + // PUBACKs for the echoes, and anything else: nothing to answer. + _ => continue, + }; + if socket.write_all(&reply).await.is_err() { + return; + } + } +} + +/// Accept forever, serving each connection with [`serve_echo`]. +pub async fn echo_broker(listener: TcpListener) { + loop { + let Ok((socket, _)) = listener.accept().await else { + return; + }; + tokio::spawn(serve_echo(socket)); + } +} From bd68be174e44365f4ad2553ada3dff0a9b4322ad Mon Sep 17 00:00:00 2001 From: "sounds.like.lx" <147444674+lxsaah@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:09:50 +0200 Subject: [PATCH 20/28] fix(mqtt): mask the broker password in native error lines (#291) --- aimdb-mqtt-connector/src/native.rs | 64 ++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/aimdb-mqtt-connector/src/native.rs b/aimdb-mqtt-connector/src/native.rs index d06298b9..efebf760 100644 --- a/aimdb-mqtt-connector/src/native.rs +++ b/aimdb-mqtt-connector/src/native.rs @@ -60,7 +60,7 @@ pub(crate) fn build<'a>( })?; let futures: Vec = vec![ - Box::pin(run_event_loop(event_loop, inbound, broker_url.to_string())), + Box::pin(run_event_loop(event_loop, inbound, redacted(broker_url))), Box::pin(run_publish_loop(client, outbound, opts)), ]; Ok(futures) @@ -91,8 +91,10 @@ impl MqttConnectorImpl { url = format!("{}/dummy", url.trim_end_matches('/')); } - let connector_url = - ConnectorUrl::parse(&url).map_err(|e| format!("Invalid MQTT URL: {}", e))?; + let connector_url = ConnectorUrl::parse(&url).map_err(|e| { + // The parse error quotes its input, password included. + format!("Invalid MQTT URL: {}", e).replace(&url, &redacted(&url)) + })?; let host = connector_url.host.clone(); let port = connector_url.port.unwrap_or_else(|| { @@ -254,6 +256,26 @@ async fn run_publish_loop( log_info!("MQTT publish loop: every outbound route has closed"); } +/// `url` with the password in its authority masked, as `ConnectorUrl`'s +/// `Display` masks it: `mqtt://user:****@host:1883`. Safe for logs and errors. +/// A URL missing its scheme (the one input `ConnectorUrl::parse` rejects, and +/// quotes) is masked the same way. +fn redacted(url: &str) -> String { + let (prefix, rest) = match url.split_once("://") { + Some((_, rest)) => (url.len() - rest.len(), rest), + None => (0, url), + }; + let authority_end = rest.find('/').unwrap_or(rest.len()); + let (authority, path) = rest.split_at(authority_end); + let Some((userinfo, host)) = authority.rsplit_once('@') else { + return url.to_string(); + }; + match userinfo.split_once(':') { + Some((user, _password)) => format!("{}{user}:****@{host}{path}", &url[..prefix]), + None => url.to_string(), + } +} + /// The TLS configuration for `mqtts://`, from whichever backend this build /// selected. #[cfg(feature = "tokio-native-tls")] @@ -420,6 +442,42 @@ mod tests { } } + #[test] + fn redacted_masks_only_the_password() { + assert_eq!( + redacted("mqtt://aimdb:s3cret@localhost:1884"), + "mqtt://aimdb:****@localhost:1884" + ); + assert_eq!( + redacted("mqtts://user:p@ss:w0rd@broker.example.com:8883/x"), + "mqtts://user:****@broker.example.com:8883/x" + ); + // Without a scheme, as a mistyped URL arrives. + assert_eq!( + redacted("user:s3cret@localhost:1884"), + "user:****@localhost:1884" + ); + // Nothing to mask: unchanged. + for url in [ + "mqtt://localhost:1883", + "mqtt://user@localhost:1883", + "not-a-url", + ] { + assert_eq!(redacted(url), url); + } + } + + #[tokio::test] + async fn an_invalid_url_error_does_not_repeat_the_password() { + let Err(err) = + MqttConnectorImpl::build_internal("user:s3cret@localhost:1884", None, None, 60, &[]) + .await + else { + panic!("a URL without a scheme must not build"); + }; + assert!(!err.contains("s3cret"), "{err}"); + } + /// The plain scheme is unaffected by which backend, if any, is selected. #[tokio::test] async fn test_connector_mqtt_url_needs_no_tls_backend() { From d4891f56b3e770e9fc0cb524d0566cc876f3d9da Mon Sep 17 00:00:00 2001 From: "sounds.like.lx" <147444674+lxsaah@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:31:59 +0200 Subject: [PATCH 21/28] =?UTF-8?q?feat(core):=20pump=5Fclient=20pulls=20fro?= =?UTF-8?q?m=20OutboundRoutes=20in=20one=20task=20(design=20054=20=C2=A74.?= =?UTF-8?q?8)=20(#292)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aimdb-client/tests/pump_client.rs | 73 ++++++++++++++ aimdb-core/src/inbound_dispatch.rs | 9 ++ aimdb-core/src/session/client.rs | 95 ++++++++----------- aimdb-core/src/session/connector.rs | 9 +- .../src/client/builder.rs | 11 +-- 5 files changed, 130 insertions(+), 67 deletions(-) diff --git a/aimdb-client/tests/pump_client.rs b/aimdb-client/tests/pump_client.rs index 4e44877a..1fb512a6 100644 --- a/aimdb-client/tests/pump_client.rs +++ b/aimdb-client/tests/pump_client.rs @@ -133,3 +133,76 @@ async fn pump_client_mirrors_record_both_directions() { .await; assert!(mirrored_in, "server→client mirror did not reach the client"); } + +/// Three outbound routes mirror through one task: all three reach the server, +/// and the client connector contributes one outbound future beside its engine. +#[tokio::test] +async fn three_outbound_routes_mirror_from_one_task() { + use aimdb_core::connector::ConnectorBuilder; + + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("aimdb.sock"); + const KEYS: [&str; 3] = ["a", "b", "c"]; + + let mut policy = SecurityPolicy::read_write(); + for key in KEYS { + policy.allow_write_key(key); + } + let config = AimxConfig::uds_default() + .socket_path(sock.to_str().unwrap()) + .security_policy(policy); + let mut sb = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(UdsServer::from_config(config)); + for key in KEYS { + sb.configure::(key, |reg| { + reg.buffer(BufferCfg::SingleLatest).with_remote_access(); + }); + } + let (server_db, server_runner) = sb.build().await.expect("build server db"); + let server_db = Arc::new(server_db); + tokio::spawn(server_runner.run()); + + let client = || { + UdsClient::new(&sock).with_config(ClientConfig { + reconnect: true, + reconnect_delay: 50, + max_reconnect_delay: 50, + max_reconnect_attempts: 0, + keepalive_interval: None, + max_offline_queue: 1024, + sends_hello: false, + }) + }; + let mut cb = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(client()); + for key in KEYS { + cb.configure::(key, move |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_to(&format!("uds://{key}")) + .with_serializer(|_ctx, m: &Msg| Ok(serde_json::to_vec(m).expect("serialize"))) + .finish(); + }); + } + let (client_db, client_runner) = cb.build().await.expect("build client db"); + let client_db = Arc::new(client_db); + + // Built, never polled: the engine dials only when driven. + let futures = client().build(&client_db).await.expect("build connector"); + assert_eq!(futures.len(), 2, "one outbound future and the engine"); + drop(futures); + + tokio::spawn(client_runner.run()); + for (i, key) in KEYS.iter().enumerate() { + let want = json!({ "v": i }); + let mirrored = mirror_reaches(&server_db, key, &want, || { + client_db + .set_record_from_json(key, json!({ "v": i })) + .expect("set client record"); + }) + .await; + assert!(mirrored, "client→server mirror of '{key}' did not arrive"); + } +} diff --git a/aimdb-core/src/inbound_dispatch.rs b/aimdb-core/src/inbound_dispatch.rs index 3b55be4b..5e264c99 100644 --- a/aimdb-core/src/inbound_dispatch.rs +++ b/aimdb-core/src/inbound_dispatch.rs @@ -31,6 +31,15 @@ impl InboundDispatch { }) } + /// A dispatcher over an already compiled `router`, for the session tests. + #[cfg(all(test, feature = "connector-session"))] + pub(crate) fn from_parts(router: Router, ctx: RuntimeContext) -> Self { + Self { + router: Arc::new(router), + ctx, + } + } + /// Matches `topic`, deserializes `payload` and produces into every /// matching record. /// diff --git a/aimdb-core/src/session/client.rs b/aimdb-core/src/session/client.rs index d3a966cb..34d2c8aa 100644 --- a/aimdb-core/src/session/client.rs +++ b/aimdb-core/src/session/client.rs @@ -27,7 +27,6 @@ use super::{ BoxFut, BoxStream, Connection, Dialer, EnvelopeCodec, Inbound, Outbound, Payload, RpcError, SubUpdate, TransportError, }; -use crate::router::Router; use crate::AimDb; /// Capacity of a subscription's client-side event sink. Bounded (was @@ -841,82 +840,68 @@ where /// [`run_client`] engine — the connector-link half of the client capability. /// /// For the given connector `scheme` (e.g. `"aimx"`): -/// - **outbound** routes (`db.collect_outbound_routes`) stream local record -/// updates to the remote via [`ClientHandle::write`]; -/// - **inbound** routes (`router`, from [`AimDb::inbound_router`]) subscribe to the remote and +/// - **outbound** routes ([`OutboundRoutes`](crate::OutboundRoutes)) stream +/// local record updates to the remote via [`ClientHandle::write`], all from +/// one task; +/// - **inbound** routes (`inbound`, an [`InboundDispatch`](crate::InboundDispatch)) subscribe to the remote and /// produce each update into the local record through the producer/arbiter path /// — single-writer-per-key stays intact (a mirrored-in record is produced /// through its inbound producer, never a direct co-writer). Mirroring is /// latest-state and best-effort — a gap the server reports /// ([`SubUpdate::skipped`]) is logged and stepped over, never backfilled. /// -/// Returns one spawn-free pump future per route for the runner to drive -/// (mirroring the `ConnectorBuilder::build -> Vec` spine); it drives -/// the **same** engine as [`run_client`], never a second one. +/// Returns spawn-free futures for the runner to drive (mirroring the +/// `ConnectorBuilder::build -> Vec` spine): one for every outbound +/// route together, when there are any, and one per inbound subscription. They +/// drive the **same** engine as [`run_client`], never a second one. Fails when +/// an outbound link cannot be served (see +/// [`OutboundRoutes::new`](crate::OutboundRoutes::new)). +/// +/// [`ClientHandle::write`] enqueues onto the engine's bounded command queue +/// and displaces the oldest command when it is full, so during an outage that +/// queue, not the record buffer, decides what survives. /// /// Reconnect caveat: inbound pumps subscribe once and are not replayed across a /// reconnect (see [`ClientConfig::reconnect`]); outbound mirroring is unaffected. pub fn pump_client( db: &AimDb, scheme: &str, - router: Router, + inbound: crate::InboundDispatch, handle: &ClientHandle, -) -> Vec> { - // The runtime context for context-aware (de)serializers. - let ctx = db.runtime_ctx(); +) -> crate::DbResult>> { let mut pumps: Vec> = Vec::new(); - // --- outbound: local record updates -> remote `write` ------------------ - for crate::OutboundRoute { - topic: destination, - source, - .. - } in db.collect_outbound_routes(scheme) - { + // --- outbound: local record updates -> remote `write`, one task -------- + // Skips, lag and closed routes are handled and counted inside + // `OutboundRoutes`; `next` ends once every route has closed. + let mut outbound = crate::OutboundRoutes::new(db, scheme)?; + if !outbound.routes().is_empty() { let handle = handle.clone(); - let ctx = ctx.clone(); pumps.push(Box::pin(async move { - let mut reader = source.subscribe(); - loop { - // The fused reader yields destination + serialized payload - // (serialize failures are logged and skipped inside it). - let msg = match reader.recv(&ctx).await { - Ok(m) => m, - // Lagged (ring overflow) — skip the gap, keep mirroring. - Err(crate::DbError::BufferLagged { .. }) => continue, - // Buffer closed — the record is gone; end this mirror. - Err(_) => break, + while let Some(msg) = outbound.next().await { + let payload = match msg.payload { + crate::OutboundPayload::Borrowed(bytes) => Payload::from(bytes), + crate::OutboundPayload::Owned(bytes) => Payload::from(bytes), }; - // Dynamic destination (topic provider) or the static link target. - let dest = msg.dest.unwrap_or_else(|| destination.clone()); - if handle - .write(dest, Payload::from(msg.payload.as_slice())) - .is_err() - { + if handle.write(msg.topic, payload).is_err() { break; // engine stopped — all handles dropped } } })); } - // --- inbound: remote events -> local producer (via the Router) --------- - // The Router applies each route's deserializer and produces the value; one - // subscription per unique remote topic feeds it. - let router = Arc::new(router); - for id in router.subscriptions() { - pumps.push(Box::pin(inbound_pump( - handle.clone(), - router.clone(), - id, - ctx.clone(), - ))); + // --- inbound: remote events -> local producer -------------------------- + // The dispatcher applies each route's deserializer and produces the + // value; one subscription per unique remote topic feeds it. + for id in inbound.subscriptions() { + pumps.push(Box::pin(inbound_pump(handle.clone(), inbound.clone(), id))); } - pumps + Ok(pumps) } /// Drive one inbound mirror: subscribe to remote topic `id` and produce every -/// update into the local record through the [`Router`](crate::router::Router). +/// update into the local record through the [`InboundDispatch`](crate::InboundDispatch). /// /// **Loss contract: a mirror is latest-state, best-effort.** A mirrored record /// answers "what is the current value", not "what was every value" — so a gap @@ -927,12 +912,7 @@ pub fn pump_client( /// `AimxConnection` (`aimdb-client`), which surfaces `skipped` per update; /// [`ClientHandle::subscribe`] carries the same metadata for anyone riding the /// handle directly. -async fn inbound_pump( - handle: ClientHandle, - router: Arc, - id: Arc, - ctx: crate::RuntimeContext, -) { +async fn inbound_pump(handle: ClientHandle, inbound: crate::InboundDispatch, id: Arc) { let mut stream = match handle.subscribe(id.as_ref()) { Ok(s) => s, Err(_e) => return, @@ -955,7 +935,7 @@ async fn inbound_pump( update.skipped ); } - let _ = router.route(id.as_ref(), &update.data, &ctx); + inbound.dispatch(id.as_ref(), &update.data); } // Server rejected the subscription — terminal, not replayed. Err(_e) => break, @@ -1563,12 +1543,13 @@ mod tests { Ok(()) }); let route = crate::router::CompiledRoute::exact("tele", ingest); - let router = Arc::new(Router::new(&crate::ExactGrammar, alloc::vec![route])); + let router = crate::router::Router::new(&crate::ExactGrammar, alloc::vec![route]); let ctx = crate::RuntimeContext::new(Arc::new(crate::executor::test_support::NoopRuntimeOps)); + let inbound = crate::InboundDispatch::from_parts(router, ctx); let (handle, cmd_rx, _prune_rx) = test_handle(); - let pump = inbound_pump(handle, router, Arc::from("tele"), ctx); + let pump = inbound_pump(handle, inbound, Arc::from("tele")); let remote = async { // The pump subscribes on its first poll; take its sink and play a // gapped update followed by a clean one. diff --git a/aimdb-core/src/session/connector.rs b/aimdb-core/src/session/connector.rs index 6e883022..09fff3b9 100644 --- a/aimdb-core/src/session/connector.rs +++ b/aimdb-core/src/session/connector.rs @@ -101,10 +101,11 @@ where self.config.clone(), db.runtime_ops(), ); - // One pump future per route; each holds a `ClientHandle` clone, so the - // engine stays alive as long as any mirror runs. `handle` drops here. - let router = db.inbound_router(&self.scheme, &crate::ExactGrammar)?; - let mut futures = pump_client(db, &self.scheme, router, &handle); + // One outbound future and one per inbound subscription; each holds a + // `ClientHandle` clone, so the engine stays alive as long as any + // mirror runs. `handle` drops here. + let inbound = crate::InboundDispatch::new(db, &self.scheme, &crate::ExactGrammar)?; + let mut futures = pump_client(db, &self.scheme, inbound, &handle)?; futures.push(engine_fut); Ok(futures) }) diff --git a/aimdb-websocket-connector/src/client/builder.rs b/aimdb-websocket-connector/src/client/builder.rs index 5d898b8a..2cb2c0ca 100644 --- a/aimdb-websocket-connector/src/client/builder.rs +++ b/aimdb-websocket-connector/src/client/builder.rs @@ -9,18 +9,17 @@ //! ```text //! AimDbBuilder::build() //! └─ WsClientConnectorBuilder::build(&db) -//! ├─ db.inbound_router("ws-client", &ExactGrammar) → Router -//! ├─ db.collect_outbound_routes("ws-client") → outbound futures +//! ├─ InboundDispatch::new(db, "ws-client", &ExactGrammar) //! ├─ connect to remote WebSocket server //! ├─ build connector_future (read + write + keepalive + reconnect) -//! ├─ build outbound publisher futures +//! ├─ pump_client: one outbound future, one per inbound subscription //! └─ return Vec (drained by AimDbRunner) //! ``` use std::pin::Pin; use aimdb_core::session::{aimx::AimxCodec, pump_client, run_client, ClientConfig}; -use aimdb_core::{ConnectorBuilder, ExactGrammar}; +use aimdb_core::{ConnectorBuilder, ExactGrammar, InboundDispatch}; use crate::transport::WsDialer; @@ -171,8 +170,8 @@ impl ConnectorBuilder for WsClientConnectorBuilder { config, db.runtime_ops(), ); - let router = db.inbound_router("ws-client", &ExactGrammar)?; - let mut futures = pump_client(db, "ws-client", router, &handle); + let inbound = InboundDispatch::new(db, "ws-client", &ExactGrammar)?; + let mut futures = pump_client(db, "ws-client", inbound, &handle)?; futures.push(engine_fut); Ok(futures) }) From f34ba37194c55f30640bf3e458f7e62918b92f53 Mon Sep 17 00:00:00 2001 From: "sounds.like.lx" <147444674+lxsaah@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:45:50 +0200 Subject: [PATCH 22/28] feat(websocket): server dispatches writes and pulls broadcasts (design 054) (#293) --- .../src/server/builder.rs | 76 +++++++++----- .../src/server/client_manager.rs | 4 +- .../src/server/connector.rs | 66 ------------- .../src/server/dispatch.rs | 16 ++- aimdb-websocket-connector/src/server/mod.rs | 3 +- .../src/server/session.rs | 1 - .../tests/decouple_record_keys_topics.rs | 98 +++++++++++-------- aimdb-websocket-connector/tests/e2e.rs | 13 +-- 8 files changed, 127 insertions(+), 150 deletions(-) delete mode 100644 aimdb-websocket-connector/src/server/connector.rs diff --git a/aimdb-websocket-connector/src/server/builder.rs b/aimdb-websocket-connector/src/server/builder.rs index 59205b5d..b35e5708 100644 --- a/aimdb-websocket-connector/src/server/builder.rs +++ b/aimdb-websocket-connector/src/server/builder.rs @@ -8,10 +8,10 @@ //! ```text //! AimDbBuilder::build() //! └─ WebSocketConnectorBuilder::build(&db) -//! ├─ inbound Router (client writes → producers, via the session Dispatch) -//! ├─ outbound `pump_sink` over the `WsBusSink` (records → broadcast bus) +//! ├─ InboundDispatch (client writes → producers, via the session Dispatch) +//! ├─ OutboundRoutes (records → broadcast bus, one broadcast loop) //! ├─ start Axum / WebSocket server (per-connection `run_session`) -//! └─ return the server + pump futures +//! └─ return the server and broadcast-loop futures //! ``` use std::{ @@ -24,7 +24,7 @@ use std::{ use aimdb_data_contracts::Streamable; -use aimdb_core::{pump_sink, ConnectorBuilder, Dispatch, ExactGrammar}; +use aimdb_core::{ConnectorBuilder, Dispatch, ExactGrammar, InboundDispatch, OutboundRoutes}; use axum::Router as AxumRouter; use aimdb_core::topic_matches; @@ -32,7 +32,6 @@ use aimdb_core::topic_matches; use super::{ auth::{AuthHandler, DynAuthHandler, NoAuth}, client_manager::ClientManager, - connector::{SnapshotCache, WsBusSink}, dispatch::WsDispatch, http::{build_server_future, ServerState}, registry::StreamableRegistry, @@ -277,14 +276,29 @@ impl ConnectorBuilder for WebSocketConnectorBuilder { { Box::pin(async move { // ── Inbound routes ────────────────────────────────────── - let router = Arc::new(db.inbound_router("ws", &ExactGrammar)?); + let inbound = InboundDispatch::new(db, "ws", &ExactGrammar)?; #[cfg(feature = "tracing")] tracing::info!( "WS connector: {} inbound routes collected", - router.route_count() + inbound.route_count() ); + // ── Outbound routes ───────────────────────────────────── + // Every route carries its record's index, which the broadcast + // bus checks client permissions against. + let outbound = OutboundRoutes::new(db, "ws")?; + if let Some(route) = outbound + .routes() + .iter() + .find(|r| r.config.record_index.is_none()) + { + return Err(aimdb_core::DbError::runtime_error(format!( + "WS connector: outbound route '{}' has no record index", + route.default_topic + ))); + } + // ── Late-join snapshot cache (only when enabled) ────── let snapshot_map: Option = self.late_join.then(|| Arc::new(Mutex::new(HashMap::new()))); @@ -319,23 +333,14 @@ impl ConnectorBuilder for WebSocketConnectorBuilder { client_mgr: client_mgr.clone(), snapshot_provider, query_handler: self.query_handler.clone(), - router: router.clone(), + inbound, schema_by_type, auth: self.auth.clone(), late_join: self.late_join, - runtime_ctx: db.runtime_ctx(), }); - // ── Outbound: the shared `pump_sink` drives records → bus ─────── - // (same helper MQTT uses; the `WsBusSink` just broadcasts + caches). - let outbound_futures = pump_sink( - db, - "ws", - Arc::new(WsBusSink { - client_mgr: client_mgr.clone(), - snapshot: snapshot_map, - }), - ); + // ── Outbound: one broadcast loop pulls records → bus ──── + let broadcast = run_broadcast(outbound, client_mgr.clone(), snapshot_map); // ── Build Axum server future ────────────────────────── let state = ServerState { @@ -352,14 +357,41 @@ impl ConnectorBuilder for WebSocketConnectorBuilder { let server_future = build_server_future(self.bind_addr, self.ws_path.clone(), state, additional); - let mut futures: Vec = Vec::with_capacity(1 + outbound_futures.len()); - futures.push(server_future); - futures.extend(outbound_futures); + let futures: Vec = vec![server_future, Box::pin(broadcast)]; Ok(futures) }) } } +/// Shared late-join cache: (RecordId, topic) → last serialized bytes. +pub(crate) type SnapshotCache = Arc>>>; + +/// Pull every outbound record update and fan it out to subscribed clients. +/// +/// The bus carries raw record-value bytes tagged with the topic and the +/// record's index; each connection's AimX codec applies the `event` envelope +/// downstream. With late-join on, the latest bytes per (record, topic) are +/// cached for snapshots first. Returns once every outbound route has closed. +async fn run_broadcast( + mut outbound: OutboundRoutes, + client_mgr: ClientManager, + snapshot: Option, +) { + while let Some(msg) = outbound.next().await { + // Checked for every route at build. + let Some(index) = msg.route.config.record_index else { + continue; + }; + let bytes = msg.payload.as_slice(); + if let Some(map) = &snapshot { + map.lock() + .unwrap() + .insert((index, msg.topic.to_string()), bytes.to_vec()); + } + client_mgr.broadcast(msg.topic, index, bytes).await; + } +} + // ════════════════════════════════════════════════════════════════════ // Dynamic snapshot provider backed by the shared Mutex // ════════════════════════════════════════════════════════════════════ diff --git a/aimdb-websocket-connector/src/server/client_manager.rs b/aimdb-websocket-connector/src/server/client_manager.rs index 7c9c40f6..4e09311a 100644 --- a/aimdb-websocket-connector/src/server/client_manager.rs +++ b/aimdb-websocket-connector/src/server/client_manager.rs @@ -5,8 +5,8 @@ //! registers a per-subscription channel and gets back a [`BoxStream`] of //! topic-tagged [`SubUpdate`]s; the engine envelopes each into an AimX `event` //! frame per connection (the payload bytes stay `Arc`-shared — only the small -//! envelope is per-subscriber). The outbound record→broadcast tasks -//! (`super::connector`) feed [`broadcast`](ClientManager::broadcast). +//! envelope is per-subscriber). The builder's outbound broadcast loop feeds +//! [`broadcast`](ClientManager::broadcast). //! //! Frame formatting lives in the codec; the per-connection send half is owned by //! `run_session`. diff --git a/aimdb-websocket-connector/src/server/connector.rs b/aimdb-websocket-connector/src/server/connector.rs deleted file mode 100644 index 2747b51b..00000000 --- a/aimdb-websocket-connector/src/server/connector.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! WebSocket outbound sink — the [`Connector`] adapter that `pump_sink` drives. -//! -//! Outbound record updates (`link_to("ws://…")`) fan out to subscribed clients -//! through the [`ClientManager`] bus. The shared -//! [`pump_sink`](aimdb_core::pump_sink) helper owns the consume → serialize → -//! publish loop (the same one MQTT uses); this adapter just routes each -//! serialized value to [`broadcast`](ClientManager::broadcast) and, when -//! late-join is enabled, caches it for snapshots. -//! -//! Inbound writes from WebSocket clients do **not** go through here — they ride -//! the session `Dispatch` (`WsSession::write` → the shared `Router`). - -use std::collections::HashMap; -use std::future::Future; -use std::pin::Pin; -use std::sync::{Arc, Mutex}; - -use aimdb_core::transport::{Connector, ConnectorConfig, PublishError}; - -use super::client_manager::ClientManager; - -/// Shared late-join cache: (RecordId, topic) → last serialized bytes. -pub(crate) type SnapshotCache = Arc>>>; - -/// Outbound sink: feeds each serialized record value into the broadcast bus. -pub(crate) struct WsBusSink { - pub(crate) client_mgr: ClientManager, - /// Late-join cache — `Some` only when late-join is on, so a disabled - /// late-join does zero per-message snapshot work. - pub(crate) snapshot: Option, -} - -impl Connector for WsBusSink { - fn publish( - &self, - destination: &str, - config: &ConnectorConfig, - payload: &[u8], - ) -> Pin> + Send + '_>> { - // Own the args so the returned future borrows only `&self` (the trait - // binds the future's lifetime to the receiver, not the arguments). - let dest = destination.to_string(); - let bytes = payload.to_vec(); - let record_index = config.record_index; - Box::pin(async move { - // The bus carries raw record-value bytes tagged with the topic; the - // per-connection AimX codec applies the `event` envelope downstream. - if let Some(index) = record_index { - if let Some(map) = &self.snapshot { - map.lock() - .unwrap() - .insert((index, dest.clone()), bytes.clone()); - } - self.client_mgr.broadcast(&dest, index, &bytes).await; - Ok(()) - } else { - #[cfg(feature = "tracing")] - tracing::warn!( - "WsBusSink.publish for dest {} not fired due to null record_index", - &dest - ); - Err(PublishError::InvalidDestination) - } - }) - } -} diff --git a/aimdb-websocket-connector/src/server/dispatch.rs b/aimdb-websocket-connector/src/server/dispatch.rs index ab5746d3..7ba7bfda 100644 --- a/aimdb-websocket-connector/src/server/dispatch.rs +++ b/aimdb-websocket-connector/src/server/dispatch.rs @@ -26,7 +26,7 @@ use serde_json::Value; use super::{ auth::{AuthHandler, ClientId, ClientInfo, Permissions, RecordsBits}, client_manager::ClientManager, - session::{QueryHandler, Router, SnapshotProvider}, + session::{QueryHandler, SnapshotProvider}, }; /// The shared WS dispatch — one `Arc` per server. @@ -37,13 +37,12 @@ pub struct WsDispatch { pub(crate) client_mgr: ClientManager, pub(crate) snapshot_provider: Arc, pub(crate) query_handler: Option>, - pub(crate) router: Arc, + pub(crate) inbound: aimdb_core::InboundDispatch, /// Record `type_id` string → data-contract schema name, used to stamp /// `schema_type` onto the `record.list` rows core hands back. pub(crate) schema_by_type: Arc>, pub(crate) auth: Arc, pub(crate) late_join: bool, - pub(crate) runtime_ctx: aimdb_core::RuntimeContext, } impl Dispatch for WsDispatch { @@ -77,11 +76,10 @@ impl Dispatch for WsDispatch { client_mgr: self.client_mgr.clone(), snapshot_provider: self.snapshot_provider.clone(), query_handler: self.query_handler.clone(), - router: self.router.clone(), + inbound: self.inbound.clone(), schema_by_type: self.schema_by_type.clone(), auth: self.auth.clone(), late_join: self.late_join, - runtime_ctx: self.runtime_ctx.clone(), info, }) } @@ -93,11 +91,10 @@ struct WsSession { client_mgr: ClientManager, snapshot_provider: Arc, query_handler: Option>, - router: Arc, + inbound: aimdb_core::InboundDispatch, schema_by_type: Arc>, auth: Arc, late_join: bool, - runtime_ctx: aimdb_core::RuntimeContext, info: Arc, } @@ -187,9 +184,8 @@ impl Session for WsSession { if !self.auth.authorize_write(&self.info, topic).await { return Err(RpcError::Denied); } - self.router - .route(topic, &payload, &self.runtime_ctx) - .map_err(|_| RpcError::Internal) + self.inbound.dispatch(topic, &payload); + Ok(()) }) } } diff --git a/aimdb-websocket-connector/src/server/mod.rs b/aimdb-websocket-connector/src/server/mod.rs index 2d5cd634..454a44a0 100644 --- a/aimdb-websocket-connector/src/server/mod.rs +++ b/aimdb-websocket-connector/src/server/mod.rs @@ -6,12 +6,11 @@ //! ([`aimdb_core::session::aimx`]) and the [`transport`](crate::transport) //! substrate, with `dispatch` supplying the subscribe/write/query semantics //! and [`client_manager`] the cross-connection fan-out bus. The outbound data -//! plane rides `connector`'s `WsBusSink` through the core `pump_sink`. +//! plane is one broadcast loop in `builder` pulling from `OutboundRoutes`. pub mod auth; pub mod builder; pub mod client_manager; -pub(crate) mod connector; pub(crate) mod dispatch; pub(crate) mod http; pub(crate) mod registry; diff --git a/aimdb-websocket-connector/src/server/session.rs b/aimdb-websocket-connector/src/server/session.rs index d38f9e30..fc3c9b37 100644 --- a/aimdb-websocket-connector/src/server/session.rs +++ b/aimdb-websocket-connector/src/server/session.rs @@ -20,7 +20,6 @@ use core::pin::Pin; pub use aimdb_core::remote::QueryRecord; // Re-export so the builder/dispatch can use it easily. -pub use aimdb_core::router::Router; // ════════════════════════════════════════════════════════════════════ // Query handler diff --git a/aimdb-websocket-connector/tests/decouple_record_keys_topics.rs b/aimdb-websocket-connector/tests/decouple_record_keys_topics.rs index 76edd632..4d16b13e 100644 --- a/aimdb-websocket-connector/tests/decouple_record_keys_topics.rs +++ b/aimdb-websocket-connector/tests/decouple_record_keys_topics.rs @@ -1,10 +1,10 @@ -//! `AimDb::collect_outbound_routes` over the `ws` scheme. +//! `OutboundRoutes` over the `ws` scheme. //! Record keys and ws topics are decoupled (#215). //! Grants described record keys. //! -//! Connectors call this during `build()` to spawn one publisher task per -//! configured `link_to("ws://…")`. The returned order must track record -//! registration order, since record ids index into it. +//! The server builds it during `build()` and pulls every configured +//! `link_to("ws://…")` from one broadcast loop. Each route must carry its +//! record's index, since grants are checked against it. //! //! Several behaviors tested: per-record gating, late-join snapshots, //! `record.list` and `record.query`, zero-grant denial, and topic-based @@ -17,7 +17,7 @@ use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; -use aimdb_core::connector::TopicProvider; +use aimdb_core::connector::{TopicBuf, TopicOverflow, TopicWriter}; use aimdb_core::remote::QueryHandlerFn; use aimdb_core::{builder::AimDb, remote::QueryHandlerParams}; use tokio::time::timeout; @@ -145,7 +145,7 @@ fn free_addr() -> SocketAddr { } #[tokio::test] -async fn collect_outbound_routes_preserves_record_order() { +async fn outbound_routes_preserve_record_order() { // A dummy address for server building only let addr = free_addr(); @@ -179,36 +179,51 @@ async fn collect_outbound_routes_preserves_record_order() { }); let (server_db, _server_runner) = sb.build().await.expect("build server db"); - let outbound_routes = server_db.collect_outbound_routes("ws"); - assert_eq!(outbound_routes.len(), keys_topics.len()); - let outbound_iter = outbound_routes.into_iter(); - - outbound_iter - .into_iter() - .zip(keys_topics.iter()) - .for_each(|(route, (k, t))| { - assert_eq!(route.topic.as_str(), *t); - - let config_record_index: Vec = route - .config - .iter() - .filter(|(k, _v)| k.as_str() == "record_index") - .map(|(_k, v)| { - v.parse::() - .expect("failed to convert record_index to usize") - }) - .collect(); - - assert_eq!( - config_record_index.len(), - 1, - "config must have one tuple for record_index" - ); - assert_eq!( - config_record_index[0], - *record_keys.get(*k).expect("key must exist") - ); - }); + let outbound = aimdb_core::OutboundRoutes::new(&server_db, "ws").expect("routes"); + assert_eq!(outbound.routes().len(), keys_topics.len()); + + for (route, (k, t)) in outbound.routes().iter().zip(keys_topics.iter()) { + assert_eq!(&*route.default_topic, *t); + assert_eq!( + route.config.record_index, + Some(*record_keys.get(*k).expect("key must exist")), + "route '{t}' must carry its record's index" + ); + } +} + +/// One broadcast loop for every outbound route: the server contributes its +/// HTTP server and that loop, whatever the number of routes. +#[tokio::test] +async fn the_server_contributes_two_futures_whatever_the_route_count() { + use aimdb_core::connector::ConnectorBuilder; + + for routes in [0usize, 1, 3] { + let addr = free_addr(); + let mut sb = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(WebSocketConnector::new().bind(addr).path("/ws")); + for i in 0..routes { + let key = aimdb_core::StringKey::intern(format!("r{i}")); + sb.configure::(key, move |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_to(&format!("ws://t{i}")) + .with_serializer(|_ctx, m: &Msg| Ok(serde_json::to_vec(m).expect("serialize"))) + .finish(); + }); + } + let (db, _runner) = sb.build().await.expect("build server db"); + + // Built, never polled: nothing binds. + let futures = WebSocketConnector::new() + .bind(free_addr()) + .path("/ws") + .build(&db) + .await + .expect("build connector"); + assert_eq!(futures.len(), 2, "{routes} routes"); + } } //----------------- Custom AuthHandler and Permissions @@ -257,9 +272,10 @@ struct Inject { } struct InjectTopic; -impl TopicProvider for InjectTopic { - fn topic(&self, value: &Inject) -> Option { - Some(value.topic.clone()) +impl TopicWriter for InjectTopic { + fn write_topic(&self, value: &Inject, out: &mut TopicBuf<'_>) -> Result { + out.push_str(&value.topic)?; + Ok(true) } } @@ -457,8 +473,8 @@ async fn test_fixture( sb.configure::(key, |reg| { reg.buffer(BufferCfg::SpmcRing { capacity: 64 }) // don't coalesce successive values .with_remote_access() - .link_to("ws://_") // placeholder; provider overrides per value - .with_topic_provider(InjectTopic) + .link_to("ws://_") // placeholder; the writer sets it per value + .with_topic_writer(128, InjectTopic) .with_serializer(|_ctx, m: &Inject| { Ok(serde_json::to_vec(&m.payload).expect("serialize")) }) diff --git a/aimdb-websocket-connector/tests/e2e.rs b/aimdb-websocket-connector/tests/e2e.rs index dd49ba68..c6c8e0b8 100644 --- a/aimdb-websocket-connector/tests/e2e.rs +++ b/aimdb-websocket-connector/tests/e2e.rs @@ -23,7 +23,7 @@ use std::sync::Arc; use std::time::Duration; use aimdb_core::buffer::BufferCfg; -use aimdb_core::connector::TopicProvider; +use aimdb_core::connector::{TopicBuf, TopicOverflow, TopicWriter}; use aimdb_core::remote::QueryHandlerFn; use aimdb_core::session::{aimx::AimxCodec, run_client, ClientConfig}; use aimdb_core::{AimDb, AimDbBuilder}; @@ -51,9 +51,10 @@ struct Inject { } struct InjectTopic; -impl TopicProvider for InjectTopic { - fn topic(&self, v: &Inject) -> Option { - Some(v.topic.clone()) +impl TopicWriter for InjectTopic { + fn write_topic(&self, v: &Inject, out: &mut TopicBuf<'_>) -> Result { + out.push_str(&v.topic)?; + Ok(true) } } @@ -165,8 +166,8 @@ async fn spawn(ws: WebSocketConnector) -> (SocketAddr, Arc) { sb.configure::("inject", |reg| { reg.buffer(BufferCfg::SpmcRing { capacity: 1024 }) .with_remote_access() - .link_to("ws://_") // overridden per-value by the topic provider - .with_topic_provider(InjectTopic) + .link_to("ws://_") // overridden per-value by the topic writer + .with_topic_writer(128, InjectTopic) .with_serializer(|_ctx, m: &Inject| { Ok(serde_json::to_vec(&m.payload).expect("serialize payload")) }) From 1a9ea7b5f0c305625f1932f7d77bf8448ac2aa90 Mon Sep 17 00:00:00 2001 From: "sounds.like.lx" <147444674+lxsaah@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:20:46 +0200 Subject: [PATCH 23/28] =?UTF-8?q?feat(knx)!:=20connection=20task=20dispatc?= =?UTF-8?q?hes=20and=20pulls;=20drop=20Channels=20(design=20054=20=C2=A74.?= =?UTF-8?q?9)=20(#294)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aimdb-codegen/src/rust.rs | 35 +- aimdb-knx-connector/Cargo.toml | 18 +- aimdb-knx-connector/README.md | 19 +- aimdb-knx-connector/src/client.rs | 337 +++++----- aimdb-knx-connector/src/connector.rs | 272 ++------ aimdb-knx-connector/src/lib.rs | 14 +- aimdb-knx-connector/src/tunnel.rs | 12 +- .../tests/shared_channel_on_std.rs | 53 -- .../tests/topic_provider_tests.rs | 590 ------------------ .../tests/topic_writer_tests.rs | 405 ++++++++++++ docs/aimdb-usage-guide.md | 6 +- .../embassy-knx-connector-demo/src/main.rs | 12 +- examples/tokio-knx-connector-demo/README.md | 4 - examples/tokio-knx-connector-demo/src/main.rs | 9 +- 14 files changed, 697 insertions(+), 1089 deletions(-) delete mode 100644 aimdb-knx-connector/tests/shared_channel_on_std.rs delete mode 100644 aimdb-knx-connector/tests/topic_provider_tests.rs create mode 100644 aimdb-knx-connector/tests/topic_writer_tests.rs diff --git a/aimdb-codegen/src/rust.rs b/aimdb-codegen/src/rust.rs index bd2c42b7..6b9940ec 100644 --- a/aimdb-codegen/src/rust.rs +++ b/aimdb-codegen/src/rust.rs @@ -251,7 +251,7 @@ pub fn generate_main_rs(state: &ArchitectureState, binary_name: &str) -> Option< .filter_map(|c| match c.protocol.as_str() { "mqtt" => Some(quote! { use aimdb_mqtt_connector::MqttConnector; }), "knx" => Some(quote! { - use aimdb_knx_connector::{Channels, KnxConnector}; + use aimdb_knx_connector::KnxConnector; use aimdb_tokio_adapter::net::{TokioDelay, TokioNet}; }), "ws" => Some(quote! { use aimdb_websocket_connector::WebSocketConnector; }), @@ -269,18 +269,13 @@ pub fn generate_main_rs(state: &ArchitectureState, binary_name: &str) -> Option< let default = &c.default; let ctor: TokenStream = match c.protocol.as_str() { "mqtt" => quote! { MqttConnector::new(&#var_ident) }, - // The adapter owns the socket and the clock; the channels are - // the binary's, in a block-scoped `static`. + // The adapter owns the socket and the clock. "knx" => quote! { - { - static KNX_CHANNELS: Channels = Channels::new(); - KnxConnector::new( - TokioNet::udp(std::net::Ipv4Addr::UNSPECIFIED), - TokioDelay, - &#var_ident, - &KNX_CHANNELS, - ) - } + KnxConnector::new( + TokioNet::udp(std::net::Ipv4Addr::UNSPECIFIED), + TokioDelay, + &#var_ident, + ) }, "ws" => quote! { WebSocketConnector::new() @@ -1411,7 +1406,7 @@ pub fn generate_hub_main_rs(state: &ArchitectureState) -> String { } if has_knx { v.push(quote! { - use aimdb_knx_connector::{Channels, KnxConnector}; + use aimdb_knx_connector::KnxConnector; use aimdb_tokio_adapter::net::{TokioDelay, TokioNet}; }); } @@ -1456,15 +1451,11 @@ pub fn generate_hub_main_rs(state: &ArchitectureState) -> String { } if has_knx { v.push(quote! { - .with_connector({ - static KNX_CHANNELS: Channels = Channels::new(); - KnxConnector::new( - TokioNet::udp(std::net::Ipv4Addr::UNSPECIFIED), - TokioDelay, - &knx_gateway, - &KNX_CHANNELS, - ) - }) + .with_connector(KnxConnector::new( + TokioNet::udp(std::net::Ipv4Addr::UNSPECIFIED), + TokioDelay, + &knx_gateway, + )) }); } if has_ws { diff --git a/aimdb-knx-connector/Cargo.toml b/aimdb-knx-connector/Cargo.toml index 34863fd4..d50e4329 100644 --- a/aimdb-knx-connector/Cargo.toml +++ b/aimdb-knx-connector/Cargo.toml @@ -21,12 +21,12 @@ default = ["aimdb-core/alloc"] # feature here — which is what lets a third runtime (FreeRTOS/lwIP) use this # crate with no edit to it. # -# `embassy-sync` is not an Embassy dependency in the executor sense: it is -# no_std, no_alloc, and pulls no executor, and its `Channel` is this connector's -# public queue type (`Channels`) on every runtime. +# `embassy-sync` no longer backs a public type here (the connection task pulls +# from the record buffers directly); it is dropped together with the old +# connector SPI. connector = [ "aimdb-core/alloc", - "aimdb-core/connector-session", # `pump_sink`/`pump_source`/`Source`/`Payload` + "aimdb-core/connector-session", # `DatagramBinder`/`Datagram`/`Delay` "embassy-sync", ] @@ -49,10 +49,9 @@ embassy-runtime = ["connector"] # the **final binary** may choose one: a library enabling it would hand every # downstream binary a duplicate-symbol link error with no way to opt out. # -# So this stays off by default and out of `connector`/`std`. This crate's own -# tests get the impl through a dev-dependency; a std binary that instantiates a -# `CriticalSectionRawMutex` channel and has no other impl in its graph can -# either depend on `critical-section` with `features = ["std"]` directly (the +# So this stays off by default and out of `connector`/`std`. A std binary that +# instantiates a `CriticalSectionRawMutex` channel and has no other impl in its +# graph can either depend on `critical-section` with `features = ["std"]` directly (the # documented way) or enable this feature. On Embassy the HAL (cortex-m / # embassy-rp / ...) already provides one. critical-section-std-impl = ["critical-section/std"] @@ -92,9 +91,6 @@ heapless = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["full"] } -# Test binaries are binaries: selecting the impl here lets `CriticalSectionRawMutex` -# link for this crate's tests without imposing that choice on consumers. -critical-section = { version = "1.2", features = ["std"] } tokio-test = "0.4" aimdb-tokio-adapter = { path = "../aimdb-tokio-adapter", features = [ "tokio-runtime", diff --git a/aimdb-knx-connector/README.md b/aimdb-knx-connector/README.md index 78add83b..9587c430 100644 --- a/aimdb-knx-connector/README.md +++ b/aimdb-knx-connector/README.md @@ -45,7 +45,7 @@ We're working with upstream to get these changes merged. Once published, the pat ## Quick Start (Tokio) ```rust -use aimdb_knx_connector::{Channels, KnxConnector}; +use aimdb_knx_connector::KnxConnector; use aimdb_tokio_adapter::net::{TokioDelay, TokioNet}; use aimdb_tokio_adapter::TokioAdapter; use std::net::Ipv4Addr; @@ -55,11 +55,6 @@ struct LightState { is_on: bool, } -// The connector's queues. `'static` because the connection task and the pumps -// are spawned as `'static` futures; a `StaticCell` supplies this on an MCU. -// One pair per connector — do not share it between two `KnxConnector`s. -static CHANNELS: Channels = Channels::new(); - #[tokio::main] async fn main() -> Result<(), Box> { let db = AimDbBuilder::new() @@ -70,7 +65,6 @@ async fn main() -> Result<(), Box> { TokioNet::udp(Ipv4Addr::UNSPECIFIED), TokioDelay, "knx://192.168.1.19:3671", - &CHANNELS, )) .configure::(|reg| { reg.buffer(BufferCfg::SingleLatest) @@ -96,14 +90,11 @@ async fn main() -> Result<(), Box> { ## Quick Start (Embassy) -The same constructor — only the binder and clock change, and the channels come -from a `static` instead of being sized at runtime: +The same constructor — only the binder and clock change: ```rust use aimdb_embassy_adapter::net::{EmbassyDelay, EmbassyNet}; -use aimdb_knx_connector::{Channels, KnxConnector}; - -static CHANNELS: Channels<32> = Channels::new(); +use aimdb_knx_connector::KnxConnector; let binder = EmbassyNet::udp(stack, rx_meta, rx_buf, tx_meta, tx_buf); @@ -113,12 +104,10 @@ let builder = AimDbBuilder::new() binder, EmbassyDelay, "knx://192.168.1.19:3671", - &CHANNELS, )); ``` -Enable `features = ["connector"], default-features = false` — no -`critical-section-std-impl`, since the HAL already provides an impl. See +Enable `features = ["connector"], default-features = false`. See `examples/embassy-knx-connector-demo/` for the full wiring. ## Group Address Format diff --git a/aimdb-knx-connector/src/client.rs b/aimdb-knx-connector/src/client.rs index 6b01384e..f13d33bf 100644 --- a/aimdb-knx-connector/src/client.rs +++ b/aimdb-knx-connector/src/client.rs @@ -4,21 +4,22 @@ //! The clock stays `RuntimeOps::now_nanos`, a plain call; only *sleeping* goes //! through [`Delay`], so nothing is boxed per loop iteration. //! -//! The `embassy-sync` and `embassy-futures` types below are executor-independent -//! — neither pulls an executor, and both build on std — so they back this task -//! on either runtime. +//! The task dispatches inbound telegrams into their records through an +//! [`InboundDispatch`](aimdb_core::InboundDispatch) and pulls outbound values +//! from an [`OutboundRoutes`] while the tunnel is connected. `embassy-futures`' +//! select is executor-independent, so the task runs on either runtime. -use alloc::string::{String, ToString}; use alloc::sync::Arc; use alloc::vec::Vec; -use core::future::Future; +use core::fmt::Write as _; +use core::future::poll_fn; use core::net::SocketAddr; +use core::task::Poll; use core::time::Duration; -use aimdb_core::session::{ - Datagram, DatagramBinder, Delay, Payload, TransportError, TransportResult, -}; -use aimdb_core::{log_debug, log_error, log_warn, RuntimeOps}; +use aimdb_core::connector::TopicBuf; +use aimdb_core::session::{Datagram, DatagramBinder, Delay, TransportError, TransportResult}; +use aimdb_core::{log_debug, log_error, log_warn, OutboundRoutes, RouteId, RuntimeOps}; use crate::tunnel::{ drain_actions, GroupWrite, LocalEndpoint, Millis, TunnelConfig, TunnelEngine, TunnelIo, @@ -30,22 +31,24 @@ const BIND_RETRY: Duration = Duration::from_secs(5); /// Per-datagram receive buffer; a KNXnet/IP frame fits comfortably. const RECV_BUF: usize = 512; -/// Where parsed telegrams go — an `embassy_sync` channel on either runtime. +/// Where parsed telegrams go: an [`InboundDispatch`](aimdb_core::InboundDispatch) +/// in the connector. /// -/// Non-blocking by contract: a full sink drops rather than stalling the -/// protocol loop. +/// Non-blocking by contract: delivery never stalls the protocol loop. pub trait TelegramSink { - /// Enqueue one `(group-address, payload)`. `false` if it was dropped. - fn try_send(&self, topic: String, payload: Payload) -> bool; + /// Deliver one telegram for group address `topic`. + fn deliver(&self, topic: &str, payload: &[u8]); } -/// Where outbound commands come from, the dual of [`TelegramSink`]. -pub trait CommandSource { - /// Yield the next command. Parks forever once no producer remains, so the - /// select arm goes quiet instead of ending the task. - fn recv(&mut self) -> impl Future + Send + '_; +impl TelegramSink for aimdb_core::InboundDispatch { + fn deliver(&self, topic: &str, payload: &[u8]) { + self.dispatch(topic, payload); + } } +/// The longest group address, `31/7/255`, is 8 bytes. +const GROUP_ADDRESS_LEN: usize = 16; + /// The socket-side glue for [`drain_actions`], written once against /// [`Datagram`] instead of once per runtime. struct NeutralIo<'a, U, S> { @@ -77,8 +80,10 @@ where fn forward(&mut self, addr: GroupAddress, payload: Vec) { log_debug!("KNX telegram: {} ({} bytes)", addr, payload.len()); - if !self.sink.try_send(addr.to_string(), Payload::from(payload)) { - log_warn!("KNX inbound: dropping telegram for {} (sink full)", addr); + let mut storage = [0u8; GROUP_ADDRESS_LEN]; + let mut topic = TopicBuf::new(&mut storage); + if write!(topic, "{addr}").is_ok() { + self.sink.deliver(topic.as_str(), &payload); } } @@ -87,20 +92,44 @@ where } } +/// Hand the value `outbound` staged for route `id` to the engine as a group +/// write. An invalid group address or an oversize payload is logged, counted +/// as rejected in the route's `RouteStats`, and skipped. +fn send_staged(engine: &mut TunnelEngine, outbound: &mut OutboundRoutes, id: RouteId, now: Millis) { + let command = { + let Some(msg) = outbound.take_staged() else { + return; + }; + GroupWrite::try_new(msg.topic, msg.payload.as_slice()) + }; + match command { + Ok(command) => { + let _ = engine.handle_command(command, now); + } + Err(_e) => { + log_warn!( + "KNX outbound: skipping a value for route '{}': {:?}", + outbound.routes()[id].default_topic, + _e + ); + outbound.reject(id); + } + } +} + /// Drive the engine over one socket's lifetime; returns when it asks for a reset. -async fn drive_connection( +async fn drive_connection( engine: &mut TunnelEngine, socket: &mut U, gateway: SocketAddr, runtime: &Arc, delay: &D, sink: &S, - commands: &mut C, + outbound: &mut OutboundRoutes, ) where U: Datagram + Send, D: Delay, S: TelegramSink + Sync, - C: CommandSource, { // Executor-independent despite the name: `embassy-futures` has no // dependencies and its select is pure `core::task`. @@ -129,9 +158,12 @@ async fn drive_connection( // one — unlike the `tokio::select!` this task replaces, which picked among // the ready arms at random. With a fixed order, sustained inbound traffic // means the first arm is ready on every pass and the command arm is never - // reached, so outbound `GroupWrite`s stall until the channel drops them. + // reached, so outbound values stall in their record buffers. // Swapping the two contended arms each pass restores that fairness. let mut inbound_first = true; + // Latched on `Ready(None)` (every route closed, or none): passing it + // through would resolve the arm on every pass and the loop would spin. + let mut outbound_done = false; loop { engine.poll(now_ms()); @@ -151,34 +183,37 @@ async fn drive_connection( let deadline = delay.sleep(Duration::from_millis(sleep_ms)); let mut recv_buf = [0u8; RECV_BUF]; - // Only drain commands while connected: during connect and backoff the - // arm stays pending, so commands queue and flush once the handshake - // completes — as both hand-written clients do. + // Only pull values while connected: during connect and backoff the + // arm stays pending, so values wait in their record buffers and go out + // once the handshake completes. A value leaves its buffer only when + // the arm resolves, so a losing arm takes nothing. let connected = engine.is_connected(); - let cmd_arm = async { - if connected { - commands.recv().await - } else { - core::future::pending().await + let cmd_arm = poll_fn(|cx| { + if !connected || outbound_done { + return Poll::Pending; } - }; + match outbound.poll_stage(cx) { + Poll::Ready(Some(id)) => Poll::Ready(id), + Poll::Ready(None) => { + outbound_done = true; + Poll::Pending + } + Poll::Pending => Poll::Pending, + } + }); // The deadline arm stays last in both orders: it only ever asks for a // `poll` the loop top would reach anyway. if inbound_first { match select3(socket.recv_from(&mut recv_buf), cmd_arm, deadline).await { Either3::First(r) => apply_inbound(engine, &recv_buf, r, now_ms()), - Either3::Second(cmd) => { - let _ = engine.handle_command(cmd, now_ms()); - } + Either3::Second(id) => send_staged(engine, outbound, id, now_ms()), // Woken for the engine deadline; `poll` at the loop top fires it. Either3::Third(()) => {} } } else { match select3(cmd_arm, socket.recv_from(&mut recv_buf), deadline).await { - Either3::First(cmd) => { - let _ = engine.handle_command(cmd, now_ms()); - } + Either3::First(id) => send_staged(engine, outbound, id, now_ms()), Either3::Second(r) => apply_inbound(engine, &recv_buf, r, now_ms()), Either3::Third(()) => {} } @@ -192,18 +227,17 @@ async fn drive_connection( /// Binds a socket, advertises its real local endpoint when the stack exposes /// one, drives the shared [`TunnelEngine`] over that socket's lifetime, then /// rebinds after the engine's backoff. -pub async fn connection_task( +pub async fn connection_task( binder: B, gateway: SocketAddr, runtime: Arc, delay: D, sink: S, - mut commands: C, + mut outbound: OutboundRoutes, ) where B: DatagramBinder, D: Delay, S: TelegramSink + Sync, - C: CommandSource, { let now_ms = || runtime.now_nanos() / 1_000_000; let mut engine = TunnelEngine::new(TunnelConfig::default(), now_ms()); @@ -267,7 +301,7 @@ pub async fn connection_task( &runtime, &delay, &sink, - &mut commands, + &mut outbound, ) .await; @@ -280,45 +314,15 @@ pub async fn connection_task( } } -/// Channel bridges over `embassy_sync`, which is executor-independent, so the -/// same types back the task on both runtimes. -#[cfg(feature = "connector")] -pub mod shared_channel { - use super::{CommandSource, GroupWrite, Payload, TelegramSink}; - use alloc::string::String; - use core::future::Future; - use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; - use embassy_sync::channel::{Receiver, Sender}; - - /// Sending half of the inbound-telegram channel. - pub struct ChannelSink<'a, const N: usize>( - pub Sender<'a, CriticalSectionRawMutex, (String, Payload), N>, - ); - - impl TelegramSink for ChannelSink<'_, N> { - fn try_send(&self, topic: String, payload: Payload) -> bool { - self.0.try_send((topic, payload)).is_ok() - } - } - - /// Receiving half of the outbound-command channel. - pub struct ChannelCommands<'a, const N: usize>( - pub Receiver<'a, CriticalSectionRawMutex, GroupWrite, N>, - ); - - impl CommandSource for ChannelCommands<'_, N> { - fn recv(&mut self) -> impl Future + Send + '_ { - self.0.receive() - } - } -} - #[cfg(all(test, feature = "std"))] mod tests { use super::*; + use aimdb_core::buffer::BufferCfg; use aimdb_core::session::TransportError; + use aimdb_core::AimDbBuilder; use aimdb_tokio_adapter::net::{TokioDelay, TokioNet}; - use aimdb_tokio_adapter::TokioAdapter; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + use core::future::Future; use core::pin::Pin; use std::net::Ipv4Addr; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -326,22 +330,25 @@ mod tests { /// Collects forwarded telegrams; `Sync`, as [`TelegramSink`] requires. #[derive(Default)] - struct VecSink(Mutex>); + struct VecSink(Mutex)>>); impl TelegramSink for VecSink { - fn try_send(&self, topic: String, payload: Payload) -> bool { - self.0.lock().expect("sink mutex").push((topic, payload)); - true + fn deliver(&self, topic: &str, payload: &[u8]) { + self.0 + .lock() + .expect("sink mutex") + .push((topic.into(), payload.into())); } } - /// No outbound producer: the command arm never fires. - struct NoCommands; - - impl CommandSource for NoCommands { - fn recv(&mut self) -> impl Future + Send + '_ { - core::future::pending() - } + /// No outbound routes: the command arm never fires. + async fn no_outbound() -> OutboundRoutes { + let (db, _runner) = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .build() + .await + .expect("build db"); + OutboundRoutes::new(&db, "knx").expect("outbound routes") } fn runtime() -> Arc { @@ -354,15 +361,15 @@ mod tests { /// `Pin + Send + 'static>>`. Everything that /// declares `+ Send` on a return type does so to make this line compile for /// a *generic* task. - #[test] - fn unified_task_is_boxable_as_the_runners_send_future() { + #[tokio::test] + async fn unified_task_is_boxable_as_the_runners_send_future() { let task = connection_task( TokioNet::udp(Ipv4Addr::LOCALHOST), "127.0.0.1:3671".parse().expect("gateway addr"), runtime(), TokioDelay, VecSink::default(), - NoCommands, + no_outbound().await, ); let _boxed: Pin + Send + 'static>> = Box::pin(task); } @@ -384,7 +391,7 @@ mod tests { runtime(), TokioDelay, VecSink::default(), - NoCommands, + no_outbound().await, )); let mut buf = [0u8; 128]; @@ -430,7 +437,7 @@ mod tests { runtime(), TokioDelay, VecSink::default(), - NoCommands, + no_outbound().await, )); let mut buf = [0u8; 128]; @@ -451,39 +458,10 @@ mod tests { task.abort(); } - /// The unified task on Tokio, moving real telegrams through the *same* - /// `embassy_sync` channel types the MCU uses: a full handshake, an inbound - /// telegram with its ACK, and an outbound command. - #[tokio::test] - async fn shared_embassy_channels_carry_telegrams_on_tokio() { - use super::shared_channel::{ChannelCommands, ChannelSink}; - use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; - use embassy_sync::channel::Channel; - - const N: usize = 8; - // Leaked for `'static` borrows, as a `StaticCell` gives on the MCU. - let inbound: &'static Channel = - Box::leak(Box::new(Channel::new())); - let commands: &'static Channel = - Box::leak(Box::new(Channel::new())); - - let gateway = tokio::net::UdpSocket::bind("127.0.0.1:0") - .await - .expect("bind gateway"); - let gateway_addr = gateway.local_addr().expect("gateway addr"); - - let task = tokio::spawn(connection_task( - TokioNet::udp(Ipv4Addr::LOCALHOST), - gateway_addr, - runtime(), - TokioDelay, - ChannelSink::(inbound.sender()), - ChannelCommands::(commands.receiver()), - )); - - let mut buf = [0u8; 1024]; - - // Handshake. + /// Answer the client's CONNECT_REQUEST with channel 7; returns the + /// client's address. + async fn accept_connect(gateway: &tokio::net::UdpSocket) -> SocketAddr { + let mut buf = [0u8; 128]; let (_, client_addr) = tokio::time::timeout(RECV_TIMEOUT, gateway.recv_from(&mut buf)) .await .expect("no CONNECT_REQUEST") @@ -498,8 +476,63 @@ mod tests { .send_to(&connect_response, client_addr) .await .expect("send CONNECT_RESPONSE"); + client_addr + } + + /// Wait for a TUNNELING_REQUEST and check it writes 1 to group 1/0/8. + async fn expect_write_to_1_0_8(gateway: &tokio::net::UdpSocket) { + let mut buf = [0u8; 128]; + let (len, _) = tokio::time::timeout(RECV_TIMEOUT, gateway.recv_from(&mut buf)) + .await + .expect("no TUNNELING_REQUEST") + .expect("recv_from"); + assert_eq!(u16::from_be_bytes([buf[2], buf[3]]), 0x0420); + assert_eq!(&buf[16..18], &[0x08, 0x08], "cEMI destination = 1/0/8"); + assert_eq!(buf[len - 1], 0x81, "APCI GroupValueWrite | value 1"); + } + + /// A db whose `knx` connector tunnels to `gateway`: record `in` reads group + /// 1/0/7 and record `out` writes group 1/0/8. + async fn knx_db(gateway: SocketAddr) -> (aimdb_core::AimDb, aimdb_core::builder::AimDbRunner) { + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(crate::KnxConnector::new( + TokioNet::udp(Ipv4Addr::LOCALHOST), + TokioDelay, + format!("knx://{gateway}"), + )); + builder.configure::("in", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from("knx://1/0/7") + .with_deserializer(|_ctx, data: &[u8]| { + data.first().copied().ok_or_else(|| String::from("empty")) + }) + .finish(); + }); + builder.configure::("out", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_to("knx://1/0/8") + .with_serializer(|_ctx, v: &u8| Ok(vec![*v])) + .finish(); + }); + builder.build().await.expect("build db") + } + + /// The connector end to end: a full handshake, an inbound telegram + /// dispatched into its record with its ACK, and a produced value on the + /// wire as a group write. + #[tokio::test] + async fn telegrams_round_trip_through_records() { + let gateway = tokio::net::UdpSocket::bind("127.0.0.1:0") + .await + .expect("bind gateway"); + let (db, runner) = knx_db(gateway.local_addr().expect("gateway addr")).await; + let mut inbound = db.subscribe::("in").expect("subscribe"); + let task = tokio::spawn(runner.run()); - // Inbound telegram -> ACK on the wire, payload on the shared channel. + let client_addr = accept_connect(&gateway).await; + + // Inbound telegram -> ACK on the wire, value in the record. let cemi = [ 0x29, 0x00, 0xBC, 0xE0, 0x00, 0x00, 0x08, 0x07, 0x01, 0x00, 0x81, ]; @@ -513,6 +546,7 @@ mod tests { .await .expect("send telegram"); + let mut buf = [0u8; 128]; tokio::time::timeout(RECV_TIMEOUT, gateway.recv_from(&mut buf)) .await .expect("no TUNNELING_ACK") @@ -520,29 +554,32 @@ mod tests { assert_eq!(u16::from_be_bytes([buf[2], buf[3]]), 0x0421); assert_eq!(buf[8], 42, "sequence echoed"); - let (topic, payload) = tokio::time::timeout(RECV_TIMEOUT, inbound.receive()) + let value = tokio::time::timeout(RECV_TIMEOUT, inbound.recv()) .await - .expect("no telegram reached the embassy-sync channel"); - assert_eq!(topic, "1/0/7"); - assert_eq!(&payload[..], &[0x01]); - - // Outbound: a command through the shared channel reaches the wire. - let mut data = heapless::Vec::new(); - data.push(0x01).expect("push"); - commands - .send(GroupWrite { - group_addr: "1/0/8".parse().expect("group address"), - data, - }) - .await; + .expect("no telegram reached the record") + .expect("recv"); + assert_eq!(value, 0x01); - let (len, _) = tokio::time::timeout(RECV_TIMEOUT, gateway.recv_from(&mut buf)) + // Outbound: a produced value reaches the wire. + db.producer::("out").expect("producer").produce(1); + expect_write_to_1_0_8(&gateway).await; + + task.abort(); + } + + /// A value produced while the tunnel is still connecting waits in its + /// record buffer and goes out once the handshake completes. + #[tokio::test] + async fn a_value_produced_during_connect_is_sent_after_the_handshake() { + let gateway = tokio::net::UdpSocket::bind("127.0.0.1:0") .await - .expect("no TUNNELING_REQUEST") - .expect("recv_from"); - assert_eq!(u16::from_be_bytes([buf[2], buf[3]]), 0x0420); - assert_eq!(&buf[16..18], &[0x08, 0x08], "cEMI destination = 1/0/8"); - assert_eq!(buf[len - 1], 0x81, "APCI GroupValueWrite | value 1"); + .expect("bind gateway"); + let (db, runner) = knx_db(gateway.local_addr().expect("gateway addr")).await; + db.producer::("out").expect("producer").produce(1); + let task = tokio::spawn(runner.run()); + + accept_connect(&gateway).await; + expect_write_to_1_0_8(&gateway).await; task.abort(); } @@ -652,7 +689,7 @@ mod tests { runtime(), TokioDelay, VecSink::default(), - NoCommands, + no_outbound().await, )); // Must exceed the two `BIND_RETRY` sleeps the task waits out. `RECV_TIMEOUT` @@ -704,7 +741,7 @@ mod tests { runtime(), TokioDelay, VecSink::default(), - NoCommands, + no_outbound().await, )); // Cycle 1: the socket knows its address, so the HPAI is explicit. diff --git a/aimdb-knx-connector/src/connector.rs b/aimdb-knx-connector/src/connector.rs index f11bf947..9e9edfd5 100644 --- a/aimdb-knx-connector/src/connector.rs +++ b/aimdb-knx-connector/src/connector.rs @@ -2,9 +2,9 @@ //! //! Generic over core's [`DatagramBinder`](aimdb_core::session::DatagramBinder) //! and [`Delay`](aimdb_core::session::Delay), so the adapter owns the UDP -//! socket and the clock while this crate owns the tunnelling protocol. -//! The channels between the pumps and the connection task are `embassy_sync`, -//! which is executor-independent, so one wiring serves both runtimes. +//! socket and the clock while this crate owns the tunnelling protocol. The one +//! connection task dispatches inbound telegrams into their records and pulls +//! outbound values from their record buffers itself; core runs no pump for it. use alloc::boxed::Box; use alloc::string::String; @@ -16,119 +16,31 @@ use core::net::SocketAddr; use core::pin::Pin; use aimdb_core::connector::{ConnectorBuilder, ConnectorUrl}; -use aimdb_core::session::{pump_sink, pump_source, Payload}; -use aimdb_core::transport::{Connector, ConnectorConfig, PublishError}; -use aimdb_core::{log_info, AimDb, DbError, DbResult, RuntimeOps}; +use aimdb_core::{log_info, AimDb, DbError, DbResult, InboundDispatch, OutboundRoutes, RuntimeOps}; -use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; -use embassy_sync::channel::Channel; - -use crate::client::{ - connection_task, - shared_channel::{ChannelCommands, ChannelSink}, -}; -use crate::tunnel::GroupWrite; +use crate::client::connection_task; type BoxFuture = Pin + Send + 'static>>; /// Default KNXnet/IP tunnelling port. const DEFAULT_PORT: u16 = 3671; -/// Capacity of the command and telegram channels. -/// -/// A const generic rather than a builder setter: the MCU allocates these in a -/// `static`, where the size must be a constant. -pub const DEFAULT_QUEUE: usize = 32; - -/// The inbound-telegram channel type. -pub type TelegramChannel = Channel; -/// The outbound-command channel type. -pub type CommandChannel = Channel; - -/// Outbound half: `pump_sink` hands each serialized record here. -struct KnxSink<'a, const N: usize> { - commands: &'a CommandChannel, -} - -impl Connector for KnxSink<'_, N> { - fn publish( - &self, - destination: &str, - _config: &ConnectorConfig, - payload: &[u8], - ) -> Pin> + Send + '_>> { - // Validation shared with the connection task (same checks, same order). - let command = GroupWrite::try_new(destination, payload); - Box::pin(async move { - self.commands.send(command?).await; - Ok(()) - }) - } -} - -/// Inbound source drained by `pump_source`. -struct KnxSource<'a, const N: usize> { - telegrams: &'a TelegramChannel, -} - -impl aimdb_core::session::Source for KnxSource<'_, N> { - fn next(&mut self) -> aimdb_core::session::BoxFut<'_, Option<(String, Payload)>> { - Box::pin(async move { Some(self.telegrams.receive().await) }) - } -} - /// KNX/IP tunnelling connector over an adapter's datagram transport. -/// -/// `N` sizes both the command and telegram channels. -pub struct KnxConnector { +pub struct KnxConnector { binder: B, delay: D, gateway_url: String, - channels: &'static Channels, -} - -/// The channel pair, held for the process lifetime. -/// -/// `'static` because the connection task and the pumps are spawned as -/// `'static` futures; a `StaticCell` supplies this on the MCU and a `static` -/// item on a host, matching design 037's allocate-at-build model. -pub struct Channels { - telegrams: TelegramChannel, - commands: CommandChannel, } -impl Default for Channels { - fn default() -> Self { - Self::new() - } -} - -impl Channels { - /// A fresh, empty channel pair. - pub const fn new() -> Self { - Self { - telegrams: Channel::new(), - commands: Channel::new(), - } - } -} - -impl KnxConnector { +impl KnxConnector { /// Connect to the KNX/IP gateway at `gateway_url` (`knx://host:port`). /// - /// `binder` and `delay` come from an adapter; `channels` is the caller's - /// `'static` channel pair. - pub fn new( - binder: B, - delay: D, - gateway_url: impl Into, - channels: &'static Channels, - ) -> Self { + /// `binder` and `delay` come from an adapter. + pub fn new(binder: B, delay: D, gateway_url: impl Into) -> Self { Self { binder, delay, gateway_url: gateway_url.into(), - channels, } } @@ -153,7 +65,7 @@ impl KnxConnector { } } -impl ConnectorBuilder for KnxConnector +impl ConnectorBuilder for KnxConnector where B: aimdb_core::session::DatagramBinder + Clone + Send + Sync + 'static, D: aimdb_core::session::Delay + Clone + Send + Sync + 'static, @@ -167,32 +79,20 @@ where log_info!("Creating KNX connector for gateway {}", gateway); let runtime: Arc = db.runtime_ops(); - let channels = self.channels; + // Built here, so routes are subscribed before the task first runs: + // a command produced while the tunnel connects waits in its record + // buffer and goes out once the handshake completes. + let inbound = InboundDispatch::new(db, "knx", &aimdb_core::ExactGrammar)?; + let outbound = OutboundRoutes::new(db, "knx")?; let task: BoxFuture = Box::pin(connection_task( self.binder.clone(), gateway, runtime, self.delay.clone(), - ChannelSink::(channels.telegrams.sender()), - ChannelCommands::(channels.commands.receiver()), + inbound, + outbound, )); - - let mut futures: Vec = vec![task]; - futures.extend(pump_source( - db, - db.inbound_router("knx", &aimdb_core::ExactGrammar)?, - KnxSource:: { - telegrams: &channels.telegrams, - }, - )); - futures.extend(pump_sink( - db, - "knx", - Arc::new(KnxSink:: { - commands: &channels.commands, - }), - )); - Ok(futures) + Ok(vec![task]) }) } @@ -222,8 +122,6 @@ mod tests { use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; use std::net::Ipv4Addr; - static CHANNELS: Channels<8> = Channels::new(); - async fn db() -> AimDb { let mut builder = AimDbBuilder::new().runtime(Arc::new(TokioAdapter)); builder.configure::("light", |reg| { @@ -236,11 +134,10 @@ mod tests { #[tokio::test] async fn an_unparsable_gateway_fails_the_build() { let db = db().await; - let connector = KnxConnector::<_, _, 8>::new( + let connector = KnxConnector::new( TokioNet::udp(Ipv4Addr::LOCALHOST), TokioDelay, "knx://not-an-ip:3671", - &CHANNELS, ); let Err(err) = connector.build(&db).await else { panic!("a hostname must be rejected: it is never resolved"); @@ -251,22 +148,18 @@ mod tests { ); } - /// A db with one inbound and one outbound `knx` route, so both pumps have - /// something to contribute. + /// A db with one inbound and one outbound `knx` route. /// /// A connector must be registered or the builder rejects the routes ("no - /// connector registered for scheme 'knx'"). It gets its own channel pair: - /// two connectors sharing one would split the command queue between them. - /// Nothing here is ever driven — `build` only collects futures. + /// connector registered for scheme 'knx'"). Nothing here is ever driven — + /// `build` only collects futures. async fn routed_db() -> AimDb { - static REGISTERED: Channels<8> = Channels::new(); let mut builder = AimDbBuilder::new() .runtime(Arc::new(TokioAdapter)) - .with_connector(KnxConnector::<_, _, 8>::new( + .with_connector(KnxConnector::new( TokioNet::udp(Ipv4Addr::LOCALHOST), TokioDelay, "knx://127.0.0.1:3671", - ®ISTERED, )); builder.configure::("switch", |reg| { reg.buffer(BufferCfg::SingleLatest) @@ -286,90 +179,51 @@ mod tests { } /// The connector registers under the `knx` scheme and contributes exactly - /// the connection task, one `pump_source`, and one publisher per outbound - /// route. - /// - /// The count is asserted, not just non-emptiness: `pump_source` yields one - /// future unconditionally and `pump_sink` one per outbound route, so a pump - /// silently dropping out of `build` is the failure this catches, and - /// `!is_empty()` would not — the connection task alone satisfies that. + /// one future, the connection task, which dispatches inbound telegrams and + /// pulls outbound values itself. #[tokio::test] - async fn build_yields_the_connection_task_and_pumps() { - static CH: Channels<8> = Channels::new(); - let db = routed_db().await; - let connector = KnxConnector::<_, _, 8>::new( - TokioNet::udp(Ipv4Addr::LOCALHOST), - TokioDelay, - "knx://127.0.0.1:3671", - &CH, - ); - assert_eq!(ConnectorBuilder::scheme(&connector), "knx"); - - let futures = connector.build(&db).await.expect("build"); - assert_eq!( - futures.len(), - 3, - "connection task + pump_source + one publisher for `knx://1/0/6`" - ); - } - - /// With no outbound route, `pump_sink` contributes nothing and the count - /// drops to two — the half of the contract the routed test cannot show. - #[tokio::test] - async fn an_inbound_only_db_yields_no_publisher() { - static CH: Channels<8> = Channels::new(); - let db = db().await; - let connector = KnxConnector::<_, _, 8>::new( - TokioNet::udp(Ipv4Addr::LOCALHOST), - TokioDelay, - "knx://127.0.0.1:3671", - &CH, - ); + async fn build_yields_only_the_connection_task() { + let connector = || { + KnxConnector::new( + TokioNet::udp(Ipv4Addr::LOCALHOST), + TokioDelay, + "knx://127.0.0.1:3671", + ) + }; + assert_eq!(ConnectorBuilder::scheme(&connector()), "knx"); - let futures = connector.build(&db).await.expect("build"); - assert_eq!( - futures.len(), - 2, - "connection task + pump_source, and no publisher" - ); + // With inbound and outbound routes, and with none. + for db in [routed_db().await, db().await] { + let futures = connector().build(&db).await.expect("build"); + assert_eq!(futures.len(), 1, "the connection task alone"); + } } - /// A second KNX connector is a configuration error, whether or not it - /// shares the first one's channels. - /// - /// Sharing is the louder mistake — both tasks would drain one command - /// queue — but separate channels are broken too: each connector collects - /// *all* `knx://` routes, so every `link_to` gets two publishers and - /// nothing says which gateway a route belongs to. + /// A second KNX connector is a configuration error: each connector + /// collects *all* `knx://` routes, so every route would be served twice + /// and nothing says which gateway it belongs to. #[tokio::test] async fn a_second_knx_connector_fails_the_build() { - static A: Channels<8> = Channels::new(); - static B: Channels<8> = Channels::new(); - - for (second_channels, case) in [(&A, "shared channels"), (&B, "separate channels")] { - let builder = AimDbBuilder::new() - .runtime(Arc::new(TokioAdapter)) - .with_connector(KnxConnector::<_, _, 8>::new( - TokioNet::udp(Ipv4Addr::LOCALHOST), - TokioDelay, - "knx://127.0.0.1:3671", - &A, - )) - .with_connector(KnxConnector::<_, _, 8>::new( - TokioNet::udp(Ipv4Addr::LOCALHOST), - TokioDelay, - "knx://127.0.0.2:3671", - second_channels, - )); + let builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(KnxConnector::new( + TokioNet::udp(Ipv4Addr::LOCALHOST), + TokioDelay, + "knx://127.0.0.1:3671", + )) + .with_connector(KnxConnector::new( + TokioNet::udp(Ipv4Addr::LOCALHOST), + TokioDelay, + "knx://127.0.0.2:3671", + )); - let Err(err) = builder.build().await else { - panic!("a second KNX connector must be rejected ({case})"); - }; - assert!( - format!("{err}").contains("More than one connector registered for scheme 'knx'"), - "unexpected error ({case}): {err}" - ); - } + let Err(err) = builder.build().await else { + panic!("a second KNX connector must be rejected"); + }; + assert!( + format!("{err}").contains("More than one connector registered for scheme 'knx'"), + "unexpected error: {err}" + ); } /// The whole wiring against a real UDP gateway: the task binds, advertises @@ -381,18 +235,16 @@ mod tests { /// unspecified bind takes has its own test in `client`. #[tokio::test] async fn the_wired_connector_reaches_a_gateway() { - static CH: Channels<8> = Channels::new(); let gateway = tokio::net::UdpSocket::bind("127.0.0.1:0") .await .expect("bind gateway"); let addr = gateway.local_addr().expect("gateway addr"); let db = db().await; - let connector = KnxConnector::<_, _, 8>::new( + let connector = KnxConnector::new( TokioNet::udp(Ipv4Addr::LOCALHOST), TokioDelay, format!("knx://{addr}"), - &CH, ); let futures = connector.build(&db).await.expect("build"); let driving: Vec<_> = futures.into_iter().map(tokio::spawn).collect(); diff --git a/aimdb-knx-connector/src/lib.rs b/aimdb-knx-connector/src/lib.rs index c717b5e7..bd7f2312 100644 --- a/aimdb-knx-connector/src/lib.rs +++ b/aimdb-knx-connector/src/lib.rs @@ -17,8 +17,8 @@ //! - `std`: `connector` plus core's `std`, knx-pico's std error impls, and the //! back-compat DPT re-exports. Lifts `no_std`; adds no runtime. //! - `critical-section-std-impl`: **final binaries only** — selects -//! `critical-section`'s std impl, which `Channels` needs to link on a host. -//! An Embassy HAL already provides one. +//! `critical-section`'s std impl for a host binary that needs one. An +//! Embassy HAL already provides one. //! - `tracing`: Debug logging support (std) //! - `defmt`: Debug logging support (no_std) //! @@ -48,7 +48,7 @@ //! ```no_run //! use aimdb_core::buffer::BufferCfg; //! use aimdb_core::AimDbBuilder; -//! use aimdb_knx_connector::{Channels, KnxConnector}; +//! use aimdb_knx_connector::KnxConnector; //! use aimdb_tokio_adapter::net::{TokioDelay, TokioNet}; //! use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; //! use std::net::Ipv4Addr; @@ -62,14 +62,12 @@ //! # async fn demo() -> Result<(), Box> { //! let runtime = Arc::new(TokioAdapter::new()?); //! -//! static CHANNELS: Channels = Channels::new(); //! let mut builder = AimDbBuilder::new() //! .runtime(runtime) //! .with_connector(KnxConnector::new( //! TokioNet::udp(Ipv4Addr::UNSPECIFIED), //! TokioDelay, //! "knx://192.168.1.19:3671", -//! &CHANNELS, //! )); //! builder.configure::("light.state", |reg| { //! reg.buffer(BufferCfg::SingleLatest) @@ -100,14 +98,14 @@ //! ```rust,ignore //! use aimdb_core::AimDbBuilder; //! use aimdb_embassy_adapter::EmbassyAdapter; -//! use aimdb_knx_connector::connector::{Channels, KnxConnector}; +//! use aimdb_knx_connector::KnxConnector; //! use alloc::sync::Arc; //! //! let runtime = Arc::new(EmbassyAdapter::new()); //! //! let db = AimDbBuilder::new() //! .runtime(runtime) -//! .with_connector(KnxConnector::new(binder, EmbassyDelay, gateway, &CHANNELS)) +//! .with_connector(KnxConnector::new(binder, EmbassyDelay, gateway)) //! .configure::(|reg| { //! reg.buffer_sized::<16, 2>(EmbassyBufferType::SpmcRing) //! .source(sensor_producer) @@ -181,4 +179,4 @@ pub mod client; pub mod connector; #[cfg(feature = "connector")] -pub use connector::{Channels, KnxConnector}; +pub use connector::KnxConnector; diff --git a/aimdb-knx-connector/src/tunnel.rs b/aimdb-knx-connector/src/tunnel.rs index ba04cae5..d3941546 100644 --- a/aimdb-knx-connector/src/tunnel.rs +++ b/aimdb-knx-connector/src/tunnel.rs @@ -43,8 +43,8 @@ const MAX_FRAME: usize = 12 + MAX_CEMI; /// A wire datagram to send to the gateway — stack-allocated. pub type Frame = heapless::Vec; -/// Outbound GroupValueWrite command, handed to the engine by the transport's -/// command channel (fed by the `Connector::publish` side). +/// Outbound GroupValueWrite command, handed to the engine by the connection +/// task for each value it pulls from an outbound route. #[derive(Debug, Clone, PartialEq, Eq)] pub struct GroupWrite { pub group_addr: GroupAddress, @@ -79,8 +79,8 @@ pub enum Action { /// number of a tracked TUNNELING_REQUEST so a failed send can stop its /// ACK tracking (see `TunnelIo::send`); `None` for everything else. Send { frame: Frame, await_ack: Option }, - /// Deliver a parsed inbound telegram toward `pump_source` - /// (`try_send`, drop-on-full — never stall the protocol loop). + /// Deliver a parsed inbound telegram into its records (synchronous; + /// never stalls the protocol loop). Telegram { addr: GroupAddress, payload: Vec, @@ -544,8 +544,8 @@ pub(crate) trait TunnelIo { /// generic over this trait and its future has to be boxable as the /// runner's `Send` future. An impl whose socket future is `!Send` wraps it. fn send(&mut self, frame: &[u8]) -> impl core::future::Future + Send; - /// Forward a parsed telegram toward `pump_source`. Non-blocking: - /// drop + log on a full channel rather than stalling the protocol loop. + /// Forward a parsed telegram into its records. Non-blocking: never + /// stalls the protocol loop. fn forward(&mut self, addr: GroupAddress, payload: Vec); /// An outbound telegram's ACK never arrived (log-only, see /// [`Action::AckTimeout`]). diff --git a/aimdb-knx-connector/tests/shared_channel_on_std.rs b/aimdb-knx-connector/tests/shared_channel_on_std.rs deleted file mode 100644 index c4bcbd24..00000000 --- a/aimdb-knx-connector/tests/shared_channel_on_std.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! The MCU's channel and select types must work in a **linked** std binary, so -//! one connection task can serve both runtimes. -//! -//! `CriticalSectionRawMutex` is the only `Sync` raw mutex `embassy-sync` offers -//! — `NoopRawMutex` is `!Sync` and cannot back a shared channel at all — and -//! using it pulls in `_critical_section_1_0_acquire`/`_release`, which nothing -//! defines on std. Selecting an impl is the final binary's call, so the library -//! does not make it: a test binary is a binary, and gets the std impl through -//! this crate's `critical-section` dev-dependency. These tests fail to *link*, -//! not to compile, if that ever comes undone. -#![cfg(feature = "std")] - -use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; -use embassy_sync::channel::Channel; - -type Cmd = (u8, u16); - -/// A send and receive through the channel, exercising the critical section. -#[tokio::test] -async fn embassy_channel_carries_a_value_on_std() { - let channel: Channel = Channel::new(); - - channel.send((1, 0x0a0b)).await; - assert_eq!(channel.receive().await, (1, 0x0a0b)); -} - -/// The channel is `Send + Sync`, so a spawned task can enqueue while the -/// protocol loop drains — what the unified connection task relies on. -#[tokio::test] -async fn embassy_channel_is_shareable_across_tasks_on_std() { - static CHANNEL: Channel = Channel::new(); - - let producer = tokio::spawn(async { - CHANNEL.send((2, 0x0c0d)).await; - }); - - assert_eq!(CHANNEL.receive().await, (2, 0x0c0d)); - producer.await.expect("producer task"); -} - -/// `embassy-futures` drives the same select on std as on the MCU. -#[tokio::test] -async fn embassy_select_resolves_on_std() { - use embassy_futures::select::{select, Either}; - - let channel: Channel = Channel::new(); - channel.send((3, 0x0e0f)).await; - - match select(channel.receive(), core::future::pending::<()>()).await { - Either::First(cmd) => assert_eq!(cmd, (3, 0x0e0f)), - Either::Second(()) => panic!("pending future must never win"), - } -} diff --git a/aimdb-knx-connector/tests/topic_provider_tests.rs b/aimdb-knx-connector/tests/topic_provider_tests.rs deleted file mode 100644 index 2441dfa7..00000000 --- a/aimdb-knx-connector/tests/topic_provider_tests.rs +++ /dev/null @@ -1,590 +0,0 @@ -//! Integration tests for KNX TopicProvider and TopicResolver functionality -//! -//! These tests verify the dynamic group address routing features: -//! - **TopicProvider**: Outbound (AimDB → KNX) dynamic group address selection per-value -//! - **TopicResolver**: Inbound (KNX → AimDB) late-binding group address resolution at startup -//! -//! The tests use mock data and don't require a running KNX/IP gateway. - -#![cfg(feature = "std")] - -use aimdb_core::buffer::BufferCfg; -use aimdb_core::connector::TopicProvider; -use aimdb_core::{AimDbBuilder, Producer, RuntimeContext}; -use aimdb_knx_connector::Channels; -use aimdb_tokio_adapter::net::{TokioDelay, TokioNet}; -use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; -use std::net::Ipv4Addr; -use std::sync::atomic::{AtomicU32, Ordering}; -use std::sync::Arc; - -/// The connector's channel pair, shared by the registration tests — none of -/// them runs a connection task, so one pair serves all three. -static CHANNELS: Channels = Channels::new(); - -// ============================================================================ -// Test Types -// ============================================================================ - -/// Dimmer value with room identifier for dynamic group address routing -#[derive(Clone, Debug)] -struct DimmerValue { - room_id: String, - level: u8, // 0-255 -} - -impl DimmerValue { - fn new(room_id: &str, level: u8) -> Self { - Self { - room_id: room_id.into(), - level, - } - } - - fn to_knx_bytes(&self) -> Vec { - vec![self.level] - } -} - -/// Switch state with zone identifier -#[derive(Clone, Debug)] -struct SwitchState { - zone_id: String, - is_on: bool, -} - -impl SwitchState { - fn new(zone_id: &str, is_on: bool) -> Self { - Self { - zone_id: zone_id.into(), - is_on, - } - } - - #[allow(dead_code)] - fn to_knx_bytes(&self) -> Vec { - vec![if self.is_on { 1 } else { 0 }] - } - - fn from_knx_bytes(data: &[u8]) -> Result { - if data.is_empty() { - return Err("Empty data".into()); - } - Ok(SwitchState { - zone_id: "unknown".into(), - is_on: data[0] != 0, - }) - } -} - -/// Temperature setpoint for HVAC control -#[derive(Clone, Debug)] -struct TemperatureSetpoint { - hvac_zone: u8, // Zone number 1-16 - setpoint: f32, // Temperature in Celsius -} - -impl TemperatureSetpoint { - fn new(hvac_zone: u8, setpoint: f32) -> Self { - Self { - hvac_zone, - setpoint, - } - } - - fn to_knx_bytes(&self) -> Vec { - // KNX DPT 9.001 encoding (simplified for test) - let value = (self.setpoint * 100.0) as i16; - vec![(value >> 8) as u8, value as u8] - } -} - -// ============================================================================ -// TopicProvider Implementations (KNX Group Addresses) -// ============================================================================ - -/// Dynamic group address provider based on room ID -/// -/// Routes dimmer commands to different KNX group addresses based on room. -/// In a real building, each room has its own dimmer actuator address. -struct RoomBasedGroupAddressProvider { - /// Base group address (main/middle) - base_main: u8, - base_middle: u8, -} - -impl RoomBasedGroupAddressProvider { - fn new(base_main: u8, base_middle: u8) -> Self { - Self { - base_main, - base_middle, - } - } - - fn room_to_sub(&self, room_id: &str) -> u8 { - // Map room names to sub-addresses - match room_id { - "living" => 1, - "bedroom" => 2, - "kitchen" => 3, - "bathroom" => 4, - "office" => 5, - _ => 0, // Default/unknown - } - } -} - -impl TopicProvider for RoomBasedGroupAddressProvider { - fn topic(&self, value: &DimmerValue) -> Option { - let sub = self.room_to_sub(&value.room_id); - Some(format!( - "knx://{}/{}/{}", - self.base_main, self.base_middle, sub - )) - } -} - -/// HVAC zone-based group address provider -/// -/// Routes temperature setpoints to zone-specific group addresses. -struct HvacZoneProvider; - -impl TopicProvider for HvacZoneProvider { - fn topic(&self, value: &TemperatureSetpoint) -> Option { - // HVAC zones mapped to group addresses 5/0/1 through 5/0/16 - if value.hvac_zone >= 1 && value.hvac_zone <= 16 { - Some(format!("knx://5/0/{}", value.hvac_zone)) - } else { - None // Invalid zone, use fallback - } - } -} - -/// Switch provider with emergency override -/// -/// Demonstrates conditional routing: emergency signals go to broadcast address. -struct SwitchWithEmergencyProvider; - -impl TopicProvider for SwitchWithEmergencyProvider { - fn topic(&self, value: &SwitchState) -> Option { - if value.zone_id == "emergency" { - // Emergency signals go to broadcast group - Some("knx://0/0/0".into()) - } else if value.zone_id.starts_with("zone-") { - // Parse zone number - let zone_num: u8 = value.zone_id[5..].parse().unwrap_or(0); - Some(format!("knx://1/1/{}", zone_num)) - } else { - None // Use default from link_to() - } - } -} - -// ============================================================================ -// Unit Tests for KNX TopicProvider -// ============================================================================ - -#[test] -fn test_room_based_group_address_provider() { - let provider = RoomBasedGroupAddressProvider::new(1, 0); - - let living = DimmerValue::new("living", 128); - assert_eq!(provider.topic(&living), Some("knx://1/0/1".into())); - - let bedroom = DimmerValue::new("bedroom", 255); - assert_eq!(provider.topic(&bedroom), Some("knx://1/0/2".into())); - - let kitchen = DimmerValue::new("kitchen", 64); - assert_eq!(provider.topic(&kitchen), Some("knx://1/0/3".into())); - - // Unknown room uses sub-address 0 - let unknown = DimmerValue::new("garage", 100); - assert_eq!(provider.topic(&unknown), Some("knx://1/0/0".into())); -} - -#[test] -fn test_hvac_zone_provider() { - let provider = HvacZoneProvider; - - let zone1 = TemperatureSetpoint::new(1, 21.0); - assert_eq!(provider.topic(&zone1), Some("knx://5/0/1".into())); - - let zone16 = TemperatureSetpoint::new(16, 22.5); - assert_eq!(provider.topic(&zone16), Some("knx://5/0/16".into())); - - // Invalid zone returns None (fallback) - let invalid = TemperatureSetpoint::new(0, 20.0); - assert_eq!(provider.topic(&invalid), None); - - let too_high = TemperatureSetpoint::new(17, 20.0); - assert_eq!(provider.topic(&too_high), None); -} - -#[test] -fn test_switch_with_emergency_provider() { - let provider = SwitchWithEmergencyProvider; - - // Emergency goes to broadcast - let emergency = SwitchState::new("emergency", true); - assert_eq!(provider.topic(&emergency), Some("knx://0/0/0".into())); - - // Normal zones - let zone1 = SwitchState::new("zone-1", true); - assert_eq!(provider.topic(&zone1), Some("knx://1/1/1".into())); - - let zone5 = SwitchState::new("zone-5", false); - assert_eq!(provider.topic(&zone5), Some("knx://1/1/5".into())); - - // Unknown zone uses fallback - let unknown = SwitchState::new("lobby", true); - assert_eq!(provider.topic(&unknown), None); -} - -// ============================================================================ -// Unit Tests for KNX TopicResolver -// ============================================================================ - -#[test] -fn test_topic_resolver_from_config_file() { - // Simulate reading group address from a config file or service - std::env::set_var("KNX_SWITCH_GROUP", "1/2/3"); - - let resolver = || { - std::env::var("KNX_SWITCH_GROUP") - .ok() - .map(|addr| format!("knx://{}", addr)) - }; - - assert_eq!(resolver(), Some("knx://1/2/3".into())); - - std::env::remove_var("KNX_SWITCH_GROUP"); -} - -#[test] -fn test_topic_resolver_with_discovery() { - use std::sync::Mutex; - - // Simulate KNX device discovery - let discovered_devices = Arc::new(Mutex::new(vec![ - ("dimmer-1", "1/0/1"), - ("dimmer-2", "1/0/2"), - ])); - - let devices_clone = discovered_devices.clone(); - let resolver = move || { - devices_clone - .lock() - .unwrap() - .iter() - .find(|(name, _)| *name == "dimmer-1") - .map(|(_, addr)| format!("knx://{}", addr)) - }; - - assert_eq!(resolver(), Some("knx://1/0/1".into())); -} - -// ============================================================================ -// Integration Test: TopicProvider with AimDbBuilder -// ============================================================================ - -/// Test that TopicProvider can be configured without connector (verifies API) -#[tokio::test] -async fn test_knx_topic_provider_registration_api() { - let runtime = Arc::new(TokioAdapter::new().unwrap()); - let produced_count = Arc::new(AtomicU32::new(0)); - let produced_count_clone = produced_count.clone(); - - let mut builder = AimDbBuilder::new().runtime(runtime); - - // Register without link_to (no connector needed) - builder.configure::("knx.dimmer.living", |reg| { - let counter = produced_count_clone.clone(); - reg.buffer(BufferCfg::SingleLatest).source( - move |_ctx: RuntimeContext, producer: Producer| { - let counter = counter.clone(); - async move { - let dimmer = DimmerValue::new("living", 200); - producer.produce(dimmer); - counter.fetch_add(1, Ordering::SeqCst); - } - }, - ); - }); - - // Build should succeed - assert!(builder.build().await.is_ok()); -} - -/// Test TopicProvider with KnxConnector registration -/// -/// This test verifies the full configuration API including link_to + with_topic_provider -/// works correctly at compile time. Runtime requires actual KNX gateway. -#[tokio::test] -async fn test_knx_topic_provider_with_connector_registration() { - let runtime = Arc::new(TokioAdapter::new().unwrap()); - - let mut builder = AimDbBuilder::new().runtime(runtime).with_connector( - aimdb_knx_connector::KnxConnector::new( - TokioNet::udp(Ipv4Addr::UNSPECIFIED), - TokioDelay, - "knx://192.168.1.10:3671", - &CHANNELS, - ), - ); - - // Register dimmer with dynamic group address provider - builder.configure::("knx.dimmer.living", |reg| { - reg.buffer(BufferCfg::SingleLatest) - .source( - |_ctx: RuntimeContext, producer: Producer| async move { - let dimmer = DimmerValue::new("living", 200); - producer.produce(dimmer); - }, - ) - .link_to("knx://1/0/0") // Fallback group address - .with_topic_provider(RoomBasedGroupAddressProvider::new(1, 0)) - .with_serializer(|_ctx, dimmer: &DimmerValue| Ok(dimmer.to_knx_bytes())) - .finish(); - }); - - // Build succeeds with connector registered - assert!(builder.build().await.is_ok()); -} - -#[tokio::test] -async fn test_knx_topic_resolver_with_connector_registration() { - let runtime = Arc::new(TokioAdapter::new().unwrap()); - - std::env::set_var("KNX_SWITCH_INPUT", "1/2/10"); - - let mut builder = AimDbBuilder::new().runtime(runtime).with_connector( - aimdb_knx_connector::KnxConnector::new( - TokioNet::udp(Ipv4Addr::UNSPECIFIED), - TokioDelay, - "knx://192.168.1.10:3671", - &CHANNELS, - ), - ); - - // Register switch with dynamic group address resolver - builder.configure::("knx.switch.zone1", |reg| { - reg.buffer(BufferCfg::SingleLatest) - .link_from("knx://1/2/0") // Fallback group address - .with_topic_resolver(|| { - std::env::var("KNX_SWITCH_INPUT") - .ok() - .map(|addr| format!("knx://{}", addr)) - }) - .with_deserializer(|_ctx, data: &[u8]| SwitchState::from_knx_bytes(data)) - .finish(); - }); - - // Build should succeed - assert!(builder.build().await.is_ok()); - - std::env::remove_var("KNX_SWITCH_INPUT"); -} - -#[tokio::test] -async fn test_hvac_zone_routing() { - let runtime = Arc::new(TokioAdapter::new().unwrap()); - - let mut builder = AimDbBuilder::new().runtime(runtime).with_connector( - aimdb_knx_connector::KnxConnector::new( - TokioNet::udp(Ipv4Addr::UNSPECIFIED), - TokioDelay, - "knx://192.168.1.10:3671", - &CHANNELS, - ), - ); - - // HVAC setpoint with zone-based routing - builder.configure::("knx.hvac.setpoint", |reg| { - reg.buffer(BufferCfg::SingleLatest) - .source( - |_ctx: RuntimeContext, producer: Producer| async move { - // Different zones get routed to different group addresses - for zone in 1..=4 { - let setpoint = TemperatureSetpoint::new(zone, 21.0 + zone as f32 * 0.5); - producer.produce(setpoint); - } - }, - ) - .link_to("knx://5/0/0") // Fallback for invalid zones - .with_topic_provider(HvacZoneProvider) - .with_serializer(|_ctx, sp: &TemperatureSetpoint| Ok(sp.to_knx_bytes())) - .finish(); - }); - - assert!(builder.build().await.is_ok()); -} - -// ============================================================================ -// Test TopicProvider as a typed trait object with KNX Types -// ============================================================================ - -#[test] -fn test_knx_topic_provider_as_trait_object() { - use aimdb_core::connector::TopicProvider; - use std::sync::Arc; - - // Providers are stored as Arc> and stay typed - // end-to-end — a wrong-type call is unrepresentable. - let provider: Arc> = - Arc::new(RoomBasedGroupAddressProvider::new(1, 0)); - - let dimmer = DimmerValue::new("living", 128); - assert_eq!(provider.topic(&dimmer), Some("knx://1/0/1".into())); -} - -// ============================================================================ -// Test: Simulate Connector Group Address Resolution Logic -// ============================================================================ -// -// These tests simulate EXACTLY what the fused outbound reader does internally -// while it still holds the typed value: -// ```rust -// let dest = topic.as_ref().and_then(|p| p.topic(&value)); -// // ...later, in the pump: -// let dest = msg.dest.unwrap_or_else(|| default_group_addr.clone()); -// ``` - -/// Simulates the fused reader's group address resolution for outbound telegrams -fn resolve_group_address_like_connector( - default_group_addr: &str, - topic_provider: Option<&dyn aimdb_core::connector::TopicProvider>, - value: &T, -) -> String { - topic_provider - .and_then(|provider| provider.topic(value)) - .unwrap_or_else(|| default_group_addr.to_string()) -} - -#[test] -fn test_connector_group_address_resolution_room_based() { - let provider = RoomBasedGroupAddressProvider::new(1, 0); - let default_addr = "knx://1/0/0"; - - // Test 1: Living room dimmer - let dimmer_living = DimmerValue::new("living", 128); - let resolved = - resolve_group_address_like_connector(default_addr, Some(&provider), &dimmer_living); - assert_eq!(resolved, "knx://1/0/1"); - - // Test 2: Bedroom dimmer - let dimmer_bedroom = DimmerValue::new("bedroom", 255); - let resolved = - resolve_group_address_like_connector(default_addr, Some(&provider), &dimmer_bedroom); - assert_eq!(resolved, "knx://1/0/2"); - - // Test 3: Kitchen dimmer - let dimmer_kitchen = DimmerValue::new("kitchen", 64); - let resolved = - resolve_group_address_like_connector(default_addr, Some(&provider), &dimmer_kitchen); - assert_eq!(resolved, "knx://1/0/3"); - - // Test 4: Unknown room → sub-address 0 - let dimmer_unknown = DimmerValue::new("garage", 100); - let resolved = - resolve_group_address_like_connector(default_addr, Some(&provider), &dimmer_unknown); - assert_eq!(resolved, "knx://1/0/0"); -} - -#[test] -fn test_connector_group_address_resolution_hvac_zones() { - let provider = HvacZoneProvider; - let default_addr = "knx://5/0/0"; // Fallback for invalid zones - - // Test valid zones 1-16 - for zone in 1..=16u8 { - let setpoint = TemperatureSetpoint::new(zone, 21.0); - let resolved = - resolve_group_address_like_connector(default_addr, Some(&provider), &setpoint); - assert_eq!(resolved, format!("knx://5/0/{}", zone)); - } - - // Test invalid zone 0 → fallback - let invalid_zone_0 = TemperatureSetpoint::new(0, 21.0); - let resolved = - resolve_group_address_like_connector(default_addr, Some(&provider), &invalid_zone_0); - assert_eq!(resolved, "knx://5/0/0"); // Fallback - - // Test invalid zone 17 → fallback - let invalid_zone_17 = TemperatureSetpoint::new(17, 21.0); - let resolved = - resolve_group_address_like_connector(default_addr, Some(&provider), &invalid_zone_17); - assert_eq!(resolved, "knx://5/0/0"); // Fallback -} - -#[test] -fn test_connector_group_address_resolution_emergency_switch() { - let provider = SwitchWithEmergencyProvider; - let default_addr = "knx://1/1/0"; - - // Test 1: Emergency switch → broadcast address - let emergency = SwitchState::new("emergency", true); - let resolved = resolve_group_address_like_connector(default_addr, Some(&provider), &emergency); - assert_eq!(resolved, "knx://0/0/0"); // Broadcast! - - // Test 2: Normal zone switches - let zone_1 = SwitchState::new("zone-1", true); - let resolved = resolve_group_address_like_connector(default_addr, Some(&provider), &zone_1); - assert_eq!(resolved, "knx://1/1/1"); - - let zone_5 = SwitchState::new("zone-5", false); - let resolved = resolve_group_address_like_connector(default_addr, Some(&provider), &zone_5); - assert_eq!(resolved, "knx://1/1/5"); - - // Test 3: Unknown zone → fallback - let lobby = SwitchState::new("lobby", true); - let resolved = resolve_group_address_like_connector(default_addr, Some(&provider), &lobby); - assert_eq!(resolved, "knx://1/1/0"); // Fallback -} - -#[test] -fn test_connector_group_address_no_provider() { - let default_addr = "knx://1/0/99"; - - // No provider → always use default - let dimmer = DimmerValue::new("living", 128); - let resolved = resolve_group_address_like_connector(default_addr, None, &dimmer); - assert_eq!(resolved, "knx://1/0/99"); -} - -/// Test inbound group address resolution (TopicResolver) -#[test] -fn test_inbound_group_address_resolver_simulation() { - fn resolve_inbound_group_addr( - default_addr: &str, - resolver: Option<&dyn Fn() -> Option>, - ) -> String { - resolver - .and_then(|r| r()) - .unwrap_or_else(|| default_addr.to_string()) - } - - // Test 1: Resolver returns dynamic address - let resolver_dynamic = || Some("knx://1/2/10".to_string()); - let addr = resolve_inbound_group_addr("knx://1/2/0", Some(&resolver_dynamic)); - assert_eq!(addr, "knx://1/2/10"); - - // Test 2: Resolver returns None → fallback - let resolver_fallback = || None; - let addr = resolve_inbound_group_addr("knx://1/2/0", Some(&resolver_fallback)); - assert_eq!(addr, "knx://1/2/0"); - - // Test 3: No resolver → default - let addr = resolve_inbound_group_addr("knx://1/2/0", None); - assert_eq!(addr, "knx://1/2/0"); - - // Test 4: Resolver from environment (simulating config file) - std::env::set_var("KNX_INBOUND_ADDR", "1/3/5"); - let resolver_env = || { - std::env::var("KNX_INBOUND_ADDR") - .ok() - .map(|a| format!("knx://{}", a)) - }; - let addr = resolve_inbound_group_addr("knx://1/0/0", Some(&resolver_env)); - assert_eq!(addr, "knx://1/3/5"); - std::env::remove_var("KNX_INBOUND_ADDR"); -} diff --git a/aimdb-knx-connector/tests/topic_writer_tests.rs b/aimdb-knx-connector/tests/topic_writer_tests.rs new file mode 100644 index 00000000..42055a0b --- /dev/null +++ b/aimdb-knx-connector/tests/topic_writer_tests.rs @@ -0,0 +1,405 @@ +//! Integration tests for KNX TopicWriter and TopicResolver functionality +//! +//! These tests verify the dynamic group address routing features: +//! - **TopicWriter**: Outbound (AimDB → KNX) dynamic group address selection per-value +//! - **TopicResolver**: Inbound (KNX → AimDB) late-binding group address resolution at startup +//! +//! A writer writes the bare group address (`1/0/1`), not the `knx://` URL. The +//! longest group address, `31/7/255`, is 8 bytes. +//! +//! The tests use mock data and don't require a running KNX/IP gateway. + +#![cfg(feature = "std")] + +use aimdb_core::buffer::BufferCfg; +use aimdb_core::connector::{TopicBuf, TopicOverflow, TopicWriter}; +use aimdb_core::{AimDbBuilder, Producer, RuntimeContext}; +use aimdb_knx_connector::KnxConnector; +use aimdb_tokio_adapter::net::{TokioDelay, TokioNet, TokioUdpBinder}; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use std::fmt::Write as _; +use std::net::Ipv4Addr; +use std::sync::Arc; + +/// Topic capacity for every KNX writer here: the longest group address. +const GROUP_ADDRESS_CAPACITY: usize = 8; + +// ============================================================================ +// Test Types +// ============================================================================ + +/// Dimmer value with room identifier for dynamic group address routing +#[derive(Clone, Debug)] +struct DimmerValue { + room_id: String, + level: u8, // 0-255 +} + +impl DimmerValue { + fn new(room_id: &str, level: u8) -> Self { + Self { + room_id: room_id.into(), + level, + } + } + + fn to_knx_bytes(&self) -> Vec { + vec![self.level] + } +} + +/// Switch state with zone identifier +#[derive(Clone, Debug)] +struct SwitchState { + zone_id: String, + #[allow(dead_code)] + is_on: bool, +} + +impl SwitchState { + fn new(zone_id: &str, is_on: bool) -> Self { + Self { + zone_id: zone_id.into(), + is_on, + } + } + + fn from_knx_bytes(data: &[u8]) -> Result { + if data.is_empty() { + return Err("Empty data".into()); + } + Ok(SwitchState { + zone_id: "unknown".into(), + is_on: data[0] != 0, + }) + } +} + +/// Temperature setpoint for HVAC control +#[derive(Clone, Debug)] +struct TemperatureSetpoint { + hvac_zone: u8, // Zone number 1-16 + setpoint: f32, // Temperature in Celsius +} + +impl TemperatureSetpoint { + fn new(hvac_zone: u8, setpoint: f32) -> Self { + Self { + hvac_zone, + setpoint, + } + } + + fn to_knx_bytes(&self) -> Vec { + // KNX DPT 9.001 encoding (simplified for test) + let value = (self.setpoint * 100.0) as i16; + vec![(value >> 8) as u8, value as u8] + } +} + +// ============================================================================ +// TopicWriter Implementations (KNX Group Addresses) +// ============================================================================ + +/// Dynamic group address writer based on room ID +/// +/// Routes dimmer commands to different KNX group addresses based on room. +/// In a real building, each room has its own dimmer actuator address. +struct RoomBasedGroupAddress { + /// Base group address (main/middle) + base_main: u8, + base_middle: u8, +} + +impl RoomBasedGroupAddress { + fn new(base_main: u8, base_middle: u8) -> Self { + Self { + base_main, + base_middle, + } + } + + fn room_to_sub(&self, room_id: &str) -> u8 { + // Map room names to sub-addresses + match room_id { + "living" => 1, + "bedroom" => 2, + "kitchen" => 3, + "bathroom" => 4, + "office" => 5, + _ => 0, // Default/unknown + } + } +} + +impl TopicWriter for RoomBasedGroupAddress { + fn write_topic( + &self, + value: &DimmerValue, + out: &mut TopicBuf<'_>, + ) -> Result { + let sub = self.room_to_sub(&value.room_id); + write!(out, "{}/{}/{}", self.base_main, self.base_middle, sub)?; + Ok(true) + } +} + +/// HVAC zone-based group address writer +/// +/// Routes temperature setpoints to zone-specific group addresses. +struct HvacZone; + +impl TopicWriter for HvacZone { + fn write_topic( + &self, + value: &TemperatureSetpoint, + out: &mut TopicBuf<'_>, + ) -> Result { + // HVAC zones mapped to group addresses 5/0/1 through 5/0/16 + if !(1..=16).contains(&value.hvac_zone) { + return Ok(false); // Invalid zone, use fallback + } + write!(out, "5/0/{}", value.hvac_zone)?; + Ok(true) + } +} + +/// Switch writer with emergency override +/// +/// Demonstrates conditional routing: emergency signals go to broadcast address. +struct SwitchWithEmergency; + +impl TopicWriter for SwitchWithEmergency { + fn write_topic( + &self, + value: &SwitchState, + out: &mut TopicBuf<'_>, + ) -> Result { + if value.zone_id == "emergency" { + // Emergency signals go to broadcast group + out.push_str("0/0/0")?; + } else if let Some(zone) = value.zone_id.strip_prefix("zone-") { + let zone_num: u8 = zone.parse().unwrap_or(0); + write!(out, "1/1/{zone_num}")?; + } else { + return Ok(false); // Use default from link_to() + } + Ok(true) + } +} + +/// The group address `writer` selects for `value`, or `default` when it +/// returns `Ok(false)` — what the outbound route resolves. +fn resolve(writer: &dyn TopicWriter, value: &T, default: &str) -> String { + let mut storage = [0u8; GROUP_ADDRESS_CAPACITY]; + let mut out = TopicBuf::new(&mut storage); + match writer.write_topic(value, &mut out) { + Ok(true) => out.as_str().to_string(), + Ok(false) => default.to_string(), + Err(TopicOverflow) => panic!("group address exceeds {GROUP_ADDRESS_CAPACITY} bytes"), + } +} + +// ============================================================================ +// Unit Tests for KNX TopicWriter +// ============================================================================ + +#[test] +fn test_room_based_group_address() { + let writer = RoomBasedGroupAddress::new(1, 0); + let default = "1/0/0"; + + for (room, expected) in [ + ("living", "1/0/1"), + ("bedroom", "1/0/2"), + ("kitchen", "1/0/3"), + // Unknown room uses sub-address 0 + ("garage", "1/0/0"), + ] { + let dimmer = DimmerValue::new(room, 128); + assert_eq!(resolve(&writer, &dimmer, default), expected, "{room}"); + } +} + +#[test] +fn test_hvac_zone_routing_and_fallback() { + let default = "5/0/0"; // Fallback for invalid zones + + for zone in 1..=16u8 { + let setpoint = TemperatureSetpoint::new(zone, 21.0); + assert_eq!( + resolve(&HvacZone, &setpoint, default), + format!("5/0/{zone}") + ); + } + + // Invalid zones fall back to the link's default. + for zone in [0, 17] { + let setpoint = TemperatureSetpoint::new(zone, 20.0); + assert_eq!(resolve(&HvacZone, &setpoint, default), default); + } +} + +#[test] +fn test_switch_with_emergency() { + let default = "1/1/0"; + + // Emergency goes to broadcast + let emergency = SwitchState::new("emergency", true); + assert_eq!(resolve(&SwitchWithEmergency, &emergency, default), "0/0/0"); + + // Normal zones + let zone1 = SwitchState::new("zone-1", true); + assert_eq!(resolve(&SwitchWithEmergency, &zone1, default), "1/1/1"); + + let zone5 = SwitchState::new("zone-5", false); + assert_eq!(resolve(&SwitchWithEmergency, &zone5, default), "1/1/5"); + + // Unknown zone uses fallback + let lobby = SwitchState::new("lobby", true); + assert_eq!(resolve(&SwitchWithEmergency, &lobby, default), default); +} + +/// The longest group address fits the capacity; one byte less overflows. +#[test] +fn test_longest_group_address_fits_the_capacity() { + let writer = RoomBasedGroupAddress::new(31, 7); + let dimmer = DimmerValue::new("living", 1); + + let mut storage = [0u8; GROUP_ADDRESS_CAPACITY]; + let mut out = TopicBuf::new(&mut storage); + assert_eq!(writer.write_topic(&dimmer, &mut out), Ok(true)); + assert_eq!(out.as_str(), "31/7/1"); + + let writer = |out: &mut TopicBuf<'_>| write!(out, "31/7/255"); + let mut storage = [0u8; GROUP_ADDRESS_CAPACITY]; + assert!(writer(&mut TopicBuf::new(&mut storage)).is_ok()); + let mut storage = [0u8; GROUP_ADDRESS_CAPACITY - 1]; + assert!(writer(&mut TopicBuf::new(&mut storage)).is_err()); +} + +// ============================================================================ +// Unit Tests for KNX TopicResolver +// ============================================================================ + +#[test] +fn test_topic_resolver_from_config_file() { + // Simulate reading group address from a config file or service + std::env::set_var("KNX_SWITCH_GROUP", "1/2/3"); + + let resolver = || { + std::env::var("KNX_SWITCH_GROUP") + .ok() + .map(|addr| format!("knx://{}", addr)) + }; + + assert_eq!(resolver(), Some("knx://1/2/3".into())); + + std::env::remove_var("KNX_SWITCH_GROUP"); +} + +// ============================================================================ +// Integration Tests: registration with KnxConnector +// ============================================================================ + +fn connector() -> KnxConnector { + KnxConnector::new( + TokioNet::udp(Ipv4Addr::UNSPECIFIED), + TokioDelay, + "knx://192.168.1.10:3671", + ) +} + +/// `link_to` + `with_topic_writer` builds with the connector registered. +/// Running it would need a KNX gateway. +#[tokio::test] +async fn test_knx_topic_writer_with_connector_registration() { + let runtime = Arc::new(TokioAdapter::new().unwrap()); + let mut builder = AimDbBuilder::new() + .runtime(runtime) + .with_connector(connector()); + + builder.configure::("knx.dimmer.living", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .source( + |_ctx: RuntimeContext, producer: Producer| async move { + producer.produce(DimmerValue::new("living", 200)); + }, + ) + .link_to("knx://1/0/0") // Fallback group address + .with_topic_writer(GROUP_ADDRESS_CAPACITY, RoomBasedGroupAddress::new(1, 0)) + .with_serializer(|_ctx, dimmer: &DimmerValue| Ok(dimmer.to_knx_bytes())) + .finish(); + }); + builder.configure::("knx.hvac.setpoint", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_to("knx://5/0/0") // Fallback for invalid zones + .with_topic_writer(GROUP_ADDRESS_CAPACITY, HvacZone) + .with_serializer(|_ctx, sp: &TemperatureSetpoint| Ok(sp.to_knx_bytes())) + .finish(); + }); + + assert!(builder.build().await.is_ok()); +} + +/// The connector pulls through `OutboundRoutes`, which rejects +/// `with_topic_provider` links at build. +#[tokio::test] +async fn test_knx_rejects_a_topic_provider() { + use aimdb_core::connector::TopicProvider; + + struct Fixed; + impl TopicProvider for Fixed { + fn topic(&self, _value: &DimmerValue) -> Option { + Some("1/0/1".into()) + } + } + + let runtime = Arc::new(TokioAdapter::new().unwrap()); + let mut builder = AimDbBuilder::new() + .runtime(runtime) + .with_connector(connector()); + builder.configure::("knx.dimmer.living", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_to("knx://1/0/0") + .with_topic_provider(Fixed) + .with_serializer(|_ctx, dimmer: &DimmerValue| Ok(dimmer.to_knx_bytes())) + .finish(); + }); + + let Err(err) = builder.build().await else { + panic!("a topic provider must be rejected"); + }; + assert!( + format!("{err}").contains("with_topic_provider"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn test_knx_topic_resolver_with_connector_registration() { + let runtime = Arc::new(TokioAdapter::new().unwrap()); + + std::env::set_var("KNX_SWITCH_INPUT", "1/2/10"); + + let mut builder = AimDbBuilder::new() + .runtime(runtime) + .with_connector(connector()); + + // Register switch with dynamic group address resolver + builder.configure::("knx.switch.zone1", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from("knx://1/2/0") // Fallback group address + .with_topic_resolver(|| { + std::env::var("KNX_SWITCH_INPUT") + .ok() + .map(|addr| format!("knx://{}", addr)) + }) + .with_deserializer(|_ctx, data: &[u8]| SwitchState::from_knx_bytes(data)) + .finish(); + }); + + assert!(builder.build().await.is_ok()); + + std::env::remove_var("KNX_SWITCH_INPUT"); +} diff --git a/docs/aimdb-usage-guide.md b/docs/aimdb-usage-guide.md index f5dee285..9cdfdb0e 100644 --- a/docs/aimdb-usage-guide.md +++ b/docs/aimdb-usage-guide.md @@ -315,7 +315,7 @@ knx-pico = { git = "https://github.com/aimdb-dev/knx-pico.git", branch = "master use aimdb_core::prelude::*; use aimdb_tokio_adapter::TokioAdapter; use aimdb_tokio_adapter::net::{TokioDelay, TokioNet}; -use aimdb_knx_connector::{Channels, KnxConnector}; +use aimdb_knx_connector::KnxConnector; use std::net::Ipv4Addr; use std::sync::Arc; @@ -324,9 +324,6 @@ struct LightState { is_on: bool, } -// One channel pair per connector, held for the process lifetime. -static CHANNELS: Channels = Channels::new(); - #[tokio::main] async fn main() -> Result<(), Box> { let runtime = Arc::new(TokioAdapter::new()?); @@ -339,7 +336,6 @@ async fn main() -> Result<(), Box> { TokioNet::udp(Ipv4Addr::UNSPECIFIED), TokioDelay, "knx://192.168.1.19:3671", - &CHANNELS, )) .configure::("light.state", |reg| { reg.buffer(BufferCfg::SingleLatest) diff --git a/examples/embassy-knx-connector-demo/src/main.rs b/examples/embassy-knx-connector-demo/src/main.rs index 7fd463cc..52286d37 100644 --- a/examples/embassy-knx-connector-demo/src/main.rs +++ b/examples/embassy-knx-connector-demo/src/main.rs @@ -45,7 +45,7 @@ use aimdb_core::{AimDbBuilder, RecordKey, RuntimeContext}; use aimdb_embassy_adapter::io::EmbassyUart; use aimdb_embassy_adapter::net::{EmbassyDelay, EmbassyNet}; use aimdb_embassy_adapter::{EmbassyAdapter, EmbassyBufferType, EmbassyRecordRegistrarExtCustom}; -use aimdb_knx_connector::connector::{Channels, KnxConnector}; +use aimdb_knx_connector::connector::KnxConnector; use aimdb_knx_connector::dpt::{Dpt1, Dpt9, DptDecode, DptEncode}; use aimdb_serial_connector::SerialServer; use defmt::*; @@ -273,12 +273,11 @@ async fn main(spawner: Spawner) { let (serial_tx, serial_rx) = uart.split(); // The adapter owns the UDP socket and the clock; the connector owns the - // tunnelling protocol. Buffers and channels are `'static`, as on any MCU. + // tunnelling protocol. Socket buffers are `'static`, as on any MCU. static KNX_RX_META: StaticCell<[PacketMetadata; 8]> = StaticCell::new(); static KNX_RX_BUF: StaticCell<[u8; 1024]> = StaticCell::new(); static KNX_TX_META: StaticCell<[PacketMetadata; 8]> = StaticCell::new(); static KNX_TX_BUF: StaticCell<[u8; 1024]> = StaticCell::new(); - static KNX_CHANNELS: Channels<32> = Channels::new(); let knx_binder = EmbassyNet::udp( *stack, KNX_RX_META.init([PacketMetadata::EMPTY; 8]), @@ -291,12 +290,7 @@ async fn main(spawner: Spawner) { // remote `record.set` is refused — peers can list/get/subscribe, not write. let mut builder = AimDbBuilder::new() .runtime(runtime.clone()) - .with_connector(KnxConnector::new( - knx_binder, - EmbassyDelay, - &gateway_url, - &KNX_CHANNELS, - )) + .with_connector(KnxConnector::new(knx_binder, EmbassyDelay, &gateway_url)) .with_connector( SerialServer::new(EmbassyUart::new(serial_rx, serial_tx)) .security_policy(SecurityPolicy::read_only()), diff --git a/examples/tokio-knx-connector-demo/README.md b/examples/tokio-knx-connector-demo/README.md index 3f5460c1..328ebc33 100644 --- a/examples/tokio-knx-connector-demo/README.md +++ b/examples/tokio-knx-connector-demo/README.md @@ -27,16 +27,12 @@ Demonstrates bidirectional KNX/IP integration with AimDB using the Tokio runtime Edit `src/main.rs` to match your KNX setup: ```rust -// The connector's queues, held for the process lifetime. -static KNX_CHANNELS: Channels = Channels::new(); - // The adapter owns the UDP socket and the clock; the connector owns the // tunnelling protocol. Only the gateway URL needs changing. .with_connector(KnxConnector::new( TokioNet::udp(Ipv4Addr::UNSPECIFIED), TokioDelay, "knx://YOUR_GATEWAY_IP:3671", // Change to your gateway IP - &KNX_CHANNELS, )) // Group addresses diff --git a/examples/tokio-knx-connector-demo/src/main.rs b/examples/tokio-knx-connector-demo/src/main.rs index fe266d9b..8fc88ef3 100644 --- a/examples/tokio-knx-connector-demo/src/main.rs +++ b/examples/tokio-knx-connector-demo/src/main.rs @@ -25,7 +25,7 @@ use aimdb_core::buffer::BufferCfg; use aimdb_core::remote::{AimxConfig, SecurityPolicy}; use aimdb_core::{AimDbBuilder, DbResult, Producer, RecordKey, RuntimeContext}; use aimdb_knx_connector::dpt::{Dpt1, Dpt9, DptDecode, DptEncode}; -use aimdb_knx_connector::{Channels, KnxConnector}; +use aimdb_knx_connector::KnxConnector; use aimdb_tokio_adapter::net::{TokioDelay, TokioNet}; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; use aimdb_uds_connector::UdsServer; @@ -103,17 +103,14 @@ async fn main() -> DbResult<()> { .security_policy(SecurityPolicy::read_only()) .max_connections(10); - // The adapter owns the UDP socket and the clock; the channels are the - // caller's, exactly as on the MCU. - static KNX_CHANNELS: Channels = Channels::new(); - + // The adapter owns the UDP socket and the clock; the connector owns the + // tunnelling protocol. let mut builder = AimDbBuilder::new() .runtime(runtime) .with_connector(KnxConnector::new( TokioNet::udp(Ipv4Addr::UNSPECIFIED), TokioDelay, "knx://192.168.1.4:3671", - &KNX_CHANNELS, )) .with_connector(UdsServer::from_config(remote_config)); From e34e359ec4767098734e3331989201fb15168156 Mon Sep 17 00:00:00 2001 From: "sounds.like.lx" <147444674+lxsaah@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:40:14 +0200 Subject: [PATCH 24/28] refactor(core)!: remove Source, pump_sink, Connector and TopicProvider (design 054) (#295) --- Cargo.lock | 2 - aimdb-bench/README.md | 11 +- aimdb-bench/benches/b0_alloc_connector.rs | 288 +---- aimdb-bench/benches/b0_alloc_linkable.rs | 5 +- aimdb-bench/benches/b1_outbound_wakeup.rs | 2 +- .../data/baselines/b0_alloc_connector.json | 88 +- aimdb-codegen/src/rust.rs | 14 +- aimdb-core/Cargo.toml | 8 +- aimdb-core/src/builder.rs | 72 +- aimdb-core/src/codec.rs | 2 +- aimdb-core/src/connector.rs | 297 +----- aimdb-core/src/inbound_dispatch.rs | 3 +- aimdb-core/src/lib.rs | 19 +- aimdb-core/src/outbound/routes.rs | 34 +- aimdb-core/src/router.rs | 4 +- aimdb-core/src/session/mod.rs | 30 +- aimdb-core/src/session/pump.rs | 164 --- aimdb-core/src/topic_pattern.rs | 2 +- aimdb-core/src/transport.rs | 111 +- aimdb-core/src/typed_api.rs | 984 +++++------------- aimdb-data-contracts/src/link_codec.rs | 133 +-- aimdb-embassy-adapter/src/connectors.rs | 97 +- aimdb-embassy-adapter/src/lib.rs | 4 +- aimdb-embassy-adapter/src/send_wrapper.rs | 9 +- aimdb-knx-connector/Cargo.toml | 37 +- aimdb-knx-connector/README.md | 8 +- aimdb-knx-connector/src/lib.rs | 6 +- .../tests/topic_writer_tests.rs | 34 - aimdb-mqtt-connector/Cargo.toml | 5 +- aimdb-mqtt-connector/tests/link_ext_tests.rs | 34 +- .../tests/topic_writer_tests.rs | 2 +- aimdb-tokio-adapter/tests/outbound_routes.rs | 58 +- .../tests/decouple_record_keys_topics.rs | 6 +- aimdb-websocket-connector/tests/e2e.rs | 4 +- examples/tokio-knx-connector-demo/Cargo.toml | 3 - 35 files changed, 472 insertions(+), 2108 deletions(-) delete mode 100644 aimdb-core/src/session/pump.rs diff --git a/Cargo.lock b/Cargo.lock index 46f2d412..b775a6e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -207,9 +207,7 @@ dependencies = [ "aimdb-core", "aimdb-knx-pico", "aimdb-tokio-adapter", - "critical-section", "embassy-futures 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", - "embassy-sync", "heapless 0.8.0", "tokio", "tokio-test", diff --git a/aimdb-bench/README.md b/aimdb-bench/README.md index 7b79866d..5bdc3a09 100644 --- a/aimdb-bench/README.md +++ b/aimdb-bench/README.md @@ -26,10 +26,9 @@ Plus two informational benches that exercise the full runner-driven pipeline. `b1_b2_remote_json` (host). These compare issue #196's direct JSON bytes with the compatibility `serde_json::Value` tree through the real typed record, buffer, `Payload` and AimX envelope. Socket I/O and scheduling are excluded. -- **connector boundary** — `b0_alloc_connector` (host). Baseline for design - 054: `Router::route`, `pump_source` with a minimal `Source`, and the - per-message `recv_into` + `Connector::publish` calls of `pump_sink`, on a - no-op connector. +- **connector boundary** — `b0_alloc_connector` (host). The connector + interfaces from design 054: `InboundDispatch::dispatch` and + `OutboundRoutes::next`, on a no-op connector. - **Embassy** — `b0_alloc_embassy`, `b1_b2_embassy` (host). These drive the real [`EmbassyBuffer`] backend via `futures::executor::block_on` over embassy-sync's poll methods — no @@ -127,11 +126,11 @@ The committed baseline lives in `data/baselines/b0_alloc_tokio.json`. When a cha `b0_alloc_embassy` mirrors this against the Embassy buffer backend and writes `data/baselines/b0_alloc_embassy.json` — also **0 allocs/msg** across all three profiles, confirming the Embassy `poll_recv` path is allocation-free on the host. The on-target B3 bench (`examples/embassy-bench-stm32h5`) re-checks the same 0-alloc claim against the real embedded allocator. `b0_alloc_linkable` warms up for 1,000 iterations, then measures 10,000 generated-shape postcard `Linkable::encode_into` calls into one stack buffer. -The required result is **0 allocation calls and 0 allocated bytes**. It isolates the codec seam: `SerializedReader` still returns a boxed future, dynamic topics may allocate and connector implementations may copy payload ownership after the core pump lends them the scratch slice. +The required result is **0 allocation calls and 0 allocated bytes**. It isolates the codec seam: connector implementations may still copy the payload after `OutboundRoutes` lends them the scratch slice. `b0_alloc_remote_json` warms the production in-memory `record.get` and subscription-event paths, then compares 5000 tree/direct operations. Its gate is relative: direct JSON must reduce both allocation calls and allocated bytes. It does not require zero allocations because the owned JSON `Vec`, `Arc<[u8]>` payload and AimX envelope serialization still own storage. -`b0_alloc_connector` measures what the connector interfaces cost per message, with no transport. It asserts today's values exactly (0 for `route`, 2 for `pump_source`, 2–3 outbound), so a regression *or* an improvement fails it until `EXPECTED` in the bench and `data/baselines/b0_alloc_connector.json` are updated together. The inbound `pump_source` row is the difference of two runs, so pump setup cancels out. See design 054 for where each allocation comes from. +`b0_alloc_connector` measures what the connector interfaces cost per message, with no transport. It asserts its values exactly (0 everywhere except a new key, 1, and the owned serializer, 1), so a regression *or* an improvement fails it until `EXPECTED` in the bench and `data/baselines/b0_alloc_connector.json` are updated together. `make bench-gate` runs it. See design 054 for where each allocation comes from. > **Embassy eager registration (design 039 F8/F9).** An Embassy `SpmcRing` reader registers its embassy `Subscriber` eagerly, at `subscribe()` time — matching Tokio's `broadcast` — so no separate priming step is needed before the first `push`. diff --git a/aimdb-bench/benches/b0_alloc_connector.rs b/aimdb-bench/benches/b0_alloc_connector.rs index b79df06a..92888351 100644 --- a/aimdb-bench/benches/b0_alloc_connector.rs +++ b/aimdb-bench/benches/b0_alloc_connector.rs @@ -1,26 +1,19 @@ //! B0-Connector — per-message allocations at the connector boundary. //! -//! Baseline for design 054. Measures what AimDB's connector interfaces cost -//! per message, independent of any real transport: +//! Measures what AimDB's connector interfaces cost per message, independent of +//! any real transport: //! -//! - **Inbound:** `Router::route` alone — for an exact topic, a pattern +//! - **Inbound:** `InboundDispatch::dispatch` for an exact topic, a pattern //! (`{device}`, MQTT grammar), and a keyed pattern with a known and a new -//! key — and the real `pump_source` driven by -//! the smallest possible `Source` (it clones a pre-built topic `String` and -//! payload `Arc` — the least any `Source` can do, since the trait returns -//! owned values). -//! - **Outbound:** the per-message calls `pump_sink` makes — -//! `SerializedReader::recv_into` followed by `Connector::publish` on a no-op -//! connector — for the scratch and owned serializers, with a static and a -//! dynamic (`TopicProvider`) topic. -//! - **`InboundDispatch` and `OutboundRoutes`:** the same inbound cases through -//! `InboundDispatch::dispatch`, and `OutboundRoutes::next` with a static -//! topic, a written topic and the owned serializer, plus eight routes that -//! are all ready (round-robin) and one pull that parks before every value -//! (the waker path). +//! key. +//! - **Outbound:** `OutboundRoutes::next` with a static topic, a written topic +//! and the owned serializer, plus eight routes that are all ready +//! (round-robin) and one pull that parks before every value (the waker +//! path). //! -//! Buffers, ingest and routing allocate nothing (design 037, and the `route` -//! row here); every non-zero row is a cost of the connector interface. The +//! Buffers, ingest and routing allocate nothing (design 037, and the +//! `inbound_dispatch` row here); every non-zero row is a cost of the connector +//! interface or of the serializer the link chose. The //! expected values are asserted, so a regression *or* an improvement fails the //! bench until `EXPECTED` and the committed baseline are updated together. //! @@ -36,15 +29,9 @@ use std::sync::Arc; use aimdb_bench::alloc::{reset, snapshot}; use aimdb_bench::reports::{write_reports, AllocReport}; use aimdb_core::buffer::BufferCfg; -use aimdb_core::connector::{ - ConnectorBuilder, SerializeError, SerializedPayload, SerializedReader, SerializedValueInto, - TopicProvider, -}; -use aimdb_core::session::{pump_source, Payload, Source}; -use aimdb_core::transport::{Connector, ConnectorConfig, PublishError}; +use aimdb_core::connector::{ConnectorBuilder, SerializeError}; use aimdb_core::{ - AimDb, AimDbBuilder, BoxFut, DbResult, ExactGrammar, InboundDispatch, OutboundRoutes, - RuntimeContext, StringKey, + AimDb, AimDbBuilder, DbResult, ExactGrammar, InboundDispatch, OutboundRoutes, StringKey, }; use aimdb_mqtt_connector::MqttGrammar; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; @@ -60,17 +47,9 @@ const SCRATCH_CAPACITY: usize = 64; /// Room for a new key on every warm-up and measured message. const KEY_CAPACITY: u16 = 4096; -/// Allocations per message on `main` when this bench was added. Update -/// together with `data/baselines/b0_alloc_connector.json`. +/// Allocations per message. Update together with +/// `data/baselines/b0_alloc_connector.json`. const EXPECTED: &[(&str, u64)] = &[ - ("inbound_route", 0), - ("inbound_route_pattern", 0), - ("inbound_route_keyed_known", 0), - ("inbound_route_keyed_new", 1), - ("inbound_pump_source_minimal", 2), - ("outbound_scratch_static_topic", 2), - ("outbound_scratch_dynamic_topic", 3), - ("outbound_owned_static_topic", 3), ("inbound_dispatch", 0), ("inbound_dispatch_pattern", 0), ("inbound_dispatch_keyed_known", 0), @@ -131,29 +110,6 @@ impl ConnectorBuilder for NoopConnectorBuilder { } } -/// Returns a ready future, boxed as the `Connector` trait requires. -struct NoopSink; - -impl Connector for NoopSink { - fn publish( - &self, - destination: &str, - _config: &ConnectorConfig, - payload: &[u8], - ) -> Pin> + Send + '_>> { - black_box((destination, payload)); - Box::pin(async { Ok(()) }) - } -} - -struct IdTopic; - -impl TopicProvider for IdTopic { - fn topic(&self, value: &Reading) -> Option { - Some(format!("out/{}", value.id)) - } -} - async fn build_db(configure: impl FnOnce(&mut AimDbBuilder)) -> AimDb { let runtime = Arc::new(TokioAdapter::new().expect("tokio adapter")); let mut builder = AimDbBuilder::new() @@ -217,24 +173,6 @@ async fn pattern_db(keyed: bool) -> AimDb { .await } -/// Routes `warmup` then `measured`, counting only the second. -async fn measure_pattern_route(keyed: bool, warmup: &[String], measured: &[String]) -> (u64, u64) { - let db = pattern_db(keyed).await; - let ctx = db.runtime_ctx(); - let router = db.inbound_router(SCHEME, &MqttGrammar).unwrap(); - let payload = [1u8; 8]; - for topic in warmup { - router.route(topic, &payload, &ctx).unwrap(); - } - reset(); - for topic in measured { - router - .route(black_box(topic), black_box(&payload), &ctx) - .unwrap(); - } - snapshot() -} - /// `n` copies of one topic, or `n` topics each naming a new device. fn pattern_topics(n: usize, first_device: usize, distinct: bool) -> Vec { (0..n) @@ -245,24 +183,7 @@ fn pattern_topics(n: usize, first_device: usize, distinct: bool) -> Vec .collect() } -async fn measure_route() -> (u64, u64) { - let db = inbound_db().await; - let ctx = db.runtime_ctx(); - let router = db.inbound_router(SCHEME, &ExactGrammar).unwrap(); - let payload = [1u8; 8]; - for _ in 0..WARMUP_ITERS { - router.route("in/target", &payload, &ctx).unwrap(); - } - reset(); - for _ in 0..MEASURE_ITERS { - router - .route("in/target", black_box(&payload), &ctx) - .unwrap(); - } - snapshot() -} - -/// `measure_route` through `InboundDispatch`. +/// One exact topic among the decoys. async fn measure_dispatch() -> (u64, u64) { let db = inbound_db().await; let inbound = InboundDispatch::new(&db, SCHEME, &ExactGrammar).unwrap(); @@ -277,7 +198,7 @@ async fn measure_dispatch() -> (u64, u64) { snapshot() } -/// `measure_pattern_route` through `InboundDispatch`. +/// Dispatches `warmup` then `measured`, counting only the second. async fn measure_pattern_dispatch( keyed: bool, warmup: &[String], @@ -296,138 +217,14 @@ async fn measure_pattern_dispatch( snapshot() } -/// Yields `remaining` copies of one message, then ends. -struct MinimalSource { - topic: String, - payload: Payload, - remaining: usize, -} - -impl Source for MinimalSource { - fn next(&mut self) -> BoxFut<'_, Option<(String, Payload)>> { - Box::pin(async move { - if self.remaining == 0 { - return None; - } - self.remaining -= 1; - Some((self.topic.clone(), self.payload.clone())) - }) - } -} - -/// Allocations of one complete `pump_source` run over `messages` messages, -/// including its one-off setup. -async fn pump_run(db: &AimDb, messages: usize) -> (u64, u64) { - let source = MinimalSource { - topic: "in/target".to_string(), - payload: Arc::from(&[1u8; 8][..]), - remaining: messages, - }; - reset(); - let router = db.inbound_router(SCHEME, &ExactGrammar).unwrap(); - for fut in pump_source(db, router, source) { - fut.await; - } - snapshot() -} - -/// Per-message cost as the difference of two runs, so pump setup (router -/// build, start-up logging) cancels out. -async fn measure_pump_source() -> (u64, u64) { - let db = inbound_db().await; - pump_run(&db, WARMUP_ITERS).await; - let short = pump_run(&db, WARMUP_ITERS).await; - let long = pump_run(&db, WARMUP_ITERS + MEASURE_ITERS).await; - (long.0 - short.0, long.1 - short.1) -} - // --- Outbound --------------------------------------------------------------- -/// The per-route state `pump_sink` keeps, and one iteration of its loop. -struct OutboundIo { - reader: Box, - scratch: Vec, - default_topic: String, - config: ConnectorConfig, -} - -impl OutboundIo { - async fn publish_one(&mut self, ctx: &RuntimeContext) { - let SerializedValueInto { dest, payload } = self - .reader - .recv_into(ctx, &mut self.scratch) - .await - .expect("recv_into"); - let dest = dest.as_deref().unwrap_or(&self.default_topic); - let bytes: &[u8] = match &payload { - SerializedPayload::Scratch { len } => &self.scratch[..*len], - SerializedPayload::Owned(v) => v, - }; - NoopSink - .publish(dest, &self.config, bytes) - .await - .expect("publish"); - } -} - #[derive(Clone, Copy)] enum Serializer { Scratch, Owned, } -async fn measure_outbound(serializer: Serializer, dynamic_topic: bool) -> (u64, u64) { - let db = build_db(|b| { - b.configure::("out.record", |reg| { - reg.buffer(BufferCfg::SpmcRing { capacity: 64 }); - let mut link = reg - .link_to("bench://out/default") - .with_serializer(|_ctx, r: &Reading| Ok(encode(r).to_vec())); - if let Serializer::Scratch = serializer { - link = link.with_serializer_into(SCRATCH_CAPACITY, |_ctx, r: &Reading, buf| { - let bytes = encode(r); - let dst = buf - .get_mut(..bytes.len()) - .ok_or(SerializeError::BufferTooSmall)?; - dst.copy_from_slice(&bytes); - Ok(bytes.len()) - }); - } - if dynamic_topic { - link = link.with_topic_provider(IdTopic); - } - link.finish(); - }); - }) - .await; - - let ctx = db.runtime_ctx(); - let route = db - .collect_outbound_routes(SCHEME) - .pop() - .expect("one outbound route"); - let mut io = OutboundIo { - reader: route.source.subscribe(), - scratch: vec![0u8; route.source.serializer_scratch_capacity().unwrap_or(0)], - default_topic: route.topic.clone(), - config: ConnectorConfig::from_query(&route.config), - }; - let producer = db.producer::("out.record").expect("producer"); - - for i in 0..WARMUP_ITERS { - producer.produce(reading(i)); - io.publish_one(&ctx).await; - } - reset(); - for i in 0..MEASURE_ITERS { - producer.produce(reading(i)); - io.publish_one(&ctx).await; - } - snapshot() -} - -// --- OutboundRoutes --------------------------------------------------------- - #[derive(Clone, Copy)] enum Topic { Static, @@ -587,57 +384,6 @@ fn main() { let measured: Vec<(&str, &str, (u64, u64))> = runtime.block_on(async { vec![ - ("inbound_route", "SpmcRing", measure_route().await), - ( - "inbound_route_pattern", - "SpmcRing", - measure_pattern_route( - false, - &pattern_topics(WARMUP_ITERS, 0, false), - &pattern_topics(MEASURE_ITERS, 0, false), - ) - .await, - ), - ( - "inbound_route_keyed_known", - "SpmcRing", - measure_pattern_route( - true, - &pattern_topics(WARMUP_ITERS, 0, false), - &pattern_topics(MEASURE_ITERS, 0, false), - ) - .await, - ), - ( - "inbound_route_keyed_new", - "SpmcRing", - measure_pattern_route( - true, - &pattern_topics(WARMUP_ITERS, 0, true), - &pattern_topics(MEASURE_ITERS, WARMUP_ITERS, true), - ) - .await, - ), - ( - "inbound_pump_source_minimal", - "SpmcRing", - measure_pump_source().await, - ), - ( - "outbound_scratch_static_topic", - "SpmcRing", - measure_outbound(Serializer::Scratch, false).await, - ), - ( - "outbound_scratch_dynamic_topic", - "SpmcRing", - measure_outbound(Serializer::Scratch, true).await, - ), - ( - "outbound_owned_static_topic", - "SpmcRing", - measure_outbound(Serializer::Owned, false).await, - ), ("inbound_dispatch", "SpmcRing", measure_dispatch().await), ( "inbound_dispatch_pattern", diff --git a/aimdb-bench/benches/b0_alloc_linkable.rs b/aimdb-bench/benches/b0_alloc_linkable.rs index 785ad28a..6a80c4dc 100644 --- a/aimdb-bench/benches/b0_alloc_linkable.rs +++ b/aimdb-bench/benches/b0_alloc_linkable.rs @@ -1,8 +1,7 @@ //! B0-Linkable — allocation gate for direct and per-link Postcard encoding. //! -//! This deliberately measures the codec seam, not a complete connector. The -//! core pump still uses a boxed `SerializedReader` future and connector adapters -//! may copy payload ownership; issue #177 only claims that a generated-shape +//! This deliberately measures the codec seam, not a complete connector, which +//! may still copy the payload; issue #177 only claims that a generated-shape //! Postcard codec writes into caller-owned storage with zero heap allocations. use std::hint::black_box; diff --git a/aimdb-bench/benches/b1_outbound_wakeup.rs b/aimdb-bench/benches/b1_outbound_wakeup.rs index bc769a36..84498f00 100644 --- a/aimdb-bench/benches/b1_outbound_wakeup.rs +++ b/aimdb-bench/benches/b1_outbound_wakeup.rs @@ -8,7 +8,7 @@ //! one wake-up of the transport. Two columns: //! //! - **task per route:** one task per route awaiting `Reader::recv`, the -//! shape of the per-route pumps. +//! shape of the per-route publishers `OutboundRoutes` replaced. //! - **OutboundRoutes:** one task pulling with `OutboundRoutes::next`, which //! polls only the routes that woke. //! diff --git a/aimdb-bench/data/baselines/b0_alloc_connector.json b/aimdb-bench/data/baselines/b0_alloc_connector.json index ae351427..21cb1034 100644 --- a/aimdb-bench/data/baselines/b0_alloc_connector.json +++ b/aimdb-bench/data/baselines/b0_alloc_connector.json @@ -1,76 +1,4 @@ [ - { - "profile": "inbound_route", - "buffer_type": "SpmcRing", - "total_allocs": 0, - "total_bytes": 0, - "batch_size": 2000, - "allocs_per_msg": 0.0, - "bytes_per_msg": 0.0 - }, - { - "profile": "inbound_route_pattern", - "buffer_type": "SpmcRing", - "total_allocs": 0, - "total_bytes": 0, - "batch_size": 2000, - "allocs_per_msg": 0.0, - "bytes_per_msg": 0.0 - }, - { - "profile": "inbound_route_keyed_known", - "buffer_type": "SpmcRing", - "total_allocs": 0, - "total_bytes": 0, - "batch_size": 2000, - "allocs_per_msg": 0.0, - "bytes_per_msg": 0.0 - }, - { - "profile": "inbound_route_keyed_new", - "buffer_type": "SpmcRing", - "total_allocs": 2010, - "total_bytes": 373456, - "batch_size": 2000, - "allocs_per_msg": 1.005, - "bytes_per_msg": 186.728 - }, - { - "profile": "inbound_pump_source_minimal", - "buffer_type": "SpmcRing", - "total_allocs": 4000, - "total_bytes": 50000, - "batch_size": 2000, - "allocs_per_msg": 2.0, - "bytes_per_msg": 25.0 - }, - { - "profile": "outbound_scratch_static_topic", - "buffer_type": "SpmcRing", - "total_allocs": 4001, - "total_bytes": 146064, - "batch_size": 2000, - "allocs_per_msg": 2.0005, - "bytes_per_msg": 73.032 - }, - { - "profile": "outbound_scratch_dynamic_topic", - "buffer_type": "SpmcRing", - "total_allocs": 6000, - "total_bytes": 162000, - "batch_size": 2000, - "allocs_per_msg": 3.0, - "bytes_per_msg": 81.0 - }, - { - "profile": "outbound_owned_static_topic", - "buffer_type": "SpmcRing", - "total_allocs": 6000, - "total_bytes": 162000, - "batch_size": 2000, - "allocs_per_msg": 3.0, - "bytes_per_msg": 81.0 - }, { "profile": "inbound_dispatch", "buffer_type": "SpmcRing", @@ -110,11 +38,11 @@ { "profile": "outbound_next_static_topic", "buffer_type": "SpmcRing", - "total_allocs": 0, - "total_bytes": 0, + "total_allocs": 1, + "total_bytes": 64, "batch_size": 2000, - "allocs_per_msg": 0.0, - "bytes_per_msg": 0.0 + "allocs_per_msg": 0.0005, + "bytes_per_msg": 0.032 }, { "profile": "outbound_next_written_topic", @@ -137,11 +65,11 @@ { "profile": "outbound_next_round_robin", "buffer_type": "SpmcRing", - "total_allocs": 0, - "total_bytes": 0, + "total_allocs": 1, + "total_bytes": 128, "batch_size": 2000, - "allocs_per_msg": 0.0, - "bytes_per_msg": 0.0 + "allocs_per_msg": 0.0005, + "bytes_per_msg": 0.064 }, { "profile": "outbound_next_parked", diff --git a/aimdb-codegen/src/rust.rs b/aimdb-codegen/src/rust.rs index 6b9940ec..9c6b1683 100644 --- a/aimdb-codegen/src/rust.rs +++ b/aimdb-codegen/src/rust.rs @@ -497,11 +497,8 @@ pub fn generate_binary_cargo_toml(state: &ArchitectureState, binary_name: &str) ); } if has_knx { - optional_connector_deps.push_str( - "# critical-section-std-impl: the KNX channels need an impl, and only \ -the binary may pick one.\n\ -aimdb-knx-connector = { version = \"0.5\", features = [\"std\", \"critical-section-std-impl\"] }\n", - ); + optional_connector_deps + .push_str("aimdb-knx-connector = { version = \"0.5\", features = [\"std\"] }\n"); } if has_ws { optional_connector_deps.push_str( @@ -1334,11 +1331,8 @@ pub fn generate_hub_cargo_toml(state: &ArchitectureState) -> String { ); } if has_knx { - connector_deps.push_str( - "# critical-section-std-impl: the KNX channels need an impl, and only \ -the binary may pick one.\n\ -aimdb-knx-connector = { version = \"0.5\", features = [\"std\", \"critical-section-std-impl\"] }\n", - ); + connector_deps + .push_str("aimdb-knx-connector = { version = \"0.5\", features = [\"std\"] }\n"); } if has_ws { connector_deps.push_str( diff --git a/aimdb-core/Cargo.toml b/aimdb-core/Cargo.toml index ff2da3fd..6b2da2c6 100644 --- a/aimdb-core/Cargo.toml +++ b/aimdb-core/Cargo.toml @@ -42,10 +42,10 @@ alloc = ["serde"] # Enable heap in no_std remote = ["alloc", "serde_json"] # The connector-session substrate (`crate::session`): the dyn-safe trait set -# (Connection/Listener/Dialer, Dispatch/EnvelopeCodec, Source + shared types) and -# the runtime-neutral engines built on it — the reactive server (`serve`/ -# `run_session`), the proactive client (`run_client`/`pump_client`), the -# `pump_sink`/`pump_source` data plane, and the generic session connectors. Engine +# (Connection/Listener/Dialer, Dispatch/EnvelopeCodec + shared types), the byte +# and datagram transports, and the runtime-neutral engines built on them — the +# reactive server (`serve`/`run_session`), the proactive client +# (`run_client`/`pump_client`), and the generic session connectors. Engine # logic included; all compiles on `no_std + alloc`. The AimX protocol port # (`session::aimx`) additionally needs `remote-access`. connector-session = ["alloc"] diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index 89c83283..dcd2d7a7 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -34,19 +34,6 @@ use crate::typed_api::RecordRegistrar; use crate::typed_record::{AnyRecord, AnyRecordExt, RecordFutureCollector, TypedRecord}; use crate::{DbError, DbResult}; -/// One outbound route returned by [`AimDb::collect_outbound_routes`] -pub struct OutboundRoute { - /// Default topic/destination from the URL path; used when the source - /// yields no per-value destination. - pub topic: String, - /// Fused wire-level source: its readers yield destination + serialized - /// payload directly (subscribe → recv → resolve topic → serialize, all - /// typed inside — no `Box` per message). - pub source: Box, - /// Configuration options from the URL query - pub config: Vec<(String, String)>, -} - /// One registered record: its key, concrete type, and type-erased storage. struct RecordEntry { key: StringKey, @@ -1182,14 +1169,13 @@ impl AimDb { /// The inbound router for `scheme`: every link compiled against the /// connector's `grammar`, keyed links sharing their record's key table. /// - /// A connector subscribes [`Router::subscriptions`](crate::Router::subscriptions) - /// and routes with this same router. Rejects every link the grammar or - /// its key cannot compile, naming the record and the resolved topic. - pub fn inbound_router( + /// Rejects every link the grammar or its key cannot compile, naming the + /// record and the resolved topic. + pub(crate) fn inbound_router( &self, scheme: &str, grammar: &'static dyn crate::TopicGrammar, - ) -> DbResult { + ) -> DbResult { let mut routes = Vec::new(); let mut errors = Vec::new(); @@ -1212,7 +1198,7 @@ impl AimDb { if !errors.is_empty() { return Err(DbError::InvalidConfiguration { errors }); } - Ok(crate::Router::new(grammar, routes)) + Ok(crate::router::Router::new(grammar, routes)) } fn inbound_route( @@ -1256,11 +1242,11 @@ impl AimDb { .name(key) } - /// Every outbound link of `scheme`, with its record's index and key. + /// Every outbound link of `scheme`, with its record's index. pub(crate) fn outbound_links<'a>( &'a self, scheme: &'a str, - ) -> impl Iterator + 'a { + ) -> impl Iterator + 'a { self.inner .storages .iter() @@ -1278,49 +1264,7 @@ impl AimDb { .outbound_connectors() .iter() .filter(move |link| link.url.scheme() == scheme) - .map(move |link| (i, entry.key.as_str(), link)) + .map(move |link| (i, link)) }) } - - /// Collects outbound routes for a specific protocol scheme - /// - /// Mirrors [`inbound_router`](Self::inbound_router). Iterates all records, - /// filters their outbound_connectors by scheme, and returns - /// [`OutboundRoute`]s carrying fused serialized sources (subscribe → - /// recv → resolve topic → serialize, all typed inside — no - /// `Box` per message). - /// - /// This method is called by connectors during their `build()` phase to - /// collect all configured outbound routes and spawn publisher tasks - /// (usually via `pump_sink`). - /// - /// # Arguments - /// * `scheme` - URL scheme to filter by (e.g., "mqtt", "kafka") - pub fn collect_outbound_routes(&self, scheme: &str) -> Vec { - let routes: Vec = self - .outbound_links(scheme) - .map(|(i, _, link)| { - // config must carry the record index - let mut config = link.config.clone(); - config.push(("record_index".to_string(), i.to_string())); - - // Create the fused source using the stored factory - OutboundRoute { - topic: link.url.resource_id().to_string(), - source: link.create_source(self), - config, - } - }) - .collect(); - - if !routes.is_empty() { - log_debug!( - "Collected {} outbound routes for scheme '{}'", - routes.len(), - scheme - ); - } - - routes - } } diff --git a/aimdb-core/src/codec.rs b/aimdb-core/src/codec.rs index 7ec2839c..3ba945d8 100644 --- a/aimdb-core/src/codec.rs +++ b/aimdb-core/src/codec.rs @@ -21,7 +21,7 @@ //! zero-sized [`SerdeJsonCodec`] implementation. A record stores //! `Option>>`; the AimX read/write/subscribe paths and //! `RecordValue::as_json` route through it. This mirrors the connector -//! layer's fused `SerializedSource` / `IngestFn` callbacks. +//! layer's typed route and `IngestFn` callbacks. use alloc::vec::Vec; use serde::{de::DeserializeOwned, Serialize}; diff --git a/aimdb-core/src/connector.rs b/aimdb-core/src/connector.rs index 4202a514..ba0ffecf 100644 --- a/aimdb-core/src/connector.rs +++ b/aimdb-core/src/connector.rs @@ -82,171 +82,6 @@ impl std::fmt::Display for SerializeError { #[cfg(feature = "std")] impl std::error::Error for SerializeError {} -/// One serialized record update, produced by a fused [`SerializedReader`] -/// -/// Carries the wire payload plus the destination resolved by the link's -/// [`TopicProvider`] while the typed value was still in hand — the last -/// erasure crossing the old `topic_any(&dyn Any)` path required. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SerializedValue { - /// Dynamic destination resolved by the link's `TopicProvider`; - /// `None` means use the route's default topic (from the URL). - pub dest: Option, - /// Wire payload from the link's serializer. - /// - /// `Vec` requires heap allocation; works on `std` and - /// `no_std + alloc` (not bare-metal without an allocator). - pub payload: Vec, -} - -/// Location of a payload produced by [`SerializedReader::recv_into`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SerializedPayload { - /// The initialized prefix of the caller-provided scratch buffer. - /// - /// A custom reader returning this variant must write the prefix during the - /// same `recv_into` call and its source must advertise a sufficient - /// [`SerializedSource::serializer_scratch_capacity`]. The pump validates - /// the length before publishing. - Scratch { - /// Number of initialized payload bytes in the scratch buffer. - len: usize, - }, - /// Compatibility fallback from the existing `Vec` serializer. - Owned(Vec), -} - -/// One serialized record produced into caller-owned scratch storage or an owned fallback. -/// -/// No reference escapes the async reader call. The pump validates `len`, then -/// borrows its own scratch buffer only for the subsequent `publish().await`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SerializedValueInto { - /// Dynamic destination resolved while the typed record was available. - pub dest: Option, - /// Scratch prefix metadata or an owned compatibility payload. - pub payload: SerializedPayload, -} - -/// Type alias for the future returned by [`SerializedReader::recv`] -/// -/// Manual boxed future for object safety — same pattern as the rest of this -/// module (`#[async_trait]` would drag in `std`). -pub type RecvSerializedFuture<'a> = - Pin> + Send + 'a>>; - -/// Future returned by [`SerializedReader::recv_into`]. -pub type RecvSerializedIntoFuture<'a> = - Pin> + Send + 'a>>; - -/// A subscription to one record, fused with destination resolution and -/// serialization at registration time — no `dyn Any` crosses this boundary -///. -pub trait SerializedReader: Send { - /// Yield the next successfully serialized value. - /// - /// `ctx` is threaded per call (not captured) for context-aware - /// serializers. Buffer errors propagate unchanged: - /// `DbError::BufferLagged` means values were skipped but the reader - /// recovered; any other error means the buffer is gone. Serialization - /// failures are logged and skipped inside the reader. - fn recv<'a>(&'a mut self, ctx: &'a crate::RuntimeContext) -> RecvSerializedFuture<'a>; - - /// Yield the next serialized value into caller-owned scratch storage. - /// - /// Third-party readers remain source-compatible: the default adapter calls - /// [`recv`](Self::recv) and returns its `Vec` as - /// [`SerializedPayload::Owned`]. AimDB's fused reader overrides this method - /// when an into-slice serializer was registered. - fn recv_into<'a>( - &'a mut self, - ctx: &'a crate::RuntimeContext, - _scratch: &'a mut [u8], - ) -> RecvSerializedIntoFuture<'a> { - Box::pin(async move { - let value = self.recv(ctx).await?; - Ok(SerializedValueInto { - dest: value.dest, - payload: SerializedPayload::Owned(value.payload), - }) - }) - } -} - -/// A record's outbound wire interface, built where the record type `T` is -/// known (`OutboundConnectorBuilder::finish`) and consumed by the pumps as -/// bytes. Replaces the erased `ConsumerTrait` + serializer + topic-provider -/// triple. -pub trait SerializedSource: Send + Sync { - /// Scratch capacity requested by this source's into-slice serializer. - /// - /// `None` means the source only supports the existing owned serializer. A - /// source whose reader can return [`SerializedPayload::Scratch`] must - /// return `Some(capacity)` here so the route pump provides that storage. - fn serializer_scratch_capacity(&self) -> Option { - None - } - - /// Subscribe to the record's updates. - /// - /// Synchronous and infallible — the buffer handle is pre-resolved at - /// construction. - fn subscribe(&self) -> Box; -} - -/// Type alias for source factory callback (alloc feature) -/// -/// Takes the live [`AimDb`] and returns the fused [`SerializedSource`]. -/// This allows capturing the record type T at link_to() time while storing -/// the factory in a type-erased ConnectorLink. The factory runs once at -/// route-collection time, not per message. -/// -/// Available in both `std` and `no_std + alloc` environments. -pub type SourceFactoryFn = Arc Box + Send + Sync>; - -// ============================================================================ -// TopicProvider - Dynamic topic/destination routing -// ============================================================================ - -/// Trait for dynamic topic providers (outbound only) -/// -/// Implement this trait to dynamically determine MQTT topics (or KNX group addresses) -/// based on the data being published. This enables reusable routing logic that -/// can be shared across multiple record types. -/// -/// # Type Safety -/// -/// The trait is generic over `T`, providing compile-time type safety -/// at the implementation site. The provider stays typed end-to-end: it is -/// fused into the link's [`SerializedSource`] at registration time and -/// called with `&T` while the value is in hand. -/// -/// # no_std Compatibility -/// -/// Works in both `std` and `no_std + alloc` environments. -/// -/// # Example -/// -/// ```rust -/// use aimdb_core::connector::TopicProvider; -/// # #[derive(Clone, Debug)] struct Temperature { sensor_id: u32 } -/// -/// struct SensorTopicProvider; -/// -/// impl TopicProvider for SensorTopicProvider { -/// fn topic(&self, value: &Temperature) -> Option { -/// Some(format!("sensors/temp/{}", value.sensor_id)) -/// } -/// } -/// ``` -pub trait TopicProvider: Send + Sync { - /// Determine the topic/destination for a given value - /// - /// Returns `Some(topic)` to use a dynamic topic, or `None` to fall back - /// to the static topic from the `link_to()` URL. - fn topic(&self, value: &T) -> Option; -} - // ============================================================================ // TopicWriter - Destinations written into bounded storage // ============================================================================ @@ -571,11 +406,10 @@ impl fmt::Display for ConnectorUrl { } } -/// Configuration for a connector link +/// Configuration for an outbound connector link /// -/// Stores the parsed URL, configuration, and the fused source factory until -/// the record is built. The actual client creation and handler spawning -/// happens during the build phase. +/// Stores the parsed URL, configuration, and the route factory until the +/// database is built. `OutboundRoutes` runs the factory once per link. #[derive(Clone)] pub struct ConnectorLink { /// Parsed link address (`scheme://resource`) @@ -584,21 +418,8 @@ pub struct ConnectorLink { /// Additional configuration options (protocol-specific) pub config: Vec<(String, String)>, - /// Fused source factory (alloc feature) - /// - /// Takes the live [`AimDb`] and returns the [`SerializedSource`] whose - /// readers yield destination + payload directly (subscribe → recv → - /// resolve topic → serialize, all typed inside). Captures the record - /// type T at link_to() configuration time — `finish()` validates the - /// serializer is present before registering the link, so the factory is - /// always set. - /// - /// Available in both `std` and `no_std + alloc` environments. - pub source_factory: SourceFactoryFn, - - /// Builds the link's route for `OutboundRoutes`; set by - /// `OutboundConnectorBuilder::finish`. - pub(crate) route_factory: Option, + /// Builds the link's route for `OutboundRoutes`. + pub(crate) route_factory: crate::outbound::RouteFactoryFn, } impl Debug for ConnectorLink { @@ -606,31 +427,19 @@ impl Debug for ConnectorLink { f.debug_struct("ConnectorLink") .field("url", &self.url) .field("config", &self.config) - .field("source_factory", &"") - .finish() + .finish_non_exhaustive() } } impl ConnectorLink { - /// Creates a new connector link from a link address and source factory - pub fn new(url: LinkAddress, source_factory: SourceFactoryFn) -> Self { + /// Creates a new connector link from a link address and route factory + pub(crate) fn new(url: LinkAddress, route_factory: crate::outbound::RouteFactoryFn) -> Self { Self { url, config: Vec::new(), - source_factory, - route_factory: None, + route_factory, } } - - /// Creates the fused serialized source using the stored factory. - /// - /// Runs once at route-collection time; the readers it hands out are the - /// per-message path (no `Box`). - /// - /// Available in both `std` and `no_std + alloc` environments. - pub fn create_source(&self, db: &AimDb) -> Box { - (self.source_factory)(db) - } } /// Fused inbound ingest callback: deserialize + produce in one typed closure @@ -881,8 +690,9 @@ fn parse_connector_url(url: &str) -> DbResult { /// ) -> Pin>> + Send + 'a>> { /// Box::pin(async move { /// // Wildcard rules are the connector's; `&ExactGrammar` if it has none. -/// let router = db.inbound_router(self.scheme(), &MqttGrammar)?; -/// let connector = MqttConnector::new(&self.broker_url, router).await?; +/// let inbound = InboundDispatch::new(db, self.scheme(), &MqttGrammar)?; +/// let outbound = OutboundRoutes::new(db, self.scheme())?; +/// let connector = MqttConnector::new(&self.broker_url, inbound, outbound).await?; /// Ok(connector.futures()) /// }) /// } @@ -896,8 +706,8 @@ pub trait ConnectorBuilder: Send + Sync { /// Build the connector and return its driving futures. /// /// Called during `AimDbBuilder::build()` after the database has been - /// constructed. The returned futures (infrastructure loops + per-route - /// publishers) are appended to the builder's accumulator and driven by + /// constructed. The returned futures (typically the transport task) + /// are appended to the builder's accumulator and driven by /// `AimDbRunner::run()`. /// /// # Arguments @@ -928,12 +738,12 @@ pub trait ConnectorBuilder: Send + Sync { /// Whether registering a second connector under this scheme is an error. /// /// Say `true` when [`build`](Self::build) claims every route for its - /// scheme — [`inbound_router`](crate::AimDb::inbound_router), - /// [`collect_outbound_routes`](crate::AimDb::collect_outbound_routes), - /// and `crate::session`'s `pump_source`, `pump_sink` and `pump_client` - /// (left unlinked: that module is behind `connector-session`, and this - /// trait is not) all filter by scheme alone, so two such connectors each - /// collect *all* of it: every `link_to` gets two publishers, and the routes + /// scheme — [`InboundDispatch`](crate::InboundDispatch), + /// [`OutboundRoutes`](crate::OutboundRoutes) and `crate::session`'s + /// `pump_client` (left unlinked: that module is behind + /// `connector-session`, and this trait is not) all filter by scheme + /// alone, so two such connectors each collect *all* of it: every + /// `link_to` gets two publishers, and the routes /// cannot be divided between the two endpoints because nothing in a route /// names which connector it belongs to. That misconfiguration is otherwise /// silent, and it fails as duplicated or misdirected traffic at runtime @@ -1060,73 +870,6 @@ mod tests { assert!(result.is_err()); } - // ======================================================================== - // TopicProvider Tests - // ======================================================================== - - #[allow(dead_code)] - #[derive(Debug, Clone)] - struct TestTemperature { - sensor_id: String, - celsius: f32, - } - - struct TestTopicProvider; - - impl super::TopicProvider for TestTopicProvider { - fn topic(&self, value: &TestTemperature) -> Option { - Some(format!("sensors/temp/{}", value.sensor_id)) - } - } - - #[test] - fn test_topic_provider_as_trait_object() { - // Providers are stored as Arc> — typed, no - // erasure. - let provider: Arc> = Arc::new(TestTopicProvider); - let temp = TestTemperature { - sensor_id: "kitchen-001".into(), - celsius: 22.5, - }; - - assert_eq!( - provider.topic(&temp), - Some("sensors/temp/kitchen-001".into()) - ); - } - - #[test] - fn test_topic_provider_returns_none() { - struct OptionalTopicProvider; - - impl super::TopicProvider for OptionalTopicProvider { - fn topic(&self, temp: &TestTemperature) -> Option { - if temp.sensor_id.is_empty() { - None // Fall back to default topic - } else { - Some(format!("sensors/{}", temp.sensor_id)) - } - } - } - - let provider: Arc> = - Arc::new(OptionalTopicProvider); - - // Non-empty sensor_id returns dynamic topic - let temp_with_id = TestTemperature { - sensor_id: "abc".into(), - celsius: 20.0, - }; - assert_eq!(provider.topic(&temp_with_id), Some("sensors/abc".into())); - - // Empty sensor_id returns None (fallback) - let temp_without_id = TestTemperature { - sensor_id: String::new(), - celsius: 20.0, - }; - assert_eq!(provider.topic(&temp_without_id), None); - } - // ======================================================================== // TopicBuf Tests // ======================================================================== diff --git a/aimdb-core/src/inbound_dispatch.rs b/aimdb-core/src/inbound_dispatch.rs index 5e264c99..6fa6519a 100644 --- a/aimdb-core/src/inbound_dispatch.rs +++ b/aimdb-core/src/inbound_dispatch.rs @@ -7,7 +7,8 @@ use alloc::sync::Arc; use alloc::vec::Vec; -use crate::{AimDb, DbResult, Router, RuntimeContext, TopicGrammar}; +use crate::router::Router; +use crate::{AimDb, DbResult, RuntimeContext, TopicGrammar}; /// Every inbound link of one scheme, compiled against the connector's grammar. /// diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index 7be4a774..b5ab18a2 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -93,7 +93,7 @@ pub mod profiling; pub mod record_id; #[cfg(feature = "remote")] pub mod remote; -pub mod router; +mod router; #[cfg(feature = "connector-session")] pub mod session; pub mod signal; @@ -117,10 +117,9 @@ pub use executor::{BoxFuture, ExecutorError, ExecutorResult, LogLevel, RuntimeOp pub use buffer::JsonReader; pub use buffer::Reader; pub use buffer::TryProduceError; -pub use builder::OutboundRoute; pub use builder::{AimDb, AimDbBuilder}; pub use connector::ConnectorBuilder; -pub use transport::{Connector, ConnectorConfig, PublishError}; +pub use transport::{ConnectorConfig, PublishError}; pub use typed_api::{ Consumer, InboundConnectorBuilder, OutboundConnectorBuilder, Producer, RecordRegistrar, StageKind, @@ -142,10 +141,9 @@ pub use remote::topic_leaf; // compatible). See docs/design/remote-access-via-connectors.md. #[cfg(feature = "connector-session")] pub use session::{ - is_wildcard, pattern_contains, pump_sink, pump_source, topic_matches, AuthError, BoxFut, - BoxStream, CodecError, Connection, Dialer, Dispatch, EnvelopeCodec, Inbound, Listener, - Outbound, Payload, PeerInfo, RpcError, SessionCtx, SessionLimits, Source, SubUpdate, - TransportError, TransportResult, + is_wildcard, pattern_contains, topic_matches, AuthError, BoxFut, BoxStream, CodecError, + Connection, Dialer, Dispatch, EnvelopeCodec, Inbound, Listener, Outbound, Payload, PeerInfo, + RpcError, SessionCtx, SessionLimits, SubUpdate, TransportError, TransportResult, }; // Signal gauge handle (always available; inert without `observability`) @@ -162,18 +160,13 @@ pub use profiling::{ pub use connector::TopicResolverFn; pub use connector::{ConnectorLink, ConnectorUrl, LinkAddress, SerializeError}; pub use connector::{IngestFactoryFn, IngestFn}; -pub use connector::{ - SerializedPayload, SerializedReader, SerializedSource, SerializedValue, SerializedValueInto, - SourceFactoryFn, -}; -pub use connector::{TopicBuf, TopicOverflow, TopicProvider, TopicWriter}; +pub use connector::{TopicBuf, TopicOverflow, TopicWriter}; // Router exports for connector implementations pub use inbound_dispatch::InboundDispatch; pub use outbound::{ OutboundMessage, OutboundPayload, OutboundRoutes, RouteId, RouteInfo, RouteStats, }; -pub use router::Router; // Topic grammar for connectors with wildcard subscriptions pub use topic_pattern::{ diff --git a/aimdb-core/src/outbound/routes.rs b/aimdb-core/src/outbound/routes.rs index 871e7cef..2d19a355 100644 --- a/aimdb-core/src/outbound/routes.rs +++ b/aimdb-core/src/outbound/routes.rs @@ -2,7 +2,6 @@ //! connector's transport task. use alloc::boxed::Box; -use alloc::string::ToString; use alloc::sync::Arc; use alloc::vec::Vec; use core::future::poll_fn; @@ -12,7 +11,7 @@ use super::ready::{Polled, ReadyRoutes}; use super::RouteId; use crate::connector::SerializeError; use crate::transport::ConnectorConfig; -use crate::{AimDb, ConfigError, DbError, DbResult, RuntimeContext}; +use crate::{AimDb, DbError, DbResult, RuntimeContext}; /// One outbound route, for parsing per-route configuration once at build. #[derive(Debug, Clone)] @@ -161,8 +160,6 @@ pub(crate) struct RouteParts { pub(crate) route: Box, pub(crate) topic_capacity: usize, pub(crate) payload_capacity: usize, - /// The link uses `with_topic_provider`, which this path does not support. - pub(crate) topic_provider: bool, } /// Builds a link's [`RouteParts`], subscribing to its record. @@ -200,31 +197,12 @@ const _: fn() = || { impl OutboundRoutes { /// Subscribes every outbound link of `scheme` and allocates the scratch /// once: the largest topic capacity plus the largest payload capacity. - /// - /// Rejects every link that uses `with_topic_provider`. pub fn new(db: &AimDb, scheme: &str) -> DbResult { let mut routes = Vec::new(); let mut states = Vec::new(); - let mut errors = Vec::new(); - - for (record_index, record_key, link) in db.outbound_links(scheme) { - let Some(factory) = &link.route_factory else { - errors.push(ConfigError::new( - record_key, - Some(link.url.to_string()), - "link was not registered through `link_to`", - )); - continue; - }; - let parts = factory(db); - if parts.topic_provider { - errors.push(ConfigError::new( - record_key, - Some(link.url.to_string()), - "`with_topic_provider` is not supported here; use `with_topic_writer` or `with_topic_fn`", - )); - continue; - } + + for (record_index, link) in db.outbound_links(scheme) { + let parts = (link.route_factory)(db); let mut config = ConnectorConfig::from_query(&link.config); config.record_index = Some(record_index); routes.push(RouteInfo { @@ -237,10 +215,6 @@ impl OutboundRoutes { states.push(parts.route); } - if !errors.is_empty() { - return Err(DbError::InvalidConfiguration { errors }); - } - let topic_region = routes.iter().map(|r| r.topic_capacity).max().unwrap_or(0); let payload_region = routes.iter().map(|r| r.payload_capacity).max().unwrap_or(0); Ok(Self { diff --git a/aimdb-core/src/router.rs b/aimdb-core/src/router.rs index 88eee71b..38b34c2e 100644 --- a/aimdb-core/src/router.rs +++ b/aimdb-core/src/router.rs @@ -19,8 +19,8 @@ use crate::topic_pattern::{Spans, TopicFilter, TopicGrammar, TopicMatch, MAX_CAP /// Generic message router for connector dispatch /// -/// Built by [`AimDb::inbound_router`](crate::AimDb::inbound_router). Routes -/// incoming messages to the matching records' ingest callbacks. Uses linear +/// Built by `AimDb::inbound_router` and held by +/// [`InboundDispatch`](crate::InboundDispatch). Routes incoming messages to the matching records' ingest callbacks. Uses linear /// search which is efficient for <100 routes. /// /// # Performance diff --git a/aimdb-core/src/session/mod.rs b/aimdb-core/src/session/mod.rs index 491436ad..98b9cba4 100644 --- a/aimdb-core/src/session/mod.rs +++ b/aimdb-core/src/session/mod.rs @@ -5,9 +5,7 @@ //! ([`EnvelopeCodec`]), and dispatch ([`Dispatch`]/[`Session`]), over a //! role-neutral [`Inbound`]/[`Outbound`] message set shared by the reactive //! server engine (`serve`/`run_session`) and the proactive client engine -//! (`run_client`/`pump_client`). Data-plane connectors use `pump_sink`/ -//! `pump_source` over the [`Source`] / [`Connector`](crate::transport::Connector) -//! capabilities. +//! (`run_client`/`pump_client`). //! //! All contracts are `dyn`-safe and compile on `std` and `no_std + alloc`. @@ -28,8 +26,6 @@ mod endpoint; #[cfg(feature = "connector-session")] mod io; #[cfg(feature = "connector-session")] -mod pump; -#[cfg(feature = "connector-session")] mod server; // Concrete AimX protocol substrate. The transport lives in a separate connector @@ -54,8 +50,6 @@ pub use io::{ OneShotListener, StreamDialer, StreamListener, }; #[cfg(feature = "connector-session")] -pub use pump::{pump_sink, pump_source}; -#[cfg(feature = "connector-session")] pub use server::{run_session, serve, SessionConfig}; // =========================================================================== @@ -517,19 +511,6 @@ pub trait EnvelopeCodec: Send + Sync { fn decode_outbound<'a>(&self, frame: &'a [u8]) -> Result, CodecError>; } -// =========================================================================== -// Data-plane capabilities — connectionless (an external library owns any -// session). The outbound `Sink` is the canonical -// [`Connector`](crate::transport::Connector); the inbound `Source` is below. -// =========================================================================== - -/// External → AimDB data-plane: a stream of inbound frames, drained by -/// `pump_source`. -pub trait Source: Send { - /// Yield the next `(topic, payload)`, or `None` when the source is done. - fn next(&mut self) -> BoxFut<'_, Option<(String, Payload)>>; -} - // =========================================================================== // Taking each trait as `&dyn Trait` forces the dyn-compatibility check on all // targets, not just under `cargo test`. @@ -543,7 +524,6 @@ fn _assert_object_safe( _dispatch: &dyn Dispatch, _session: &dyn Session, _codec: &dyn EnvelopeCodec, - _source: &dyn Source, ) { } @@ -632,13 +612,6 @@ mod tests { } } - struct MockSource; - impl Source for MockSource { - fn next(&mut self) -> BoxFut<'_, Option<(String, Payload)>> { - unimplemented!() - } - } - /// Every trait is `dyn`-usable. #[test] fn traits_are_object_safe() { @@ -648,7 +621,6 @@ mod tests { let _dispatch: Box = Box::new(MockDispatch); let _session: Box = Box::new(MockSession); let _codec: Box = Box::new(MockCodec); - let _source: Box = Box::new(MockSource); } /// `Box` satisfies the `Dialer` bound, so a runtime-selected diff --git a/aimdb-core/src/session/pump.rs b/aimdb-core/src/session/pump.rs deleted file mode 100644 index 7030acbf..00000000 --- a/aimdb-core/src/session/pump.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! Data-plane pump helpers. -//! -//! Two free functions that own the boilerplate a data-plane connector used to -//! hand-roll. The author writes only the pure I/O adapter — a -//! [`Connector`](crate::transport::Connector) (outbound) and a [`Source`] -//! (inbound) — and composes the helpers in `build()` -//! (illustrative — `sink()`/`subscription()` are the author's own constructors): -//! -//! ```rust,ignore -//! let mut f = pump_sink(db, "redis", self.sink().await?); // outbound -//! let router = db.inbound_router("redis", &ExactGrammar)?; -//! f.extend(pump_source(db, router, self.subscription().await?)); // inbound -//! Ok(f) -//! ``` -//! -//! Both are `no_std + alloc`-native (boxed futures, no `tokio`). - -use alloc::boxed::Box; -use alloc::sync::Arc; -use alloc::vec; -use alloc::vec::Vec; - -use super::Source; -use crate::builder::{AimDb, BoxFuture}; -use crate::router::Router; -use crate::transport::{Connector, ConnectorConfig}; - -/// Outbound pump: one publisher future per outbound route on `scheme`. -/// -/// Extracts the consume-and-publish loop a data-plane connector used to write by -/// hand. For each route from [`collect_outbound_routes`](AimDb::collect_outbound_routes), -/// the returned future subscribes to the route's fused -/// [`SerializedSource`](crate::connector::SerializedSource) — whose readers -/// yield destination + serialized payload directly (no `Box` per -/// message) — and publishes through `sink`. Per-route -/// configuration (`qos`/`retain`/…) is built once from the route's URL query -/// via [`ConnectorConfig::from_query`]. -/// -/// The publisher future terminates when its subscription yields an error (e.g. the -/// record buffer closed), matching the legacy hand-rolled loop. -pub fn pump_sink(db: &AimDb, scheme: &str, sink: Arc) -> Vec { - let routes = db.collect_outbound_routes(scheme); - let mut futures: Vec = Vec::with_capacity(routes.len()); - - for crate::OutboundRoute { - topic: default_topic, - source, - config, - } in routes - { - let sink = sink.clone(); - let runtime_ctx = db.runtime_ctx(); - let cfg = ConnectorConfig::from_query(&config); - let scratch_capacity = source.serializer_scratch_capacity().unwrap_or(0); - - futures.push(Box::pin(async move { - // Subscribe inside the pump future (not at collect time), so the - // ring-buffer cursor starts when the publisher actually runs. - let mut reader = source.subscribe(); - // One bounded allocation per route, reused for every successful - // into-slice serialization. Owned-only sources request zero bytes. - let mut scratch = vec![0_u8; scratch_capacity]; - - log_info!( - "pump_sink: publisher started for destination: {}", - default_topic - ); - - loop { - let msg = match reader - .recv_into(&runtime_ctx, scratch.as_mut_slice()) - .await - { - Ok(m) => m, - // SPMC-ring overflow: messages were missed, but the reader - // recovers (cursor resets to the oldest live value). Skip the - // gap and keep pumping — a transient lag must not permanently - // kill the publisher. - Err(crate::DbError::BufferLagged { .. }) => { - log_warn!("pump_sink: consumer lagged for '{}'", default_topic); - continue; - } - // Buffer closed / fatal — the record is gone; end the publisher. - Err(_e) => { - log_info!( - "pump_sink: publisher stopping for '{}': {:?}", - default_topic, - _e - ); - break; - } - }; - let crate::connector::SerializedValueInto { dest, payload } = msg; - // Destination: dynamic (resolved by the source) or default (from URL). - let dest = dest.as_deref().unwrap_or(&default_topic); - - let payload = match &payload { - crate::connector::SerializedPayload::Scratch { len } => { - let Some(payload) = scratch.get(..*len) else { - log_error!( - "pump_sink: serializer returned invalid length {} for {}-byte scratch buffer", - len, - scratch.len() - ); - continue; - }; - payload - } - crate::connector::SerializedPayload::Owned(payload) => payload.as_slice(), - }; - - // Publish through the connector's pure I/O adapter. - if let Err(_e) = sink.publish(dest, &cfg, payload).await { - log_error!("pump_sink: failed to publish to '{}': {:?}", dest, _e); - } else { - log_debug!("pump_sink: published to: {}", dest); - } - } - - log_info!( - "pump_sink: publisher stopped for destination: {}", - default_topic - ); - })); - } - - futures -} - -/// Inbound pump: a single multiplexed reader future. -/// -/// Drives one [`Source`] (never one task per topic), fanning each -/// `(topic, payload)` out to the matching producers via `router`, from -/// [`AimDb::inbound_router`], whose subscriptions the connector made. -/// -/// Backpressure: [`Router::route`] drops + logs on a full producer buffer rather -/// than blocking, so one slow record never stalls the shared source. Route errors -/// are non-fatal. -pub fn pump_source(db: &AimDb, router: Router, mut src: impl Source + 'static) -> Vec { - let router = Arc::new(router); - let ctx = db.runtime_ctx(); - - vec![Box::pin(async move { - log_info!( - "pump_source: reader started ({} topics)", - router.resource_ids().len() - ); - - while let Some((topic, payload)) = src.next().await { - // `route` deserializes and fans out to producers (synchronously — - // the fused ingest path never awaits); it drops + logs on a full - // producer buffer and never returns a fatal error. - if let Err(_e) = router.route(&topic, &payload, &ctx) { - log_error!( - "pump_source: failed to route message on '{}': {}", - topic, - _e - ); - } - } - - log_info!("pump_source: reader stopped"); - })] -} diff --git a/aimdb-core/src/topic_pattern.rs b/aimdb-core/src/topic_pattern.rs index 7f7610b6..8a1eb946 100644 --- a/aimdb-core/src/topic_pattern.rs +++ b/aimdb-core/src/topic_pattern.rs @@ -216,7 +216,7 @@ impl TopicFilter for ExactFilter { } } -/// A stub grammar for tests of the router and of `inbound_router`. +/// A stub grammar for tests of the router and of `InboundDispatch`. #[cfg(test)] pub(crate) mod test_support { use super::*; diff --git a/aimdb-core/src/transport.rs b/aimdb-core/src/transport.rs index da5cf6a5..ef9ebf56 100644 --- a/aimdb-core/src/transport.rs +++ b/aimdb-core/src/transport.rs @@ -1,23 +1,13 @@ -//! Transport connector traits for protocol-agnostic publishing +//! Protocol-agnostic per-route configuration and publish errors. //! -//! Provides a generic `Connector` trait that enables scheme-based routing -//! to different transport protocols. Each connector manages a single connection -//! to a specific endpoint (e.g., one MQTT broker). -//! -//! # Design Philosophy -//! -//! - **Scheme-based routing**: the URL scheme (e.g. `mqtt://`, `knx://`) determines which connector handles requests -//! - **Single endpoint per connector**: Each connector connects to ONE broker/resource -//! - **Multi-transport publishing**: Same data can be published to multiple protocols -//! - **Protocol-agnostic core**: Core knows schemes and key/value options, never protocol semantics +//! Core knows schemes and key/value options, never protocol semantics. -use alloc::{boxed::Box, string::String, vec::Vec}; -use core::future::Future; -use core::pin::Pin; +use alloc::{string::String, vec::Vec}; /// Protocol-agnostic connector configuration /// -/// Carries the route's key/value options to [`Connector::publish`]. Only the +/// Carries the route's key/value options to the connector +/// ([`RouteInfo::config`](crate::RouteInfo::config)). Only the /// genuinely protocol-agnostic `timeout_ms` is a typed field; every /// protocol-specific knob (e.g. MQTT's `qos`/`retain`) travels in /// [`protocol_options`](ConnectorConfig::protocol_options) and is interpreted @@ -53,12 +43,8 @@ impl Default for ConnectorConfig { impl ConnectorConfig { /// Build a config from a route's URL-query key/value pairs. /// - /// This is the shared seam the data-plane `pump_sink` helper uses to thread - /// per-route configuration through to [`Connector::publish`] without changing - /// the `publish` signature. - /// - /// Only the protocol-agnostic `timeout_ms` and `record_index` (stamped by - /// `AimDb::collect_outbound_routes`; the last occurrence wins) are lifted + /// Only the protocol-agnostic `timeout_ms` and `record_index` (the last + /// occurrence wins) are lifted /// into typed fields; every other key is passed through verbatim in /// [`protocol_options`](ConnectorConfig::protocol_options) for the /// connector to interpret with its own defaults. @@ -134,92 +120,9 @@ impl std::fmt::Display for PublishError { #[cfg(feature = "std")] impl std::error::Error for PublishError {} -/// Generic transport connector trait for protocol-agnostic publishing -/// -/// This trait enables multi-protocol publishing via scheme-based routing -/// (e.g. `mqtt://topic` → MQTT broker, `knx://1/0/6` → KNX group address). -/// -/// Each connector manages ONE connection/endpoint. For multiple brokers/endpoints, -/// create multiple connectors and register them with different schemes. -/// -/// # Example Implementation -/// -/// Illustrative sketch (not compiled: the MQTT client types are fictional — -/// see `aimdb-mqtt-connector` for a real implementation): -/// -/// ```rust,ignore -/// impl Connector for MqttConnector { -/// fn publish( -/// &self, -/// destination: &str, // "sensors/temperature" -/// config: &ConnectorConfig, -/// payload: &[u8], -/// ) -> Pin> + Send + '_>> { -/// // Protocol knobs come from the route's key/value options, -/// // with connector-chosen defaults. -/// let qos = config -/// .protocol_options -/// .iter() -/// .find(|(k, _)| k == "qos") -/// .and_then(|(_, v)| v.parse::().ok()) -/// .unwrap_or(1); -/// Box::pin(async move { -/// self.client.publish(destination, qos, payload).await -/// .map_err(|_| PublishError::ConnectionFailed) -/// }) -/// } -/// } -/// ``` -/// -/// # Thread Safety -/// -/// Requires Send + Sync for Tokio compatibility. For Embassy (single-threaded), -/// use `unsafe impl Send + Sync` with safety documentation. -pub trait Connector: Send + Sync { - /// Publish data to a protocol-specific destination - /// - /// # Arguments - /// * `destination` - Protocol-specific path, no broker/host info - /// (e.g. an MQTT topic like "sensors/temperature") - /// * `config` - Publishing configuration (timeout + protocol options) - /// * `payload` - Message payload as byte slice - /// - /// # Returns - /// `Ok(())` on success, `PublishError` on failure - fn publish( - &self, - destination: &str, - config: &ConnectorConfig, - payload: &[u8], - ) -> Pin> + Send + '_>>; -} - #[cfg(test)] mod tests { use super::*; - use alloc::sync::Arc; - - // Mock connector for testing - struct MockConnector; - - impl Connector for MockConnector { - fn publish( - &self, - _destination: &str, - _config: &ConnectorConfig, - _payload: &[u8], - ) -> Pin> + Send + '_>> { - Box::pin(async move { Ok(()) }) - } - } - - #[test] - fn test_connector_trait() { - let connector = Arc::new(MockConnector); - - // Verify the connector can be used as a trait object - let _trait_obj: Arc = connector; - } #[test] fn test_connector_config_default() { diff --git a/aimdb-core/src/typed_api.rs b/aimdb-core/src/typed_api.rs index 3a0e784d..8490cb3a 100644 --- a/aimdb-core/src/typed_api.rs +++ b/aimdb-core/src/typed_api.rs @@ -284,10 +284,10 @@ impl Clone for Consumer { } // ============================================================================ -// Fused outbound source +// Outbound routes // ============================================================================ -/// Type alias for the unified typed serializer captured by [`FusedSource`] +/// Type alias for the unified typed serializer captured by [`TypedRoute`] /// /// Raw and context-aware serializers collapse into this shape at `finish()`; /// the raw variant simply ignores the threaded context. @@ -302,7 +302,7 @@ type ConsumerFactoryFn = Arc Consumer + Send + Sync>; /// Optional allocation-free serializer captured beside [`FusedSerializeFn`]. /// -/// The callback writes into one pump-owned bounded scratch buffer. Returning +/// The callback writes into the `OutboundRoutes` scratch buffer. Returning /// `BufferTooSmall` selects the owned serializer for that value; other failures /// retain the existing skip-and-log behavior. type FusedSerializeIntoFn = Arc< @@ -311,88 +311,12 @@ type FusedSerializeIntoFn = Arc< + Sync, >; -/// How an outbound link picks each value's destination. Setting one replaces -/// the other. -enum TopicSelector { - None, - Provider(Arc>), - Writer { - capacity: usize, - writer: Arc>, - }, -} - -impl Clone for TopicSelector { - fn clone(&self) -> Self { - match self { - Self::None => Self::None, - Self::Provider(p) => Self::Provider(p.clone()), - Self::Writer { capacity, writer } => Self::Writer { - capacity: *capacity, - writer: writer.clone(), - }, - } - } -} - -/// The [`SerializedSource`](crate::connector::SerializedSource) built by -/// `OutboundConnectorBuilder::finish()` — holds the typed consumer, -/// serializer, and optional topic provider, so every per-message step stays -/// typed (no `Box`). -struct FusedSource { - consumer: Consumer, - serialize: FusedSerializeFn, - serialize_into: Option<(usize, FusedSerializeIntoFn)>, - topic: TopicSelector, -} - -impl crate::connector::SerializedSource for FusedSource -where - T: Send + Sync + 'static + Debug + Clone, -{ - fn serializer_scratch_capacity(&self) -> Option { - self.serialize_into.as_ref().map(|(capacity, _)| *capacity) - } - - fn subscribe(&self) -> Box { - let topic_capacity = match &self.topic { - TopicSelector::Writer { capacity, .. } => *capacity, - _ => 0, - }; - Box::new(FusedReader { - inner: self.consumer.subscribe(), - serialize: self.serialize.clone(), - serialize_into: self - .serialize_into - .as_ref() - .map(|(_, serialize_into)| serialize_into.clone()), - topic: self.topic.clone(), - topic_buf: alloc::vec![0; topic_capacity].into_boxed_slice(), - }) - } -} - -/// One subscription of a [`FusedSource`]: recv → resolve destination → -/// serialize, all on the typed value. -/// -/// The connector SPI keeps its boxed `RecvSerializedFuture` (BYOC stays -/// stable); only the *inner* per-message box is eliminated by reading through -/// the allocation-free [`Reader`](crate::buffer::Reader). -struct FusedReader { - inner: crate::buffer::Reader, - serialize: FusedSerializeFn, - serialize_into: Option>, - topic: TopicSelector, - /// Storage a [`TopicWriter`](crate::connector::TopicWriter) writes into; - /// empty without one. - topic_buf: Box<[u8]>, -} +/// An outbound link's topic writer and the capacity it writes into. +type TopicWriterCfg = (usize, Arc>); /// Runs `writer` for `value` into `out`. `Ok(true)`: publish to what `out` /// holds; `Ok(false)`: to the link's default topic; `Err`: the topic did not /// fit and the value is skipped, whatever the writer returned. -/// -/// Shared by [`FusedReader`] and [`TypedRoute`]. fn write_topic + ?Sized>( writer: &W, value: &T, @@ -407,8 +331,6 @@ fn write_topic + ?Sized>( /// Serializes one outbound value: into `scratch` through /// `with_serializer_into` when the link has one, else (or when the value does /// not fit) through the owned serializer. -/// -/// Shared by [`FusedReader`] and [`TypedRoute`]. fn serialize_outbound( serialize: &FusedSerializeFn, serialize_into: Option<&FusedSerializeIntoFn>, @@ -437,103 +359,6 @@ fn serialize_outbound( } } -impl FusedReader { - /// The value's destination (`None`: the route's default), or `Err` when - /// its written topic overflowed and the value must be skipped. - fn resolve_dest(&mut self, value: &T) -> Result, ()> { - match &self.topic { - TopicSelector::None => Ok(None), - TopicSelector::Provider(p) => Ok(p.topic(value)), - TopicSelector::Writer { writer, .. } => { - let mut out = crate::connector::TopicBuf::new(&mut self.topic_buf); - match write_topic(&**writer, value, &mut out) { - Ok(true) => Ok(Some(out.as_str().to_string())), - Ok(false) => Ok(None), - Err(_) => { - log_warn!( - "outbound link: topic for {} does not fit in {} bytes, value skipped", - core::any::type_name::(), - out.capacity() - ); - Err(()) - } - } - } - } - } -} - -impl crate::connector::SerializedReader for FusedReader { - fn recv<'a>( - &'a mut self, - ctx: &'a crate::RuntimeContext, - ) -> crate::connector::RecvSerializedFuture<'a> { - Box::pin(async move { - loop { - // Buffer errors propagate unchanged: `BufferLagged` lets the - // pump skip the gap and keep going; anything else ends it. - let value = self.inner.recv().await?; - // Resolve the destination while the typed value is in hand. - let Ok(dest) = self.resolve_dest(&value) else { - continue; - }; - match (self.serialize)(ctx, &value) { - Ok(payload) => return Ok(crate::connector::SerializedValue { dest, payload }), - Err(_e) => { - // Same skip-and-log the pumps used to do around the - // erased serializer. - log_error!( - "outbound link: failed to serialize {} (dest {:?}): {:?}", - core::any::type_name::(), - dest, - _e - ); - continue; - } - } - } - }) - } - - fn recv_into<'a>( - &'a mut self, - ctx: &'a crate::RuntimeContext, - scratch: &'a mut [u8], - ) -> crate::connector::RecvSerializedIntoFuture<'a> { - use crate::connector::SerializedPayload; - use crate::outbound::StagedPayload; - - Box::pin(async move { - loop { - let value = self.inner.recv().await?; - let Ok(dest) = self.resolve_dest(&value) else { - continue; - }; - let payload = match serialize_outbound( - &self.serialize, - self.serialize_into.as_ref(), - ctx, - &value, - scratch, - ) { - Ok(StagedPayload::Scratch(len)) => SerializedPayload::Scratch { len }, - Ok(StagedPayload::Owned(bytes)) => SerializedPayload::Owned(bytes), - Err(_failure) => { - log_error!( - "outbound link: {} (dest {:?}): {}, value skipped", - core::any::type_name::(), - dest, - _failure - ); - continue; - } - }; - return Ok(crate::connector::SerializedValueInto { dest, payload }); - } - }) - } -} - /// One outbound link's per-route state inside /// [`OutboundRoutes`](crate::OutboundRoutes): reader, topic writer and /// serializers, all typed. @@ -868,7 +693,7 @@ where config: Vec::new(), context_serializer: None, context_serializer_into: None, - topic: TopicSelector::None, + topic_writer: None, } } @@ -902,7 +727,7 @@ pub struct OutboundConnectorBuilder<'r, 'a, T: Send + Sync + 'static + Debug + C config: Vec<(String, String)>, context_serializer: Option>, context_serializer_into: Option<(usize, TypedContextSerializerIntoFn)>, - topic: TopicSelector, + topic_writer: Option>, } impl<'r, 'a, T> OutboundConnectorBuilder<'r, 'a, T> @@ -982,26 +807,6 @@ where self } - /// Sets a dynamic topic provider - /// - /// The provider receives the value being published and returns - /// the topic/destination to publish to. Return `None` to use the default - /// static topic from the URL. - /// - /// # Type Safety - /// - /// The provider is type-checked at compile time against `T` and stays - /// typed end-to-end: it is fused into the link's serialized source and - /// called with `&T` per value. - pub fn with_topic_provider

(mut self, provider: P) -> Self - where - P: crate::connector::TopicProvider + 'static, - { - // Stays typed: fused into the link's SerializedSource at finish(). - self.topic = TopicSelector::Provider(Arc::new(provider)); - self - } - /// Sets a [`TopicWriter`](crate::connector::TopicWriter) that writes each /// value's destination. /// @@ -1012,10 +817,7 @@ where where W: crate::connector::TopicWriter + 'static, { - self.topic = TopicSelector::Writer { - capacity, - writer: Arc::new(writer), - }; + self.topic_writer = Some((capacity, Arc::new(writer))); self } @@ -1172,33 +974,13 @@ where }) }; - // Fused source for the pumps: the serializer and topic selector ride - // along typed, so its readers yield destination + payload with no - // erasure crossing. - let source_factory: crate::connector::SourceFactoryFn = { - let make_consumer = make_consumer.clone(); - let (serialize, serialize_into, topic) = ( - serialize.clone(), - serialize_into.clone(), - self.topic.clone(), - ); - Arc::new(move |db: &AimDb| { - Box::new(FusedSource { - consumer: make_consumer(db), - serialize: serialize.clone(), - serialize_into: serialize_into.clone(), - topic: topic.clone(), - }) as Box - }) - }; - - // The same parts for `OutboundRoutes`, subscribed when it is built. + // Subscribed when `OutboundRoutes` is built. let route_factory: crate::outbound::RouteFactoryFn = { - let topic = self.topic; + let topic_writer = self.topic_writer; Arc::new(move |db: &AimDb| { - let (writer, topic_capacity) = match &topic { - TopicSelector::Writer { capacity, writer } => (Some(writer.clone()), *capacity), - _ => (None, 0), + let (topic_capacity, writer) = match &topic_writer { + Some((capacity, writer)) => (*capacity, Some(writer.clone())), + None => (0, None), }; crate::outbound::RouteParts { route: Box::new(TypedRoute { @@ -1209,17 +991,14 @@ where }), topic_capacity, payload_capacity: serialize_into.as_ref().map_or(0, |(capacity, _)| *capacity), - topic_provider: matches!(topic, TopicSelector::Provider(_)), } }) }; - let mut link = ConnectorLink::new(url, source_factory); + let mut link = ConnectorLink::new(url, route_factory); link.config = self.config; - link.route_factory = Some(route_factory); - // Store the connector link - sources will be created later in build() - // after connectors are actually built + // Routes are built later, when a connector builds its `OutboundRoutes`. self.registrar.rec.add_outbound_connector(link); self.registrar } @@ -1489,10 +1268,7 @@ where #[cfg(test)] mod tests { use super::*; - use crate::{ - connector::{SerializeError, SerializedPayload, SerializedReader as _, TopicProvider}, - DbResult, - }; + use crate::{connector::SerializeError, DbResult}; use core::pin::Pin; #[cfg(not(feature = "std"))] @@ -2189,10 +1965,11 @@ mod tests { } } - /// Routes `payload` on `topic` through the `mqtt` inbound router. + /// Dispatches `payload` on `topic` through the `mqtt` inbound routes. fn route(db: &crate::AimDb, topic: &str, payload: &[u8]) { - let router = db.inbound_router("mqtt", &Plus).expect("routes compile"); - router.route(topic, payload, &db.runtime_ctx()).unwrap(); + crate::InboundDispatch::new(db, "mqtt", &Plus) + .expect("routes compile") + .dispatch(topic, payload); } /// End-to-end inbound path: bytes → fused ingest → typed buffer push, @@ -2239,7 +2016,7 @@ mod tests { } // ==================================================================== - // inbound_router: patterns, keys and connector-build errors + // InboundDispatch: patterns, keys and connector-build errors // ==================================================================== use crate::topic_pattern::test_support::Plus; @@ -2276,8 +2053,16 @@ mod tests { }) } + fn config_errors(result: crate::DbResult) -> Vec { + match result { + Err(crate::DbError::InvalidConfiguration { errors }) => errors, + Err(e) => panic!("unexpected error {e:?}"), + Ok(_) => panic!("expected configuration errors"), + } + } + #[tokio::test] - async fn inbound_router_routes_patterns_with_shared_keys() { + async fn inbound_dispatch_routes_patterns_with_shared_keys() { let keys: Arc>> = Default::default(); let seen = keys.clone(); let (db, last, count) = inbound_db(move |reg| { @@ -2298,25 +2083,25 @@ mod tests { }) .await; - let router = db.inbound_router("mqtt", &Plus).expect("routes compile"); - let subscriptions: Vec = router + let inbound = crate::InboundDispatch::new(&db, "mqtt", &Plus).expect("routes compile"); + let subscriptions: Vec = inbound .subscriptions() .iter() .map(|s| s.to_string()) .collect(); assert_eq!(subscriptions, ["cmd/in", "hum/+", "temp/+"]); + assert_eq!(inbound.route_count(), 3); - let ctx = db.runtime_ctx(); - let route = |topic: &str| { - router.route(topic, b"", &ctx).unwrap(); + let dispatch = |topic: &str| { + inbound.dispatch(topic, b""); last.load(Ordering::SeqCst) }; - assert_eq!(route("temp/a"), 0); - assert_eq!(route("hum/b"), 1); - assert_eq!(route("hum/a"), 0, "one key table per record"); - assert_eq!(route("cmd/in"), 100); + assert_eq!(dispatch("temp/a"), 0); + assert_eq!(dispatch("hum/b"), 1); + assert_eq!(dispatch("hum/a"), 0, "one key table per record"); + assert_eq!(dispatch("cmd/in"), 100); let produced = count.load(Ordering::SeqCst); - route("temp/c"); + dispatch("temp/c"); assert_eq!(count.load(Ordering::SeqCst), produced, "full table drops"); let a = keys.lock()[0]; @@ -2332,16 +2117,8 @@ mod tests { } } - fn config_errors(result: crate::DbResult) -> Vec { - match result { - Err(crate::DbError::InvalidConfiguration { errors }) => errors, - Err(e) => panic!("unexpected error {e:?}"), - Ok(_) => panic!("expected configuration errors"), - } - } - #[tokio::test] - async fn inbound_router_rejects_links_it_cannot_compile() { + async fn inbound_dispatch_rejects_links_it_cannot_compile() { let (db, _, _) = inbound_db(|reg| { reg.link_from("mqtt://s/{d}/t") .with_deserializer(|_ctx, _bytes: &[u8]| Ok(TestRecord { value: 0 })) @@ -2358,7 +2135,7 @@ mod tests { }) .await; - let errors = config_errors(db.inbound_router("mqtt", &Plus)); + let errors = config_errors(crate::InboundDispatch::new(&db, "mqtt", &Plus)); assert_eq!(errors.len(), 2, "{errors:?}"); assert!(errors.iter().all(|e| e.record_key == "rec.in")); assert!(errors[0].message.contains("unbalanced '{' in 'r/{d'")); @@ -2366,80 +2143,17 @@ mod tests { .message .contains("key 'd' is not a capture of 'k/{x}'")); - let errors = config_errors(db.inbound_router("mqtt", &crate::ExactGrammar)); + let errors = config_errors(crate::InboundDispatch::new( + &db, + "mqtt", + &crate::ExactGrammar, + )); assert_eq!(errors.len(), 3, "{errors:?}"); assert!(errors[0] .message .contains("does not support topic patterns")); } - // ==================================================================== - // InboundDispatch: the same cases through the connector entry point - // ==================================================================== - - #[tokio::test] - async fn inbound_dispatch_routes_patterns_with_shared_keys() { - let (db, last, count) = inbound_db(|reg| { - reg.link_from("mqtt://temp/{d}") - .key("d", 2) - .with_match_deserializer(key_deser) - .finish(); - reg.link_from("mqtt://hum/{id}") - .key("id", 2) - .with_match_deserializer(key_deser) - .finish(); - reg.link_from("mqtt://cmd/in") - .with_deserializer(|_ctx, _bytes: &[u8]| Ok(TestRecord { value: 100 })) - .finish(); - }) - .await; - - let inbound = crate::InboundDispatch::new(&db, "mqtt", &Plus).expect("routes compile"); - let subscriptions: Vec = inbound - .subscriptions() - .iter() - .map(|s| s.to_string()) - .collect(); - assert_eq!(subscriptions, ["cmd/in", "hum/+", "temp/+"]); - assert_eq!(inbound.route_count(), 3); - - let dispatch = |topic: &str| { - inbound.dispatch(topic, b""); - last.load(Ordering::SeqCst) - }; - assert_eq!(dispatch("temp/a"), 0); - assert_eq!(dispatch("hum/b"), 1); - assert_eq!(dispatch("hum/a"), 0, "one key table per record"); - assert_eq!(dispatch("cmd/in"), 100); - let produced = count.load(Ordering::SeqCst); - dispatch("temp/c"); - assert_eq!(count.load(Ordering::SeqCst), produced, "full table drops"); - } - - #[tokio::test] - async fn inbound_dispatch_rejects_links_it_cannot_compile() { - let (db, _, _) = inbound_db(|reg| { - reg.link_from("mqtt://r/one") - .with_topic_resolver(|| Some("r/{d".into())) - .with_deserializer(|_ctx, _bytes: &[u8]| Ok(TestRecord { value: 0 })) - .finish(); - reg.link_from("mqtt://k/{d}") - .key("d", 4) - .with_topic_resolver(|| Some("k/{x}".into())) - .with_match_deserializer(key_deser) - .finish(); - }) - .await; - - let errors = config_errors(crate::InboundDispatch::new(&db, "mqtt", &Plus)); - assert_eq!(errors.len(), 2, "{errors:?}"); - assert!(errors.iter().all(|e| e.record_key == "rec.in")); - assert!(errors[0].message.contains("unbalanced '{' in 'r/{d'")); - assert!(errors[1] - .message - .contains("key 'd' is not a capture of 'k/{x}'")); - } - #[tokio::test] async fn inbound_dispatch_clones_share_records() { fn assert_send_sync() {} @@ -2464,34 +2178,8 @@ mod tests { assert_eq!(last.load(Ordering::SeqCst), 4); } - #[cfg(feature = "connector-session")] - #[tokio::test] - async fn pump_source_routes_through_the_given_router() { - struct Once(Option<(String, crate::Payload)>); - impl crate::Source for Once { - fn next(&mut self) -> crate::BoxFut<'_, Option<(String, crate::Payload)>> { - let next = self.0.take(); - Box::pin(async move { next }) - } - } - - let (db, last, _) = inbound_db(|reg| { - reg.link_from("mqtt://temp/{d}") - .key("d", 2) - .with_match_deserializer(key_deser) - .finish(); - }) - .await; - let router = db.inbound_router("mqtt", &Plus).unwrap(); - let source = Once(Some(("temp/a".into(), Arc::from(&b"x"[..])))); - for pump in crate::pump_source(&db, router, source) { - pump.await; - } - assert_eq!(last.load(Ordering::SeqCst), 0); - } - // ==================================================================== - // Fused outbound reader tests + // OutboundRoutes over typed links // ==================================================================== /// Buffer reader that replays a fixed script, then reports the buffer @@ -2500,430 +2188,294 @@ mod tests { script: Vec>, } - impl ScriptedReader { - fn closed() -> crate::DbError { - crate::DbError::BufferClosed { - buffer_name: "scripted".to_string(), - } - } - } - impl crate::buffer::BufferReader for ScriptedReader { fn poll_recv( &mut self, _cx: &mut core::task::Context<'_>, ) -> core::task::Poll> { let next = if self.script.is_empty() { - Err(Self::closed()) + Err(crate::DbError::BufferClosed { + buffer_name: "scripted".to_string(), + }) } else { self.script.remove(0) }; core::task::Poll::Ready(next) } fn try_recv(&mut self) -> Result { - unimplemented!("not needed for fused reader tests") + unimplemented!("not needed for outbound route tests") } } - fn lagged() -> crate::DbError { - crate::DbError::BufferLagged { - lag_count: 1, - buffer_name: "scripted".to_string(), + type Script = fn() -> Vec>; + + /// Buffer whose readers replay `script`. + struct ScriptedBuffer(Script); + + impl crate::buffer::DynBuffer for ScriptedBuffer { + fn push(&self, _value: TestRecord) {} + fn subscribe_boxed(&self) -> Box + Send> { + Box::new(ScriptedReader { script: (self.0)() }) + } + fn as_any(&self) -> &dyn core::any::Any { + self } } - fn fused_reader( - script: Vec>, - serialize: FusedSerializeFn, - topic: TopicSelector, - ) -> FusedReader { - let topic_capacity = match &topic { - TopicSelector::Writer { capacity, .. } => *capacity, - _ => 0, - }; - FusedReader { - inner: crate::buffer::Reader::new(Box::new(ScriptedReader { script })), - serialize, - serialize_into: None, - topic, - topic_buf: alloc::vec![0; topic_capacity].into_boxed_slice(), - } + fn values(values: &[i32]) -> Vec> { + values + .iter() + .map(|&value| Ok(TestRecord { value })) + .collect() } - fn fused_reader_into( - script: Vec>, - serialize: FusedSerializeFn, - serialize_into: FusedSerializeIntoFn, - ) -> FusedReader { - FusedReader { - inner: crate::buffer::Reader::new(Box::new(ScriptedReader { script })), - serialize, - serialize_into: Some(serialize_into), - topic: TopicSelector::None, - topic_buf: Box::default(), - } + fn le(_ctx: crate::RuntimeContext, r: &TestRecord) -> Result, SerializeError> { + Ok(r.value.to_le_bytes().to_vec()) } - fn test_ctx() -> crate::RuntimeContext { - crate::RuntimeContext::new(Arc::new(MockRuntime)) + /// Writes `r` into `out`. + fn le_into( + _ctx: crate::RuntimeContext, + r: &TestRecord, + out: &mut [u8], + ) -> Result { + let bytes = r.value.to_le_bytes(); + out.get_mut(..bytes.len()) + .ok_or(SerializeError::BufferTooSmall)? + .copy_from_slice(&bytes); + Ok(bytes.len()) } - /// Buffer errors propagate through the fused reader unchanged, so the - /// pumps keep their `BufferLagged => continue / Err => break` shape. - #[tokio::test] - async fn fused_reader_propagates_buffer_errors() { - let mut reader = fused_reader( - vec![ - Ok(TestRecord { value: 1 }), - Err(lagged()), - Ok(TestRecord { value: 2 }), - ], - Arc::new(|_ctx, r| Ok(r.value.to_le_bytes().to_vec())), - TopicSelector::None, - ); - let ctx = test_ctx(); + /// `OutboundRoutes` for the `mqtt` links `links` registers on a record + /// whose buffer replays `script`. + async fn outbound( + script: Script, + links: impl FnOnce(&mut RecordRegistrar<'_, TestRecord>) + Send + 'static, + ) -> (crate::AimDb, crate::OutboundRoutes) { + let mut builder = crate::AimDbBuilder::new() + .runtime(Arc::new(MockRuntime)) + .with_connector(NoopConnectorBuilder); + builder.configure::("rec.out", move |reg| { + reg.buffer_raw(Box::new(ScriptedBuffer(script))); + links(reg); + }); + let (db, _runner) = builder.build().await.expect("build must succeed"); + let outbound = crate::OutboundRoutes::new(&db, "mqtt").expect("outbound routes"); + (db, outbound) + } - let first = reader.recv(&ctx).await.expect("first value"); - assert_eq!(first.payload, 1i32.to_le_bytes().to_vec()); - assert_eq!(first.dest, None); + /// The next message as `(topic, value, owned)`. + async fn pull(o: &mut crate::OutboundRoutes) -> Option<(String, i32, bool)> { + let msg = o.next().await?; + let owned = matches!(msg.payload, crate::OutboundPayload::Owned(_)); + let bytes: [u8; 4] = msg.payload.as_slice().try_into().expect("4 bytes"); + Some((msg.topic.to_string(), i32::from_le_bytes(bytes), owned)) + } - let err = reader.recv(&ctx).await.expect_err("lag must propagate"); - assert!(matches!(err, crate::DbError::BufferLagged { .. })); + /// A lag is counted and skipped; a closed buffer ends the route. + #[tokio::test] + async fn outbound_routes_count_lag_and_end_on_close() { + let (_db, mut o) = outbound( + || { + vec![ + Ok(TestRecord { value: 1 }), + Err(lagged()), + Ok(TestRecord { value: 2 }), + ] + }, + |reg| { + reg.link_to("mqtt://tele/out").with_serializer(le).finish(); + }, + ) + .await; - let second = reader.recv(&ctx).await.expect("second value"); - assert_eq!(second.payload, 2i32.to_le_bytes().to_vec()); + assert_eq!(pull(&mut o).await, Some(("tele/out".into(), 1, true))); + assert_eq!(pull(&mut o).await, Some(("tele/out".into(), 2, true))); + assert_eq!(o.stats(0).unwrap().lagged, 1); + assert_eq!(pull(&mut o).await, None); + } - let closed = reader.recv(&ctx).await.expect_err("closed must propagate"); - assert!(matches!(closed, crate::DbError::BufferClosed { .. })); + fn lagged() -> crate::DbError { + crate::DbError::BufferLagged { + lag_count: 1, + buffer_name: "scripted".to_string(), + } } - /// Serialization failures are skipped inside the reader (logged), exactly - /// like the old pump-side `continue`. + /// A value whose serializer fails is counted and skipped. #[tokio::test] - async fn fused_reader_skips_serialize_failures() { - let mut reader = fused_reader( - vec![Ok(TestRecord { value: 13 }), Ok(TestRecord { value: 42 })], - Arc::new(|_ctx, r| { - if r.value == 13 { - Err(SerializeError::InvalidData) - } else { - Ok(r.value.to_le_bytes().to_vec()) - } - }), - TopicSelector::None, - ); + async fn outbound_routes_skip_serialize_failures() { + let (_db, mut o) = outbound( + || values(&[13, 42]), + |reg| { + reg.link_to("mqtt://tele/out") + .with_serializer(|ctx, r: &TestRecord| { + if r.value == 13 { + return Err(SerializeError::InvalidData); + } + le(ctx, r) + }) + .finish(); + }, + ) + .await; - // One recv: the failing value is skipped, the next good one returned. - let msg = reader.recv(&test_ctx()).await.expect("value"); - assert_eq!(msg.payload, 42i32.to_le_bytes().to_vec()); + assert_eq!(pull(&mut o).await.map(|m| m.1), Some(42)); + assert_eq!(o.stats(0).unwrap().serialize_failed, 1); } #[tokio::test] - async fn fused_reader_into_uses_scratch_without_owned_fallback() { + async fn outbound_routes_use_scratch_without_owned_fallback() { let owned_calls = Arc::new(AtomicUsize::new(0)); let into_calls = Arc::new(AtomicUsize::new(0)); - let owned_counter = owned_calls.clone(); - let into_counter = into_calls.clone(); - let mut reader = fused_reader_into( - vec![Ok(TestRecord { value: 7 })], - Arc::new(move |_ctx, r| { - owned_counter.fetch_add(1, Ordering::SeqCst); - Ok(r.value.to_le_bytes().to_vec()) - }), - Arc::new(move |_ctx, r, out| { - into_counter.fetch_add(1, Ordering::SeqCst); - let bytes = r.value.to_le_bytes(); - out.get_mut(..bytes.len()) - .ok_or(SerializeError::BufferTooSmall)? - .copy_from_slice(&bytes); - Ok(bytes.len()) - }), - ); - let mut scratch = [0_u8; 8]; - - let msg = reader - .recv_into(&test_ctx(), &mut scratch) - .await - .expect("value"); + let (owned_counter, into_counter) = (owned_calls.clone(), into_calls.clone()); + let (_db, mut o) = outbound( + || values(&[7]), + move |reg| { + reg.link_to("mqtt://tele/out") + .with_serializer(move |ctx, r: &TestRecord| { + owned_counter.fetch_add(1, Ordering::SeqCst); + le(ctx, r) + }) + .with_serializer_into(8, move |ctx, r: &TestRecord, out| { + into_counter.fetch_add(1, Ordering::SeqCst); + le_into(ctx, r, out) + }) + .finish(); + }, + ) + .await; - assert_eq!(msg.payload, SerializedPayload::Scratch { len: 4 }); - assert_eq!(&scratch[..4], 7i32.to_le_bytes().as_slice()); + assert_eq!(pull(&mut o).await, Some(("tele/out".into(), 7, false))); assert_eq!(into_calls.load(Ordering::SeqCst), 1); assert_eq!(owned_calls.load(Ordering::SeqCst), 0); } #[tokio::test] - async fn fused_reader_into_falls_back_once_when_scratch_is_small() { + async fn outbound_routes_fall_back_once_when_scratch_is_small() { let owned_calls = Arc::new(AtomicUsize::new(0)); let owned_counter = owned_calls.clone(); - let mut reader = fused_reader_into( - vec![Ok(TestRecord { value: 9 })], - Arc::new(move |_ctx, r| { - owned_counter.fetch_add(1, Ordering::SeqCst); - Ok(r.value.to_le_bytes().to_vec()) - }), - Arc::new(|_ctx, _r, _out| Err(SerializeError::BufferTooSmall)), - ); - let mut scratch = [0_u8; 2]; - - let msg = reader - .recv_into(&test_ctx(), &mut scratch) - .await - .expect("fallback value"); + let (_db, mut o) = outbound( + || values(&[9]), + move |reg| { + reg.link_to("mqtt://tele/out") + .with_serializer(move |ctx, r: &TestRecord| { + owned_counter.fetch_add(1, Ordering::SeqCst); + le(ctx, r) + }) + .with_serializer_into(2, |_ctx, _r: &TestRecord, _out| { + Err(SerializeError::BufferTooSmall) + }) + .finish(); + }, + ) + .await; - assert_eq!( - msg.payload, - SerializedPayload::Owned(9i32.to_le_bytes().to_vec()) - ); + assert_eq!(pull(&mut o).await, Some(("tele/out".into(), 9, true))); assert_eq!(owned_calls.load(Ordering::SeqCst), 1); } + /// An into-slice serializer that reports more bytes than the scratch + /// holds, or fails with anything but `BufferTooSmall`, skips the value. #[tokio::test] - async fn fused_reader_into_rejects_invalid_length_and_skips_value() { - let mut reader = fused_reader_into( - vec![Ok(TestRecord { value: 1 }), Ok(TestRecord { value: 2 })], - Arc::new(|_ctx, r| Ok(r.value.to_le_bytes().to_vec())), - Arc::new(|_ctx, r, out| { - if r.value == 1 { - return Ok(out.len() + 1); - } - let bytes = r.value.to_le_bytes(); - out[..bytes.len()].copy_from_slice(&bytes); - Ok(bytes.len()) - }), - ); - let mut scratch = [0_u8; 8]; - - let msg = reader - .recv_into(&test_ctx(), &mut scratch) - .await - .expect("second value"); - - assert_eq!(msg.payload, SerializedPayload::Scratch { len: 4 }); - assert_eq!(&scratch[..4], 2i32.to_le_bytes().as_slice()); - } - - #[tokio::test] - async fn fused_reader_into_skips_invalid_data() { - let mut reader = fused_reader_into( - vec![Ok(TestRecord { value: 1 }), Ok(TestRecord { value: 2 })], - Arc::new(|_ctx, r| Ok(r.value.to_le_bytes().to_vec())), - Arc::new(|_ctx, r, out| { - if r.value == 1 { - return Err(SerializeError::InvalidData); - } - let bytes = r.value.to_le_bytes(); - out[..bytes.len()].copy_from_slice(&bytes); - Ok(bytes.len()) - }), - ); - let mut scratch = [0_u8; 8]; - - let msg = reader - .recv_into(&test_ctx(), &mut scratch) - .await - .expect("second value"); - - assert_eq!(msg.payload, SerializedPayload::Scratch { len: 4 }); - assert_eq!(&scratch[..4], 2i32.to_le_bytes().as_slice()); - } - - /// The destination is resolved from the typed value while it is in hand. - #[tokio::test] - async fn fused_reader_resolves_dynamic_topic() { - struct PositiveTopic; - impl TopicProvider for PositiveTopic { - fn topic(&self, value: &TestRecord) -> Option { - (value.value > 0).then(|| alloc::format!("dyn/{}", value.value)) - } - } - - let mut reader = fused_reader( - vec![Ok(TestRecord { value: 5 }), Ok(TestRecord { value: 0 })], - Arc::new(|_ctx, r| Ok(r.value.to_le_bytes().to_vec())), - TopicSelector::Provider(Arc::new(PositiveTopic)), - ); - let ctx = test_ctx(); - - let first = reader.recv(&ctx).await.expect("value"); - assert_eq!(first.dest.as_deref(), Some("dyn/5")); - - let second = reader.recv(&ctx).await.expect("value"); - assert_eq!(second.dest, None); // falls back to the route default - } + async fn outbound_routes_skip_invalid_lengths_and_data() { + let (_db, mut o) = outbound( + || values(&[1, 2, 3]), + |reg| { + reg.link_to("mqtt://tele/out") + .with_serializer(le) + .with_serializer_into(8, |ctx, r: &TestRecord, out| match r.value { + 1 => Ok(out.len() + 1), + 2 => Err(SerializeError::InvalidData), + _ => le_into(ctx, r, out), + }) + .finish(); + }, + ) + .await; - fn writer( - capacity: usize, - f: impl Fn(&TestRecord, &mut crate::TopicBuf<'_>) -> Result - + Send - + Sync - + 'static, - ) -> TopicSelector { - TopicSelector::Writer { - capacity, - writer: Arc::new(f), - } + assert_eq!(pull(&mut o).await, Some(("tele/out".into(), 3, false))); + assert_eq!(o.stats(0).unwrap().serialize_failed, 2); } - /// The same case with a written topic; `Ok(false)` uses the default. + /// `with_topic_fn` infers an unannotated closure's argument types; + /// `Ok(false)` publishes to the link's default topic. #[tokio::test] - async fn fused_reader_resolves_written_topic() { + async fn with_topic_fn_writes_the_topic_or_the_default() { use core::fmt::Write as _; - let mut reader = fused_reader( - vec![Ok(TestRecord { value: 5 }), Ok(TestRecord { value: 0 })], - Arc::new(|_ctx, r| Ok(r.value.to_le_bytes().to_vec())), - writer(8, |v, out| { - if v.value <= 0 { - return Ok(false); - } - write!(out, "dyn/{}", v.value)?; - Ok(true) - }), - ); - let ctx = test_ctx(); - - let first = reader.recv(&ctx).await.expect("value"); - assert_eq!(first.dest.as_deref(), Some("dyn/5")); + let (_db, mut o) = outbound( + || values(&[5, 0]), + |reg| { + reg.link_to("mqtt://tele/out") + .with_topic_fn(8, |v, out| { + if v.value <= 0 { + return Ok(false); + } + write!(out, "dyn/{}", v.value)?; + Ok(true) + }) + .with_serializer(le) + .finish(); + }, + ) + .await; - let second = reader.recv(&ctx).await.expect("value"); - assert_eq!(second.dest, None); + assert_eq!(o.routes()[0].topic_capacity, 8); + assert_eq!(pull(&mut o).await.map(|m| m.0), Some("dyn/5".into())); + assert_eq!(pull(&mut o).await.map(|m| m.0), Some("tele/out".into())); } /// A topic that overflows skips its value, even when the writer ignores /// the error and returns `Ok(true)`. #[tokio::test] - async fn fused_reader_skips_overflowing_topics() { + async fn overflowing_topics_are_skipped() { use core::fmt::Write as _; - let mut reader = fused_reader( - vec![ - Ok(TestRecord { value: 123_456 }), - Ok(TestRecord { value: 1_234_567 }), - Ok(TestRecord { value: 7 }), - ], - Arc::new(|_ctx, r| Ok(r.value.to_le_bytes().to_vec())), - writer(6, |v, out| { - if v.value == 1_234_567 { - let _ = write!(out, "t/{}", v.value); - return Ok(true); - } - write!(out, "t/{}", v.value)?; - Ok(true) - }), - ); - let ctx = test_ctx(); + let (_db, mut o) = outbound( + || values(&[123_456, 1_234_567, 7]), + |reg| { + reg.link_to("mqtt://tele/out") + .with_topic_fn(6, |v, out| { + if v.value == 1_234_567 { + let _ = write!(out, "t/{}", v.value); + return Ok(true); + } + write!(out, "t/{}", v.value)?; + Ok(true) + }) + .with_serializer(le) + .finish(); + }, + ) + .await; // "t/123456" (8 bytes) and "t/1234567" (9 bytes) do not fit in 6. - let msg = reader.recv(&ctx).await.expect("value"); - assert_eq!(msg.dest.as_deref(), Some("t/7")); - assert_eq!(msg.payload, 7i32.to_le_bytes().to_vec()); - } - - /// `with_topic_fn` infers an unannotated closure's argument types, which - /// a generic `W: TopicWriter` bound cannot. - #[tokio::test] - async fn with_topic_fn_infers_and_writes_the_topic() { - use core::fmt::Write as _; - struct CannedBuffer; - impl crate::buffer::DynBuffer for CannedBuffer { - fn push(&self, _value: TestRecord) {} - fn subscribe_boxed(&self) -> Box + Send> { - Box::new(ScriptedReader { - script: vec![Ok(TestRecord { value: 5 })], - }) - } - fn as_any(&self) -> &dyn core::any::Any { - self - } - } - - let mut builder = crate::AimDbBuilder::new() - .runtime(Arc::new(MockRuntime)) - .with_connector(NoopConnectorBuilder); - builder.configure::("rec.out", |reg| { - reg.buffer_raw(Box::new(CannedBuffer)); - reg.link_to("mqtt://tele/out") - .with_topic_fn(16, |v, out| { - write!(out, "dyn/{}", v.value)?; - Ok(true) - }) - .with_serializer(|_ctx, r: &TestRecord| Ok(r.value.to_le_bytes().to_vec())) - .finish(); - }); - let (db, _runner) = builder.build().await.expect("build must succeed"); - - let routes = db.collect_outbound_routes("mqtt"); - let mut reader = routes[0].source.subscribe(); - let msg = reader.recv(&db.runtime_ctx()).await.expect("value"); - assert_eq!(msg.dest.as_deref(), Some("dyn/5")); + assert_eq!(pull(&mut o).await, Some(("t/7".into(), 7, true))); + assert_eq!(o.stats(0).unwrap().topic_overflow, 2); } - /// End-to-end outbound path: registrar → build → collect → subscribe → - /// recv, pinning the factory wiring (raw and context serializers). + /// Registrar → build → `OutboundRoutes`, pinning the factory wiring: the + /// serializer set last wins, whichever way its context is typed. #[tokio::test] async fn outbound_roundtrip_yields_serialized_values() { - /// Buffer whose readers replay one canned value, then close. - struct CannedBuffer; - impl crate::buffer::DynBuffer for CannedBuffer { - fn push(&self, _value: TestRecord) {} - fn subscribe_boxed(&self) -> Box + Send> { - Box::new(ScriptedReader { - script: vec![Ok(TestRecord { value: 5 })], - }) - } - fn as_any(&self) -> &dyn core::any::Any { - self - } - } - - struct FixedTopic; - impl TopicProvider for FixedTopic { - fn topic(&self, value: &TestRecord) -> Option { - Some(alloc::format!("dyn/{}", value.value)) - } - } - - let mut builder = crate::AimDbBuilder::new() - .runtime(Arc::new(MockRuntime)) - .with_connector(NoopConnectorBuilder); - builder.configure::("rec.out", |reg| { - reg.buffer_raw(Box::new(CannedBuffer)); - // Raw set first, context set last — context must win (the kind - // enum is gone; mutual exclusion is behavior now). - reg.link_to("mqtt://tele/out") - .with_topic_provider(FixedTopic) - .with_serializer(|_ctx, _r: &TestRecord| Ok(vec![0])) - .with_serializer(|_ctx: crate::RuntimeContext, r: &TestRecord| { - Ok(r.value.to_le_bytes().to_vec()) - }) - .with_serializer_into(4, |_ctx, r: &TestRecord, out| { - let encoded = r.value.to_le_bytes(); - let dest = out - .get_mut(..encoded.len()) - .ok_or(SerializeError::BufferTooSmall)?; - dest.copy_from_slice(&encoded); - Ok(encoded.len()) - }) - .finish(); - }); - let (db, _runner) = builder.build().await.expect("build must succeed"); + let (_db, mut o) = outbound( + || values(&[5]), + |reg| { + reg.link_to("mqtt://tele/out?qos=1") + .with_serializer(|_ctx, _r: &TestRecord| Ok(vec![0])) + .with_serializer(|_ctx: crate::RuntimeContext, r: &TestRecord| { + Ok(r.value.to_le_bytes().to_vec()) + }) + .with_serializer_into(4, |ctx, r: &TestRecord, out| le_into(ctx, r, out)) + .finish(); + }, + ) + .await; - let routes = db.collect_outbound_routes("mqtt"); - assert_eq!(routes.len(), 1); - assert_eq!(routes[0].topic, "tele/out"); - assert_eq!(routes[0].source.serializer_scratch_capacity(), Some(4)); - - let mut reader = routes[0].source.subscribe(); - let ctx = db.runtime_ctx(); - let mut scratch = [0u8; 4]; - let msg = reader.recv_into(&ctx, &mut scratch).await.expect("value"); - assert_eq!(msg.dest.as_deref(), Some("dyn/5")); - assert_eq!(msg.payload, SerializedPayload::Scratch { len: 4 }); - assert_eq!(scratch, 5i32.to_le_bytes()); - - let closed = reader - .recv_into(&ctx, &mut scratch) - .await - .expect_err("buffer closed"); - assert!(matches!(closed, crate::DbError::BufferClosed { .. })); + let route = &o.routes()[0]; + assert_eq!(&*route.default_topic, "tele/out"); + assert_eq!(route.payload_capacity, 4); + assert_eq!(route.config.record_index, Some(0)); + assert_eq!(pull(&mut o).await, Some(("tele/out".into(), 5, false))); + assert_eq!(pull(&mut o).await, None); } } diff --git a/aimdb-data-contracts/src/link_codec.rs b/aimdb-data-contracts/src/link_codec.rs index aa5a6b52..d49cbb54 100644 --- a/aimdb-data-contracts/src/link_codec.rs +++ b/aimdb-data-contracts/src/link_codec.rs @@ -226,13 +226,16 @@ mod tests { #[cfg(all(feature = "linkable-json", feature = "linkable-postcard"))] use aimdb_core::buffer::{BufferReader, DynBuffer}; - use aimdb_core::connector::SerializeError; #[cfg(all(feature = "linkable-json", feature = "linkable-postcard"))] - use aimdb_core::connector::{ConnectorBuilder, SerializedPayload}; + use aimdb_core::connector::ConnectorBuilder; + use aimdb_core::connector::SerializeError; #[cfg(all(feature = "linkable-json", feature = "linkable-postcard"))] use aimdb_core::executor::test_support::NoopRuntimeOps; #[cfg(all(feature = "linkable-json", feature = "linkable-postcard"))] - use aimdb_core::{AimDb, AimDbBuilder, BoxFuture, DbError, DbResult}; + use aimdb_core::{ + AimDb, AimDbBuilder, BoxFuture, DbError, DbResult, InboundDispatch, OutboundPayload, + OutboundRoutes, + }; use serde::{Deserialize, Serialize}; #[cfg(all(feature = "linkable-json", feature = "linkable-postcard"))] @@ -523,106 +526,72 @@ mod tests { }); let (db, _runner) = builder.build().await.expect("codec routes build"); - let routes = db.collect_outbound_routes("test"); - assert_eq!(routes.len(), 4); - - let json_route = routes - .iter() - .find(|route| route.topic == "json") - .expect("JSON route"); - assert_eq!(json_route.source.serializer_scratch_capacity(), None); - let mut json_reader = json_route.source.subscribe(); - let mut unused_scratch = []; - let json_message = json_reader - .recv_into(&db.runtime_ctx(), &mut unused_scratch) - .await - .expect("JSON payload"); - let SerializedPayload::Owned(json_bytes) = json_message.payload else { - panic!("JSON must use owned serialization"); + let mut outbound = OutboundRoutes::new(&db, "test").expect("outbound routes"); + let route = |topic: &str| { + outbound + .routes() + .iter() + .find(|route| &*route.default_topic == topic) + .expect("route") + .clone() }; + assert_eq!(outbound.routes().len(), 4); + // A payload capacity of 0 means owned serialization only. + assert_eq!(route("json").payload_capacity, 0); + assert_eq!(route("postcard").payload_capacity, 64); + assert!(route("postcard") + .config + .protocol_options + .contains(&("wire".to_string(), "binary".to_string()))); + assert_eq!(route("postcard-owned-fallback").payload_capacity, 1); + assert_eq!( + route("postcard-replaced-by-json").payload_capacity, + 0, + "owned-only replacement must clear the previous scratch codec" + ); + + // Each canned reader yields one value, then closes its route. + let mut messages = std::collections::BTreeMap::new(); + while let Some(msg) = outbound.next().await { + let owned = matches!(msg.payload, OutboundPayload::Owned(_)); + messages.insert(msg.topic.to_string(), (msg.payload.into_vec(), owned)); + } + let message = |topic: &str| messages.get(topic).expect("message").clone(); + + let (json_bytes, owned) = message("json"); + assert!(owned, "JSON must use owned serialization"); let decoded_json: Reading = link_codecs::Json.decode(&json_bytes).expect("JSON decode"); assert_eq!(decoded_json, reading); - let postcard_route = routes - .iter() - .find(|route| route.topic == "postcard") - .expect("Postcard route"); - assert_eq!( - postcard_route.source.serializer_scratch_capacity(), - Some(64) - ); - assert!(postcard_route - .config - .contains(&("wire".to_string(), "binary".to_string()))); - let mut postcard_reader = postcard_route.source.subscribe(); - let mut scratch = [0_u8; 64]; - let postcard_message = postcard_reader - .recv_into(&db.runtime_ctx(), &mut scratch) - .await - .expect("Postcard payload"); - let SerializedPayload::Scratch { len } = postcard_message.payload else { - panic!("Postcard must use route scratch storage"); - }; + let (postcard_bytes, owned) = message("postcard"); + assert!(!owned, "Postcard must use route scratch storage"); let decoded_postcard: Reading = link_codecs::Postcard::<64> - .decode(&scratch[..len]) + .decode(&postcard_bytes) .expect("Postcard decode"); assert_eq!(decoded_postcard, reading); - assert_ne!(json_bytes, scratch[..len]); - - let fallback_route = routes - .iter() - .find(|route| route.topic == "postcard-owned-fallback") - .expect("undersized Postcard route"); - assert_eq!(fallback_route.source.serializer_scratch_capacity(), Some(1)); - let mut fallback_reader = fallback_route.source.subscribe(); - let mut undersized_scratch = [0_u8; 1]; - let fallback_message = fallback_reader - .recv_into(&db.runtime_ctx(), &mut undersized_scratch) - .await - .expect("owned Postcard fallback"); - let SerializedPayload::Owned(fallback_bytes) = fallback_message.payload else { - panic!("undersized Postcard scratch must use owned fallback"); - }; + assert_ne!(json_bytes, postcard_bytes); + + let (fallback_bytes, owned) = message("postcard-owned-fallback"); + assert!(owned, "undersized Postcard scratch must use owned fallback"); let decoded_fallback: Reading = link_codecs::Postcard::<1> .decode(&fallback_bytes) .expect("fallback Postcard decode"); assert_eq!(decoded_fallback, reading); - let replacement_route = routes - .iter() - .find(|route| route.topic == "postcard-replaced-by-json") - .expect("replacement route"); - assert_eq!( - replacement_route.source.serializer_scratch_capacity(), - None, - "owned-only replacement must clear the previous scratch codec" - ); - let mut replacement_reader = replacement_route.source.subscribe(); - let replacement_message = replacement_reader - .recv_into(&db.runtime_ctx(), &mut []) - .await - .expect("replacement JSON payload"); - let SerializedPayload::Owned(replacement_bytes) = replacement_message.payload else { - panic!("replacement JSON codec must use owned serialization"); - }; + let (replacement_bytes, owned) = message("postcard-replaced-by-json"); + assert!(owned, "replacement JSON codec must use owned serialization"); assert_eq!(replacement_bytes, json_bytes); - let inbound = db - .inbound_router("test", &aimdb_core::ExactGrammar) + let inbound = InboundDispatch::new(&db, "test", &aimdb_core::ExactGrammar) .expect("inbound routes"); assert_eq!(inbound.route_count(), 2); - let ctx = db.runtime_ctx(); - inbound - .route("json-in", &json_bytes, &ctx) - .expect("JSON ingest"); + inbound.dispatch("json-in", &json_bytes); assert_eq!( json_in.lock().expect("JSON capture lock").as_ref(), Some(&reading) ); - inbound - .route("postcard-in", &scratch[..len], &ctx) - .expect("Postcard ingest"); + inbound.dispatch("postcard-in", &postcard_bytes); assert_eq!( postcard_in.lock().expect("Postcard capture lock").as_ref(), Some(&reading) diff --git a/aimdb-embassy-adapter/src/connectors.rs b/aimdb-embassy-adapter/src/connectors.rs index 899cfd9f..770b23b8 100644 --- a/aimdb-embassy-adapter/src/connectors.rs +++ b/aimdb-embassy-adapter/src/connectors.rs @@ -1,28 +1,20 @@ -//! The data-plane bridge — the one audited home for the single-core `unsafe` + -//! [`SendFutureWrapper`] that every Embassy data-plane connector used to -//! hand-roll. +//! The one audited home for the single-core `unsafe` that Embassy connectors +//! need. //! //! AimDB's connector contract is `Send`-everywhere (so a Tokio app can //! `tokio::spawn(runner.run())`). Embassy's primitives (channels over //! `NoopRawMutex`, a borrowed `embassy_net::Stack`, …) are `!Send` *by design* — //! single-core, cooperative, no preemption or thread migration. Bridging the two //! requires force-`Send`ing the Embassy futures; this module does that **once**, -//! so a connector crate carries **no `unsafe` and no wrapper**. +//! so a connector crate carries **no `unsafe` and no wrapper**: it boxes its +//! protocol task with [`into_box_future`] and holds the network stack as a +//! `NetStack`. //! -//! Data-plane transports (MQTT, KNX) contribute an [`EmbassySinkRaw`] (outbound) -//! and/or [`EmbassySourceRaw`] (inbound) and ride core's -//! [`pump_sink`](aimdb_core::session::pump_sink) / -//! [`pump_source`](aimdb_core::session::pump_source) via the force-`Send` -//! bridges [`EmbassySink`] / [`EmbassySource`]. -//! -//! Session transports (serial, TCP, …) no longer come through here. They ride +//! Session transports (serial, TCP, …) do not come through here. They ride //! core's runtime-neutral spine directly — `SessionClientConnector` / //! `SessionServerConnector` over `FramedConnection`, with the byte source from //! this crate's `io` or `net` module (unlinked: neither exists in a -//! `connectors`-only build) — so the Embassy duals this module used to -//! carry (`EmbassySessionClient`/`Server`, `EmbassyConnection`, `OneShotCell` -//! and the one-shot dialer/listener) are gone. Their one-shot semantics live in -//! core as `OneShot`, `OneShotDialer` and `OneShotListener`. +//! `connectors`-only build). //! //! # Safety invariant (shared by every `unsafe impl` below) //! @@ -34,87 +26,12 @@ use core::future::Future; use core::pin::Pin; use alloc::boxed::Box; -use alloc::string::{String, ToString}; -use alloc::vec::Vec; - -use aimdb_core::session::{BoxFut, Payload, Source}; -use aimdb_core::transport::{Connector, ConnectorConfig, PublishError}; use crate::SendFutureWrapper; /// The runner's collected future type (`Send`, as the std contract requires). type BoxFuture = Pin + Send + 'static>>; -// =========================================================================== -// Data-plane bridges — let a `!Send` sink/source ride core's pumps. -// =========================================================================== - -/// The pure outbound I/O a data-plane connector contributes: publish one payload. -/// The `!Send` dual of [`Connector`]; [`EmbassySink`] force-`Send`s it so it can -/// drive core's [`pump_sink`](aimdb_core::session::pump_sink). -/// -/// Args are owned (a data-plane sink enqueues owned data onto its channel anyway), -/// so the returned future borrows only `&self` — matching [`Connector::publish`]'s -/// `'_` return shape. -pub trait EmbassySinkRaw { - /// Publish `payload` to `destination` (e.g. enqueue onto an Embassy channel). - fn publish( - &self, - destination: String, - config: ConnectorConfig, - payload: Vec, - ) -> impl Future>; -} - -/// Force-`Send` bridge turning an [`EmbassySinkRaw`] into a [`Connector`], so an -/// Embassy outbound sink rides core's [`pump_sink`](aimdb_core::session::pump_sink) -/// unchanged. -pub struct EmbassySink(pub C); - -// SAFETY: single-core cooperative Embassy executor — see the module-level invariant. -unsafe impl Send for EmbassySink {} -// SAFETY: same invariant; `Connector` is shared behind `Arc`. -unsafe impl Sync for EmbassySink {} - -impl Connector for EmbassySink { - fn publish( - &self, - destination: &str, - config: &ConnectorConfig, - payload: &[u8], - ) -> Pin> + Send + '_>> { - // Own the args so the inner future borrows only `&self` (see trait doc). - Box::pin(SendFutureWrapper(self.0.publish( - destination.to_string(), - config.clone(), - payload.to_vec(), - ))) - } -} - -/// The pure inbound I/O a data-plane connector contributes: yield the next -/// `(topic, payload)`. The `!Send` dual of [`Source`]; [`EmbassySource`] -/// force-`Send`s it so it can drive core's -/// [`pump_source`](aimdb_core::session::pump_source). -pub trait EmbassySourceRaw { - /// Yield the next `(topic, payload)`, or `None` when the source is done. - fn next(&mut self) -> impl Future>; -} - -/// Force-`Send` bridge turning an [`EmbassySourceRaw`] into a [`Source`], so an -/// Embassy inbound stream rides core's -/// [`pump_source`](aimdb_core::session::pump_source) unchanged. -pub struct EmbassySource(pub S); - -// SAFETY: single-core cooperative Embassy executor — see the module-level invariant. -unsafe impl Send for EmbassySource {} - -impl Source for EmbassySource { - fn next(&mut self) -> BoxFut<'_, Option<(String, Payload)>> { - Box::pin(SendFutureWrapper(self.0.next())) - } -} - /// Force-`Send` + box a connector's long-lived **protocol task** (an MQTT broker /// manager, a KNX tunnelling state machine, …) so it can join the runner's /// `Send` future set without the connector touching [`SendFutureWrapper`]. diff --git a/aimdb-embassy-adapter/src/lib.rs b/aimdb-embassy-adapter/src/lib.rs index 63cdc955..263de655 100644 --- a/aimdb-embassy-adapter/src/lib.rs +++ b/aimdb-embassy-adapter/src/lib.rs @@ -28,11 +28,11 @@ pub mod buffer; #[cfg(not(feature = "std"))] mod runtime; -// Force-`Send` helper for Embassy data-plane connectors (see module docs). +// Force-`Send` helper for Embassy connectors (see module docs). #[cfg(not(feature = "std"))] pub mod send_wrapper; -// Centralized Embassy connector spines (session + data-plane) — the one audited +// Centralized Embassy connector helpers — the one audited // home for the single-core `unsafe` + `SendFutureWrapper`. #[cfg(all(not(feature = "std"), feature = "connectors"))] pub mod connectors; diff --git a/aimdb-embassy-adapter/src/send_wrapper.rs b/aimdb-embassy-adapter/src/send_wrapper.rs index 6a634a03..b2db959c 100644 --- a/aimdb-embassy-adapter/src/send_wrapper.rs +++ b/aimdb-embassy-adapter/src/send_wrapper.rs @@ -4,13 +4,8 @@ //! returns `Vec>>` and `AimDbRunner` drives them on a //! `Send` `BoxFuture`. Embassy's primitives (channels over `NoopRawMutex`, …) are //! `!Send` *by design* — single-core, cooperative, no preemption or thread -//! migration — so an Embassy connector's data-plane futures must be force-`Send`ed -//! to satisfy that bound. -//! -//! This is also *why* Embassy data-plane connectors hand-roll their outbound / -//! inbound loops instead of riding core's `pump_sink` / `pump_source`: those need a -//! `Send + Sync` `Connector` / `Send` `Source`, which `!Send` Embassy channels -//! cannot be without force-`Send`ing every primitive. +//! migration — so an Embassy connector's futures must be force-`Send`ed to +//! satisfy that bound. use core::future::Future; use core::pin::Pin; diff --git a/aimdb-knx-connector/Cargo.toml b/aimdb-knx-connector/Cargo.toml index d50e4329..198c074f 100644 --- a/aimdb-knx-connector/Cargo.toml +++ b/aimdb-knx-connector/Cargo.toml @@ -20,14 +20,9 @@ default = ["aimdb-core/alloc"] # by passing an adapter's binder and clock — a dependency the caller adds, not a # feature here — which is what lets a third runtime (FreeRTOS/lwIP) use this # crate with no edit to it. -# -# `embassy-sync` no longer backs a public type here (the connection task pulls -# from the record buffers directly); it is dropped together with the old -# connector SPI. connector = [ "aimdb-core/alloc", "aimdb-core/connector-session", # `DatagramBinder`/`Datagram`/`Delay` - "embassy-sync", ] # Orthogonal to `connector`: the code is `alloc`-only either way, so this only @@ -41,20 +36,10 @@ std = ["connector", "aimdb-core/std", "knx-pico/std"] tokio-runtime = ["std"] embassy-runtime = ["connector"] -# Selects `critical-section`'s std implementation. -# -# `embassy-sync`'s only `Sync` raw mutex is `CriticalSectionRawMutex`, which -# needs a `critical-section` impl to link. That impl is registered by symbol -# name and is global to the binary, so per `critical-section`'s own docs only -# the **final binary** may choose one: a library enabling it would hand every -# downstream binary a duplicate-symbol link error with no way to opt out. -# -# So this stays off by default and out of `connector`/`std`. A std binary that -# instantiates a `CriticalSectionRawMutex` channel and has no other impl in its -# graph can either depend on `critical-section` with `features = ["std"]` directly (the -# documented way) or enable this feature. On Embassy the HAL (cortex-m / -# embassy-rp / ...) already provides one. -critical-section-std-impl = ["critical-section/std"] +# Deprecated no-op. It selected `critical-section`'s std impl for the +# channels the connector used to take; nothing here needs one any more. Kept +# so existing consumers keep building; remove after a release. +critical-section-std-impl = [] # Design 050 §10.4/§10.5: the facade reaches both destinations through # `aimdb_core::__private`, so neither dependency is declared here any more. @@ -72,19 +57,11 @@ aimdb-core = { version = "2.0.0", path = "../aimdb-core", default-features = fal # Keyed `knx-pico` to keep imports and feature references unchanged. knx-pico = { package = "aimdb-knx-pico", version = "0.3.1", default-features = false } -# Executor-independent, despite the names: neither pulls an executor and both -# build on std, so one channel type and one select serve every runtime. -# `embassy-sync` is optional because it is only reachable through `connector`; -# `embassy-futures` is unconditional (its `[dependencies]` is empty bar optional -# defmt/log, so the std graph is unaffected). -embassy-sync = { version = "0.8.0", path = "../_external/embassy/embassy-sync", optional = true } +# Executor-independent, despite the name: it pulls no executor and builds on +# std, so one select serves every runtime. Its `[dependencies]` is empty bar +# optional defmt/log, so the std graph is unaffected. embassy-futures = { version = "0.1.2" } -# A `critical-section` impl must be linked wherever `CriticalSectionRawMutex` -# is used. Choosing one is the final binary's call, so it is reachable only -# through the opt-in `critical-section-std-impl` feature above. -critical-section = { version = "1.1", optional = true } - # Embedded utilities (heapless is unconditional: the shared sans-io tunnel # engine uses stack-allocated frames on both runtimes) heapless = { workspace = true } diff --git a/aimdb-knx-connector/README.md b/aimdb-knx-connector/README.md index 9587c430..4fff84d5 100644 --- a/aimdb-knx-connector/README.md +++ b/aimdb-knx-connector/README.md @@ -10,12 +10,8 @@ Add to your `Cargo.toml`: ```toml [dependencies] -# `std` is the host leg. `critical-section-std-impl` selects the impl the -# connector's channels need to link — only a final binary may pick one. -aimdb-knx-connector = { version = "0.5", features = [ - "std", - "critical-section-std-impl", -] } +# `std` is the host leg. +aimdb-knx-connector = { version = "0.5", features = ["std"] } # The host also needs the adapter's UDP socket and clock. aimdb-tokio-adapter = { version = "0.6", features = ["tokio-runtime", "net"] } diff --git a/aimdb-knx-connector/src/lib.rs b/aimdb-knx-connector/src/lib.rs index bd7f2312..6e5cdf03 100644 --- a/aimdb-knx-connector/src/lib.rs +++ b/aimdb-knx-connector/src/lib.rs @@ -16,14 +16,12 @@ //! enables. //! - `std`: `connector` plus core's `std`, knx-pico's std error impls, and the //! back-compat DPT re-exports. Lifts `no_std`; adds no runtime. -//! - `critical-section-std-impl`: **final binaries only** — selects -//! `critical-section`'s std impl for a host binary that needs one. An -//! Embassy HAL already provides one. //! - `tracing`: Debug logging support (std) //! - `defmt`: Debug logging support (no_std) //! //! `tokio-runtime` and `embassy-runtime` are deprecated aliases for `std` and -//! `connector` respectively, kept for one release. +//! `connector` respectively, and `critical-section-std-impl` a deprecated +//! no-op, kept for one release. //! //! ## Production Status //! diff --git a/aimdb-knx-connector/tests/topic_writer_tests.rs b/aimdb-knx-connector/tests/topic_writer_tests.rs index 42055a0b..c976ed45 100644 --- a/aimdb-knx-connector/tests/topic_writer_tests.rs +++ b/aimdb-knx-connector/tests/topic_writer_tests.rs @@ -342,40 +342,6 @@ async fn test_knx_topic_writer_with_connector_registration() { assert!(builder.build().await.is_ok()); } -/// The connector pulls through `OutboundRoutes`, which rejects -/// `with_topic_provider` links at build. -#[tokio::test] -async fn test_knx_rejects_a_topic_provider() { - use aimdb_core::connector::TopicProvider; - - struct Fixed; - impl TopicProvider for Fixed { - fn topic(&self, _value: &DimmerValue) -> Option { - Some("1/0/1".into()) - } - } - - let runtime = Arc::new(TokioAdapter::new().unwrap()); - let mut builder = AimDbBuilder::new() - .runtime(runtime) - .with_connector(connector()); - builder.configure::("knx.dimmer.living", |reg| { - reg.buffer(BufferCfg::SingleLatest) - .link_to("knx://1/0/0") - .with_topic_provider(Fixed) - .with_serializer(|_ctx, dimmer: &DimmerValue| Ok(dimmer.to_knx_bytes())) - .finish(); - }); - - let Err(err) = builder.build().await else { - panic!("a topic provider must be rejected"); - }; - assert!( - format!("{err}").contains("with_topic_provider"), - "unexpected error: {err}" - ); -} - #[tokio::test] async fn test_knx_topic_resolver_with_connector_registration() { let runtime = Arc::new(TokioAdapter::new().unwrap()); diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 8680be7f..185a36b5 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -13,9 +13,8 @@ categories = ["network-programming", "embedded", "asynchronous"] [features] default = ["aimdb-core/alloc"] -# `aimdb-core/connector-session` provides the data-plane `pump_sink`/`pump_source` -# helpers the tokio client builds on (re-exported there; `std` implies it too). -# The `rumqttc` backend, which owns its socket, TLS and reconnect. +# `aimdb-core/connector-session` provides the `StreamDialer`/`Delay` traits +# `MqttConnector`'s transport bounds name. The `rumqttc` backend, which owns its socket, TLS and reconnect. std = [ "aimdb-core/std", "aimdb-core/alloc", diff --git a/aimdb-mqtt-connector/tests/link_ext_tests.rs b/aimdb-mqtt-connector/tests/link_ext_tests.rs index 1e4076f1..6952ebd0 100644 --- a/aimdb-mqtt-connector/tests/link_ext_tests.rs +++ b/aimdb-mqtt-connector/tests/link_ext_tests.rs @@ -7,7 +7,7 @@ #![cfg(feature = "std")] use aimdb_core::buffer::BufferCfg; -use aimdb_core::AimDbBuilder; +use aimdb_core::{AimDbBuilder, InboundDispatch, OutboundRoutes}; use aimdb_data_contracts::{link_codecs, LinkCodec, LinkCodecBuilderExt}; use aimdb_mqtt_connector::{MqttConnector, MqttLinkExt, MqttOutboundLinkExt}; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; @@ -16,7 +16,6 @@ use std::sync::Arc; #[derive(Clone, Debug, Deserialize, Serialize)] struct Reading { - #[allow(dead_code)] value: f32, } @@ -103,30 +102,29 @@ async fn per_link_codec_preserves_mqtt_extensions_and_wiring() { let (db, _runner) = builder.build().await.expect("build must succeed"); - let outbound = db.collect_outbound_routes("mqtt"); - assert_eq!(outbound.len(), 1); - assert_eq!(outbound[0].topic, "sensors/codec"); - assert_eq!(outbound[0].source.serializer_scratch_capacity(), Some(64)); - assert!(outbound[0] - .config - .contains(&("qos".to_string(), "2".to_string()))); - assert!(outbound[0] - .config - .contains(&("retain".to_string(), "true".to_string()))); + let outbound = OutboundRoutes::new(&db, "mqtt").expect("outbound routes"); + let routes = outbound.routes(); + assert_eq!(routes.len(), 1); + assert_eq!(&*routes[0].default_topic, "sensors/codec"); + assert_eq!(routes[0].payload_capacity, 64); + let options = &routes[0].config.protocol_options; + assert!(options.contains(&("qos".to_string(), "2".to_string()))); + assert!(options.contains(&("retain".to_string(), "true".to_string()))); let id = db.inner().resolve_str("test.reading.codec").unwrap(); let record = db.inner().storage(id).unwrap(); let inbound_config = &record.inbound_connectors()[0].config; assert!(inbound_config.contains(&("qos".to_string(), "0".to_string()))); - let inbound = db - .inbound_router("mqtt", &aimdb_core::ExactGrammar) - .expect("inbound routes"); + let inbound = + InboundDispatch::new(&db, "mqtt", &aimdb_core::ExactGrammar).expect("inbound routes"); assert_eq!(inbound.subscriptions(), [Arc::from("commands/codec")]); let encoded = link_codecs::Postcard::<64> .encode(&Reading { value: 17.5 }) .expect("Postcard encode must succeed"); - inbound - .route("commands/codec", &encoded, &db.runtime_ctx()) - .expect("Postcard ingest must succeed"); + let mut reader = db + .subscribe::("test.reading.codec") + .expect("subscribe"); + inbound.dispatch("commands/codec", &encoded); + assert_eq!(reader.try_recv().expect("Postcard ingest").value, 17.5); } diff --git a/aimdb-mqtt-connector/tests/topic_writer_tests.rs b/aimdb-mqtt-connector/tests/topic_writer_tests.rs index 13e38657..3f5e8b38 100644 --- a/aimdb-mqtt-connector/tests/topic_writer_tests.rs +++ b/aimdb-mqtt-connector/tests/topic_writer_tests.rs @@ -504,7 +504,7 @@ fn test_connector_topic_resolution_with_threshold_writer() { /// Test that verifies the inbound topic resolver is called correctly #[test] fn test_inbound_topic_resolver_simulation() { - // Simulate what inbound_router does for TopicResolver + // Simulate how inbound routes resolve a TopicResolver fn resolve_inbound_topic( default_topic: &str, resolver: Option<&dyn Fn() -> Option>, diff --git a/aimdb-tokio-adapter/tests/outbound_routes.rs b/aimdb-tokio-adapter/tests/outbound_routes.rs index a713225d..ae4b5906 100644 --- a/aimdb-tokio-adapter/tests/outbound_routes.rs +++ b/aimdb-tokio-adapter/tests/outbound_routes.rs @@ -9,7 +9,7 @@ use std::task::{Context, Poll, Waker}; use std::time::Duration; use aimdb_core::buffer::BufferCfg; -use aimdb_core::connector::{ConnectorBuilder, SerializeError, TopicProvider}; +use aimdb_core::connector::{ConnectorBuilder, SerializeError}; use aimdb_core::{AimDb, AimDbBuilder, DbResult, OutboundPayload, OutboundRoutes, RecordRegistrar}; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; @@ -422,37 +422,7 @@ async fn wakes_from_other_threads_lose_nothing() { } #[tokio::test] -async fn a_topic_provider_link_is_rejected() { - struct Fixed; - impl TopicProvider for Fixed { - fn topic(&self, _: &V) -> Option { - Some("x".into()) - } - } - let db = db(vec![Box::new(|reg| { - reg.buffer(BufferCfg::SingleLatest) - .link_to("test://r0") - .with_topic_provider(Fixed) - .with_serializer(le) - .finish(); - })]) - .await; - let Err(aimdb_core::DbError::InvalidConfiguration { errors }) = - OutboundRoutes::new(&db, "test") - else { - panic!("expected a configuration error"); - }; - assert_eq!(errors.len(), 1); - assert_eq!(errors[0].record_key, "r0"); - assert!( - errors[0].message.contains("with_topic_writer"), - "{}", - errors[0].message - ); -} - -#[tokio::test] -async fn route_info_matches_collect_outbound_routes() { +async fn route_info_carries_topic_config_and_record_index() { let db = db(vec![ Box::new(|reg| { reg.buffer(BufferCfg::SingleLatest); @@ -460,24 +430,24 @@ async fn route_info_matches_collect_outbound_routes() { spmc(1, 16), Box::new(|reg| { reg.buffer(BufferCfg::Mailbox) - .link_to("test://two?qos=1") + .link_to("test://two") + .with_config("qos", "1") .with_serializer(le) .finish(); }), ]) .await; let o = OutboundRoutes::new(&db, "test").unwrap(); - let old = db.collect_outbound_routes("test"); - assert_eq!(o.routes().len(), old.len()); - for (info, route) in o.routes().iter().zip(&old) { - let config = aimdb_core::transport::ConnectorConfig::from_query(&route.config); - assert!(info.config.record_index.is_some()); - assert_eq!(info.config.record_index, config.record_index); - assert_eq!(&*info.default_topic, route.topic); - assert_eq!(info.config.protocol_options, config.protocol_options); - } - assert_eq!(o.routes()[0].config.record_index, Some(1)); - assert_eq!(o.routes()[1].config.record_index, Some(2)); + let routes = o.routes(); + assert_eq!(routes.len(), 2); + assert_eq!(&*routes[0].default_topic, "r1"); + assert_eq!(routes[0].config.record_index, Some(1)); + assert_eq!(&*routes[1].default_topic, "two"); + assert_eq!(routes[1].config.record_index, Some(2)); + assert_eq!( + routes[1].config.protocol_options, + [("qos".to_string(), "1".to_string())] + ); } #[tokio::test] diff --git a/aimdb-websocket-connector/tests/decouple_record_keys_topics.rs b/aimdb-websocket-connector/tests/decouple_record_keys_topics.rs index 4d16b13e..2727200a 100644 --- a/aimdb-websocket-connector/tests/decouple_record_keys_topics.rs +++ b/aimdb-websocket-connector/tests/decouple_record_keys_topics.rs @@ -382,7 +382,7 @@ async fn test_fixture( ) } GrantType::Injected => { - // Grant for TopicProvider tests + // Grant for topic writer tests let perms = Permissions { read_patterns: vec!["injected.granted".to_string()], write_patterns: vec![], @@ -468,7 +468,7 @@ async fn test_fixture( }); }); - // Extra keys for TopicProvider test + // Extra keys for the topic writer test for key in ["injected.granted", "injected.denied"] { sb.configure::(key, |reg| { reg.buffer(BufferCfg::SpmcRing { capacity: 64 }) // don't coalesce successive values @@ -754,7 +754,7 @@ async fn client_wildcard_subscription_receives_public_only() { } #[tokio::test] -async fn topic_provider_injects_for_unsubscribed_client() { +async fn topic_writer_injects_for_unsubscribed_client() { let addr = free_addr(); let server_db = test_fixture(addr, GrantType::Injected, None).await; diff --git a/aimdb-websocket-connector/tests/e2e.rs b/aimdb-websocket-connector/tests/e2e.rs index c6c8e0b8..8f712c81 100644 --- a/aimdb-websocket-connector/tests/e2e.rs +++ b/aimdb-websocket-connector/tests/e2e.rs @@ -6,7 +6,7 @@ //! `run_client` + [`WsDialer`] engine). Server→client data is pushed by //! *producing a record* — an "injector" record whose dynamic topic + raw //! serializer let a test broadcast an arbitrary `(topic, payload)` through the -//! real `pump_sink` → bus → session path. +//! real `OutboundRoutes` → bus → session path. //! //! The parity block at the bottom locks the AimX WS wire to the semantics the //! retired ws-protocol offered (subscribe ack, wildcard fan-out, late-join @@ -879,7 +879,7 @@ async fn stalled_client_does_not_block_a_healthy_one() { tokio::time::sleep(Duration::from_millis(100)).await; // let the stalled sub register // Flood well past the bounded funnel (256). This also overruns the injector - // ring, so the outbound `pump_sink` consumer lags — it must skip the gap and + // ring, so the outbound route lags — it must skip the gap and // keep publishing (not die), while the stalled client's pump drops on overflow // and the healthy client keeps up. for i in 0..2000u32 { diff --git a/examples/tokio-knx-connector-demo/Cargo.toml b/examples/tokio-knx-connector-demo/Cargo.toml index 59f39adc..3d96256c 100644 --- a/examples/tokio-knx-connector-demo/Cargo.toml +++ b/examples/tokio-knx-connector-demo/Cargo.toml @@ -30,9 +30,6 @@ knx-connector-demo-common = { path = "../knx-connector-demo-common", features = aimdb-knx-connector = { path = "../../aimdb-knx-connector", features = [ # The host leg: the runtime-neutral connector plus core's `std`. "std", - # The connector's channels are `CriticalSectionRawMutex`; only the final - # binary may pick the impl they need to link. - "critical-section-std-impl", "tracing", ] } From 669bbfb0c4f7bd379a45d9d721cc3a02ffffc475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Tue, 6 Oct 2026 19:10:36 +0000 Subject: [PATCH 25/28] fix(demo): update binary path to debug build and simplify run command --- examples/embassy-mqtt-connector-demo/flash.sh | 2 +- examples/embassy-mqtt-connector-demo/src/main.rs | 4 ++-- examples/embassy-serial-connector-demo/src/main.rs | 6 +++++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/examples/embassy-mqtt-connector-demo/flash.sh b/examples/embassy-mqtt-connector-demo/flash.sh index 12bdd887..e30b49ee 100755 --- a/examples/embassy-mqtt-connector-demo/flash.sh +++ b/examples/embassy-mqtt-connector-demo/flash.sh @@ -6,7 +6,7 @@ set -e -BINARY="../../target/thumbv8m.main-none-eabihf/release/embassy-mqtt-connector-demo" +BINARY="../../target/thumbv8m.main-none-eabihf/debug/embassy-mqtt-connector-demo" if [ ! -f "$BINARY" ]; then echo "Error: Binary not found at $BINARY" diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index 38fa7e5c..2181f690 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -40,7 +40,7 @@ //! 3. Build and flash from this directory — its `.cargo/config.toml` selects //! the thumbv8m target and the probe-rs runner: //! ```bash -//! cargo run --release +//! cargo run //! ``` //! //! 4. Watch the traffic, and send the board a command: @@ -63,7 +63,7 @@ //! skipped), which is why the script puts even an IPv4 literal in as one. //! //! ```bash -//! cargo run --release --features tls +//! cargo run --features tls //! ``` extern crate alloc; diff --git a/examples/embassy-serial-connector-demo/src/main.rs b/examples/embassy-serial-connector-demo/src/main.rs index c5ee2288..28832b0f 100644 --- a/examples/embassy-serial-connector-demo/src/main.rs +++ b/examples/embassy-serial-connector-demo/src/main.rs @@ -21,9 +21,13 @@ //! //! ## Host side //! +//! Run from the workspace root, on the machine the board is plugged into. This +//! directory's `.cargo/config.toml` sets the thumbv8m target, so running from +//! here tries to build the host demo for the MCU. +//! //! ```bash //! cargo run -p aimdb-serial-connector --example serial_demo \ -//! --features _test-tokio -- client /dev/ttyACM0 115200 +//! --features std -- client /dev/ttyACM0 115200 # macOS: /dev/cu.usbmodemXXXX //! ``` //! //! You should see `record.list` return the `counter` record, then `counter` From 29b87ca5215f2af8cec15a479d9cdc2b0d4f7569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Tue, 6 Oct 2026 19:17:50 +0000 Subject: [PATCH 26/28] Add AimDB architecture diagram in SVG format - Introduced a new SVG file depicting the AimDB architecture. - The diagram illustrates the flow of data through inbound and outbound connectors, detailing the roles of aimdb CLI, MCP server, and AimX server. - Included annotations for various components such as TCP, Unix socket, Serial, and WebSocket connections. - Visual representation of the AIMDB NODE and its record handling mechanisms. --- README.md | 7 ++ assets/aimdb-architecture-dark.svg | 130 ++++++++++++++++++++++++++++ assets/aimdb-architecture-light.svg | 130 ++++++++++++++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 assets/aimdb-architecture-dark.svg create mode 100644 assets/aimdb-architecture-light.svg diff --git a/README.md b/README.md index 8846bce3..1090eb3d 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,13 @@ docker compose up ## Under the hood +

+ + + AimDB architecture: inbound connectors push into typed records, outbound connectors pull from the same buffers, and the aimdb CLI and MCP server reach every record through one AimX server + +

+ - **The Rust type is the contract.** No IDL, no schema registry. CI cross-compiles the same contracts from Cortex-M to WASM. → [Data contracts](https://aimdb.dev/blog/data-contracts-deep-dive) - **Buffers decide how data moves.** SPMC Ring for streams, SingleLatest for state, Mailbox for commands. Zero allocations per message, [measured](aimdb-bench/data/baselines). → [Buffers](https://aimdb.dev/docs/getting-started) - **Optional persistence.** `.persist()` with a SQLite backend keeps history across restarts. → [`aimdb-persistence`](aimdb-persistence) diff --git a/assets/aimdb-architecture-dark.svg b/assets/aimdb-architecture-dark.svg new file mode 100644 index 00000000..20da1599 --- /dev/null +++ b/assets/aimdb-architecture-dark.svg @@ -0,0 +1,130 @@ + +AimDB architecture + +REQUEST / REPLY +AimX · per request · JSON +records with .with_remote_access() + +aimdb CLI +terminal + +aimdb-mcp +AI clients · stdio + + + + + + + +TCP +TcpServer +CLI* + + +Unix socket +UdsServer +CLI · MCP + + +Serial +SerialServer +CLI* · MCP* + + +MCU + +AimX server · one engine for every transport + +get · set · subscribe +list · graph · query + +WebSocket +browsers (WASM) +list · query only + + +AIMDB NODE +typed records · one buffer each + +Inbound +Dispatch +push · sync + +Outbound +Routes +pull · ready set + + + + + + +SpmcRing +streams · every reader gets each value + + + +SingleLatest +state · newest value wins + + + +Mailbox +commands · one reader takes each + + +0 allocations per message across this boundary · CI-gated † +INBOUND +link_from +OUTBOUND +link_to + +MQTT +subscribe +embedded: 0 alloc / round trip + + +KNX +group telegram +KNXnet/IP gateway + + +WebSocket +client write +server · auth-checked + + +AimDB peer +mirror in +uds · tcp · serial · ws-client + + +MQTT +publish +embedded: 0 alloc / round trip + + +KNX +group write +KNXnet/IP gateway + + +WebSocket +broadcast +subscribed browsers + + +AimDB peer +mirror out +uds · tcp · serial · ws-client + + +Same three buffers on Tokio · Embassy (no_std, Cortex-M) · WASM, checked by one shared conformance suite + +hot path · per message · connectors push in and pull out, core runs no tasks in between + +request / reply · per request · reads and writes the same records +* opt-in build feature +† links with with_serializer_into + diff --git a/assets/aimdb-architecture-light.svg b/assets/aimdb-architecture-light.svg new file mode 100644 index 00000000..40d2baa3 --- /dev/null +++ b/assets/aimdb-architecture-light.svg @@ -0,0 +1,130 @@ + +AimDB architecture + +REQUEST / REPLY +AimX · per request · JSON +records with .with_remote_access() + +aimdb CLI +terminal + +aimdb-mcp +AI clients · stdio + + + + + + + +TCP +TcpServer +CLI* + + +Unix socket +UdsServer +CLI · MCP + + +Serial +SerialServer +CLI* · MCP* + + +MCU + +AimX server · one engine for every transport + +get · set · subscribe +list · graph · query + +WebSocket +browsers (WASM) +list · query only + + +AIMDB NODE +typed records · one buffer each + +Inbound +Dispatch +push · sync + +Outbound +Routes +pull · ready set + + + + + + +SpmcRing +streams · every reader gets each value + + + +SingleLatest +state · newest value wins + + + +Mailbox +commands · one reader takes each + + +0 allocations per message across this boundary · CI-gated † +INBOUND +link_from +OUTBOUND +link_to + +MQTT +subscribe +embedded: 0 alloc / round trip + + +KNX +group telegram +KNXnet/IP gateway + + +WebSocket +client write +server · auth-checked + + +AimDB peer +mirror in +uds · tcp · serial · ws-client + + +MQTT +publish +embedded: 0 alloc / round trip + + +KNX +group write +KNXnet/IP gateway + + +WebSocket +broadcast +subscribed browsers + + +AimDB peer +mirror out +uds · tcp · serial · ws-client + + +Same three buffers on Tokio · Embassy (no_std, Cortex-M) · WASM, checked by one shared conformance suite + +hot path · per message · connectors push in and pull out, core runs no tasks in between + +request / reply · per request · reads and writes the same records +* opt-in build feature +† links with with_serializer_into + From de99ef0716b32718f4ade94b5e12679da975ffec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Tue, 6 Oct 2026 19:22:31 +0000 Subject: [PATCH 27/28] docs: changelog entries for the 054 connector boundary Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 22 ++++++++++++ aimdb-codegen/CHANGELOG.md | 6 ++++ aimdb-core/CHANGELOG.md | 49 +++++++++++++++++++++++++- aimdb-embassy-adapter/CHANGELOG.md | 8 +++++ aimdb-knx-connector/CHANGELOG.md | 17 +++++++++ aimdb-mqtt-connector/CHANGELOG.md | 25 +++++++++++++ aimdb-websocket-connector/CHANGELOG.md | 11 ++++++ 7 files changed, 137 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd7465e3..d50272bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,28 @@ is unchanged. ([aimdb-core](aimdb-core/CHANGELOG.md)) withheld by the broker instead of reconnecting the client forever. ([aimdb-mqtt-connector](aimdb-mqtt-connector/CHANGELOG.md)) +### Changed (breaking) — Design 054: zero-allocation connector boundary + +Each connector's own task now drives both directions; core runs no task per +connector or per route, and a steady-state message crosses the boundary +without allocating on a scratch serializer. + +- **Core** adds `OutboundRoutes` (one pull for every outbound link of a scheme, + with a lock-free ready set and per-route `RouteStats`) and `InboundDispatch`, + plus `Reader::poll_recv`. `pump_sink`, `pump_source`, `Source`, `Connector`, + `TopicProvider` and `collect_outbound_routes` are removed, and + `with_topic_provider` becomes `with_topic_writer` / `with_topic_fn`, which + write the topic into a bounded buffer. ([aimdb-core](aimdb-core/CHANGELOG.md)) +- **MQTT** encodes embedded packets into one `bbqueue` write ring, sized with + `with_write_buffer`, and the native backend moves the topic instead of + cloning it. ([aimdb-mqtt-connector](aimdb-mqtt-connector/CHANGELOG.md)) +- **KNX**: `KnxConnector::new` loses its `&'static Channels` argument. + ([aimdb-knx-connector](aimdb-knx-connector/CHANGELOG.md)) +- **WebSocket** runs one broadcast loop over `OutboundRoutes`. + ([aimdb-websocket-connector](aimdb-websocket-connector/CHANGELOG.md)) +- **Embassy adapter** drops `EmbassySink` and `EmbassySource`. + ([aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md)) + ## [2.0.0] - 2026-09-18 ### Added diff --git a/aimdb-codegen/CHANGELOG.md b/aimdb-codegen/CHANGELOG.md index 2bece6d6..45383bc6 100644 --- a/aimdb-codegen/CHANGELOG.md +++ b/aimdb-codegen/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Generated KNX wiring drops the `Channels` static**: it calls + `KnxConnector::new(TokioNet::udp(..), TokioDelay, url)`, matching + `aimdb-knx-connector`'s new signature. + ## [0.3.0] - 2026-09-18 ### Added diff --git a/aimdb-core/CHANGELOG.md b/aimdb-core/CHANGELOG.md index df7f5dd1..1cd6608c 100644 --- a/aimdb-core/CHANGELOG.md +++ b/aimdb-core/CHANGELOG.md @@ -25,9 +25,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 including patterns a `TopicResolverFn` returns, and reports every link it cannot compile at once. `Router::subscriptions()` lists the filters to subscribe, without those another filter covers. +- **`OutboundRoutes`: one pull for every outbound link of a scheme (design + 054).** `OutboundRoutes::new(db, scheme)` builds a route per link, each with + its record's reader, topic writer and serializers, and a lock-free ready set + that wakes only routes with data. A connector's own task calls + `next().await` (or `poll_next`, or `poll_stage` / `take_staged` / `reject` + to commit a message only once it has room) and receives an `OutboundMessage` + borrowing the route's topic and payload buffers, so a message allocates + nothing on a scratch serializer. `routes()` lists `RouteInfo` (default topic, + `ConnectorConfig` with `record_index`, topic and payload capacities); + `stats(id)` reports `RouteStats` (`sent`, `rejected`, `lagged`, + `topic_overflow`, `serialize_failed`). Lag, serialize failures and a closed + buffer are handled and counted inside; `next` ends when every route has + closed. `OutboundPayload` is `Borrowed` or `Owned`, with `as_slice` and + `into_vec`. +- **`InboundDispatch`: the inbound counterpart.** `InboundDispatch::new(db, + scheme, grammar)` wraps the router and the runtime context; + `dispatch(topic, payload)` deserializes and produces, logging failures, and + `subscriptions()` lists the filters to subscribe. It is `Clone`, so the + session task, the event loop and the inbound pumps share one. +- **`with_topic_writer(capacity, writer)` and `with_topic_fn(capacity, f)`** + replace `with_topic_provider`. The writer receives the value and a + `TopicBuf` (`push_str`, `write!`) of `capacity` bytes and returns + `Ok(true)` to publish there or `Ok(false)` for the link's default topic. A + topic that overflows the buffer skips the value and is counted in + `topic_overflow`, whatever the writer returns. +- **`Reader::poll_recv`**, the poll form of `recv`, for tasks that wait on + several readers at once. `recv` is unchanged. ### Changed (breaking, API) +- **Connector SPI: the push pumps are gone (design 054).** Removed + `session::pump_sink`, `session::pump_source`, `Source`, the `Connector` + trait (with `transport::Connector::publish`), `TopicProvider`, + `OutboundRoute`, `AimDb::collect_outbound_routes`, `SerializedSource`, + `SerializedReader`, `SerializedValue`, `SerializedValueInto`, + `SerializedPayload` and `SourceFactoryFn`. A connector now builds + `InboundDispatch` and `OutboundRoutes` in `build()` and drives both + directions from its own transport task; core spawns no task per connector or + per route. +- **`with_topic_provider` is replaced by `with_topic_writer` / + `with_topic_fn`**, which write the topic into a bounded buffer instead of + returning an `Option`. +- **`pump_client(db, scheme, inbound, handle)` returns `DbResult>`** + and drives all outbound routes from one task instead of one per route, plus + one future per inbound subscription. During an outage the engine's command + queue, not the record buffer, decides what survives. +- **`OutboundConnectorLink`'s route factory and `new` are `pub(crate)`, and the + `router` module is no longer public.** + - **One inbound path.** Removed `AimDb::collect_inbound_routes`, `RouterBuilder`, `Route` and the public `Router::new`; a `Router` comes from `inbound_router`. `IngestFn` takes the `TopicMatch`, and @@ -58,7 +104,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `ConnectorConfig::from_query` parses that pair into `record_index` rather than passing it on in `protocol_options`. `record_index` is therefore a reserved config key: a value set through `with_config("record_index", …)` is - overridden by the stamped one, which is appended last. + overridden by the stamped one, which is appended last. `OutboundRoutes` + now stamps it, in `RouteInfo::config`. ## [2.0.0] - 2026-09-18 diff --git a/aimdb-embassy-adapter/CHANGELOG.md b/aimdb-embassy-adapter/CHANGELOG.md index bd8fdbe0..2e00597d 100644 --- a/aimdb-embassy-adapter/CHANGELOG.md +++ b/aimdb-embassy-adapter/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Removed (breaking) + +- **`EmbassySinkRaw`, `EmbassySink`, `EmbassySourceRaw` and `EmbassySource`** + (design 054). They force-`Send`ed a `!Send` sink or source so it could ride + core's `pump_sink` / `pump_source`, which no longer exist: a connector now + runs its own task and pulls from `OutboundRoutes`. The `into_box_future` + helper and `NetStack` remain. + ## [0.7.0] - 2026-09-18 ### Added diff --git a/aimdb-knx-connector/CHANGELOG.md b/aimdb-knx-connector/CHANGELOG.md index bd995f96..48d157fa 100644 --- a/aimdb-knx-connector/CHANGELOG.md +++ b/aimdb-knx-connector/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed (breaking) + +- **`KnxConnector::new(binder, delay, url)` no longer takes `&'static + Channels`** (design 054). The connection task dispatches inbound telegrams + into their records and pulls outbound values from their record buffers + itself, so no `embassy_sync` queue sits between it and core. `Channels`, + `TelegramChannel`, `CommandChannel`, `DEFAULT_QUEUE` and the `N` const + generic on `KnxConnector` are removed, as is the `embassy-sync` dependency. + Delete the `static CHANNELS` and the argument. +- **A command produced while the tunnel connects waits in its record buffer** + and goes out once the handshake completes; routes are subscribed in + `build()`, before the task first runs. Which values survive an outage is the + record buffer's decision. +- **`critical-section-std-impl` is a deprecated no-op**, kept so existing + consumers keep building. Nothing here needs a `critical-section` + implementation any more. + ## [0.5.0] - 2026-09-18 ### Changed diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index 5d499e8d..733bd014 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -13,8 +13,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `{name}` match one level, `#` and `{name..}` the rest (last only, including the parent level); a leading wildcard does not match a `$…` topic. +- **`MqttConnector::with_write_buffer(bytes)`** sizes the `Embedded` and + `EmbeddedTls` backends' write ring (default 4,096 bytes), allocated once and + reused across reconnects. An outbound PUBLISH plus a 64-byte reserve must fit + in half the ring. `build()` fails for a route whose largest frame, or a + CONNECT or SUBSCRIBE, does not fit; an owned payload over the limit at + runtime is skipped and counted as rejected in the route's `RouteStats`. + ### Changed +- **No core pumps (design 054).** Each backend's own task drives both + directions: it dispatches inbound publishes through an `InboundDispatch` and + pulls outbound messages from `OutboundRoutes`. The `Embedded` backends + encode packets into one `bbqueue` write ring instead of a `Vec` per packet + on a `Channel`, and the `Native` backend moves the topic out of `rumqttc`'s + `Publish` instead of cloning it. `AimdbMqttAction` and `AimdbMqttEvent` are + gone. +- **At QoS 1 the PUBACK is sent before delivery**, so it means the message + reached AimDB, not that every record kept it: a record whose buffer is full + drops it, and the broker does not resend. +- **A route's `qos` and `retain` are parsed once at `build()`**, and a bad + value fails the build rather than the first publish. The `Embedded` backends + warn once per route asking for `qos=2`, which they send at QoS 1. +- **`bbqueue` is a new dependency of the `embedded` features.** - **Both backends route through `inbound_router("mqtt", &MqttGrammar)`** and subscribe `subscriptions()`: a filter another one covers is not subscribed, so the MQTT 3.1.1 (`Native`) and MQTT 5 (`Embedded`) backends each receive an @@ -25,6 +46,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **A failed `Native` publish is logged with its topic and counted as + rejected** in the route's `RouteStats`. +- **The `Native` backend masks the broker password in its error lines.** The + URL parse error quotes its input, password included. - **An oversized retained message no longer reconnects the `Embedded` backend forever.** The session receives packets of up to 3,328 bytes (its 3,584-byte buffer minus one read), but its MQTT 5 CONNECT did not say so. A broker diff --git a/aimdb-websocket-connector/CHANGELOG.md b/aimdb-websocket-connector/CHANGELOG.md index ab79aa10..bb110148 100644 --- a/aimdb-websocket-connector/CHANGELOG.md +++ b/aimdb-websocket-connector/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **The server dispatches client writes and pulls broadcasts itself (design + 054).** One broadcast loop pulls from `OutboundRoutes` and feeds the client + bus, caching late-join snapshots on the way; client writes go through an + `InboundDispatch`. `WsBusSink` is removed and `server::Router` is no longer + re-exported. `build()` fails if an outbound route has no record index, + instead of the first publish failing. +- **The client connector builds an `InboundDispatch`** and passes it to + `pump_client`, which drives all outbound routes from one task. + ### Changed (breaking, API) - **Read grants are record-key patterns, not topic patterns.** From 4a587a607df8c2699a3b6ca69cf87d69f8d1f2c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Tue, 6 Oct 2026 19:24:07 +0000 Subject: [PATCH 28/28] docs: drop design references from the changelogs Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 ++-- aimdb-core/CHANGELOG.md | 8 ++++---- aimdb-embassy-adapter/CHANGELOG.md | 4 ++-- aimdb-knx-connector/CHANGELOG.md | 2 +- aimdb-mqtt-connector/CHANGELOG.md | 2 +- aimdb-websocket-connector/CHANGELOG.md | 6 +++--- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d50272bf..04bdce37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added — Design 055: wildcard inbound links +### Added — wildcard inbound links - **One inbound link can feed many topics into one record.** `link_from("mqtt://sensors/{device}/temp")` matches every device; @@ -60,7 +60,7 @@ is unchanged. ([aimdb-core](aimdb-core/CHANGELOG.md)) withheld by the broker instead of reconnecting the client forever. ([aimdb-mqtt-connector](aimdb-mqtt-connector/CHANGELOG.md)) -### Changed (breaking) — Design 054: zero-allocation connector boundary +### Changed (breaking) — zero-allocation connector boundary Each connector's own task now drives both directions; core runs no task per connector or per route, and a steady-state message crosses the boundary diff --git a/aimdb-core/CHANGELOG.md b/aimdb-core/CHANGELOG.md index 1cd6608c..133ef1e6 100644 --- a/aimdb-core/CHANGELOG.md +++ b/aimdb-core/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Topic patterns on inbound links (design 055).** `{name}` captures one +- **Topic patterns on inbound links.** `{name}` captures one level, `{name..}` the rest; the syntax is checked at `build()`, and the connector's `TopicGrammar` compiles each pattern into a `TopicFilter` when it builds. `ExactGrammar` is the grammar for connectors without wildcards. @@ -25,8 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 including patterns a `TopicResolverFn` returns, and reports every link it cannot compile at once. `Router::subscriptions()` lists the filters to subscribe, without those another filter covers. -- **`OutboundRoutes`: one pull for every outbound link of a scheme (design - 054).** `OutboundRoutes::new(db, scheme)` builds a route per link, each with +- **`OutboundRoutes`: one pull for every outbound link of a scheme.** + `OutboundRoutes::new(db, scheme)` builds a route per link, each with its record's reader, topic writer and serializers, and a lock-free ready set that wakes only routes with data. A connector's own task calls `next().await` (or `poll_next`, or `poll_stage` / `take_staged` / `reject` @@ -55,7 +55,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed (breaking, API) -- **Connector SPI: the push pumps are gone (design 054).** Removed +- **Connector SPI: the push pumps are gone.** Removed `session::pump_sink`, `session::pump_source`, `Source`, the `Connector` trait (with `transport::Connector::publish`), `TopicProvider`, `OutboundRoute`, `AimDb::collect_outbound_routes`, `SerializedSource`, diff --git a/aimdb-embassy-adapter/CHANGELOG.md b/aimdb-embassy-adapter/CHANGELOG.md index 2e00597d..8f931a96 100644 --- a/aimdb-embassy-adapter/CHANGELOG.md +++ b/aimdb-embassy-adapter/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed (breaking) -- **`EmbassySinkRaw`, `EmbassySink`, `EmbassySourceRaw` and `EmbassySource`** - (design 054). They force-`Send`ed a `!Send` sink or source so it could ride +- **`EmbassySinkRaw`, `EmbassySink`, `EmbassySourceRaw` and `EmbassySource`**. + They force-`Send`ed a `!Send` sink or source so it could ride core's `pump_sink` / `pump_source`, which no longer exist: a connector now runs its own task and pulls from `OutboundRoutes`. The `into_box_future` helper and `NetStack` remain. diff --git a/aimdb-knx-connector/CHANGELOG.md b/aimdb-knx-connector/CHANGELOG.md index 48d157fa..a8224990 100644 --- a/aimdb-knx-connector/CHANGELOG.md +++ b/aimdb-knx-connector/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed (breaking) - **`KnxConnector::new(binder, delay, url)` no longer takes `&'static - Channels`** (design 054). The connection task dispatches inbound telegrams + Channels`**. The connection task dispatches inbound telegrams into their records and pulls outbound values from their record buffers itself, so no `embassy_sync` queue sits between it and core. `Channels`, `TelegramChannel`, `CommandChannel`, `DEFAULT_QUEUE` and the `N` const diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index 733bd014..5cb52ea4 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **No core pumps (design 054).** Each backend's own task drives both +- **No core pumps.** Each backend's own task drives both directions: it dispatches inbound publishes through an `InboundDispatch` and pulls outbound messages from `OutboundRoutes`. The `Embedded` backends encode packets into one `bbqueue` write ring instead of a `Vec` per packet diff --git a/aimdb-websocket-connector/CHANGELOG.md b/aimdb-websocket-connector/CHANGELOG.md index bb110148..9e84288d 100644 --- a/aimdb-websocket-connector/CHANGELOG.md +++ b/aimdb-websocket-connector/CHANGELOG.md @@ -9,9 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **The server dispatches client writes and pulls broadcasts itself (design - 054).** One broadcast loop pulls from `OutboundRoutes` and feeds the client - bus, caching late-join snapshots on the way; client writes go through an +- **The server dispatches client writes and pulls broadcasts itself.** + One broadcast loop pulls from `OutboundRoutes` and feeds the client bus, + caching late-join snapshots on the way; client writes go through an `InboundDispatch`. `WsBusSink` is removed and `server::Router` is no longer re-exported. `build()` fails if an outbound route has no record index, instead of the first publish failing.