Skip to main content

dial9_core/
pipeline.rs

1//! Segment-processing pipeline contract.
2//!
3//! [`SegmentProcessor`](crate::pipeline::SegmentProcessor) is the extension point for
4//! post-seal segment handling:
5//! compression, symbolization, upload, and so on. A driver reads each sealed
6//! segment into a [`SegmentData`](crate::pipeline::SegmentData) and runs it through a
7//! sequence of processors.
8
9use crate::fs::SegmentAccounting;
10use std::collections::HashMap;
11use std::future::Future;
12use std::io;
13use std::pin::Pin;
14
15/// The segment payload threaded through the pipeline.
16pub use crate::payload::Payload;
17/// References to the sealed segment a [`SegmentData`] was loaded from.
18pub use crate::sealed::{MemorySegment, SealedSegment, SegmentRef};
19
20/// Data flowing through the processor pipeline.
21///
22/// The driver reads the sealed segment into `payload`, populates initial
23/// `metadata`, then passes this through each [`SegmentProcessor`] in order.
24///
25/// `SegmentData` is intentionally `!Clone`: it is moved through the pipeline
26/// and dropped exactly once, which is crucial for in-flight byte accounting
27/// (a single `Drop` decrements the counters).
28pub struct SegmentData {
29    segment: SegmentRef,
30    payload: Payload,
31    metadata: HashMap<String, String>,
32    /// Post-compression size reported by a processor via
33    /// [`set_compressed_size`](Self::set_compressed_size). `None` until set.
34    compressed_size: Option<u64>,
35    /// Memory-mode in-flight accounting. `None` for disk-backed segments.
36    /// Held only for its `Drop` (releases in-flight counters).
37    accounting: Option<SegmentAccounting>,
38}
39
40impl SegmentData {
41    /// Build segment data for the pipeline. Called by the driver after loading
42    /// a sealed segment.
43    pub(crate) fn new(
44        segment: SegmentRef,
45        payload: Payload,
46        metadata: HashMap<String, String>,
47        accounting: Option<SegmentAccounting>,
48    ) -> Self {
49        Self {
50            segment,
51            payload,
52            metadata,
53            compressed_size: None,
54            accounting,
55        }
56    }
57
58    /// Information about the sealed segment being processed.
59    pub fn segment(&self) -> &SegmentRef {
60        &self.segment
61    }
62
63    /// Current payload (raw, symbolized, compressed, etc.).
64    pub fn payload(&self) -> &Payload {
65        &self.payload
66    }
67
68    /// Take ownership of the payload, leaving an empty [`Payload`] in its place.
69    pub fn take_payload(&mut self) -> Payload {
70        std::mem::take(&mut self.payload)
71    }
72
73    /// Replace the payload.
74    pub fn set_payload(&mut self, payload: impl Into<Payload>) {
75        self.payload = payload.into();
76    }
77
78    /// Record the segment's post-compression size. Surfaces as the
79    /// `CompressedSize` metric for this segment.
80    pub fn set_compressed_size(&mut self, bytes: u64) {
81        self.compressed_size = Some(bytes);
82    }
83
84    /// The post-compression size reported by a processor, if any.
85    pub fn compressed_size(&self) -> Option<u64> {
86        self.compressed_size
87    }
88
89    /// Metadata accumulated by upstream processors.
90    pub fn metadata(&self) -> &HashMap<String, String> {
91        &self.metadata
92    }
93
94    /// Mutable reference to the metadata map. Processors can insert keys
95    /// (e.g. `"content_encoding"`, `"write_back_extension"`) to signal
96    /// downstream stages.
97    pub fn metadata_mut(&mut self) -> &mut HashMap<String, String> {
98        &mut self.metadata
99    }
100
101    /// Update memory-mode in-flight accounting to the current payload size.
102    /// No-op for disk-backed segments.
103    pub fn adjust_accounting(&mut self) {
104        if let Some(acct) = self.accounting.as_mut() {
105            acct.adjust(self.payload.len() as u64);
106        }
107    }
108
109    /// In-flight accounting, for tests that assert the worker re-balances
110    /// counters between pipeline stages.
111    #[cfg(test)]
112    pub(crate) fn accounting(&self) -> Option<&SegmentAccounting> {
113        self.accounting.as_ref()
114    }
115}
116
117impl std::fmt::Debug for SegmentData {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.debug_struct("SegmentData")
120            .field("segment", &self.segment)
121            .field("payload", &self.payload)
122            .field("metadata", &self.metadata)
123            .finish_non_exhaustive()
124    }
125}
126
127/// A single step in the segment processing pipeline.
128///
129/// Implementations handle one concern: compress, symbolize, upload, etc.
130/// The driver calls processors in sequence for each segment.
131///
132/// # Panic safety
133///
134/// The driver catches panics from [`process()`](Self::process) and skips the
135/// panicking segment. The same processor instance is reused for subsequent
136/// segments, so implementations **must** remain in a valid state after a panic
137/// (i.e., no partially-updated invariants that would cause incorrect behavior
138/// on the next call).
139pub trait SegmentProcessor: Send {
140    /// Human-readable name for this processor (used in metrics).
141    fn name(&self) -> &'static str;
142
143    /// Initialize this processor on the worker's Tokio runtime before the
144    /// pipeline begins processing segments.
145    ///
146    /// Drivers should call this once before [`process`](Self::process) and abort
147    /// pipeline construction if it returns an error. Default: no-op.
148    ///
149    /// The same panic-safety contract as [`process`](Self::process) applies.
150    fn initialize(&mut self) -> Pin<Box<dyn Future<Output = io::Result<()>> + Send + '_>> {
151        Box::pin(std::future::ready(Ok(())))
152    }
153
154    /// Process a segment, transforming or consuming its data.
155    /// Returns the (possibly modified) data for the next processor,
156    /// or an error to skip this segment.
157    fn process(
158        &mut self,
159        data: SegmentData,
160    ) -> Pin<Box<dyn Future<Output = Result<SegmentData, ProcessError>> + Send + '_>>;
161
162    /// Called once per finished dump in triggered mode (see
163    /// [`crate::dump`]), in pipeline order, so stages can flush any
164    /// per-dump state they accumulated. Return the S3 key of a manifest
165    /// written for this dump, or `None`; the last `Some` across the
166    /// pipeline lands on [`DumpReceipt::manifest_key`](crate::dump::DumpReceipt::manifest_key).
167    ///
168    /// Default: no-op returning `None`. Never called in continuous mode.
169    /// The same panic-safety contract as [`process()`](Self::process)
170    /// applies.
171    fn finalize_dump(
172        &mut self,
173        completion: &crate::dump::DumpCompletion,
174    ) -> Pin<Box<dyn Future<Output = Option<String>> + Send + '_>> {
175        let _ = completion;
176        Box::pin(std::future::ready(None))
177    }
178}
179
180/// Error returned by a [`SegmentProcessor`].
181///
182/// Carries the [`SegmentData`] back so the caller can recover it for retry,
183/// release, or error handling.
184#[derive(Debug)]
185pub struct ProcessError {
186    data: SegmentData,
187    kind: ProcessErrorKind,
188}
189
190impl ProcessError {
191    /// Wrap `data` and `kind` into a new [`ProcessError`].
192    pub fn new(data: SegmentData, kind: ProcessErrorKind) -> Self {
193        Self { data, kind }
194    }
195
196    /// Shorthand for [`ProcessError::new`] with an I/O error.
197    pub fn io(data: SegmentData, err: std::io::Error) -> Self {
198        Self::new(data, ProcessErrorKind::Io(err))
199    }
200
201    /// The kind of failure.
202    pub fn kind(&self) -> &ProcessErrorKind {
203        &self.kind
204    }
205
206    /// Recover the carried [`SegmentData`].
207    pub fn into_data(self) -> SegmentData {
208        self.data
209    }
210
211    /// Recover both the carried [`SegmentData`] and the failure kind.
212    pub fn into_parts(self) -> (SegmentData, ProcessErrorKind) {
213        (self.data, self.kind)
214    }
215}
216
217/// Kind of failure reported by a [`SegmentProcessor`].
218#[derive(Debug)]
219#[non_exhaustive]
220pub enum ProcessErrorKind {
221    /// The processor hit an `std::io::Error`.
222    #[non_exhaustive]
223    Io(std::io::Error),
224
225    /// An error transferring data off the host.
226    #[non_exhaustive]
227    Transfer {
228        /// Underlying error source.
229        source: Box<dyn std::error::Error + Send + Sync>,
230        /// Whether this error is transient and the segment should be kept on
231        /// disk for retry.
232        retryable: bool,
233    },
234}
235
236impl ProcessErrorKind {
237    /// Build a transfer error from an arbitrary source. Used by upload
238    /// processors that classify their own retryability.
239    pub fn transfer(source: Box<dyn std::error::Error + Send + Sync>, retryable: bool) -> Self {
240        Self::Transfer { source, retryable }
241    }
242
243    /// True when the underlying error is "not found", meaning the segment was
244    /// already deleted.
245    pub fn already_deleted(&self) -> bool {
246        matches!(self, ProcessErrorKind::Io(err) if err.kind() == io::ErrorKind::NotFound)
247    }
248
249    /// Whether this error is transient and the segment should be kept on disk
250    /// for retry.
251    pub fn retryable(&self) -> bool {
252        match self {
253            ProcessErrorKind::Transfer { retryable, .. } => *retryable,
254            _ => false,
255        }
256    }
257}
258
259impl std::fmt::Display for ProcessErrorKind {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        match self {
262            Self::Io(e) => write!(f, "I/O error: {e}"),
263            Self::Transfer { source, .. } => write!(f, "S3 transfer error: {source}"),
264        }
265    }
266}
267
268impl std::fmt::Display for ProcessError {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        self.kind.fmt(f)
271    }
272}
273
274impl std::error::Error for ProcessError {
275    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
276        match &self.kind {
277            ProcessErrorKind::Io(e) => Some(e),
278            ProcessErrorKind::Transfer { source, .. } => Some(source.as_ref()),
279        }
280    }
281}
282
283impl From<std::io::Error> for ProcessErrorKind {
284    fn from(e: std::io::Error) -> Self {
285        Self::Io(e)
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::primitives::sync::Arc;
293    use crate::primitives::sync::atomic::{AtomicU64, Ordering};
294
295    /// `adjust_accounting` re-balances in-flight bytes when a processor grows
296    /// or shrinks the payload, and `Drop` releases the last reported size.
297    #[test]
298    fn adjust_accounting_tracks_payload_size() {
299        let in_flight = Arc::new(AtomicU64::new(50));
300        let accounting = SegmentAccounting {
301            in_flight_bytes: Arc::clone(&in_flight),
302            in_flight_segments: Arc::new(AtomicU64::new(1)),
303            in_flight_bytes_peak: Arc::new(AtomicU64::new(50)),
304            size: 50,
305        };
306        let mut data = SegmentData::new(
307            SegmentRef::Disk(SealedSegment {
308                path: "x".into(),
309                index: 0,
310            }),
311            Payload::from_vec(vec![0u8; 50]),
312            HashMap::new(),
313            Some(accounting),
314        );
315
316        // Growth (e.g. symbolize appends symbols).
317        data.set_payload(Payload::from_vec(vec![0u8; 150]));
318        data.adjust_accounting();
319        assert_eq!(in_flight.load(Ordering::Acquire), 150);
320
321        // Shrinkage (e.g. gzip).
322        data.set_payload(Payload::from_vec(vec![0u8; 5]));
323        data.adjust_accounting();
324        assert_eq!(in_flight.load(Ordering::Acquire), 5);
325
326        // Drop returns the last reported size to the atomic.
327        drop(data);
328        assert_eq!(in_flight.load(Ordering::Acquire), 0);
329    }
330}