diff --git a/CHANGELOG.md b/CHANGELOG.md index cd7465e3..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,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) — 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/Cargo.lock b/Cargo.lock index 265f74df..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", @@ -264,6 +262,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 +683,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 +947,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..dc085e25 100644 --- a/Makefile +++ b/Makefile @@ -249,10 +249,14 @@ 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" 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" @@ -398,10 +402,14 @@ 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" 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/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/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/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 45778fcd..92888351 100644 --- a/aimdb-bench/benches/b0_alloc_connector.rs +++ b/aimdb-bench/benches/b0_alloc_connector.rs @@ -1,21 +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. +//! 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. //! @@ -31,13 +29,10 @@ 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::connector::{ConnectorBuilder, SerializeError}; +use aimdb_core::{ + AimDb, AimDbBuilder, DbResult, ExactGrammar, InboundDispatch, OutboundRoutes, StringKey, }; -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_mqtt_connector::MqttGrammar; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; @@ -52,19 +47,23 @@ 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), + ("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, @@ -111,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() @@ -197,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) @@ -225,153 +183,198 @@ fn pattern_topics(n: usize, first_device: usize, distinct: bool) -> Vec .collect() } -async fn measure_route() -> (u64, u64) { +/// One exact topic among the decoys. +async fn measure_dispatch() -> (u64, u64) { let db = inbound_db().await; - let ctx = db.runtime_ctx(); - let router = db.inbound_router(SCHEME, &ExactGrammar).unwrap(); + let inbound = InboundDispatch::new(&db, SCHEME, &ExactGrammar).unwrap(); let payload = [1u8; 8]; for _ in 0..WARMUP_ITERS { - router.route("in/target", &payload, &ctx).unwrap(); + inbound.dispatch("in/target", &payload); } reset(); for _ in 0..MEASURE_ITERS { - router - .route("in/target", black_box(&payload), &ctx) - .unwrap(); + inbound.dispatch("in/target", black_box(&payload)); } 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())) - }) +/// Dispatches `warmup` then `measured`, counting only the second. +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); } -} - -/// 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; + for topic in measured { + inbound.dispatch(black_box(topic), black_box(&payload)); } 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, +#[derive(Clone, Copy)] +enum Serializer { + Scratch, + Owned, +} + +#[derive(Clone, Copy)] +enum Topic { + Static, + Written, } -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"); +/// 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(); } -#[derive(Clone, Copy)] -enum Serializer { - Scratch, - Owned, +/// 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_outbound(serializer: Serializer, dynamic_topic: bool) -> (u64, u64) { +async fn measure_next(serializer: Serializer, topic: Topic) -> (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(); + b.configure::("out.record", move |reg| { + outbound_link(reg, serializer, topic) }); }) .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 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)); - io.publish_one(&ctx).await; + pull_one(&mut outbound).await; } reset(); for i in 0..MEASURE_ITERS { producer.produce(reading(i)); - io.publish_one(&ctx).await; + 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() { @@ -381,11 +384,11 @@ fn main() { let measured: Vec<(&str, &str, (u64, u64))> = runtime.block_on(async { vec![ - ("inbound_route", "SpmcRing", measure_route().await), + ("inbound_dispatch", "SpmcRing", measure_dispatch().await), ( - "inbound_route_pattern", + "inbound_dispatch_pattern", "SpmcRing", - measure_pattern_route( + measure_pattern_dispatch( false, &pattern_topics(WARMUP_ITERS, 0, false), &pattern_topics(MEASURE_ITERS, 0, false), @@ -393,9 +396,9 @@ fn main() { .await, ), ( - "inbound_route_keyed_known", + "inbound_dispatch_keyed_known", "SpmcRing", - measure_pattern_route( + measure_pattern_dispatch( true, &pattern_topics(WARMUP_ITERS, 0, false), &pattern_topics(MEASURE_ITERS, 0, false), @@ -403,9 +406,9 @@ fn main() { .await, ), ( - "inbound_route_keyed_new", + "inbound_dispatch_keyed_new", "SpmcRing", - measure_pattern_route( + measure_pattern_dispatch( true, &pattern_topics(WARMUP_ITERS, 0, true), &pattern_topics(MEASURE_ITERS, WARMUP_ITERS, true), @@ -413,24 +416,29 @@ fn main() { .await, ), ( - "inbound_pump_source_minimal", + "outbound_next_static_topic", + "SpmcRing", + measure_next(Serializer::Scratch, Topic::Static).await, + ), + ( + "outbound_next_written_topic", "SpmcRing", - measure_pump_source().await, + measure_next(Serializer::Scratch, Topic::Written).await, ), ( - "outbound_scratch_static_topic", + "outbound_next_owned", "SpmcRing", - measure_outbound(Serializer::Scratch, false).await, + measure_next(Serializer::Owned, Topic::Static).await, ), ( - "outbound_scratch_dynamic_topic", + "outbound_next_round_robin", "SpmcRing", - measure_outbound(Serializer::Scratch, true).await, + measure_next_round_robin().await, ), ( - "outbound_owned_static_topic", + "outbound_next_parked", "SpmcRing", - measure_outbound(Serializer::Owned, false).await, + measure_next_parked().await, ), ] }); 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 new file mode 100644 index 00000000..84498f00 --- /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 publishers `OutboundRoutes` replaced. +//! - **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..21cb1034 100644 --- a/aimdb-bench/data/baselines/b0_alloc_connector.json +++ b/aimdb-bench/data/baselines/b0_alloc_connector.json @@ -1,6 +1,6 @@ [ { - "profile": "inbound_route", + "profile": "inbound_dispatch", "buffer_type": "SpmcRing", "total_allocs": 0, "total_bytes": 0, @@ -9,7 +9,7 @@ "bytes_per_msg": 0.0 }, { - "profile": "inbound_route_pattern", + "profile": "inbound_dispatch_pattern", "buffer_type": "SpmcRing", "total_allocs": 0, "total_bytes": 0, @@ -18,7 +18,7 @@ "bytes_per_msg": 0.0 }, { - "profile": "inbound_route_keyed_known", + "profile": "inbound_dispatch_keyed_known", "buffer_type": "SpmcRing", "total_allocs": 0, "total_bytes": 0, @@ -27,7 +27,7 @@ "bytes_per_msg": 0.0 }, { - "profile": "inbound_route_keyed_new", + "profile": "inbound_dispatch_keyed_new", "buffer_type": "SpmcRing", "total_allocs": 2010, "total_bytes": 373456, @@ -36,39 +36,48 @@ "bytes_per_msg": 186.728 }, { - "profile": "inbound_pump_source_minimal", + "profile": "outbound_next_static_topic", "buffer_type": "SpmcRing", - "total_allocs": 4000, - "total_bytes": 50000, + "total_allocs": 1, + "total_bytes": 64, "batch_size": 2000, - "allocs_per_msg": 2.0, - "bytes_per_msg": 25.0 + "allocs_per_msg": 0.0005, + "bytes_per_msg": 0.032 }, { - "profile": "outbound_scratch_static_topic", + "profile": "outbound_next_written_topic", "buffer_type": "SpmcRing", - "total_allocs": 4001, - "total_bytes": 146064, + "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": 2.0005, - "bytes_per_msg": 73.032 + "allocs_per_msg": 1.0, + "bytes_per_msg": 8.0 }, { - "profile": "outbound_scratch_dynamic_topic", + "profile": "outbound_next_round_robin", "buffer_type": "SpmcRing", - "total_allocs": 6000, - "total_bytes": 162000, + "total_allocs": 1, + "total_bytes": 128, "batch_size": 2000, - "allocs_per_msg": 3.0, - "bytes_per_msg": 81.0 + "allocs_per_msg": 0.0005, + "bytes_per_msg": 0.064 }, { - "profile": "outbound_owned_static_topic", + "profile": "outbound_next_parked", "buffer_type": "SpmcRing", - "total_allocs": 6000, - "total_bytes": 162000, + "total_allocs": 0, + "total_bytes": 0, "batch_size": 2000, - "allocs_per_msg": 3.0, - "bytes_per_msg": 81.0 + "allocs_per_msg": 0.0, + "bytes_per_msg": 0.0 } ] \ No newline at end of file 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-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-codegen/src/rust.rs b/aimdb-codegen/src/rust.rs index bd2c42b7..9c6b1683 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() @@ -502,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( @@ -1339,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( @@ -1411,7 +1400,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 +1445,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-core/CHANGELOG.md b/aimdb-core/CHANGELOG.md index df7f5dd1..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,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.** + `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.** 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-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/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)))); + } +} diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index 127fc89d..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,60 +1242,29 @@ impl AimDb { .name(key) } - /// 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 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; - } - - // 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 { - topic: link.url.resource_id().to_string(), - source: link.create_source(self), - config, - }); - } - } - - if !routes.is_empty() { - log_debug!( - "Collected {} outbound routes for scheme '{}'", - routes.len(), - scheme - ); - } - - routes + /// Every outbound link of `scheme`, with its record's index. + pub(crate) fn outbound_links<'a>( + &'a self, + scheme: &'a str, + ) -> impl Iterator + 'a { + self.inner + .storages + .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() + .iter() + .filter(move |link| link.url.scheme() == scheme) + .map(move |link| (i, link)) + }) } } 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 1364cd3f..ba0ffecf 100644 --- a/aimdb-core/src/connector.rs +++ b/aimdb-core/src/connector.rs @@ -82,169 +82,131 @@ impl std::fmt::Display for SerializeError { #[cfg(feature = "std")] impl std::error::Error for SerializeError {} -/// One serialized record update, produced by a fused [`SerializedReader`] +// ============================================================================ +// 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 /// -/// 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. +/// ```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`. /// - /// `Vec` requires heap allocation; works on `std` and - /// `no_std + alloc` (not bare-metal without an allocator). - pub payload: Vec, + /// `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; } -/// 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), +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) + } } -/// 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, +/// 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 + } } -/// Type alias for the future returned by [`SerializedReader::recv`] +/// Bounded topic storage handed to a [`TopicWriter`]. /// -/// 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 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, } -/// 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 +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, + } } - /// Subscribe to the record's updates. - /// - /// Synchronous and infallible — the buffer handle is pre-resolved at - /// construction. - fn subscribe(&self) -> Box; -} + /// 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) + } + } + } -/// 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>; + /// Bytes written so far. + pub fn len(&self) -> usize { + self.len + } -// ============================================================================ -// TopicProvider - Dynamic topic/destination routing -// ============================================================================ + /// Whether nothing has been written. + pub fn is_empty(&self) -> bool { + self.len == 0 + } -/// 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; + /// 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`). @@ -444,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`) @@ -457,17 +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`. + pub(crate) route_factory: crate::outbound::RouteFactoryFn, } impl Debug for ConnectorLink { @@ -475,30 +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, } } - - /// 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 @@ -749,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()) /// }) /// } @@ -764,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 @@ -796,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 @@ -929,70 +871,50 @@ mod tests { } // ======================================================================== - // TopicProvider Tests + // TopicBuf 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 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 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()) - ); + 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 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)) - } - } + 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 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); + 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) + ); } // ======================================================================== diff --git a/aimdb-core/src/inbound_dispatch.rs b/aimdb-core/src/inbound_dispatch.rs new file mode 100644 index 00000000..6fa6519a --- /dev/null +++ b/aimdb-core/src/inbound_dispatch.rs @@ -0,0 +1,64 @@ +//! 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::router::Router; +use crate::{AimDb, DbResult, 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(), + }) + } + + /// 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. + /// + /// 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..b5ab18a2 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -85,13 +85,15 @@ mod error; pub mod executor; pub mod extensions; pub mod graph; +mod inbound_dispatch; mod inbound_key; +mod outbound; #[cfg(feature = "observability")] 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; @@ -115,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, @@ -140,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`) @@ -157,17 +157,16 @@ 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}; -pub use connector::{ - SerializedPayload, SerializedReader, SerializedSource, SerializedValue, SerializedValueInto, - SourceFactoryFn, -}; +pub use connector::{TopicBuf, TopicOverflow, TopicWriter}; // Router exports for connector implementations -pub use router::Router; +pub use inbound_dispatch::InboundDispatch; +pub use outbound::{ + OutboundMessage, OutboundPayload, OutboundRoutes, RouteId, RouteInfo, RouteStats, +}; // Topic grammar for connectors with wildcard subscriptions pub use topic_pattern::{ diff --git a/aimdb-core/src/outbound/mod.rs b/aimdb-core/src/outbound/mod.rs new file mode 100644 index 00000000..4274b90e --- /dev/null +++ b/aimdb-core/src/outbound/mod.rs @@ -0,0 +1,13 @@ +//! Outbound path: connectors pull serialized messages from their routes. + +mod ready; +mod routes; + +pub use routes::{OutboundMessage, OutboundPayload, OutboundRoutes, RouteInfo, RouteStats}; +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. +pub type RouteId = usize; diff --git a/aimdb-core/src/outbound/ready.rs b/aimdb-core/src/outbound/ready.rs new file mode 100644 index 00000000..e163463e --- /dev/null +++ b/aimdb-core/src/outbound/ready.rs @@ -0,0 +1,834 @@ +//! 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. +//! +//! [`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. +//! +//! 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; +use alloc::sync::Arc; +use alloc::task::Wake; +use core::sync::atomic::Ordering; +use core::task::{Context, Poll, Waker}; + +use futures_util::task::AtomicWaker; +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 { + /// 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, up to [`SKIP_BUDGET`] times in a row. + 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 { + /// One bit per route: woken since it was last taken. + ready: Box<[AtomicU32]>, + /// The transport task. + 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) { + self.ready[id / 32].fetch_or(bit(id), Ordering::Release); + } + + fn clear(&self, id: RouteId) { + self.ready[id / 32].fetch_and(!bit(id), Ordering::Acquire); + } +} + +struct RouteWake { + id: RouteId, + shared: Arc, +} + +impl Wake for RouteWake { + fn wake(self: Arc) { + 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(); + } +} + +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. +/// +/// 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: + /// `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 { + // 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(), + }); + let wakers = (0..len) + .map(|id| { + Waker::from(Arc::new(RouteWake { + id, + shared: shared.clone(), + })) + }) + .collect(); + Self { + shared, + wakers, + open, + open_count: len, + // The first pass starts after the cursor, at route 0. + cursor: len.saturating_sub(1), + } + } + + /// 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. 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. + 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]); + let mut skips = 0; + 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, 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; + } + } + } + } + if self.open_count == 0 { + Poll::Ready(None) + } else { + Poll::Pending + } + } + + fn len(&self) -> usize { + self.wakers.len() + } + + /// Start a pass at the route after the one served last. + 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 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. + fn take_next(&self, pass: &mut Pass) -> Option { + let len = self.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(pass.next - len, pass.start) + }?; + pass.next = if id >= pass.start { id } else { id + len } + 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 mut bits = self.shared.ready[w].load(Ordering::Acquire) & self.open[w]; + 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` 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. + fn served(&mut self, id: RouteId) { + self.shared.set(id); + self.cursor = id; + } + + /// Route `id`'s buffer closed; it is never taken again. + 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; + } + } + + /// Some open route woke and has not been taken. + #[cfg(test)] + 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::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() + } + + 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, + 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), + routes: (0..len).map(|_| Route::default()).collect(), + task: Arc::default(), + sent: Vec::new(), + budget_spent: false, + inject: None, + } + } + + fn write(&mut self, id: RouteId, v: u32) { + self.routes[id].write(Value::Good(v)); + } + + 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, + } + } + + fn drain(&mut self) -> Vec { + core::iter::from_fn(|| self.next()).collect() + } + + fn polls(&self) -> Vec { + self.routes.iter().map(|r| r.polls).collect() + } + + 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.write(id, v); + } + } + 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.write(0, v); + } + 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.write(1, 1); + assert_eq!(sim.next(), Some(1)); + } + + #[test] + fn a_write_wakes_the_task_and_is_served() { + let mut sim = Sim::new(3); + assert!(sim.drain().is_empty()); + let wakes = sim.wakes(); + + 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 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_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 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); + 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 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!(take_all(&ready), (0..len).collect::>(), "len {len}"); + assert!(!ready.any_ready()); + + let mut woken: Vec<_> = [0, 31, 32, len - 1] + .into_iter() + .filter(|&id| id < len) + .collect(); + woken.dedup(); + for &id in &woken { + ready.wakers[id].wake_by_ref(); + } + 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.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 + /// polls them: nothing is lost, and the task never sleeps through a + /// wake-up. + #[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; + 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 = ready.wakers.to_vec(); + 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 mut total = 0; + while total < TOTAL { + let polled = ready.poll_ready(&mut cx, |id, _| { + let n = pending[id].swap(0, Ordering::Acquire); + total += n; + if n > 0 { + Polled::Staged + } else { + Polled::Pending + } + }); + 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, TOTAL); + } +} diff --git a/aimdb-core/src/outbound/routes.rs b/aimdb-core/src/outbound/routes.rs new file mode 100644 index 00000000..2d19a355 --- /dev/null +++ b/aimdb-core/src/outbound/routes.rs @@ -0,0 +1,397 @@ +//! [`OutboundRoutes`]: every outbound link of one scheme, pulled by the +//! connector's transport task. + +use alloc::boxed::Box; +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::connector::SerializeError; +use crate::transport::ConnectorConfig; +use crate::{AimDb, 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), +} + +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 { + /// 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. + 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. + SerializeFailed(SerializeFailure), + /// The buffer is gone. + Closed(DbError), +} + +/// Where a staged payload is. +pub(crate) enum StagedPayload { + /// The first `len` bytes of the payload scratch. + Scratch(usize), + 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 { + /// 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, +} + +/// 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, +} + +// Moved into the connector's `Send` transport task. +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. + pub fn new(db: &AimDb, scheme: &str) -> DbResult { + let mut routes = Vec::new(); + let mut states = Vec::new(); + + 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 { + id: routes.len(), + default_topic: Arc::from(link.url.resource_id()), + config, + topic_capacity: parts.topic_capacity, + payload_capacity: parts.payload_capacity, + }); + states.push(parts.route); + } + + 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(_failure)) => { + stats.serialize_failed += 1; + log_error!( + "outbound route '{}': {}, value skipped", + info.default_topic, + _failure + ); + Polled::Skipped + } + Poll::Ready(RouteOutcome::Closed(_e)) => { + log_info!("outbound route '{}' closed: {:?}", info.default_topic, _e); + 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, + }) + } + + /// 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>> { + 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() + } +} + +#[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"); + } + + #[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-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/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/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-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 e677ca3a..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. @@ -297,9 +297,12 @@ 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 +/// 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< @@ -308,157 +311,105 @@ type FusedSerializeIntoFn = Arc< + Sync, >; -/// The [`SerializedSource`](crate::connector::SerializedSource) built by -/// `OutboundConnectorBuilder::finish()` — holds the typed consumer, -/// serializer, and optional topic provider, so every per-message step stays -/// typed (no `Box`). -struct FusedSource { - consumer: Consumer, - serialize: FusedSerializeFn, - serialize_into: Option<(usize, FusedSerializeIntoFn)>, - topic: Option>>, +/// 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. +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), + } } -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 { - 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(), - }) +/// 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. +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)), } } -/// 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, +/// 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>, - topic: Option>>, } -impl crate::connector::SerializedReader for FusedReader { - fn recv<'a>( - &'a mut self, - ctx: &'a crate::RuntimeContext, - ) -> crate::connector::RecvSerializedFuture<'a> { - Box::pin(async move { - loop { - // Buffer errors propagate unchanged: `BufferLagged` lets the - // pump skip the gap and keep going; anything else ends it. - let value = self.inner.recv().await?; - // Resolve the destination while the typed value is in hand. - let dest = self.topic.as_ref().and_then(|p| p.topic(&value)); - match (self.serialize)(ctx, &value) { - Ok(payload) => return Ok(crate::connector::SerializedValue { dest, payload }), - Err(_e) => { - // Same skip-and-log the pumps used to do around the - // erased serializer. - log_error!( - "outbound link: failed to serialize {} (dest {:?}): {:?}", - core::any::type_name::(), - dest, - _e - ); - continue; - } - } +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; + 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)); } - }) - } - - fn recv_into<'a>( - &'a mut self, - ctx: &'a crate::RuntimeContext, - scratch: &'a mut [u8], - ) -> crate::connector::RecvSerializedIntoFuture<'a> { - Box::pin(async move { - loop { - let value = self.inner.recv().await?; - let dest = self.topic.as_ref().and_then(|p| p.topic(&value)); - - 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; - } - } - }; + Poll::Ready(Err(e)) => return Poll::Ready(RouteOutcome::Closed(e)), + }; - 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) => { - log_error!( - "outbound link: failed to serialize {} into scratch buffer (dest {:?}): {:?}", - core::any::type_name::(), - dest, - _e - ); - continue; - } + let topic_len = match &self.writer { + None => None, + Some(writer) => { + let mut out = crate::connector::TopicBuf::new(topic); + match write_topic(&**writer, &value, &mut out) { + Ok(written) => written.then_some(out.len()), + Err(_) => return Poll::Ready(RouteOutcome::TopicOverflow), } } + }; + + 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), }) } } @@ -742,7 +693,7 @@ where config: Vec::new(), context_serializer: None, context_serializer_into: None, - topic_provider: None, + topic_writer: None, } } @@ -776,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_provider: Option>>, + topic_writer: Option>, } impl<'r, 'a, T> OutboundConnectorBuilder<'r, 'a, T> @@ -856,26 +807,42 @@ 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 + /// Sets a [`TopicWriter`](crate::connector::TopicWriter) that writes each + /// value's destination. /// - /// 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 + /// `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 - P: crate::connector::TopicProvider + 'static, + W: crate::connector::TopicWriter + 'static, { - // Stays typed: fused into the link's SerializedSource at finish(). - self.topic_provider = Some(Arc::new(provider)); + self.topic_writer = Some((capacity, 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 @@ -969,38 +936,32 @@ where self.registrar.last_stage = Some((StageKind::Link, 0)); } - // Fused source factory that captures type T and record key. - // - // 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. + // 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`. // - // 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_provider = self.topic_provider; Arc::new(move |db: &AimDb| { let typed_rec = db .inner() .get_typed_record_by_key::(&record_key) .unwrap_or_else(|e| { panic!( - "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" ) }); @@ -1009,20 +970,35 @@ where let mut consumer = Consumer::::new(buffer); #[cfg(feature = "observability")] consumer.set_profiling(link_metrics.clone(), db.profiling_clock().clone()); - Box::new(FusedSource { - consumer, - serialize: serialize.clone(), - serialize_into: serialize_into.clone(), - topic: topic_provider.clone(), - }) as Box + consumer + }) + }; + + // Subscribed when `OutboundRoutes` is built. + let route_factory: crate::outbound::RouteFactoryFn = { + let topic_writer = self.topic_writer; + Arc::new(move |db: &AimDb| { + let (topic_capacity, writer) = match &topic_writer { + Some((capacity, writer)) => (*capacity, Some(writer.clone())), + None => (0, None), + }; + 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), + } }) }; - let mut link = ConnectorLink::new(url, source_factory); + let mut link = ConnectorLink::new(url, route_factory); link.config = self.config; - // 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 } @@ -1292,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"))] @@ -1992,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, @@ -2042,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; @@ -2079,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| { @@ -2101,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]; @@ -2135,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 })) @@ -2161,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'")); @@ -2169,41 +2143,43 @@ 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")); } - #[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 }) - } - } + async fn inbound_dispatch_clones_share_records() { + fn assert_send_sync() {} + assert_send_sync::(); - let (db, last, _) = inbound_db(|reg| { - reg.link_from("mqtt://temp/{d}") - .key("d", 2) - .with_match_deserializer(key_deser) + 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 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); + + 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); } // ==================================================================== - // Fused outbound reader tests + // OutboundRoutes over typed links // ==================================================================== /// Buffer reader that replays a fixed script, then reports the buffer @@ -2212,319 +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: Option>>, - ) -> FusedReader { - FusedReader { - inner: crate::buffer::Reader::new(Box::new(ScriptedReader { script })), - serialize, - serialize_into: None, - topic, - } + 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: None, - } + 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())), - 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()) - } - }), - 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"); + 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; - assert_eq!(msg.payload, SerializedPayload::Scratch { len: 4 }); - assert_eq!(&scratch[..4], 2i32.to_le_bytes().as_slice()); + assert_eq!(pull(&mut o).await, Some(("tele/out".into(), 3, false))); + assert_eq!(o.stats(0).unwrap().serialize_failed, 2); } + /// `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_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"); + async fn with_topic_fn_writes_the_topic_or_the_default() { + use core::fmt::Write as _; + 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; - assert_eq!(msg.payload, SerializedPayload::Scratch { len: 4 }); - assert_eq!(&scratch[..4], 2i32.to_le_bytes().as_slice()); + 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())); } - /// The destination is resolved from the typed value while it is in hand. + /// A topic that overflows skips its value, even when the writer ignores + /// the error and returns `Ok(true)`. #[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())), - Some(Arc::new(PositiveTopic)), - ); - let ctx = test_ctx(); - - let first = reader.recv(&ctx).await.expect("value"); - assert_eq!(first.dest.as_deref(), Some("dyn/5")); + async fn overflowing_topics_are_skipped() { + use core::fmt::Write as _; + 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; - let second = reader.recv(&ctx).await.expect("value"); - assert_eq!(second.dest, None); // falls back to the route default + // "t/123456" (8 bytes) and "t/1234567" (9 bytes) do not fit in 6. + 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/CHANGELOG.md b/aimdb-embassy-adapter/CHANGELOG.md index bd8fdbe0..8f931a96 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`**. + 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-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-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-knx-connector/CHANGELOG.md b/aimdb-knx-connector/CHANGELOG.md index bd995f96..a8224990 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`**. 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-knx-connector/Cargo.toml b/aimdb-knx-connector/Cargo.toml index 34863fd4..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` 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. connector = [ "aimdb-core/alloc", - "aimdb-core/connector-session", # `pump_sink`/`pump_source`/`Source`/`Payload` - "embassy-sync", + "aimdb-core/connector-session", # `DatagramBinder`/`Datagram`/`Delay` ] # Orthogonal to `connector`: the code is `alloc`-only either way, so this only @@ -41,21 +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`. 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 -# 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. @@ -73,28 +57,17 @@ 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 } [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..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"] } @@ -45,7 +41,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 +51,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 +61,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 +86,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 +100,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..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, which `Channels` needs to link on a host. -//! 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 //! @@ -48,7 +46,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 +60,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 +96,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 +177,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..c976ed45 --- /dev/null +++ b/aimdb-knx-connector/tests/topic_writer_tests.rs @@ -0,0 +1,371 @@ +//! 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()); +} + +#[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/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index 5d499e8d..5cb52ea4 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.** 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-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 187c709d..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", @@ -49,6 +48,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 +158,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/connector.rs b/aimdb-mqtt-connector/src/connector.rs index ccf9ac59..a6b56fde 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -39,9 +39,15 @@ 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, + pub(crate) write_buffer: usize, } /// The `mountain-mqtt` backend over `embedded-tls`, on the same @@ -50,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`. @@ -85,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, + }, } } @@ -105,6 +115,7 @@ impl MqttConnector { backend: EmbeddedTls { dialer, options: crate::embedded::TlsSlot::new(options), + write_buffer: crate::embedded::DEFAULT_WRITE_BUFFER, }, } } @@ -138,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(); @@ -223,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 206b7425..ec0259f7 100644 --- a/aimdb-mqtt-connector/src/embedded/manager.rs +++ b/aimdb-mqtt-connector/src/embedded/manager.rs @@ -1,35 +1,18 @@ -//! Session cadence and the channels a session talks over. +//! Session cadence and the reasons a session ends. //! -//! Channels use `CriticalSectionRawMutex`, so they are `Sync` and the sink and -//! source need 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, EventHandlerError}; -use mountain_mqtt::packets::publish::ApplicationMessage; - -/// The event channel: broker session to `pump_source`. -pub(crate) type EventChannel = Channel; - -/// The action channel: `pump_sink` to broker session. -pub(crate) type ActionChannel = Channel; +use mountain_mqtt::client::ClientError; /// Monotonic milliseconds. Only differences are meaningful. 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 1201b07d..ef958e89 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. +//! 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. @@ -13,6 +14,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")] @@ -23,8 +25,6 @@ pub mod tls; extern crate alloc; use aimdb_core::connector::ConnectorUrl; -use aimdb_core::session::{pump_sink, pump_source, Payload}; -use aimdb_core::transport::{ConnectorConfig, PublishError}; use alloc::boxed::Box; use alloc::format; use alloc::string::{String, ToString}; @@ -37,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 and events -pub(crate) const CHANNEL_SIZE: usize = 32; - /// Buffer size for MQTT packets (4KB) pub(crate) const BUFFER_SIZE: usize = 4096; @@ -60,126 +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 two channel ends the pumps ride, -/// plus the tasks that serve them. -type ManagerSetup = (Arc, 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. -/// -/// 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, - }, -} - -/// 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. -// =========================================================================== - -/// 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(()) - }) - } -} - -/// 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. @@ -194,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 @@ -204,25 +86,30 @@ where + 'static, { Box::pin(async move { - let router = db.inbound_router("mqtt", &crate::MqttGrammar)?; - let topics = inbound_topics(&router); - warn_unsupported_qos(db); + let inbound = aimdb_core::InboundDispatch::new(db, "mqtt", &crate::MqttGrammar)?; + let topics = inbound_topics(&inbound); 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, events, manager_tasks) = setup_manager( + setup_manager( &broker, connection_settings, dialer.clone(), topics, - Settings::from_keep_alive_secs(keep_alive_secs), + inbound, + outbound, + opts, + write_buffer, + settings, db.runtime_ops(), - )?; - Ok(collect_pumps(db, router, actions, events, manager_tasks)) + ) }) } @@ -245,9 +132,8 @@ where + 'static, { Box::pin(async move { - let router = db.inbound_router("mqtt", &crate::MqttGrammar)?; - let topics = inbound_topics(&router); - warn_unsupported_qos(db); + let inbound = aimdb_core::InboundDispatch::new(db, "mqtt", &crate::MqttGrammar)?; + let topics = inbound_topics(&inbound); let broker = parse_broker_url(broker_url)?; if !broker.tls { return Err(build_err(".tls(...) requires an mqtts:// broker URL")); @@ -258,23 +144,34 @@ 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, events, manager_tasks) = setup_tls_manager( + setup_tls_manager( &broker, options, connection_settings, backend.dialer.clone(), topics, - Settings::from_keep_alive_secs(keep_alive_secs), + inbound, + outbound, + opts, + backend.write_buffer, + settings, db.runtime_ops(), - )?; - Ok(collect_pumps(db, router, actions, events, 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()) @@ -286,19 +183,76 @@ fn inbound_topics(router: &aimdb_core::Router) -> Vec { topics } -/// Outbound publishes and inbound routing ride core's pumps; the session tasks -/// 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, - 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 + 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. @@ -382,17 +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 @@ -401,20 +359,16 @@ where + Sync + '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(); 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(); - let events = events.clone(); async move { #[cfg(feature = "defmt")] defmt::info!("MQTT background task starting"); @@ -426,8 +380,10 @@ where topics, connection_settings, settings, - events, - actions, + inbound, + outbound, + opts, + write_buffer, runtime, ) .await @@ -435,21 +391,26 @@ where }) }); - Ok((actions, events, alloc::vec![manager_task])) + Ok(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, + 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 @@ -485,9 +446,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; #[cfg(feature = "embassy-tls")] @@ -495,13 +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(); - let events = events.clone(); async move { #[cfg(feature = "defmt")] defmt::info!("MQTT-TLS background task starting"); @@ -516,8 +472,10 @@ where topics, connection_settings, settings, - events, - actions, + inbound, + outbound, + opts, + write_buffer, delay, runtime, ) @@ -538,86 +496,5 @@ where })); } - Ok((actions, events, 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/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 2adc9f63..b41bec41 100644 --- a/aimdb-mqtt-connector/src/embedded/session.rs +++ b/aimdb-mqtt-connector/src/embedded/session.rs @@ -53,8 +53,10 @@ 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, - actions: alloc::sync::Arc, + inbound: aimdb_core::InboundDispatch, + mut outbound: aimdb_core::OutboundRoutes, + opts: alloc::vec::Vec, + write_buffer: usize, runtime: alloc::sync::Arc, ) -> ! where @@ -64,6 +66,7 @@ where use mountain_mqtt::data::quality_of_service::QualityOfService; use crate::embedded::session_loop::run_session; + 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 @@ -71,6 +74,9 @@ where .map(|topic| (topic.as_str(), QualityOfService::Qos1)) .collect(); + // Allocated once and reused by every session. + let ring = WriteRing::new(write_buffer); + loop { let mut stream = match dialer.connect(&host, port).await { Ok(stream) => stream, @@ -90,8 +96,10 @@ where tx, &connection_settings, &subscribe_topics, - &events, - &actions, + &inbound, + &mut outbound, + &opts, + &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 2533670d..b8708a9c 100644 --- a/aimdb-mqtt-connector/src/embedded/session_loop.rs +++ b/aimdb-mqtt-connector/src/embedded/session_loop.rs @@ -5,47 +5,53 @@ //! 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 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::codec::mqtt_writer::{MqttBufWriter, MqttLenWriter, MqttWriter}; -use mountain_mqtt::codec::write::Write; -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, FromApplicationMessage, Settings}; +use crate::embedded::manager::{now_ms, Error, Settings}; use crate::embedded::packet_reader::PacketReader; -use crate::embedded::{ - ActionChannel, AimdbMqttAction, AimdbMqttEvent, EventChannel, BUFFER_SIZE, MAX_PROPERTIES, -}; +use crate::embedded::write_ring::{encoded_len, WriteRing, CONTROL_RESERVE}; +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; /// 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 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 /// buffer minus one feed chunk (see [`PacketReader`]). Advertised as the /// CONNECT's Maximum Packet Size, so a broker never sends a larger packet — @@ -58,20 +64,28 @@ 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 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, tx: W, connection_settings: &ConnectionSettings<'static>, subscribe_topics: &[(&str, QualityOfService)], - events: &EventChannel, - actions: &ActionChannel, + dispatch: &InboundDispatch, + outbound: &mut OutboundRoutes, + opts: &[PublishOpts], + ring: &WriteRing, settings: &Settings, delay: &D, runtime: &dyn RuntimeOps, @@ -82,23 +96,22 @@ 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, - actions, + dispatch, + outbound, + opts, settings, delay, 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, @@ -129,20 +142,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)) } @@ -151,11 +150,12 @@ 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, - actions: &ActionChannel, + dispatch: &InboundDispatch, + outbound: &mut OutboundRoutes, + opts: &[PublishOpts], settings: &Settings, delay: &D, runtime: &dyn RuntimeOps, @@ -176,30 +176,19 @@ 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)?; - queue(outbound, encode(&connect)?).await; + ring.put(&connect, 0).await?; } loop { @@ -222,9 +211,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 @@ -232,7 +228,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 @@ -244,22 +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 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 + // one in-flight slot), every subscription placed, and room in the ring + // 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() - && !outbound.is_full(); - let action_arm = async { - if !action_ready { - core::future::pending::<()>().await; + && next_topic >= subscribe_topics.len(); + let publish_room = ring.max_publish() + CONTROL_RESERVE; + let publish_arm = poll_fn(|cx| { + if !publish_ready || !ring.poll_room(publish_room, cx.waker()) { + return Poll::Pending; } - actions.receive().await - }; + match outbound.poll_stage(cx) { + Poll::Ready(Some(id)) => Poll::Ready(id), + Poll::Ready(None) => { + outbound_done = true; + Poll::Pending + } + Poll::Pending => Poll::Pending, + } + }); let sleep_for = Duration::from_millis(next_deadline( now, @@ -269,22 +275,23 @@ 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( &mut reader, &mut state, - outbound, - events, + ring, + dispatch, runtime, &mut last_ack_ms, &mut connected, + &mut broker_max, ) .await?; } - Either3::Second(action) => { - perform(action, &mut state, outbound).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(()) => {} @@ -298,43 +305,58 @@ 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, - outbound: &Channel, 4>, - events: &EventChannel, + ring: &WriteRing, + dispatch: &InboundDispatch, 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)? { - // 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) = { + // 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. + if reader.head_needs_ack() { + ring.wait_room(PUBACK_ROOM).await; + } + + // 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)?; + 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. - 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)?) - }; - reader.consume(total); - - if let Some(bytes) = response { - queue(outbound, bytes).await; + deliver(event, dispatch)?; } + reader.consume(total); // Every packet the state accepted proves the broker is alive. *last_ack_ms = now_ms(runtime); @@ -344,157 +366,176 @@ 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. +/// 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, - outbound: &Channel, 4>, + 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)?; - queue(outbound, encode(&packet)?).await; + 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)?; - queue(outbound, encode(&packet)?).await; - state.subscribe_update(&packet).map_err(client_error)?; - } + }; + if rejected { + outbound.reject(id); } 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) +/// 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, + ) } -/// 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; +/// 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) } -/// 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"); - } +/// 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. @@ -519,13 +560,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. @@ -577,11 +615,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"); + 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( @@ -589,8 +634,10 @@ mod tests { NullWrite, &connection_settings, &[], - &events, - &actions, + &dispatch, + &mut outbound, + &[], + &ring, &settings, &NullDelay, &runtime, @@ -731,9 +778,218 @@ mod tests { .any(|r| r == Err(PacketReadError::PacketTooLargeForBuffer))); } + #[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: 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 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() + } + + /// 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" + } + } + + /// 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) + } + + 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 (_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); + } + + #[tokio::test] + async fn a_1985_byte_publish_is_skipped_and_counted() { + let ring = WriteRing::new(DEFAULT_WRITE_BUFFER); + let mut state = connected(); + 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"); + 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. + 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..c2c58f3e 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; use mountain_mqtt::client::ConnectionSettings; use mountain_mqtt::data::quality_of_service::QualityOfService; @@ -320,8 +321,10 @@ pub(crate) async fn run_tls( topics: Vec, connection_settings: ConnectionSettings<'static>, settings: Settings, - events: Arc, - actions: Arc, + inbound: aimdb_core::InboundDispatch, + mut outbound: aimdb_core::OutboundRoutes, + opts: Vec, + write_buffer: usize, delay: D, runtime: Arc, ) -> ! @@ -343,6 +346,9 @@ where .map(|topic| (topic.as_str(), QualityOfService::Qos1)) .collect(); + // Allocated once and reused by every session. + let ring = WriteRing::new(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` @@ -408,8 +414,10 @@ where TlsWrite(tls_tx), &connection_settings, &subscribe_topics, - &events, - &actions, + &inbound, + &mut outbound, + &opts, + &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..d94fe078 --- /dev/null +++ b/aimdb-mqtt-connector/src/embedded/write_ring.rs @@ -0,0 +1,459 @@ +//! 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 { + fits_ring(self.queue.capacity(), len, reserve) + } + + /// 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(); + } + } +} + +/// 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(); + 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/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index b5d1389f..e9291287 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(any(feature = "std", 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/native.rs b/aimdb-mqtt-connector/src/native.rs index f45ad04f..efebf760 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, redacted(broker_url))), + 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(); @@ -90,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(|| { @@ -169,14 +172,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,113 +186,93 @@ 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()) +/// `_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; + } + } } } -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(()) - }) +/// 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"); } -/// 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; - } - } - } - }) +/// `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(), } } @@ -393,6 +375,109 @@ 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"); + } + } + + #[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() { 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/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)); + } +} 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/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 3acbfccb..1295ad27 100644 --- a/aimdb-mqtt-connector/tests/tokio_broker.rs +++ b/aimdb-mqtt-connector/tests/tokio_broker.rs @@ -349,3 +349,162 @@ 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"); +} + +// --------------------------------------------------------------------------- +// 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/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..3f5e8b38 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,117 +386,125 @@ 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 } /// 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-mqtt-connector/tests/write_ring_proofs.rs b/aimdb-mqtt-connector/tests/write_ring_proofs.rs new file mode 100644 index 00000000..ea9ced11 --- /dev/null +++ b/aimdb-mqtt-connector/tests/write_ring_proofs.rs @@ -0,0 +1,114 @@ +//! The embedded backend's packet-size behaviour against a fake 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}; +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. 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 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())); + + 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"); +} diff --git a/aimdb-tokio-adapter/tests/outbound_routes.rs b/aimdb-tokio-adapter/tests/outbound_routes.rs new file mode 100644 index 00000000..ae4b5906 --- /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}; +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?; + Some((m.route.id, m.topic.to_string(), m.payload.into_vec())) +} + +/// 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 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; + 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(); + got.push(value(m.payload.as_slice())); + } + } + } + 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 route_info_carries_topic_config_and_record_index() { + 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") + .with_config("qos", "1") + .with_serializer(le) + .finish(); + }), + ]) + .await; + let o = OutboundRoutes::new(&db, "test").unwrap(); + 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] +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()); +} + +#[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)); +} diff --git a/aimdb-websocket-connector/CHANGELOG.md b/aimdb-websocket-connector/CHANGELOG.md index ab79aa10..9e84288d 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.** + 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.** 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) }) 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..2727200a 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) } } @@ -366,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![], @@ -452,13 +468,13 @@ 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 .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")) }) @@ -738,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 dd49ba68..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 @@ -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")) }) @@ -878,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/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 + 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/docs/design/054-zero-alloc-connector-boundary.md b/docs/design/054-zero-alloc-connector-boundary.md index 89fdda0d..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,13 +333,35 @@ 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. -- `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, 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. -- **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. @@ -511,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 − @@ -990,6 +1017,26 @@ 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. 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 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/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` 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", ] } 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));