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}