Skip to main content

dataprof_core/
execution.rs

1/// Reason why profiling was truncated before exhausting the source.
2#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
3pub enum TruncationReason {
4    /// Stopped after processing a maximum number of rows.
5    MaxRows(u64),
6    /// Stopped after consuming a maximum number of bytes.
7    MaxBytes(u64),
8    /// Stopped due to memory pressure.
9    MemoryPressure,
10    /// Stopped due to a user-defined stop condition.
11    StopCondition(String),
12    /// The input stream was closed by the producer.
13    StreamClosed,
14    /// Stopped due to a timeout.
15    Timeout,
16}
17
18/// Metadata about the profiling execution.
19#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
20pub struct ExecutionMetadata {
21    /// Engine or parser that actually produced the report.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub engine: Option<String>,
24    /// Number of rows actually processed or analyzed.
25    pub rows_processed: usize,
26    /// Number of bytes consumed from the source, if known.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub bytes_consumed: Option<u64>,
29    /// Number of columns detected in the data.
30    pub columns_detected: usize,
31    /// Total execution time in milliseconds.
32    pub scan_time_ms: u128,
33    /// Throughput in rows per second, auto-calculated when possible.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub throughput_rows_sec: Option<f64>,
36    /// Peak process memory usage (resident set size) in megabytes observed
37    /// during profiling, sampled at chunk/batch boundaries. Absent when the
38    /// platform provided no reading or the input path does not track memory.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub memory_peak_mb: Option<f64>,
41    /// Number of errors encountered during profiling.
42    pub error_count: usize,
43    /// Number of data rows whose field count differed from the header's.
44    ///
45    /// These rows were recovered — extra trailing fields dropped, missing
46    /// trailing fields padded to null — so profiling continued, but each one
47    /// is a structural violation in the source. A nonzero value means the file
48    /// did not parse cleanly even though a report was produced. Additive field:
49    /// legacy documents without it deserialize as `0`.
50    #[serde(default)]
51    #[schemars(default)]
52    pub ragged_row_count: usize,
53    /// Whether the entire source was consumed.
54    pub source_exhausted: bool,
55    /// If the source was not exhausted, why processing stopped.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub truncation_reason: Option<TruncationReason>,
58    /// Whether sampling was applied.
59    pub sampling_applied: bool,
60    /// Ratio of rows analyzed to total rows when sampling is meaningful.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub sampling_ratio: Option<f64>,
63}
64
65impl ExecutionMetadata {
66    /// Create new execution metadata with throughput calculated automatically.
67    pub fn new(rows_processed: usize, columns_detected: usize, scan_time_ms: u128) -> Self {
68        let throughput_rows_sec = if scan_time_ms > 0 {
69            Some(rows_processed as f64 / (scan_time_ms as f64 / 1000.0))
70        } else {
71            None
72        };
73
74        Self {
75            engine: None,
76            rows_processed,
77            bytes_consumed: None,
78            columns_detected,
79            scan_time_ms,
80            throughput_rows_sec,
81            memory_peak_mb: None,
82            error_count: 0,
83            ragged_row_count: 0,
84            source_exhausted: true,
85            truncation_reason: None,
86            sampling_applied: false,
87            sampling_ratio: None,
88        }
89    }
90
91    /// Record the engine or parser that actually produced the report.
92    pub fn with_engine(mut self, engine: impl Into<String>) -> Self {
93        self.engine = Some(engine.into());
94        self
95    }
96
97    /// Set sampling information.
98    pub fn with_sampling(mut self, ratio: f64) -> Self {
99        self.sampling_applied = true;
100        self.sampling_ratio = Some(ratio);
101        self
102    }
103
104    /// Explicitly set whether the source was fully consumed.
105    pub fn with_source_exhausted(mut self, exhausted: bool) -> Self {
106        self.source_exhausted = exhausted;
107        self
108    }
109
110    /// Mark the execution as truncated.
111    pub fn with_truncation(mut self, reason: TruncationReason) -> Self {
112        self.source_exhausted = false;
113        self.truncation_reason = Some(reason);
114        self
115    }
116
117    /// Set the number of bytes consumed from the source.
118    pub fn with_bytes_consumed(mut self, bytes: u64) -> Self {
119        self.bytes_consumed = Some(bytes);
120        self
121    }
122
123    /// Set the error count.
124    pub fn with_error_count(mut self, count: usize) -> Self {
125        self.error_count = count;
126        self
127    }
128
129    /// Set the count of ragged rows (field count != header field count).
130    pub fn with_ragged_row_count(mut self, count: usize) -> Self {
131        self.ragged_row_count = count;
132        self
133    }
134
135    /// Set peak memory usage.
136    pub fn with_memory_peak_mb(mut self, mb: f64) -> Self {
137        self.memory_peak_mb = Some(mb);
138        self
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn test_execution_metadata_throughput_calculation() {
148        let meta = ExecutionMetadata::new(1000, 5, 500);
149        assert!(meta.throughput_rows_sec.is_some());
150        assert!((meta.throughput_rows_sec.unwrap() - 2000.0).abs() < 1.0);
151        assert!(meta.source_exhausted);
152        assert!(!meta.sampling_applied);
153        assert!(meta.sampling_ratio.is_none());
154    }
155
156    #[test]
157    fn test_execution_metadata_zero_time_no_throughput() {
158        let meta = ExecutionMetadata::new(100, 3, 0);
159        assert!(meta.throughput_rows_sec.is_none());
160    }
161
162    #[test]
163    fn test_execution_metadata_with_engine() {
164        let meta = ExecutionMetadata::new(100, 3, 25).with_engine("incremental");
165        assert_eq!(meta.engine.as_deref(), Some("incremental"));
166    }
167
168    #[test]
169    fn test_execution_metadata_with_sampling() {
170        let meta = ExecutionMetadata::new(500, 3, 100).with_sampling(0.5);
171        assert!(meta.sampling_applied);
172        assert_eq!(meta.sampling_ratio, Some(0.5));
173    }
174
175    #[test]
176    fn test_execution_metadata_ragged_row_count() {
177        let meta = ExecutionMetadata::new(100, 3, 25);
178        assert_eq!(meta.ragged_row_count, 0);
179        let meta = meta.with_ragged_row_count(2);
180        assert_eq!(meta.ragged_row_count, 2);
181    }
182
183    #[test]
184    fn test_execution_metadata_ragged_defaults_when_absent() {
185        // A document written before the field existed must deserialize as 0,
186        // not fail or fabricate a value.
187        let json = r#"{
188            "rows_processed": 10,
189            "columns_detected": 2,
190            "scan_time_ms": 5,
191            "error_count": 0,
192            "source_exhausted": true,
193            "sampling_applied": false
194        }"#;
195        let meta: ExecutionMetadata = serde_json::from_str(json).unwrap();
196        assert_eq!(meta.ragged_row_count, 0);
197    }
198
199    #[test]
200    fn test_execution_metadata_with_truncation() {
201        let meta =
202            ExecutionMetadata::new(1000, 5, 200).with_truncation(TruncationReason::MaxRows(1000));
203        assert!(!meta.source_exhausted);
204        assert!(meta.truncation_reason.is_some());
205    }
206
207    #[test]
208    fn test_truncation_reason_serde_roundtrip() {
209        let reasons = vec![
210            TruncationReason::MaxRows(5000),
211            TruncationReason::MaxBytes(1_000_000),
212            TruncationReason::MemoryPressure,
213            TruncationReason::StopCondition("accuracy > 0.95".to_string()),
214            TruncationReason::StreamClosed,
215            TruncationReason::Timeout,
216        ];
217
218        for reason in reasons {
219            let json = serde_json::to_string(&reason).unwrap();
220            let deserialized: TruncationReason = serde_json::from_str(&json).unwrap();
221            let json2 = serde_json::to_string(&deserialized).unwrap();
222            assert_eq!(json, json2);
223        }
224    }
225}