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}