1use 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 pub fn label(self) -> &'static str {
18 match self {
19 FormatType::Parquet => "parquet",
20 FormatType::Csv => "csv",
21 }
22 }
23}
24
25pub 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 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 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#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy, PartialEq, Eq, Default)]
99#[serde(rename_all = "snake_case")]
100pub enum RowGroupStrategy {
101 #[default]
105 Auto,
106 FixedRows,
108 FixedMemory,
110}
111
112#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Default)]
114#[serde(deny_unknown_fields)]
115pub struct ParquetConfig {
116 pub row_group_strategy: Option<RowGroupStrategy>,
118 pub row_group_rows: Option<usize>,
120 pub target_row_group_mb: Option<usize>,
122 pub max_row_group_mb: Option<usize>,
124}
125
126impl ParquetConfig {
127 pub const DEFAULT_TARGET_ROW_GROUP_MB: usize = 128;
128
129 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 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 let rows = rows.clamp(1_000, 10_000_000);
150 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#[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
205pub 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 #[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 #[test]
262 fn profile_override_warns_on_conflicting_explicit_codec() {
263 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 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 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 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}