Skip to main content

tenshift_core/
source.rs

1//! Source trait  -  the extension point for data origins.
2//!
3//! A [`Source`] produces [`Sample`]s from some origin (filesystem, network, database, etc.).
4//! Community contributors add new sources by implementing this single trait.
5//!
6//! Built-in sources include [`GlobSource`][crate::sources::GlobSource],
7//! [`MemorySource`][crate::sources::MemorySource], and
8//! [`DistributedSampler`][crate::sources::DistributedSampler], with optional
9//! feature-gated sources such as CSV and `io_uring` readers.
10//!
11//! # Async Sources
12//!
13//! For async data origins, implement [`AsyncSource`] and wrap with
14//! [`Pipeline::from_async_source`](crate::pipeline::Pipeline::from_async_source).
15//! This runs the async source in a dedicated Tokio runtime without blocking
16//! the pipeline's worker threads.
17
18use crate::error::Result;
19use crate::sample::Sample;
20use std::future::Future;
21use std::pin::Pin;
22
23/// A source of training data.
24///
25/// Sources are lazy  -  they describe WHERE data comes from, not the data itself.
26/// Data is only loaded when the pipeline pulls from the source.
27///
28/// # Implementing a Source
29///
30/// ```rust
31/// use tenshift_core::source::{Source, SourceIterator};
32/// use tenshift_core::sample::Sample;
33/// use tenshift_core::error::Result;
34///
35/// struct MyDatabase {
36///     connection_string: String,
37/// }
38///
39/// impl Source for MyDatabase {
40///     fn open(&self) -> Result<Box<dyn SourceIterator>> {
41///         // Open connection, return iterator over rows
42///         # Ok(Box::new(std::iter::empty::<Result<Sample>>()))
43///     }
44///
45///     fn len_hint(&self) -> Option<u64> {
46///         None // unknown size
47///     }
48///
49///     fn name(&self) -> &str {
50///         "my_database"
51///     }
52/// }
53/// ```
54pub trait Source: Send + Sync {
55    /// Create an iterator over samples from this source.
56    ///
57    /// Called once per epoch. The iterator is consumed by the pipeline's
58    /// worker threads.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if the source cannot be opened (e.g., file not found,
63    /// network unreachable, invalid credentials).
64    fn open(&self) -> Result<Box<dyn SourceIterator>>;
65
66    /// Optional hint about the total number of samples.
67    ///
68    /// Used for progress reporting and pre-allocation. Return `None` if unknown.
69    fn len_hint(&self) -> Option<u64> {
70        None
71    }
72
73    /// Human-readable name for this source (for logging and error messages).
74    fn name(&self) -> &str;
75}
76
77impl<T> Source for Box<T>
78where
79    T: Source + ?Sized,
80{
81    fn open(&self) -> Result<Box<dyn SourceIterator>> {
82        (**self).open()
83    }
84
85    fn len_hint(&self) -> Option<u64> {
86        (**self).len_hint()
87    }
88
89    fn name(&self) -> &str {
90        (**self).name()
91    }
92}
93
94/// Boxed future used by async source extension traits.
95pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
96
97/// Async-capable source of training data.
98pub trait AsyncSource: Send + Sync {
99    /// Open the source asynchronously and return an async iterator.
100    fn open_async(&self) -> BoxFuture<'_, Result<Box<dyn AsyncSourceIterator>>>;
101
102    /// Optional hint about the total number of samples.
103    fn len_hint(&self) -> Option<u64> {
104        None
105    }
106
107    /// Human-readable name for this source.
108    fn name(&self) -> &str;
109}
110
111/// Iterator over samples from a source.
112///
113/// This is a regular iterator that yields `Result<Sample>`. Errors on individual
114/// items (e.g., corrupt file) are returned as `Err` without stopping the iterator.
115pub trait SourceIterator: Send {
116    /// Get the next sample, or `None` if exhausted.
117    fn next_sample(&mut self) -> Option<Result<Sample>>;
118}
119
120/// Blanket implementation: any `Iterator<Item = Result<Sample>> + Send` is a `SourceIterator`.
121impl<I> SourceIterator for I
122where
123    I: Iterator<Item = Result<Sample>> + Send,
124{
125    fn next_sample(&mut self) -> Option<Result<Sample>> {
126        self.next()
127    }
128}
129
130/// Async iterator over samples from a source.
131pub trait AsyncSourceIterator: Send {
132    /// Get the next sample asynchronously, or `None` if exhausted.
133    fn next_sample_async(&mut self) -> BoxFuture<'_, Option<Result<Sample>>>;
134}
135
136struct SyncSourceAdapter {
137    inner: Box<dyn SourceIterator>,
138}
139
140impl AsyncSourceIterator for SyncSourceAdapter {
141    fn next_sample_async(&mut self) -> BoxFuture<'_, Option<Result<Sample>>> {
142        Box::pin(async move { self.inner.next_sample() })
143    }
144}
145
146impl<T> AsyncSource for T
147where
148    T: Source,
149{
150    fn open_async(&self) -> BoxFuture<'_, Result<Box<dyn AsyncSourceIterator>>> {
151        Box::pin(async move {
152            self.open()
153                .map(|inner| Box::new(SyncSourceAdapter { inner }) as Box<dyn AsyncSourceIterator>)
154        })
155    }
156
157    fn len_hint(&self) -> Option<u64> {
158        Source::len_hint(self)
159    }
160
161    fn name(&self) -> &str {
162        Source::name(self)
163    }
164}
165
166/// Adapts an [`AsyncSource`] into a synchronous [`Source`] by running a Tokio runtime.
167pub struct AsyncToSyncAdapter<A> {
168    inner: A,
169}
170
171impl<A: AsyncSource> AsyncToSyncAdapter<A> {
172    /// Create a new adapter.
173    pub fn new(inner: A) -> Self {
174        Self { inner }
175    }
176}
177
178impl<A: AsyncSource + 'static> Source for AsyncToSyncAdapter<A> {
179    fn open(&self) -> Result<Box<dyn SourceIterator>> {
180        let rt = tokio::runtime::Builder::new_multi_thread()
181            .worker_threads(1)
182            .enable_all()
183            .build()
184            .map_err(|e| crate::error::Error::InvalidConfig {
185                reason: format!("failed to build tokio runtime for async source: {e}"),
186            })?;
187
188        // Block to initialize the iter, since the caller requires `Result`
189        let mut inner_iter = rt.block_on(self.inner.open_async())?;
190
191        // Construct high-speed queue bridging async I/O worker to sync transform workers
192        let (tx, rx) = crossbeam_channel::bounded(128);
193
194        // Native prefetching: Decouple the async source polling from the CPU pipeline
195        // to immediately start fetching the next item in the background.
196        rt.spawn(async move {
197            loop {
198                match inner_iter.next_sample_async().await {
199                    Some(Ok(sample)) => {
200                        if tx.send(Some(Ok(sample))).is_err() {
201                            break;
202                        }
203                    }
204                    Some(Err(e)) => {
205                        let _ = tx.send(Some(Err(e)));
206                        break;
207                    }
208                    None => {
209                        let _ = tx.send(None);
210                        break;
211                    }
212                }
213            }
214        });
215
216        Ok(Box::new(AsyncIteratorAdapter { rt, rx }))
217    }
218
219    fn len_hint(&self) -> Option<u64> {
220        self.inner.len_hint()
221    }
222
223    fn name(&self) -> &str {
224        self.inner.name()
225    }
226}
227
228/// Runs [`AsyncSourceIterator`] asynchronously block by block.
229pub struct AsyncIteratorAdapter {
230    #[allow(dead_code)]
231    rt: tokio::runtime::Runtime,
232    rx: crossbeam_channel::Receiver<Option<Result<Sample>>>,
233}
234
235impl SourceIterator for AsyncIteratorAdapter {
236    fn next_sample(&mut self) -> Option<Result<Sample>> {
237        match self.rx.recv() {
238            Ok(item) => item,
239            // Channel disconnected  -  async source dropped without sending None sentinel.
240            // Surface as an error rather than silently ending the stream.
241            Err(_) => Some(Err(crate::error::Error::SourceFailed {
242                source_name: "async_adapter".into(),
243                reason: "async source channel disconnected unexpectedly".into(),
244            })),
245        }
246    }
247}