aimdb_core/transform/join.rs
1use core::any::Any;
2use core::fmt::Debug;
3use core::marker::PhantomData;
4
5use alloc::{
6 boxed::Box,
7 string::{String, ToString},
8 sync::Arc,
9 vec::Vec,
10};
11
12use crate::executor::{ExecutorError, ExecutorResult};
13
14use crate::transform::{CollectedTransform, TransformDescriptor};
15use crate::typed_record::BoxFuture;
16
17// ============================================================================
18// Fan-in queue
19// ============================================================================
20
21/// Capacity of the bounded fan-in channel between input forwarders and the
22/// trigger loop. Sized per target to match the per-adapter GAT queue family
23/// this replaces: 64 on std and wasm32 (tokio/WASM used 64 — a blocked
24/// forwarder stops draining its input buffer, so burst headroom matters
25/// there; the WASM adapter builds core without `std`, hence the target cfg).
26#[cfg(any(feature = "std", target_arch = "wasm32"))]
27const JOIN_QUEUE_CAPACITY: usize = 64;
28/// Embedded no_std: the Embassy queue this replaces used 8; 16 adds burst
29/// headroom at a few words of RAM per slot.
30#[cfg(not(any(feature = "std", target_arch = "wasm32")))]
31const JOIN_QUEUE_CAPACITY: usize = 16;
32
33/// One bounded queue for all runtimes — the same `async-channel` primitive the
34/// session engine already uses on tokio, Embassy, and WASM.
35fn join_channel() -> (
36 async_channel::Sender<JoinTrigger>,
37 async_channel::Receiver<JoinTrigger>,
38) {
39 async_channel::bounded(JOIN_QUEUE_CAPACITY)
40}
41
42// ============================================================================
43// JoinTrigger
44// ============================================================================
45
46/// Identifies which input produced a value in a multi-input join transform.
47///
48/// Passed to the event loop inside the closure registered with [`JoinBuilder::on_triggers`].
49/// Use [`JoinTrigger::index`] to branch on the source input and
50/// [`JoinTrigger::as_input`] to downcast the value to the concrete type.
51///
52/// # Why this carries `Box<dyn Any>`
53///
54/// This is the one deliberate exception to the data-plane de-erasure
55/// A join fans N *differently-typed* inputs into a single
56/// trigger channel, and the user's `on_triggers` closure branches on them
57/// via [`JoinTrigger::as_input`] — the erasure *is* the public API here, not
58/// an implementation accident. Removing it would mean N channels or a
59/// generated input enum, i.e. a different feature. The cost (one box +
60/// downcast per join input value) is confined to this module and applies
61/// only to explicit user-built joins, never to the connector spine.
62pub enum JoinTrigger {
63 Input {
64 index: usize,
65 value: Box<dyn Any + Send>,
66 },
67}
68
69impl JoinTrigger {
70 pub fn as_input<T: 'static>(&self) -> Option<&T> {
71 match self {
72 JoinTrigger::Input { value, .. } => value.downcast_ref::<T>(),
73 }
74 }
75
76 pub fn index(&self) -> usize {
77 match self {
78 JoinTrigger::Input { index, .. } => *index,
79 }
80 }
81}
82
83// ============================================================================
84// JoinEventRx — trigger receiver
85// ============================================================================
86
87/// Receiver for join trigger events.
88///
89/// Obtained as the first argument to the [`JoinBuilder::on_triggers`] closure.
90/// Call `.recv().await` in a loop to consume trigger events from all input forwarders.
91/// Returns `Err` when all input forwarders have exited and the channel is closed.
92pub struct JoinEventRx {
93 inner: async_channel::Receiver<JoinTrigger>,
94}
95
96impl JoinEventRx {
97 fn new(inner: async_channel::Receiver<JoinTrigger>) -> Self {
98 Self { inner }
99 }
100
101 /// Receive the next trigger event.
102 ///
103 /// Returns `Ok(JoinTrigger)` when an input fires, or `Err` when all input
104 /// forwarders have dropped their senders — on every runtime, ending any
105 /// `while let Ok(_) = rx.recv().await` loop.
106 pub async fn recv(&mut self) -> ExecutorResult<JoinTrigger> {
107 self.inner
108 .recv()
109 .await
110 .map_err(|_| ExecutorError::QueueClosed)
111 }
112}
113
114// ============================================================================
115// JoinBuilder → JoinPipeline
116// ============================================================================
117
118/// Type-erased factory for creating a forwarder task for one join input.
119type JoinInputFactory = Box<
120 dyn FnOnce(
121 Arc<crate::AimDb>,
122 usize,
123 async_channel::Sender<JoinTrigger>,
124 ) -> BoxFuture<'static, ()>
125 + Send
126 + Sync,
127>;
128
129/// Configures a multi-input join transform.
130///
131/// Available on every runtime. The fan-in queue (bounded channel between input
132/// forwarders and the trigger loop) lives in core, with capacity 64 on std and
133/// wasm32 and 16 on embedded `no_std`.
134///
135/// Obtain via [`RecordRegistrar::transform_join`](crate::RecordRegistrar::transform_join).
136pub struct JoinBuilder<O> {
137 inputs: Vec<(String, JoinInputFactory)>,
138 _phantom: PhantomData<O>,
139}
140
141impl<O> JoinBuilder<O>
142where
143 O: Send + Sync + Clone + Debug + 'static,
144{
145 pub(crate) fn new() -> Self {
146 Self {
147 inputs: Vec::new(),
148 _phantom: PhantomData,
149 }
150 }
151
152 /// Add a typed input to the join.
153 pub fn input<I>(mut self, key: impl crate::RecordKey) -> Self
154 where
155 I: Send + Sync + Clone + Debug + 'static,
156 {
157 let key_str = key.as_str().to_string();
158 let key_for_factory = key_str.clone();
159
160 let factory: JoinInputFactory = Box::new(
161 move |db: Arc<crate::AimDb>, index: usize, tx: async_channel::Sender<JoinTrigger>| {
162 Box::pin(async move {
163 let consumer = match db.consumer::<I>(&key_for_factory) {
164 Ok(c) => c,
165 Err(_e) => {
166 log_error!(
167 "🔄 Join input '{}' (index {}) consumer resolution failed: {:?}",
168 key_for_factory,
169 index,
170 _e
171 );
172 return;
173 }
174 };
175 let mut reader = consumer.subscribe();
176
177 loop {
178 let value = match reader.recv().await {
179 Ok(v) => v,
180 // SPMC-ring overflow: the reader recovers (cursor
181 // resets to the oldest live value), so a transient
182 // lag must not permanently silence this input —
183 // same policy as every other recv loop in core.
184 Err(crate::DbError::BufferLagged { .. }) => {
185 log_warn!(
186 "🔄 Join input '{}' (index {}) lagged behind, some values skipped",
187 key_for_factory,
188 index
189 );
190 continue;
191 }
192 // Buffer closed / fatal — the input record is gone.
193 Err(_) => break,
194 };
195 let trigger = JoinTrigger::Input {
196 index,
197 value: Box::new(value),
198 };
199 if tx.send(trigger).await.is_err() {
200 break;
201 }
202 }
203 }) as BoxFuture<'static, ()>
204 },
205 );
206
207 self.inputs.push((key_str, factory));
208 self
209 }
210
211 /// Complete the pipeline by providing an async task that owns the event loop and state.
212 ///
213 /// The closure receives a [`JoinEventRx`] to read trigger events and a `Producer<O>`
214 /// to emit output values. Both are owned — moved into the `async move` block — so the
215 /// closure can freely hold borrows across `.await` points and maintain any state it needs.
216 ///
217 /// The task runs until all input forwarders close (i.e., all upstream records stop producing).
218 ///
219 /// Illustrative (not compiled: fragment of a `transform_join` pipeline with
220 /// user-defined input types):
221 ///
222 /// ```rust,ignore
223 /// .on_triggers(|mut rx, producer| async move {
224 /// let mut last_a: Option<f32> = None;
225 /// let mut last_b: Option<f32> = None;
226 /// while let Ok(trigger) = rx.recv().await {
227 /// match trigger.index() {
228 /// 0 => last_a = trigger.as_input::<InputA>().copied(),
229 /// 1 => last_b = trigger.as_input::<InputB>().copied(),
230 /// _ => {}
231 /// }
232 /// if let (Some(a), Some(b)) = (last_a, last_b) {
233 /// producer.produce(compute(a, b));
234 /// }
235 /// }
236 /// })
237 /// ```
238 pub fn on_triggers<F, Fut>(self, handler: F) -> JoinPipeline<O>
239 where
240 F: FnOnce(JoinEventRx, crate::Producer<O>) -> Fut + Send + 'static,
241 Fut: core::future::Future<Output = ()> + Send + 'static,
242 {
243 let inputs = self.inputs;
244 let input_keys_for_descriptor: Vec<String> =
245 inputs.iter().map(|(k, _)| k.clone()).collect();
246
247 JoinPipeline {
248 spawn_factory: Box::new(move |_| TransformDescriptor {
249 input_keys: input_keys_for_descriptor,
250 build_fn: Box::new(move |producer, db, output_key, _profiling| {
251 build_join_collected(db, inputs, producer, handler, output_key)
252 }),
253 }),
254 }
255 }
256}
257
258/// Completed multi-input join pipeline, ready to be registered on a record.
259///
260/// Produced by [`JoinBuilder::on_triggers`] and consumed by
261/// [`RecordRegistrar::transform_join`](crate::RecordRegistrar::transform_join). Not normally constructed directly.
262pub struct JoinPipeline<O: Send + Sync + Clone + Debug + 'static> {
263 pub(crate) spawn_factory: Box<dyn FnOnce(()) -> TransformDescriptor<O> + Send>,
264}
265
266impl<O> JoinPipeline<O>
267where
268 O: Send + Sync + Clone + Debug + 'static,
269{
270 pub(crate) fn into_descriptor(self) -> TransformDescriptor<O> {
271 (self.spawn_factory)(())
272 }
273}
274
275// ============================================================================
276// Join Transform Build (forwarders + handler future, both collected at build time)
277// ============================================================================
278
279fn build_join_collected<O, F, Fut>(
280 db: Arc<crate::AimDb>,
281 inputs: Vec<(String, JoinInputFactory)>,
282 producer: crate::Producer<O>,
283 handler: F,
284 output_key: &str,
285) -> CollectedTransform
286where
287 O: Send + Sync + Clone + Debug + 'static,
288 F: FnOnce(JoinEventRx, crate::Producer<O>) -> Fut + Send + 'static,
289 Fut: core::future::Future<Output = ()> + Send + 'static,
290{
291 // Output key is threaded in from the descriptor so diagnostics stay
292 // unambiguous when multiple records share output type `O`.
293 // Owned copies are build-time, one-shot allocations.
294 let output_key = output_key.to_string();
295 let input_keys: Vec<String> = inputs.iter().map(|(k, _)| k.clone()).collect();
296 log_info!(
297 "🔄 Join transform building: {:?} → '{}'",
298 input_keys,
299 output_key
300 );
301
302 let (tx, rx) = join_channel();
303
304 // Build all forwarder futures eagerly; they will be driven by AimDbRunner.
305 let fanin_futures: Vec<BoxFuture<'static, ()>> = inputs
306 .into_iter()
307 .enumerate()
308 .map(|(index, (_key, factory))| {
309 let sender = tx.clone();
310 factory(db.clone(), index, sender)
311 })
312 .collect();
313
314 // Drop our local sender so the receiver closes once all forwarders exit.
315 drop(tx);
316
317 let task_future: BoxFuture<'static, ()> = Box::pin(async move {
318 log_debug!(
319 "✅ Join transform '{}' handing receiver to user task",
320 output_key
321 );
322
323 handler(JoinEventRx::new(rx), producer).await;
324
325 log_warn!("🔄 Join transform '{}' user task exited", output_key);
326 });
327
328 CollectedTransform {
329 task_future,
330 fanin_futures,
331 }
332}