Skip to main content

tradingview/loader/
mod.rs

1//! Generic event-driven data loader.
2//!
3//! `DataLoader` is the central orchestrator that connects a [`DataSource`](crate::source::DataSource) to
4//! one or more [`EventSink`](crate::sink::EventSink)s. It handles:
5//!
6//! - Source → fan-out task → per-sink tasks pipeline
7//! - Bounded channels for backpressure
8//! - Graceful shutdown via `CancellationToken`
9//! - Error propagation
10//!
11//! # Example
12//!
13//! ```rust,ignore
14//! use tradingview::loader::DataLoader;
15//! use tradingview::sink::{ChannelSink, CallbackSink};
16//! use tradingview::source::tradingview::TradingViewSource;
17//!
18//! # async fn example() -> tradingview::Result<()> {
19//! let (channel_sink, mut rx) = ChannelSink::new(1024);
20//!
21//! let mut loader = DataLoader::builder()
22//!     .source(TradingViewSource::new())
23//!     .sink(channel_sink)
24//!     .sink(CallbackSink::new("debug", |events| async move {
25//!         for event in &events {
26//!             tracing::debug!("{:?}", event);
27//!         }
28//!         Ok(())
29//!     }))
30//!     .build()?;
31//!
32//! loader.start().await?;
33//! // ... events flow ...
34//! loader.shutdown().await?;
35//! # Ok(())
36//! # }
37//! ```
38
39use tokio::{sync::mpsc, task::JoinSet};
40use tokio_util::sync::CancellationToken;
41use tracing::{debug, error, info, warn};
42
43use crate::{Result, events::MarketEvent, sink::EventSink, source::DataSource};
44
45// ---------------------------------------------------------------------------
46// Type aliases
47// ---------------------------------------------------------------------------
48
49/// A channel pair for dispatching event batches to a sink.
50type EventChannel = (
51    mpsc::Sender<Vec<MarketEvent>>,
52    mpsc::Receiver<Vec<MarketEvent>>,
53);
54
55// ---------------------------------------------------------------------------
56// Configuration
57// ---------------------------------------------------------------------------
58
59/// Configuration for the data loader.
60#[derive(Debug, Clone)]
61pub struct LoaderConfig {
62    /// Size of the bounded channel between source and fan-out task.
63    /// Default: 4096.
64    pub channel_capacity: usize,
65    /// Maximum events per batch (for future batching logic).
66    /// Default: 256.
67    pub batch_size: usize,
68    /// If `true`, the loader continues even if a sink rejects events.
69    /// Default: `false`.
70    pub continue_on_sink_error: bool,
71}
72
73impl Default for LoaderConfig {
74    fn default() -> Self {
75        Self {
76            channel_capacity: 4096,
77            batch_size: 256,
78            continue_on_sink_error: false,
79        }
80    }
81}
82
83// ---------------------------------------------------------------------------
84// DataLoader
85// ---------------------------------------------------------------------------
86
87/// Event-driven data loader — connects a source to multiple sinks.
88///
89/// Architecture:
90///
91/// ```text
92/// Source task → source_tx ──→ [fan-out task] ──→ sink_tx_0 → sink task 0
93///                                        ├───────→ sink_tx_1 → sink task 1
94///                                        └───────→ sink_tx_N → sink task N
95/// ```
96///
97/// The source produces `Vec<MarketEvent>` batches and sends them via a single
98/// `mpsc` channel. A fan-out task reads from that channel and clones each
99/// batch to every sink's individual channel. Each sink has its own task that
100/// reads from its channel and calls `sink.accept()`.
101pub struct DataLoader<S: DataSource = crate::source::tradingview::TradingViewSource> {
102    source: Option<S>,
103    sinks: Vec<Box<dyn EventSink>>,
104    /// Per-sink (tx, rx) channel pairs.
105    sink_channels: Vec<EventChannel>,
106    config: LoaderConfig,
107    cancel: CancellationToken,
108    tasks: JoinSet<Result<()>>,
109}
110
111impl<S: DataSource> DataLoader<S> {
112    /// Create a new builder with default configuration.
113    pub fn builder() -> DataLoaderBuilder<S> {
114        DataLoaderBuilder::new()
115    }
116
117    /// Start the loader: spawns source, fan-out, and per-sink tasks.
118    ///
119    /// This method consumes the source and sink channels; calling it twice
120    /// returns an error.
121    pub async fn start(&mut self) -> Result<()> {
122        let source = self
123            .source
124            .take()
125            .ok_or_else(|| crate::Error::Internal(ustr::ustr("loader already started")))?;
126
127        if self.sinks.is_empty() {
128            return Err(crate::Error::Internal(ustr::ustr(
129                "no sinks registered — add at least one sink before starting",
130            )));
131        }
132
133        let sink_count = self.sinks.len();
134        info!(
135            source = %source.name(),
136            sink_count,
137            channel_capacity = self.config.channel_capacity,
138            "starting data loader",
139        );
140
141        // ---- Source → fan-out channel ----
142        let (source_tx, source_rx) =
143            mpsc::channel::<Vec<MarketEvent>>(self.config.channel_capacity);
144
145        // ---- Spawn source task ----
146        let cancel_src = self.cancel.clone();
147        let source_name = source.name().to_string();
148        self.tasks.spawn(async move {
149            debug!(source = %source_name, "source task started");
150            source.run(source_tx, cancel_src).await.inspect_err(|e| {
151                error!(source = %source_name, error = %e, "source failed");
152            })
153        });
154
155        // ---- Fan-out task ----
156        let sink_txs: Vec<mpsc::Sender<Vec<MarketEvent>>> = self
157            .sink_channels
158            .iter()
159            .map(|(tx, _)| tx.clone())
160            .collect();
161        let fan_cancel = self.cancel.clone();
162        let fan_config = self.config.clone();
163        self.tasks
164            .spawn(async move { fan_out_task(source_rx, sink_txs, fan_config, fan_cancel).await });
165
166        // ---- Per-sink tasks ----
167        let sink_rxs: Vec<(usize, mpsc::Receiver<Vec<MarketEvent>>)> = self
168            .sink_channels
169            .drain(..)
170            .enumerate()
171            .map(|(i, (_, rx))| (i, rx))
172            .collect();
173
174        let mut sinks = std::mem::take(&mut self.sinks);
175
176        for (idx, mut rx) in sink_rxs {
177            // SAFETY: each sink is unique; we remove them in order.
178            let sink = sinks.remove(0);
179            let cancel_s = self.cancel.clone();
180            let sink_name = sink.name().to_string();
181            let cont_on_err = self.config.continue_on_sink_error;
182
183            self.tasks.spawn(async move {
184                sink_task(idx, sink, &mut rx, sink_name, cont_on_err, cancel_s).await
185            });
186        }
187
188        Ok(())
189    }
190
191    /// Initiate graceful shutdown.
192    ///
193    /// Cancels all spawned tasks and waits for them to finish.
194    pub async fn shutdown(&mut self) -> Result<()> {
195        info!("initiating loader shutdown");
196        self.cancel.cancel();
197
198        while let Some(result) = self.tasks.join_next().await {
199            match result {
200                Ok(Ok(())) => debug!("task completed successfully"),
201                Ok(Err(e)) => warn!(error = %e, "task completed with error"),
202                Err(e) => warn!(error = %e, "task join error"),
203            }
204        }
205
206        info!("loader shutdown complete");
207        Ok(())
208    }
209
210    /// Returns a clone of the cancellation token for external monitoring.
211    pub fn cancel_token(&self) -> CancellationToken {
212        self.cancel.clone()
213    }
214}
215
216// ---------------------------------------------------------------------------
217// Background tasks
218// ---------------------------------------------------------------------------
219
220/// Fan-out: reads from `source_rx` and sends batches to every `sink_tx`.
221///
222/// Optimizes allocation: zero clones for single-sink loaders, and N-1 clones
223/// for N sinks by moving into the last active sender while preserving error semantics.
224async fn fan_out_task(
225    mut source_rx: mpsc::Receiver<Vec<MarketEvent>>,
226    sink_txs: Vec<mpsc::Sender<Vec<MarketEvent>>>,
227    config: LoaderConfig,
228    cancel: CancellationToken,
229) -> Result<()> {
230    loop {
231        tokio::select! {
232            biased;
233
234            _ = cancel.cancelled() => {
235                debug!("fan-out task cancelled");
236                break;
237            }
238
239            result = source_rx.recv() => {
240                match result {
241                    Some(events) => {
242                        if sink_txs.is_empty() {
243                            continue;
244                        }
245
246                        if !config.continue_on_sink_error {
247                            for (i, tx) in sink_txs.iter().enumerate() {
248                                if tx.is_closed() {
249                                    return Err(crate::Error::Internal(ustr::ustr(
250                                        &format!("sink channel {i} closed")
251                                    )));
252                                }
253                            }
254                        }
255
256                        let active_indices: Vec<usize> = sink_txs
257                            .iter()
258                            .enumerate()
259                            .filter_map(|(i, tx)| if !tx.is_closed() { Some(i) } else { None })
260                            .collect();
261
262                        if active_indices.is_empty() {
263                            if config.continue_on_sink_error {
264                                for i in 0..sink_txs.len() {
265                                    warn!(sink_index = i, "sink channel dropped");
266                                }
267                                continue;
268                            } else {
269                                return Err(crate::Error::Internal(ustr::ustr("all sink channels closed")));
270                            }
271                        }
272
273                        let (&last_idx, head_indices) = active_indices
274                            .split_last()
275                            .expect("active_indices is not empty");
276
277                        for &i in head_indices {
278                            let tx = &sink_txs[i];
279                            if let Err(e) = tx.send(events.clone()).await {
280                                if config.continue_on_sink_error {
281                                    warn!(sink_index = i, error = %e, "sink channel dropped");
282                                } else {
283                                    return Err(crate::Error::Internal(ustr::ustr(
284                                        &format!("sink channel {i} closed: {e}")
285                                    )));
286                                }
287                            }
288                        }
289
290                        let last_tx = &sink_txs[last_idx];
291                        if let Err(e) = last_tx.send(events).await {
292                            if config.continue_on_sink_error {
293                                warn!(sink_index = last_idx, error = %e, "sink channel dropped");
294                            } else {
295                                return Err(crate::Error::Internal(ustr::ustr(
296                                    &format!("sink channel {last_idx} closed: {e}")
297                                )));
298                            }
299                        }
300
301                        if config.continue_on_sink_error {
302                            for (i, tx) in sink_txs.iter().enumerate() {
303                                if tx.is_closed() {
304                                    warn!(sink_index = i, "sink channel dropped");
305                                }
306                            }
307                        }
308                    }
309                    None => {
310                        info!("source channel closed — fan-out exiting");
311                        break;
312                    }
313                }
314            }
315        }
316    }
317
318    Ok(())
319}
320
321/// Single sink task: reads from `rx` and calls `sink.accept()`.
322async fn sink_task(
323    idx: usize,
324    sink: Box<dyn EventSink>,
325    rx: &mut mpsc::Receiver<Vec<MarketEvent>>,
326    name: String,
327    continue_on_error: bool,
328    cancel: CancellationToken,
329) -> Result<()> {
330    debug!(sink_index = idx, sink = %name, "sink task started");
331
332    loop {
333        tokio::select! {
334            biased;
335
336            _ = cancel.cancelled() => {
337                debug!(sink = %name, "sink task cancelled");
338                // Give the sink a chance to flush
339                let _ = sink.shutdown(cancel).await;
340                break;
341            }
342
343            result = rx.recv() => {
344                match result {
345                    Some(events) => {
346                        if let Err(e) = sink.accept(&events).await {
347                            warn!(sink = %name, error = %e, "sink accept failed");
348                            if !continue_on_error {
349                                return Err(e);
350                            }
351                        }
352                    }
353                    None => {
354                        debug!(sink = %name, "sink channel closed");
355                        break;
356                    }
357                }
358            }
359        }
360    }
361
362    debug!(sink = %name, "sink task exiting");
363    Ok(())
364}
365
366// ---------------------------------------------------------------------------
367// Builder
368// ---------------------------------------------------------------------------
369
370/// Builder for [`DataLoader`].
371///
372/// # Example
373///
374/// ```rust,ignore
375/// let loader = DataLoader::<TradingViewSource>::builder()
376///     .source(tv_source)
377///     .sink(channel_sink)
378///     .channel_capacity(8192)
379///     .build()?;
380/// ```
381#[must_use]
382pub struct DataLoaderBuilder<S: DataSource> {
383    source: Option<S>,
384    sinks: Vec<Box<dyn EventSink>>,
385    sink_channels: Vec<EventChannel>,
386    config: LoaderConfig,
387}
388
389impl<S: DataSource> DataLoaderBuilder<S> {
390    /// Create a new builder with defaults.
391    pub fn new() -> Self {
392        Self {
393            source: None,
394            sinks: Vec::new(),
395            sink_channels: Vec::new(),
396            config: LoaderConfig::default(),
397        }
398    }
399
400    /// Set the data source (required).
401    pub fn source(mut self, source: S) -> Self {
402        self.source = Some(source);
403        self
404    }
405
406    /// Register an event sink. You can call this multiple times to fan out
407    /// to several sinks.
408    pub fn sink(mut self, sink: impl EventSink) -> Self {
409        let (tx, rx) = mpsc::channel::<Vec<MarketEvent>>(self.config.channel_capacity);
410        self.sink_channels.push((tx, rx));
411        let boxed: Box<dyn EventSink> = Box::new(sink);
412        self.sinks.push(boxed);
413        self
414    }
415
416    /// Override the default channel capacity (4096).
417    pub fn channel_capacity(mut self, capacity: usize) -> Self {
418        self.config.channel_capacity = capacity;
419        self
420    }
421
422    /// If `true`, the loader keeps running even when a sink rejects events.
423    pub fn continue_on_sink_error(mut self, val: bool) -> Self {
424        self.config.continue_on_sink_error = val;
425        self
426    }
427
428    /// Finish building the [`DataLoader`].
429    ///
430    /// # Errors
431    ///
432    /// - No source configured.
433    /// - No sinks registered.
434    pub fn build(self) -> Result<DataLoader<S>> {
435        let source = self
436            .source
437            .ok_or_else(|| crate::Error::Internal(ustr::ustr("no data source configured")))?;
438
439        if self.sinks.is_empty() {
440            return Err(crate::Error::Internal(ustr::ustr(
441                "at least one sink is required",
442            )));
443        }
444
445        Ok(DataLoader {
446            source: Some(source),
447            sinks: self.sinks,
448            sink_channels: self.sink_channels,
449            config: self.config,
450            cancel: CancellationToken::new(),
451            tasks: JoinSet::new(),
452        })
453    }
454}
455
456impl<S: DataSource> Default for DataLoaderBuilder<S> {
457    fn default() -> Self {
458        Self::new()
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use crate::events::CandleData;
466
467    fn test_candle(ts: i64) -> MarketEvent {
468        MarketEvent::Candle(CandleData::new(
469            ts,
470            "AAPL",
471            "1D",
472            150.0,
473            155.0,
474            149.0,
475            153.0,
476            1_000_000.0,
477        ))
478    }
479
480    #[tokio::test]
481    async fn test_fanout_single_sink_avoids_cloning() {
482        let (source_tx, source_rx) = mpsc::channel(16);
483        let (sink_tx, mut sink_rx) = mpsc::channel(16);
484        let cancel = CancellationToken::new();
485
486        let handle = tokio::spawn(fan_out_task(
487            source_rx,
488            vec![sink_tx],
489            LoaderConfig::default(),
490            cancel.clone(),
491        ));
492
493        let batch = vec![test_candle(1000)];
494        let original_ptr = batch.as_ptr();
495        source_tx.send(batch).await.unwrap();
496        drop(source_tx);
497
498        let received = sink_rx.recv().await.expect("received event batch");
499        assert_eq!(received.len(), 1);
500        assert_eq!(
501            received.as_ptr(),
502            original_ptr,
503            "single sink must move the batch buffer without cloning"
504        );
505
506        handle.await.unwrap().unwrap();
507    }
508
509    #[tokio::test]
510    async fn test_fanout_identical_observable_events_one_two_three_sinks() {
511        for sink_count in 1..=3 {
512            let (source_tx, source_rx) = mpsc::channel(16);
513            let mut sink_rxs = Vec::new();
514            let mut sink_txs = Vec::new();
515            for _ in 0..sink_count {
516                let (tx, rx) = mpsc::channel(16);
517                sink_txs.push(tx);
518                sink_rxs.push(rx);
519            }
520            let cancel = CancellationToken::new();
521
522            let handle = tokio::spawn(fan_out_task(
523                source_rx,
524                sink_txs,
525                LoaderConfig::default(),
526                cancel.clone(),
527            ));
528
529            let batch = vec![test_candle(1000), test_candle(2000)];
530            source_tx.send(batch.clone()).await.unwrap();
531            drop(source_tx);
532
533            for (sink_idx, rx) in sink_rxs.iter_mut().enumerate() {
534                let received = rx.recv().await.expect("sink received batch");
535                assert_eq!(
536                    received, batch,
537                    "sink {sink_idx}/{sink_count} must receive identical observable events"
538                );
539            }
540
541            handle.await.unwrap().unwrap();
542        }
543    }
544
545    #[tokio::test]
546    async fn test_fanout_trailing_closed_sink_fails_fast_when_continue_false() {
547        let (source_tx, source_rx) = mpsc::channel(16);
548        let (sink_tx_active, mut sink_rx_active) = mpsc::channel(16);
549        let (sink_tx_closed, sink_rx_closed) = mpsc::channel(16);
550        drop(sink_rx_closed); // Close the trailing sink channel
551
552        let cancel = CancellationToken::new();
553        let handle = tokio::spawn(fan_out_task(
554            source_rx,
555            vec![sink_tx_active, sink_tx_closed],
556            LoaderConfig {
557                continue_on_sink_error: false,
558                ..Default::default()
559            },
560            cancel.clone(),
561        ));
562
563        let batch = vec![test_candle(1000)];
564        source_tx.send(batch).await.unwrap();
565        drop(source_tx);
566
567        let task_result = handle.await.unwrap();
568        assert!(
569            task_result.is_err(),
570            "expected error due to closed trailing sink when continue_on_sink_error is false"
571        );
572        // The active sink should not have received events because pre-detection caught the closed sink before dispatch
573        assert!(sink_rx_active.try_recv().is_err());
574    }
575
576    #[tokio::test]
577    async fn test_fanout_trailing_closed_sink_continues_when_continue_true() {
578        let (source_tx, source_rx) = mpsc::channel(16);
579        let (sink_tx_active, mut sink_rx_active) = mpsc::channel(16);
580        let (sink_tx_closed, sink_rx_closed) = mpsc::channel(16);
581        drop(sink_rx_closed); // Close the trailing sink channel
582
583        let cancel = CancellationToken::new();
584        let handle = tokio::spawn(fan_out_task(
585            source_rx,
586            vec![sink_tx_active, sink_tx_closed],
587            LoaderConfig {
588                continue_on_sink_error: true,
589                ..Default::default()
590            },
591            cancel.clone(),
592        ));
593
594        let batch = vec![test_candle(1000)];
595        let original_ptr = batch.as_ptr();
596        source_tx.send(batch).await.unwrap();
597        drop(source_tx);
598
599        let task_result = handle.await.unwrap();
600        assert!(
601            task_result.is_ok(),
602            "expected success when continue_on_sink_error is true"
603        );
604
605        let received = sink_rx_active
606            .recv()
607            .await
608            .expect("active sink received batch");
609        assert_eq!(received.len(), 1);
610        assert_eq!(
611            received.as_ptr(),
612            original_ptr,
613            "single remaining active sink must move the batch buffer without cloning"
614        );
615    }
616}