Skip to main content

rivet/config/
format.rs

1//! Output format & compression: parquet/csv selector, codecs, parquet tuning.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy, PartialEq, Eq)]
7#[serde(rename_all = "lowercase")]
8pub enum FormatType {
9    Parquet,
10    Csv,
11}
12
13impl FormatType {
14    /// Stable lowercase string label for persistence and display.
15    /// Prefer this over `format!("{:?}", self).to_lowercase()` — `Debug` output
16    /// is not a stable format contract.
17    pub fn label(self) -> &'static str {
18        match self {
19            FormatType::Parquet => "parquet",
20            FormatType::Csv => "csv",
21        }
22    }
23}
24
25/// Whether `format` actually encodes `compression` on write.
26///
27/// Only Parquet has a compression encoder (see [`crate::format::parquet`]); the
28/// CSV writer ([`crate::format::csv`]) ignores the codec entirely. Accepting a
29/// non-`None` codec for CSV would be a silent no-op — the file stays
30/// uncompressed while the run manifest records the codec — so config validation
31/// rejects that combination up-front (Finding #10). `None` is always supported
32/// (it means "do not compress").
33pub fn compression_supported(format: FormatType, compression: CompressionType) -> bool {
34    match format {
35        FormatType::Parquet => true,
36        FormatType::Csv => matches!(compression, CompressionType::None),
37    }
38}
39
40#[derive(Debug, Default, Deserialize, Serialize, JsonSchema, Clone, Copy, PartialEq, Eq)]
41#[serde(rename_all = "lowercase")]
42pub enum CompressionType {
43    #[default]
44    Zstd,
45    Snappy,
46    Gzip,
47    Lz4,
48    None,
49}
50
51impl CompressionType {
52    /// Stable lowercase string label for persistence and display.
53    pub fn label(self) -> &'static str {
54        match self {
55            CompressionType::Zstd => "zstd",
56            CompressionType::Snappy => "snappy",
57            CompressionType::Gzip => "gzip",
58            CompressionType::Lz4 => "lz4",
59            CompressionType::None => "none",
60        }
61    }
62
63    /// Parse a [`label`](Self::label)-shaped string back to a codec.
64    ///
65    /// Used by config validation to evaluate an *explicitly-written* codec
66    /// against [`compression_supported`] without re-deriving the serde mapping.
67    /// Returns `None` for any unrecognised string (serde rejects those during
68    /// the real parse anyway).
69    pub fn from_label(s: &str) -> Option<Self> {
70        match s {
71            "zstd" => Some(CompressionType::Zstd),
72            "snappy" => Some(CompressionType::Snappy),
73            "gzip" => Some(CompressionType::Gzip),
74            "lz4" => Some(CompressionType::Lz4),
75            "none" => Some(CompressionType::None),
76            _ => None,
77        }
78    }
79}
80
81/// Parquet row group tuning strategy.
82///
83/// Controls how many rows Rivet places in each Parquet row group. Row group size
84/// affects memory usage during write, compression ratio, and downstream read
85/// performance (predicate pushdown, column skipping).
86///
87/// ```yaml
88/// exports:
89///   - name: events
90///     parquet:
91///       row_group_strategy: auto          # compute from schema + target_row_group_mb
92///       target_row_group_mb: 128          # default target; auto + fixed_memory only
93///       max_row_group_mb: 256             # optional upper bound (all strategies)
94///       # row_group_strategy: fixed_rows  # exact row count
95///       # row_group_rows: 500000          # used with fixed_rows
96///       # row_group_strategy: fixed_memory  # same math as auto, made explicit
97/// ```
98#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy, PartialEq, Eq, Default)]
99#[serde(rename_all = "snake_case")]
100pub enum RowGroupStrategy {
101    /// Compute rows-per-group from schema column types and `target_row_group_mb`.
102    /// For narrow tables this produces large groups (efficient). For wide tables
103    /// it reduces group size to stay within the memory target.
104    #[default]
105    Auto,
106    /// Use `row_group_rows` as a literal row count. Ignores memory targets.
107    FixedRows,
108    /// Identical math to `auto`, but the strategy label is explicit in logs.
109    FixedMemory,
110}
111
112/// Parquet-specific tuning for row group sizing.
113#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Default)]
114#[serde(deny_unknown_fields)]
115pub struct ParquetConfig {
116    /// How to determine the row group size. Default: `auto`.
117    pub row_group_strategy: Option<RowGroupStrategy>,
118    /// Exact number of rows per group (`fixed_rows` only).
119    pub row_group_rows: Option<usize>,
120    /// Target Arrow buffer memory per row group in MB (`auto` and `fixed_memory`). Default: 128.
121    pub target_row_group_mb: Option<usize>,
122    /// Hard upper bound on row group memory in MB. When set, further reduces computed row count.
123    pub max_row_group_mb: Option<usize>,
124}
125
126impl ParquetConfig {
127    pub const DEFAULT_TARGET_ROW_GROUP_MB: usize = 128;
128
129    /// Compute the effective rows-per-group from schema column types.
130    ///
131    /// Returns `None` for `fixed_rows` when `row_group_rows` is not set (caller
132    /// falls back to the parquet library default of 1,048,576 rows).
133    pub fn effective_row_group_rows(&self, schema: &arrow::datatypes::SchemaRef) -> Option<usize> {
134        let strategy = self.row_group_strategy.unwrap_or_default();
135        match strategy {
136            // Clamp to >= 1 like the Auto/FixedMemory arms below: a row group cannot
137            // hold 0 rows, so `row_group_rows: 0` would panic parquet-rs's
138            // set_max_row_group_row_count(0) mid-export (config passed, run crashed).
139            // Any positive count the user pins still stands (this arm is deliberately
140            // unclamped-above so an exact large/small group size is honoured).
141            RowGroupStrategy::FixedRows => self.row_group_rows.map(|n| n.max(1)),
142            RowGroupStrategy::Auto | RowGroupStrategy::FixedMemory => {
143                let target_mb = self
144                    .target_row_group_mb
145                    .unwrap_or(Self::DEFAULT_TARGET_ROW_GROUP_MB);
146                let row_bytes = crate::tuning::estimate_row_bytes(schema).max(1);
147                let rows = (target_mb * 1024 * 1024) / row_bytes;
148                // Clamp to a safe range: at least 1 000 rows, at most 10 M rows.
149                let rows = rows.clamp(1_000, 10_000_000);
150                // Apply optional max_row_group_mb cap.
151                let rows = if let Some(max_mb) = self.max_row_group_mb {
152                    let max_rows = ((max_mb * 1024 * 1024) / row_bytes).max(1_000);
153                    rows.min(max_rows)
154                } else {
155                    rows
156                };
157                Some(rows)
158            }
159        }
160    }
161}
162
163/// High-level compression preset. Maps to a `(CompressionType, level)` pair.
164///
165/// ```yaml
166/// exports:
167///   - name: events
168///     compression_profile: fast   # snappy — fastest, larger files
169///     # compression_profile: balanced  # zstd level 3 — default for production
170///     # compression_profile: compact   # zstd level 9 — smallest files, more CPU
171///     # compression_profile: none      # no compression
172/// ```
173///
174/// When set, takes precedence over `compression` and `compression_level`.
175#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy, PartialEq, Eq)]
176#[serde(rename_all = "lowercase")]
177pub enum CompressionProfile {
178    None,
179    Fast,
180    Balanced,
181    Compact,
182}
183
184impl CompressionProfile {
185    #[allow(dead_code)]
186    pub fn label(self) -> &'static str {
187        match self {
188            CompressionProfile::None => "none",
189            CompressionProfile::Fast => "fast",
190            CompressionProfile::Balanced => "balanced",
191            CompressionProfile::Compact => "compact",
192        }
193    }
194
195    pub fn to_codec(self) -> (CompressionType, Option<u32>) {
196        match self {
197            CompressionProfile::None => (CompressionType::None, None),
198            CompressionProfile::Fast => (CompressionType::Snappy, None),
199            CompressionProfile::Balanced => (CompressionType::Zstd, Some(3)),
200            CompressionProfile::Compact => (CompressionType::Zstd, Some(9)),
201        }
202    }
203}
204
205/// L24: when a `compression_profile` is set *and* the user also wrote an
206/// explicit codec the profile silently discards, return a one-line warning
207/// naming both — otherwise `None`.
208///
209/// Pure (no logging) so it can be unit-tested; the caller emits the message.
210/// `explicit_compression` is `Some` only when the user actually wrote a
211/// `compression:` codec — a `#[serde(default)]` Zstd cannot be distinguished
212/// from an omitted field, so the caller passes `None` for the defaulted case.
213pub fn compression_profile_override_warning(
214    profile: CompressionProfile,
215    explicit_compression: Option<CompressionType>,
216    explicit_level: Option<u32>,
217) -> Option<String> {
218    let (codec, _) = profile.to_codec();
219    if let Some(c) = explicit_compression
220        && c != codec
221    {
222        return Some(format!(
223            "compression_profile '{}' overrides explicit compression '{}' (using '{}')",
224            profile.label(),
225            c.label(),
226            codec.label(),
227        ));
228    }
229    if explicit_level.is_some() {
230        return Some(format!(
231            "compression_profile '{}' overrides explicit compression_level (the profile sets its own level)",
232            profile.label(),
233        ));
234    }
235    None
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    // ── Label methods stability ──────────────────────────────────────────────
243
244    #[test]
245    fn format_type_labels_stable() {
246        assert_eq!(FormatType::Parquet.label(), "parquet");
247        assert_eq!(FormatType::Csv.label(), "csv");
248    }
249
250    #[test]
251    fn compression_type_labels_stable() {
252        assert_eq!(CompressionType::Zstd.label(), "zstd");
253        assert_eq!(CompressionType::Snappy.label(), "snappy");
254        assert_eq!(CompressionType::Gzip.label(), "gzip");
255        assert_eq!(CompressionType::Lz4.label(), "lz4");
256        assert_eq!(CompressionType::None.label(), "none");
257    }
258
259    // ── L24: compression_profile override warning ───────────────────────────
260
261    #[test]
262    fn profile_override_warns_on_conflicting_explicit_codec() {
263        // `compression_profile: fast` (snappy) with explicit `compression: gzip`
264        // must warn, naming both codecs and the winner.
265        let msg = compression_profile_override_warning(
266            CompressionProfile::Fast,
267            Some(CompressionType::Gzip),
268            None,
269        )
270        .expect("conflicting explicit codec must warn");
271        assert!(msg.contains("fast"), "got: {msg}");
272        assert!(msg.contains("gzip"), "got: {msg}");
273        assert!(msg.contains("snappy"), "winner codec named, got: {msg}");
274    }
275
276    #[test]
277    fn profile_override_warns_on_explicit_level() {
278        let msg = compression_profile_override_warning(CompressionProfile::Balanced, None, Some(7))
279            .expect("explicit compression_level under a profile must warn");
280        assert!(msg.contains("compression_level"), "got: {msg}");
281    }
282
283    #[test]
284    fn profile_override_silent_when_codec_matches_profile() {
285        // Profile `fast` resolves to snappy; an explicit `snappy` is not a
286        // conflict, so no warning.
287        assert!(
288            compression_profile_override_warning(
289                CompressionProfile::Fast,
290                Some(CompressionType::Snappy),
291                None,
292            )
293            .is_none()
294        );
295    }
296
297    #[test]
298    fn profile_override_silent_when_nothing_explicit() {
299        assert!(
300            compression_profile_override_warning(CompressionProfile::Compact, None, None).is_none()
301        );
302    }
303
304    // ── ParquetConfig::effective_row_group_rows ─────────────────────────────
305
306    fn narrow_schema() -> arrow::datatypes::SchemaRef {
307        use arrow::datatypes::{DataType, Field, Schema};
308        std::sync::Arc::new(Schema::new(vec![
309            Field::new("id", DataType::Int64, false),
310            Field::new("created_at", DataType::Int64, false),
311        ]))
312    }
313
314    fn wide_schema() -> arrow::datatypes::SchemaRef {
315        use arrow::datatypes::{DataType, Field, Schema};
316        let fields: Vec<Field> = (0..50)
317            .map(|i| Field::new(format!("col{i}"), DataType::Utf8, true))
318            .collect();
319        std::sync::Arc::new(Schema::new(fields))
320    }
321
322    #[test]
323    fn parquet_config_fixed_rows_returns_explicit_count() {
324        let pc = ParquetConfig {
325            row_group_strategy: Some(RowGroupStrategy::FixedRows),
326            row_group_rows: Some(250_000),
327            ..Default::default()
328        };
329        assert_eq!(pc.effective_row_group_rows(&narrow_schema()), Some(250_000));
330    }
331
332    #[test]
333    fn parquet_config_fixed_rows_zero_is_clamped_to_one_not_a_panic() {
334        // RED before the .max(1) clamp: `row_group_rows: 0` returned Some(0), which
335        // panics parquet-rs's set_max_row_group_row_count(0) mid-export (config
336        // passed `check`, run crashed). A row group needs >= 1 row.
337        let pc = ParquetConfig {
338            row_group_strategy: Some(RowGroupStrategy::FixedRows),
339            row_group_rows: Some(0),
340            ..Default::default()
341        };
342        assert_eq!(
343            pc.effective_row_group_rows(&narrow_schema()),
344            Some(1),
345            "row_group_rows: 0 must clamp to 1, never reach the parquet writer as 0"
346        );
347    }
348
349    #[test]
350    fn parquet_config_fixed_rows_without_row_group_rows_returns_none() {
351        let pc = ParquetConfig {
352            row_group_strategy: Some(RowGroupStrategy::FixedRows),
353            row_group_rows: None,
354            ..Default::default()
355        };
356        assert_eq!(pc.effective_row_group_rows(&narrow_schema()), None);
357    }
358
359    #[test]
360    fn parquet_config_auto_narrow_table_produces_large_groups() {
361        let pc = ParquetConfig {
362            row_group_strategy: Some(RowGroupStrategy::Auto),
363            target_row_group_mb: Some(128),
364            ..Default::default()
365        };
366        let rows = pc.effective_row_group_rows(&narrow_schema()).unwrap();
367        assert!(
368            rows >= 1_000_000,
369            "narrow table should get large groups, got {rows}"
370        );
371    }
372
373    #[test]
374    fn parquet_config_auto_wide_table_produces_smaller_groups() {
375        let pc = ParquetConfig {
376            row_group_strategy: Some(RowGroupStrategy::Auto),
377            target_row_group_mb: Some(128),
378            ..Default::default()
379        };
380        let rows = pc.effective_row_group_rows(&wide_schema()).unwrap();
381        assert!(
382            rows < 100_000,
383            "wide table should get smaller groups, got {rows}"
384        );
385        assert!(rows >= 1_000, "should be at least the minimum, got {rows}");
386    }
387
388    #[test]
389    fn parquet_config_max_row_group_mb_caps_result() {
390        let pc = ParquetConfig {
391            row_group_strategy: Some(RowGroupStrategy::Auto),
392            target_row_group_mb: Some(128),
393            max_row_group_mb: Some(1),
394            ..Default::default()
395        };
396        let rows = pc.effective_row_group_rows(&narrow_schema()).unwrap();
397        assert!(
398            rows <= 100_000,
399            "max_row_group_mb should cap rows, got {rows}"
400        );
401    }
402
403    #[test]
404    fn parquet_config_deserializes_from_yaml() {
405        let yaml = "row_group_strategy: auto\ntarget_row_group_mb: 64\n";
406        let pc: ParquetConfig = serde_yaml_ng::from_str(yaml).unwrap();
407        assert_eq!(pc.row_group_strategy, Some(RowGroupStrategy::Auto));
408        assert_eq!(pc.target_row_group_mb, Some(64));
409    }
410
411    #[test]
412    fn parquet_config_fixed_memory_same_math_as_auto() {
413        let auto_pc = ParquetConfig {
414            row_group_strategy: Some(RowGroupStrategy::Auto),
415            target_row_group_mb: Some(64),
416            ..Default::default()
417        };
418        let fixed_mem_pc = ParquetConfig {
419            row_group_strategy: Some(RowGroupStrategy::FixedMemory),
420            target_row_group_mb: Some(64),
421            ..Default::default()
422        };
423        assert_eq!(
424            auto_pc.effective_row_group_rows(&narrow_schema()),
425            fixed_mem_pc.effective_row_group_rows(&narrow_schema()),
426            "FixedMemory and Auto must produce identical row counts for the same target"
427        );
428        assert_eq!(
429            auto_pc.effective_row_group_rows(&wide_schema()),
430            fixed_mem_pc.effective_row_group_rows(&wide_schema()),
431        );
432    }
433
434    #[test]
435    fn parquet_config_auto_without_target_uses_default_128mb() {
436        let pc = ParquetConfig {
437            row_group_strategy: Some(RowGroupStrategy::Auto),
438            target_row_group_mb: None,
439            ..Default::default()
440        };
441        let rows = pc.effective_row_group_rows(&narrow_schema()).unwrap();
442        assert!(
443            rows >= 1_000_000,
444            "default 128 MB target should give large groups for narrow table; got {rows}"
445        );
446    }
447
448    #[test]
449    fn parquet_config_no_block_gives_none_for_row_group_rows() {
450        let pc = ParquetConfig::default();
451        let rows = pc.effective_row_group_rows(&narrow_schema());
452        assert!(
453            rows.is_some(),
454            "default ParquetConfig (strategy: None) must return Some, got None"
455        );
456    }
457
458    #[test]
459    fn parquet_config_small_target_clamps_to_minimum_1000_rows() {
460        let pc = ParquetConfig {
461            row_group_strategy: Some(RowGroupStrategy::Auto),
462            target_row_group_mb: Some(1),
463            ..Default::default()
464        };
465        let rows = pc.effective_row_group_rows(&wide_schema()).unwrap();
466        assert!(
467            rows >= 1_000,
468            "must not go below minimum 1 000 rows; got {rows}"
469        );
470    }
471}