Skip to main content

tenshift_core/pipeline/
iterator.rs

1//! Iterator types for consuming pipeline output.
2//!
3//! This module is the consumer-facing endpoint of the pipeline architecture,
4//! turning collector output into a pull-based iterator with stats and timeout
5//! helpers.
6
7#![allow(clippy::module_name_repetitions)]
8
9use super::get_fatal_error;
10use crate::sample::Sample;
11use crossbeam_channel::Receiver;
12use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16/// A running pipeline that yields batches of samples.
17pub struct PipelineIterator {
18    pub(crate) receiver: Receiver<Vec<Sample>>,
19    pub(crate) shutdown: Arc<AtomicBool>,
20    #[allow(dead_code)]
21    pub(crate) workers: Vec<std::thread::JoinHandle<()>>,
22    pub(crate) items_yielded: u64,
23    pub(crate) errors_skipped: Arc<AtomicU64>,
24    pub(crate) started_at: Instant,
25    pub(crate) fatal_error: Arc<std::sync::Mutex<Option<crate::error::Error>>>,
26}
27
28/// Error returned when waiting for the next batch with a timeout.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum NextTimeoutError {
32    /// The timeout elapsed before a batch was ready.
33    Timeout,
34    /// The pipeline finished and disconnected.
35    Disconnected,
36}
37
38impl PipelineIterator {
39    /// Number of items yielded so far.
40    pub fn items_yielded(&self) -> u64 {
41        self.items_yielded
42    }
43
44    /// Number of errors that were skipped.
45    pub fn errors_skipped(&self) -> u64 {
46        self.errors_skipped.load(Ordering::Relaxed)
47    }
48
49    /// Wall-clock time since the pipeline started.
50    pub fn elapsed(&self) -> Duration {
51        self.started_at.elapsed()
52    }
53
54    /// Current throughput in items per second.
55    ///
56    /// Returns `0.0` if no items have been yielded yet.
57    #[allow(clippy::cast_precision_loss)] // throughput metrics don't need sub-ulp precision
58    pub fn throughput(&self) -> f64 {
59        let elapsed = self.started_at.elapsed().as_secs_f64();
60        if elapsed <= 0.0 {
61            return 0.0;
62        }
63        self.items_yielded as f64 / elapsed
64    }
65
66    /// Snapshot of pipeline statistics for observability.
67    #[allow(clippy::cast_precision_loss)] // throughput metrics don't need sub-ulp precision
68    pub fn error(&self) -> Option<crate::error::Error> {
69        get_fatal_error(&self.fatal_error)
70    }
71
72    /// Snapshot of pipeline statistics for observability.
73    #[allow(clippy::cast_precision_loss)] // throughput metrics don't need sub-ulp precision
74    pub fn stats(&self) -> PipelineStats {
75        let elapsed = self.started_at.elapsed();
76        let elapsed_secs = elapsed.as_secs_f64();
77        PipelineStats {
78            items_yielded: self.items_yielded,
79            errors_skipped: self.errors_skipped.load(Ordering::Relaxed),
80            elapsed,
81            throughput: if elapsed_secs > 0.0 {
82                self.items_yielded as f64 / elapsed_secs
83            } else {
84                0.0
85            },
86        }
87    }
88
89    /// Signal the pipeline to stop.
90    pub fn stop(&self) {
91        self.shutdown.store(true, Ordering::Relaxed);
92    }
93
94    /// Block for the next batch with a timeout.
95    ///
96    /// Useful for external bindings (e.g. Python `PyO3`) that must periodically
97    /// wake up to check for signals like Ctrl+C.
98    ///
99    /// # Errors
100    ///
101    /// Returns [`NextTimeoutError::Timeout`] if the timeout elapses.
102    /// Returns [`NextTimeoutError::Disconnected`] if the pipeline finishes.
103    pub fn next_timeout(
104        &mut self,
105        timeout: Duration,
106    ) -> std::result::Result<Vec<Sample>, NextTimeoutError> {
107        let deadline = std::time::Instant::now() + timeout;
108        loop {
109            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
110            if remaining.is_zero() {
111                return Err(NextTimeoutError::Timeout);
112            }
113            match self.receiver.recv_timeout(remaining) {
114                Ok(batch) if batch.is_empty() => {
115                    // Empty batches are control signals (e.g., error indicators).
116                    // Continue waiting, but with the original deadline.
117                }
118                Ok(batch) => {
119                    self.items_yielded += 1;
120                    return Ok(batch);
121                }
122                Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
123                    return Err(NextTimeoutError::Timeout)
124                }
125                Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
126                    return Err(NextTimeoutError::Disconnected);
127                }
128            }
129        }
130    }
131}
132
133impl Iterator for PipelineIterator {
134    type Item = Vec<Sample>;
135
136    // Fail-closed by design: a fatal worker error panics instead of silently
137    // ending the epoch (see the `Err(_)` arm below). The fallible inherent
138    // `next()` and `.error()` APIs are the non-panicking alternatives.
139    #[allow(clippy::panic)]
140    fn next(&mut self) -> Option<Vec<Sample>> {
141        loop {
142            match self.receiver.recv() {
143                Ok(batch) if batch.is_empty() => {}
144                Ok(batch) => {
145                    self.items_yielded += 1;
146                    return Some(batch);
147                }
148                // The channel disconnected. A clean end (all workers finished with
149                // no error) yields `None`. But if a worker died on a FATAL error it
150                // also disconnects the channel, and returning `None` there would let
151                // a `for batch in pipeline {}` / `.collect()` consumer finish exactly
152                // as on a clean epoch — silently training on a TRUNCATED epoch with
153                // no signal (Law 10). Fail closed: surface the captured error so it
154                // is impossible to miss. Consumers that need to recover use the
155                // fallible inherent `next()` (returns a `Result`) or `.error()`.
156                Err(_) => {
157                    if let Some(error) = self.error() {
158                        panic!(
159                            "tenshift pipeline terminated early on a fatal worker error after {} batch(es): {error}. \
160                             The epoch is TRUNCATED and must not be treated as a clean end; use the fallible `next()` \
161                             (Result) API or check `.error()` to handle this without panicking.",
162                            self.items_yielded
163                        );
164                    }
165                    return None;
166                }
167            }
168        }
169    }
170}
171
172impl Drop for PipelineIterator {
173    fn drop(&mut self) {
174        let stats = self.stats();
175        // A pipeline that ended on a fatal worker error must not drop quietly at
176        // debug level — an operator watching at the default log level would see
177        // nothing wrong even though the epoch was truncated (Law 10). Surface it
178        // at error level, distinct from the clean-completion debug stats.
179        if let Some(error) = self.error() {
180            tracing::error!(%error, "{stats} — pipeline terminated on a FATAL worker error; the epoch was truncated");
181        } else {
182            tracing::debug!("{stats}");
183        }
184
185        self.shutdown.store(true, Ordering::Relaxed);
186        // Clear the receiver safely to unblock any pending channels.
187        while self.receiver.try_recv().is_ok() {}
188
189        // SQLite Fix: We explicitly do NOT `join()` the worker handles here.
190        // `self.receiver` doesn't drop until after this scope completes.
191        // Calling `join()` while channels are still alive causes indefinite
192        // cyclic deadlocks if the threads were halted on a full `out_tx.send()`.
193        // By relying on the structural crossbeam drop propagation, dropping `self`
194        // cleanly shuts down all senders upstream automatically.
195    }
196}
197
198/// Observability snapshot from a running or completed pipeline.
199#[derive(Debug, Clone)]
200pub struct PipelineStats {
201    /// Total items (batches) yielded to the consumer.
202    pub items_yielded: u64,
203    /// Total items skipped due to errors.
204    pub errors_skipped: u64,
205    /// Wall-clock time since pipeline started.
206    pub elapsed: Duration,
207    /// Throughput in items per second.
208    pub throughput: f64,
209}
210
211impl std::fmt::Display for PipelineStats {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        write!(
214            f,
215            "tenshift: {} items in {:.2}s ({:.0} items/s, {} errors skipped)",
216            self.items_yielded,
217            self.elapsed.as_secs_f64(),
218            self.throughput,
219            self.errors_skipped,
220        )
221    }
222}