1use parquet::errors::ParquetError;
9use serde::{Deserialize, Serialize};
10use snafu::{Backtrace, prelude::*};
11
12use crate::{
13 coverage::EntityIdentity,
14 metadata::{
15 index::{IndexKind, IndexValue, IndexValueError},
16 logical_schema::{ArrowToLogicalSchemaError, LogicalSchemaValidationError},
17 },
18};
19
20#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
27#[serde(rename_all = "lowercase")]
28pub enum FileFormat {
29 #[default]
31 Parquet,
32 }
37
38#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
40pub enum SegmentEntityLayout {
41 NotApplicable,
43 Single(EntityIdentity),
45 Mixed,
47}
48
49#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
53pub struct SegmentMeta {
54 pub path: String,
56
57 pub format: FileFormat,
59
60 pub entity_layout: SegmentEntityLayout,
62
63 pub index_min: IndexValue,
65
66 pub index_max: IndexValue,
68
69 pub row_count: u64,
71
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub file_size: Option<u64>,
75
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub coverage_path: Option<String>,
79}
80
81#[derive(Debug, Snafu, Clone, PartialEq, Eq)]
83#[snafu(display(
84 "Invalid ordered-index column {column} in segment at {path}: expected {expected_domain}, observed {observed_type}"
85))]
86pub struct ParquetIndexColumnError {
87 pub path: String,
89 pub column: String,
91 pub expected_domain: &'static str,
93 pub observed_type: String,
95}
96
97impl SegmentMeta {
98 pub fn with_coverage_path(mut self, path: impl Into<String>) -> Self {
100 self.coverage_path = Some(path.into());
101 self
102 }
103
104 pub fn validate_bounds(&self, kind: &IndexKind) -> Result<(), SegmentMetaError> {
110 self.index_min.validate_kind(kind).map_err(|source| {
111 SegmentMetaError::InvalidIndexBounds {
112 path: self.path.clone(),
113 source,
114 }
115 })?;
116 self.index_max.validate_kind(kind).map_err(|source| {
117 SegmentMetaError::InvalidIndexBounds {
118 path: self.path.clone(),
119 source,
120 }
121 })?;
122 if self
123 .index_min
124 .compare(&self.index_max)
125 .map_err(|source| SegmentMetaError::InvalidIndexBounds {
126 path: self.path.clone(),
127 source,
128 })?
129 .is_gt()
130 {
131 return Err(SegmentMetaError::InvalidIndexBounds {
132 path: self.path.clone(),
133 source: IndexValueError::InvalidBounds {
134 min: self.index_min.clone(),
135 max: self.index_max.clone(),
136 },
137 });
138 }
139 Ok(())
140 }
141}
142
143#[derive(Debug, Snafu)]
149#[non_exhaustive]
150pub enum SegmentMetaError {
151 #[snafu(display("Invalid ordered-index bounds in segment at {path}: {source}"))]
153 InvalidIndexBounds {
154 path: String,
156 source: IndexValueError,
158 },
159
160 #[snafu(display("Segment file too short to be valid Parquet: {path}"))]
162 TooShort {
163 path: String,
165 },
166
167 #[snafu(display("Error reading Parquet metadata for segment at {path}: {source}"))]
169 ParquetRead {
170 path: String,
172 source: ParquetError,
174 backtrace: Backtrace,
176 },
177
178 #[snafu(display("Row-group inspection task failed for segment at {path}: {source}"))]
180 RowGroupTask {
181 path: String,
183 source: tokio::task::JoinError,
185 backtrace: Backtrace,
187 },
188
189 #[snafu(transparent)]
191 OrderedIndexColumn {
192 source: ParquetIndexColumnError,
194 },
195
196 #[snafu(display(
198 "Parquet statistics shape invalid for {column} in segment at {path}: {detail}"
199 ))]
200 ParquetStatsShape {
201 path: String,
203 column: String,
205 detail: String,
207 },
208
209 #[snafu(display(
211 "No observed {expected_domain} value for ordered-index column {column} in segment at {path}"
212 ))]
213 NoObservedIndexValue {
214 path: String,
216 column: String,
218 expected_domain: &'static str,
220 },
221
222 #[snafu(display("Invalid logical schema derived from Parquet at {path}: {source}"))]
224 LogicalSchemaInvalid {
225 path: String,
227 #[snafu(source(from(LogicalSchemaValidationError, Box::new)))]
229 source: Box<LogicalSchemaValidationError>,
230 backtrace: Backtrace,
232 },
233
234 #[snafu(display("Invalid Arrow schema derived from Parquet at {path}: {source}"))]
236 ArrowToLogicalSchema {
237 path: String,
239 #[snafu(source(from(ArrowToLogicalSchemaError, Box::new)))]
241 source: Box<ArrowToLogicalSchemaError>,
242 backtrace: Backtrace,
244 },
245
246 #[snafu(display(
248 "Invalid metadata for row group {row_group_index} in segment at {path}: {detail}"
249 ))]
250 InvalidRowGroupMetadata {
251 path: String,
253 row_group_index: usize,
255 detail: String,
257 },
258}
259
260pub(crate) fn cmp_segment_meta_by_index(
265 a: &SegmentMeta,
266 b: &SegmentMeta,
267) -> Result<std::cmp::Ordering, IndexValueError> {
268 let min_order = a.index_min.compare(&b.index_min)?;
269 if !min_order.is_eq() {
270 return Ok(min_order);
271 }
272 let max_order = a.index_max.compare(&b.index_max)?;
273 Ok(max_order.then_with(|| a.path.cmp(&b.path)))
274}
275
276pub(crate) fn sort_segment_meta_by_index<T>(segments: &mut [T]) -> Result<(), IndexValueError>
278where
279 T: std::borrow::Borrow<SegmentMeta>,
280{
281 let mut domain: Option<&IndexValue> = None;
282 for segment in segments.iter().map(std::borrow::Borrow::borrow) {
283 if segment.index_min.compare(&segment.index_max)?.is_gt() {
284 return Err(IndexValueError::InvalidBounds {
285 min: segment.index_min.clone(),
286 max: segment.index_max.clone(),
287 });
288 }
289 if let Some(domain) = domain {
290 domain.compare(&segment.index_min)?;
291 domain.compare(&segment.index_max)?;
292 } else {
293 domain = Some(&segment.index_min);
294 }
295 }
296
297 let mut sort_error = None;
298 segments.sort_unstable_by(
299 |a, b| match cmp_segment_meta_by_index(a.borrow(), b.borrow()) {
300 Ok(order) => order,
301 Err(error) => {
302 sort_error.get_or_insert(error);
303 std::cmp::Ordering::Equal
304 }
305 },
306 );
307 if let Some(error) = sort_error {
308 return Err(error);
309 }
310 Ok(())
311}
312
313#[cfg(test)]
314mod tests {
315 use std::num::NonZeroU64;
316
317 use super::*;
318 use chrono::{TimeZone, Utc};
319
320 fn seg(id: &str, ts_min: i64, ts_max: i64) -> SegmentMeta {
321 SegmentMeta {
322 path: format!("data/{id}.parquet"),
323 format: FileFormat::Parquet,
324 entity_layout: SegmentEntityLayout::NotApplicable,
325 index_min: IndexValue::Timestamp(Utc.timestamp_opt(ts_min, 0).single().unwrap()),
326 index_max: IndexValue::Timestamp(Utc.timestamp_opt(ts_max, 0).single().unwrap()),
327 row_count: 1,
328 file_size: None,
329 coverage_path: None,
330 }
331 }
332
333 #[test]
334 fn entity_layout_json_roundtrips_are_stable() {
335 let cases = [
336 (SegmentEntityLayout::NotApplicable, "\"NotApplicable\""),
337 (
338 SegmentEntityLayout::Single(
339 EntityIdentity::try_new(vec!["us".into(), "device-1".into()]).unwrap(),
340 ),
341 r#"{"Single":[{"type":"utf8","value":"us"},{"type":"utf8","value":"device-1"}]}"#,
342 ),
343 (SegmentEntityLayout::Mixed, "\"Mixed\""),
344 ];
345
346 for (layout, expected_json) in cases {
347 let json = serde_json::to_string(&layout).unwrap();
348 assert_eq!(json, expected_json);
349 assert_eq!(
350 serde_json::from_str::<SegmentEntityLayout>(&json).unwrap(),
351 layout
352 );
353 }
354 }
355
356 #[test]
357 fn single_layout_rejects_an_empty_identity() {
358 let error = serde_json::from_str::<SegmentEntityLayout>(r#"{"Single":[]}"#)
359 .expect_err("empty identity must be rejected");
360 assert!(error.to_string().contains("at least one component"));
361 }
362
363 #[test]
364 fn ordering_is_deterministic_with_tie_breakers() {
365 let mut v = vec![
366 seg("c", 10, 20),
367 seg("b", 10, 20),
368 seg("a", 10, 30),
369 seg("d", 5, 7),
370 ];
371
372 v.sort_unstable_by(|a, b| cmp_segment_meta_by_index(a, b).expect("matching domains"));
373
374 let paths: Vec<String> = v.into_iter().map(|s| s.path).collect();
375 assert_eq!(
376 paths,
377 vec![
378 "data/d.parquet",
379 "data/b.parquet",
380 "data/c.parquet",
381 "data/a.parquet"
382 ]
383 );
384 }
385
386 #[test]
387 fn ordering_is_equal_for_identical_segments() {
388 let a = seg("same", 10, 20);
389 let b = seg("same", 10, 20);
390 assert_eq!(
391 cmp_segment_meta_by_index(&a, &b).unwrap(),
392 std::cmp::Ordering::Equal
393 );
394 assert_eq!(
395 cmp_segment_meta_by_index(&b, &a).unwrap(),
396 std::cmp::Ordering::Equal
397 );
398 }
399
400 #[test]
401 fn ordering_primary_key_ts_min_dominates() {
402 let mut v = vec![seg("z", 20, 30), seg("a", 10, 50), seg("m", 15, 10)];
403
404 v.sort_unstable_by(|a, b| cmp_segment_meta_by_index(a, b).expect("matching domains"));
405
406 let paths: Vec<String> = v.into_iter().map(|s| s.path).collect();
407 assert_eq!(
408 paths,
409 vec!["data/a.parquet", "data/m.parquet", "data/z.parquet"]
410 );
411 }
412
413 #[test]
414 fn ordering_uses_path_as_final_tie_breaker() {
415 let mut v = vec![seg("b", 10, 20), seg("a", 10, 20), seg("c", 10, 20)];
416
417 v.sort_unstable_by(|a, b| cmp_segment_meta_by_index(a, b).expect("matching domains"));
418
419 let paths: Vec<String> = v.into_iter().map(|s| s.path).collect();
420 assert_eq!(
421 paths,
422 vec!["data/a.parquet", "data/b.parquet", "data/c.parquet"]
423 );
424 }
425
426 #[test]
427 fn segment_bounds_validate_domain_and_native_order() {
428 let signed = IndexKind::Int64 {
429 index_granularity: NonZeroU64::new(1).unwrap(),
430 };
431 let valid = SegmentMeta {
432 path: "data/valid.parquet".to_string(),
433 format: FileFormat::Parquet,
434 entity_layout: SegmentEntityLayout::NotApplicable,
435 index_min: IndexValue::Int64(i64::MIN),
436 index_max: IndexValue::Int64(i64::MAX),
437 row_count: 1,
438 file_size: None,
439 coverage_path: None,
440 };
441 valid.validate_bounds(&signed).unwrap();
442
443 let reversed = SegmentMeta {
444 index_min: IndexValue::Int64(1),
445 index_max: IndexValue::Int64(0),
446 ..valid.clone()
447 };
448 assert!(matches!(
449 reversed.validate_bounds(&signed),
450 Err(SegmentMetaError::InvalidIndexBounds {
451 source: IndexValueError::InvalidBounds { .. },
452 ..
453 })
454 ));
455
456 let wrong_domain = SegmentMeta {
457 index_min: IndexValue::UInt64(0),
458 index_max: IndexValue::UInt64(u64::MAX),
459 ..valid
460 };
461 assert!(matches!(
462 wrong_domain.validate_bounds(&signed),
463 Err(SegmentMetaError::InvalidIndexBounds {
464 source: IndexValueError::KindMismatch { .. },
465 ..
466 })
467 ));
468 }
469
470 #[test]
471 fn integer_segment_ordering_uses_native_bounds_then_path() {
472 let mut segments = vec![
473 SegmentMeta {
474 path: "data/z.parquet".to_string(),
475 format: FileFormat::Parquet,
476 entity_layout: SegmentEntityLayout::NotApplicable,
477 index_min: IndexValue::UInt64(u64::MAX),
478 index_max: IndexValue::UInt64(u64::MAX),
479 row_count: 1,
480 file_size: None,
481 coverage_path: None,
482 },
483 SegmentMeta {
484 path: "data/b.parquet".to_string(),
485 format: FileFormat::Parquet,
486 entity_layout: SegmentEntityLayout::NotApplicable,
487 index_min: IndexValue::UInt64(0),
488 index_max: IndexValue::UInt64(7),
489 row_count: 1,
490 file_size: None,
491 coverage_path: None,
492 },
493 SegmentMeta {
494 path: "data/a.parquet".to_string(),
495 format: FileFormat::Parquet,
496 entity_layout: SegmentEntityLayout::NotApplicable,
497 index_min: IndexValue::UInt64(0),
498 index_max: IndexValue::UInt64(7),
499 row_count: 1,
500 file_size: None,
501 coverage_path: None,
502 },
503 ];
504 segments.sort_unstable_by(|a, b| cmp_segment_meta_by_index(a, b).unwrap());
505 assert_eq!(
506 segments
507 .into_iter()
508 .map(|segment| segment.path)
509 .collect::<Vec<_>>(),
510 vec!["data/a.parquet", "data/b.parquet", "data/z.parquet"]
511 );
512 }
513
514 #[test]
515 fn sorting_rejects_cross_domain_segments() {
516 let mut segments = vec![
517 seg("timestamp", 0, 1),
518 SegmentMeta {
519 path: "data/integer.parquet".to_string(),
520 format: FileFormat::Parquet,
521 entity_layout: SegmentEntityLayout::NotApplicable,
522 index_min: IndexValue::Int64(0),
523 index_max: IndexValue::Int64(1),
524 row_count: 1,
525 file_size: None,
526 coverage_path: None,
527 },
528 ];
529
530 assert!(matches!(
531 sort_segment_meta_by_index(&mut segments),
532 Err(IndexValueError::DomainMismatch { .. })
533 ));
534 }
535
536 #[test]
537 fn segment_json_preserves_integer_bound_extremes() {
538 for (minimum, maximum) in [
539 (IndexValue::Int64(i64::MIN), IndexValue::Int64(i64::MAX)),
540 (IndexValue::UInt64(0), IndexValue::UInt64(u64::MAX)),
541 ] {
542 let segment = SegmentMeta {
543 path: "data/extremes.parquet".to_string(),
544 format: FileFormat::Parquet,
545 entity_layout: SegmentEntityLayout::NotApplicable,
546 index_min: minimum,
547 index_max: maximum,
548 row_count: 2,
549 file_size: Some(42),
550 coverage_path: Some("_coverage/segments/extremes.roar".to_string()),
551 };
552 let json = serde_json::to_string(&segment).unwrap();
553 assert_eq!(serde_json::from_str::<SegmentMeta>(&json).unwrap(), segment);
554 }
555 }
556}