Skip to main content

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}