Skip to content

Commit b5945a8

Browse files
committed
feat: add no_std support for transform API and update dependencies
1 parent 80e45b0 commit b5945a8

7 files changed

Lines changed: 144 additions & 23 deletions

File tree

aimdb-core/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
1717
#![cfg_attr(not(feature = "std"), no_std)]
1818

19+
#[cfg(feature = "alloc")]
20+
extern crate alloc;
21+
1922
pub mod buffer;
2023
pub mod builder;
2124
pub mod connector;

aimdb-core/src/transform/join.rs

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ use core::any::Any;
22
use core::fmt::Debug;
33
use core::marker::PhantomData;
44

5-
extern crate alloc;
65
use alloc::{
76
boxed::Box,
87
string::{String, ToString},
@@ -85,6 +84,16 @@ impl JoinEventRx {
8584
/// Receive the next trigger event.
8685
///
8786
/// Returns `Ok(JoinTrigger)` when an input fires, or `Err` when all inputs are closed.
87+
///
88+
/// # Runtime portability
89+
///
90+
/// On Tokio and WASM, the channel closes once every input forwarder has
91+
/// dropped its sender, and `recv` returns `Err`, ending any
92+
/// `while let Ok(_) = rx.recv().await` loop.
93+
///
94+
/// On Embassy the channel **never** closes — this branch is unreachable
95+
/// and the loop runs for the device lifetime. Portable handlers should
96+
/// not rely on the loop exiting to release resources.
8897
pub async fn recv(&mut self) -> ExecutorResult<JoinTrigger> {
8998
self.inner.recv_boxed().await
9099
}
@@ -289,11 +298,17 @@ async fn run_join_transform<O, R, F, Fut>(
289298
output_key
290299
);
291300

292-
let runtime: &R = runtime_ctx
293-
.downcast_ref::<Arc<R>>()
294-
.map(|arc| arc.as_ref())
295-
.or_else(|| runtime_ctx.downcast_ref::<R>())
296-
.expect("Failed to extract runtime from context for join transform");
301+
let runtime: &R = match runtime_ctx.downcast_ref::<R>() {
302+
Some(r) => r,
303+
None => {
304+
#[cfg(feature = "tracing")]
305+
tracing::error!(
306+
"🔄 Join transform '{}' FATAL: runtime context downcast failed",
307+
output_key
308+
);
309+
return;
310+
}
311+
};
297312

298313
let queue = match runtime.create_join_queue::<JoinTrigger>() {
299314
Ok(q) => q,

aimdb-core/src/transform/mod.rs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
use core::any::Any;
1414
use core::fmt::Debug;
1515

16-
extern crate alloc;
1716
use alloc::{boxed::Box, string::String, sync::Arc, vec::Vec};
1817

1918
use crate::typed_record::BoxFuture;
@@ -27,10 +26,6 @@ pub use single::{StatefulTransformBuilder, TransformBuilder, TransformPipeline};
2726
#[cfg(feature = "alloc")]
2827
pub use join::{JoinBuilder, JoinEventRx, JoinPipeline, JoinTrigger};
2928

30-
// JoinTrigger is always available (no std dependency)
31-
#[cfg(not(feature = "alloc"))]
32-
pub use join::JoinTrigger;
33-
3429
// ============================================================================
3530
// TransformDescriptor — stored per output record in TypedRecord
3631
// ============================================================================

aimdb-core/src/transform/single.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
use core::fmt::Debug;
22
use core::marker::PhantomData;
33

4-
extern crate alloc;
54
use alloc::{
65
boxed::Box,
76
string::{String, ToString},

aimdb-embassy-adapter/src/join_queue.rs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,19 @@ impl JoinFanInRuntime for EmbassyAdapter {
101101
// These tests cover: roundtrip ordering, bounded backpressure, and sender cloning.
102102
// Embassy channels do not close — there are no QueueClosed scenarios to test.
103103
//
104-
// NOTE: these tests require the ARM embedded target. They compile as part of
105-
// `cargo check --target thumbv7em-none-eabihf --features embassy-runtime` but
106-
// cannot run on x86_64 because the workspace `embassy-executor` uses
107-
// `platform-cortex-m` (ARM assembly). Run them on an Embassy-capable board or
108-
// ARM simulator. The `critical-section` dev-dep with `std` feature satisfies
109-
// the CriticalSectionRawMutex requirement for the channel on the target.
104+
// NOTE: the tests themselves only depend on `embassy_sync::Channel` and
105+
// `futures::executor::block_on`, both of which are host-portable. The
106+
// `critical-section` dev-dep with `std` feature is provided so the
107+
// `CriticalSectionRawMutex` link target is satisfied on host.
108+
//
109+
// However, the tests live in a module gated on `feature = "embassy-runtime"`,
110+
// which transitively pulls in `embassy-executor`'s `platform-cortex-m` (ARM
111+
// assembly) and so does not compile under `cargo test` on x86_64. As a result
112+
// they are NOT exercised by `make check` / `make all` today — only by
113+
// `cargo check --target thumbv7em-none-eabihf --features embassy-runtime`,
114+
// which type-checks but does not execute them. Run them manually on an
115+
// Embassy-capable board or ARM simulator, or via a host-side harness that
116+
// builds the queue module without the executor.
110117
#[cfg(test)]
111118
mod tests {
112119
use super::*;

aimdb-tokio-adapter/tests/transform_join_integration_tests.rs

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,14 @@ async fn transform_join_produces_sum_on_both_inputs() {
3333
let runtime = Arc::new(TokioAdapter::new().unwrap());
3434
let mut builder = AimDbBuilder::new().runtime(runtime);
3535

36+
// SpmcRing inputs (vs SingleLatest) so that values produced before the join
37+
// transform's forwarders subscribe are still buffered and replayed — removes
38+
// a startup race where the test might otherwise need a hand-tuned barrier.
3639
builder.configure::<ValueA>("test::A", |reg| {
37-
reg.buffer(BufferCfg::SingleLatest);
40+
reg.buffer(BufferCfg::SpmcRing { capacity: 16 });
3841
});
3942
builder.configure::<ValueB>("test::B", |reg| {
40-
reg.buffer(BufferCfg::SingleLatest);
43+
reg.buffer(BufferCfg::SpmcRing { capacity: 16 });
4144
});
4245
builder.configure::<Sum>("test::Sum", |reg| {
4346
reg.buffer(BufferCfg::SpmcRing { capacity: 16 })
@@ -92,3 +95,104 @@ async fn transform_join_produces_sum_on_both_inputs() {
9295
.unwrap();
9396
assert_eq!(s.0, 22, "expected 2+20=22");
9497
}
98+
99+
/// Stress test for the bounded(64) Tokio fan-in channel: pushes well over 64
100+
/// events through a single-input join while the handler intentionally yields
101+
/// between receives. If backpressure is wired correctly, this completes
102+
/// without deadlock and every produced value is observed in order.
103+
#[tokio::test]
104+
async fn transform_join_bounded_fanin_backpressure_no_deadlock() {
105+
const N: u32 = 200;
106+
const SENTINEL: u32 = u32::MAX;
107+
let cap = (N + 16) as usize;
108+
109+
let runtime = Arc::new(TokioAdapter::new().unwrap());
110+
let mut builder = AimDbBuilder::new().runtime(runtime);
111+
112+
// Input/output rings sized larger than the bounded(64) fan-in so the SpmcRing
113+
// itself isn't the limiter — we want the bounded channel to be the bottleneck.
114+
builder.configure::<ValueA>("stress::A", |reg| {
115+
reg.buffer(BufferCfg::SpmcRing { capacity: cap });
116+
});
117+
builder.configure::<Sum>("stress::Echo", |reg| {
118+
reg.buffer(BufferCfg::SpmcRing { capacity: cap })
119+
.transform_join(|b| {
120+
b.input::<ValueA>("stress::A")
121+
.on_triggers(|mut rx, producer| async move {
122+
while let Ok(trigger) = rx.recv().await {
123+
if let Some(v) = trigger.as_input::<ValueA>().copied() {
124+
// Yield between receives to keep the fan-in channel
125+
// pressured well above its 64-slot capacity.
126+
tokio::task::yield_now().await;
127+
let _ = producer.produce(Sum(v.0)).await;
128+
}
129+
}
130+
})
131+
});
132+
});
133+
134+
let db = builder.build().await.unwrap();
135+
let mut echo_rx = db.subscribe::<Sum>("stress::Echo").unwrap();
136+
137+
// Warm-up: keep producing a sentinel until its echo lands. SpmcRing buffers
138+
// are tokio broadcast channels, so subscribers (including the join input
139+
// forwarder) only see values produced after they subscribe — the round-trip
140+
// gives us a deterministic barrier for "forwarder is up".
141+
{
142+
let warmup_db = db.clone();
143+
let warmup = tokio::spawn(async move {
144+
loop {
145+
warmup_db
146+
.produce::<ValueA>("stress::A", ValueA(SENTINEL))
147+
.await
148+
.unwrap();
149+
tokio::time::sleep(Duration::from_millis(5)).await;
150+
}
151+
});
152+
loop {
153+
let s = tokio::time::timeout(Duration::from_secs(2), echo_rx.recv())
154+
.await
155+
.expect("warm-up: join forwarder did not subscribe in time")
156+
.unwrap();
157+
if s.0 == SENTINEL {
158+
break;
159+
}
160+
}
161+
warmup.abort();
162+
let _ = warmup.await;
163+
}
164+
165+
// Drain any remaining warm-up echoes so the burst checker sees a clean stream.
166+
while let Ok(Ok(s)) = tokio::time::timeout(Duration::from_millis(50), echo_rx.recv()).await {
167+
assert_eq!(
168+
s.0, SENTINEL,
169+
"only warm-up sentinels should be in flight here"
170+
);
171+
}
172+
173+
// Burst N events. The join handler yields between every receive, so the
174+
// bounded(64) fan-in fills up and backpressures the input forwarder. A
175+
// missing or broken backpressure path would deadlock here.
176+
let producer_db = db.clone();
177+
let producer_task = tokio::spawn(async move {
178+
for i in 0..N {
179+
producer_db
180+
.produce::<ValueA>("stress::A", ValueA(i))
181+
.await
182+
.unwrap();
183+
}
184+
});
185+
186+
for expected in 0..N {
187+
let s = tokio::time::timeout(Duration::from_secs(5), echo_rx.recv())
188+
.await
189+
.expect("backpressured fan-in should not deadlock")
190+
.unwrap();
191+
assert_eq!(
192+
s.0, expected,
193+
"values must arrive in order under backpressure"
194+
);
195+
}
196+
197+
producer_task.await.unwrap();
198+
}

examples/weather-mesh-demo/weather-station-gamma/Cargo.toml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,7 @@ aimdb-embassy-adapter = { path = "../../../aimdb-embassy-adapter", default-featu
2626
"embassy-runtime",
2727
"embassy-task-pool-16", # 2 producers + net + MQTT + join transform + 2 forwarders
2828
] }
29-
aimdb-executor = { path = "../../../aimdb-executor", default-features = false, features = [
30-
"embassy-types",
31-
] }
29+
aimdb-executor = { path = "../../../aimdb-executor", default-features = false }
3230
aimdb-data-contracts = { path = "../../../aimdb-data-contracts", default-features = false, features = [
3331
"simulatable",
3432
] }

0 commit comments

Comments
 (0)