1use std::path::Path;
14
15use arrow::datatypes::{DataType, TimeUnit};
16use arrow_array::{
17 Array, Int64Array, TimestampMicrosecondArray, TimestampMillisecondArray,
18 TimestampNanosecondArray, TimestampSecondArray, UInt64Array,
19};
20use chrono::{TimeZone, Utc};
21use futures::{Stream, StreamExt};
22use parquet::{
23 arrow::{
24 ProjectionMask,
25 arrow_reader::{ArrowReaderMetadata, ArrowReaderOptions},
26 async_reader::ParquetRecordBatchStreamBuilder,
27 },
28 errors::ParquetError,
29};
30use roaring::RoaringTreemap;
31use snafu::{Backtrace, Snafu};
32use tokio::task::JoinSet;
33
34use crate::{
35 coverage::bucket::{BucketError, bucket_id},
36 coverage::{Coverage, EntityIdentityError},
37 metadata::{
38 segments::ParquetIndexColumnError,
39 table_metadata::{IndexKind, IndexSpec, IndexValue},
40 },
41 storage::{StorageError, TableLocation, open_parquet_reader},
42};
43
44use super::schema::validate_parquet_index;
45use super::{INSPECTION_BATCH_SIZE, resolve_rg_settings};
46
47#[derive(Debug, Snafu)]
59pub enum SegmentCoverageError {
60 #[snafu(display("Storage error reading parquet file {path}: {source}"))]
64 Storage {
65 path: String,
67 #[snafu(source, backtrace)]
69 source: StorageError,
70 },
71
72 #[snafu(display("Parquet read error for {path}: {source}"))]
77 ParquetRead {
78 path: String,
80 #[snafu(source)]
82 source: ParquetError,
83 backtrace: Backtrace,
85 },
86
87 #[snafu(transparent)]
89 OrderedIndexColumn {
90 source: ParquetIndexColumnError,
92 },
93
94 #[snafu(display(
96 "Invalid {expected_domain} value for ordered-index column {column} in segment at {path}: {detail}"
97 ))]
98 IndexValue {
99 path: String,
101 column: String,
103 expected_domain: &'static str,
105 detail: String,
107 },
108
109 #[snafu(display("Bucket mapping failed for segment {path}: {source}"))]
111 Bucket {
112 path: String,
114 source: BucketError,
116 },
117
118 #[snafu(display("Entity column not found in {path}: {column}"))]
120 EntityColumnNotFound {
121 path: String,
123 column: String,
125 },
126
127 #[snafu(display("Unsupported entity column type in {path}: {column} has {datatype}"))]
129 EntityColumnUnsupportedType {
130 path: String,
132 column: String,
134 datatype: String,
136 },
137
138 #[snafu(display("Entity column contains nulls in {path}: {column}"))]
140 EntityColumnHasNull {
141 path: String,
143 column: String,
145 },
146
147 #[snafu(display("Entity column has no values (empty segment) in {path}: {column}"))]
149 EntityColumnEmpty {
150 path: String,
152 column: String,
154 },
155
156 #[snafu(display("Invalid entity identity in segment {path}: {source}"))]
158 EntityIdentity {
159 path: String,
161 source: EntityIdentityError,
163 },
164}
165
166pub(super) fn arrow_index_error(
167 path: &str,
168 index: &IndexSpec,
169 observed_type: String,
170) -> SegmentCoverageError {
171 SegmentCoverageError::OrderedIndexColumn {
172 source: ParquetIndexColumnError {
173 path: path.to_string(),
174 column: index.column.clone(),
175 expected_domain: index.kind.name(),
176 observed_type,
177 },
178 }
179}
180
181pub(super) fn timestamp_value(
182 path: &str,
183 index: &IndexSpec,
184 unit: TimeUnit,
185 raw: i64,
186) -> Result<IndexValue, SegmentCoverageError> {
187 let value = match unit {
188 TimeUnit::Second => Utc.timestamp_opt(raw, 0),
189 TimeUnit::Millisecond => Utc.timestamp_millis_opt(raw),
190 TimeUnit::Microsecond => Utc.timestamp_micros(raw),
191 TimeUnit::Nanosecond => {
192 let seconds = raw.div_euclid(1_000_000_000);
193 let nanos = raw.rem_euclid(1_000_000_000) as u32;
194 Utc.timestamp_opt(seconds, nanos)
195 }
196 };
197 value
198 .single()
199 .map(IndexValue::Timestamp)
200 .ok_or_else(|| SegmentCoverageError::IndexValue {
201 path: path.to_string(),
202 column: index.column.clone(),
203 expected_domain: index.kind.name(),
204 detail: format!("timestamp value {raw} is out of range for {unit:?}"),
205 })
206}
207
208pub(super) fn insert_bucket(
209 bitmap: &mut RoaringTreemap,
210 path: &str,
211 index: &IndexSpec,
212 value: IndexValue,
213) -> Result<(), SegmentCoverageError> {
214 let bucket = bucket_id(&index.kind, &value).map_err(|source| SegmentCoverageError::Bucket {
215 path: path.to_string(),
216 source,
217 })?;
218 bitmap.insert(bucket);
219 Ok(())
220}
221
222fn add_array_buckets<T, F>(
223 bitmap: &mut RoaringTreemap,
224 path: &str,
225 index: &IndexSpec,
226 array: &arrow_array::PrimitiveArray<T>,
227 mut to_value: F,
228) -> Result<(), SegmentCoverageError>
229where
230 T: arrow_array::types::ArrowPrimitiveType,
231 F: FnMut(T::Native) -> Result<IndexValue, SegmentCoverageError>,
232{
233 if array.null_count() == 0 {
234 for &raw in array.values() {
235 insert_bucket(bitmap, path, index, to_value(raw)?)?;
236 }
237 } else {
238 for raw in array.iter().flatten() {
239 insert_bucket(bitmap, path, index, to_value(raw)?)?;
240 }
241 }
242 Ok(())
243}
244
245async fn compute_bitmap_from_stream(
246 mut reader: impl Stream<
247 Item = Result<arrow::record_batch::RecordBatch, parquet::errors::ParquetError>,
248 > + Unpin,
249 path_str: &str,
250 index: &IndexSpec,
251) -> Result<RoaringTreemap, SegmentCoverageError> {
252 let mut bitmap = RoaringTreemap::new();
253
254 while let Some(batch_res) = reader.next().await {
255 let batch = batch_res.map_err(|source| SegmentCoverageError::ParquetRead {
256 path: path_str.to_string(),
257 source,
258 backtrace: Backtrace::capture(),
259 })?;
260
261 let col = batch.column(0);
262
263 match (&index.kind, col.data_type()) {
264 (IndexKind::Timestamp { .. }, DataType::Timestamp(unit, _)) => {
265 macro_rules! process_timestamp_array {
266 ($array_type:ty) => {{
267 let array =
268 col.as_any().downcast_ref::<$array_type>().ok_or_else(|| {
269 arrow_index_error(
270 path_str,
271 index,
272 format!("Arrow {}", col.data_type()),
273 )
274 })?;
275 add_array_buckets(&mut bitmap, path_str, index, array, |raw| {
276 timestamp_value(path_str, index, unit.clone(), raw)
277 })?;
278 }};
279 }
280 match unit {
281 TimeUnit::Second => process_timestamp_array!(TimestampSecondArray),
282 TimeUnit::Millisecond => {
283 process_timestamp_array!(TimestampMillisecondArray)
284 }
285 TimeUnit::Microsecond => {
286 process_timestamp_array!(TimestampMicrosecondArray)
287 }
288 TimeUnit::Nanosecond => process_timestamp_array!(TimestampNanosecondArray),
289 }
290 }
291 (IndexKind::Int64 { .. }, DataType::Int64) => {
292 let array = col.as_any().downcast_ref::<Int64Array>().ok_or_else(|| {
293 arrow_index_error(path_str, index, format!("Arrow {}", col.data_type()))
294 })?;
295 add_array_buckets(&mut bitmap, path_str, index, array, |raw| {
296 Ok(IndexValue::Int64(raw))
297 })?;
298 }
299 (IndexKind::UInt64 { .. }, DataType::UInt64) => {
300 let array = col.as_any().downcast_ref::<UInt64Array>().ok_or_else(|| {
301 arrow_index_error(path_str, index, format!("Arrow {}", col.data_type()))
302 })?;
303 add_array_buckets(&mut bitmap, path_str, index, array, |raw| {
304 Ok(IndexValue::UInt64(raw))
305 })?;
306 }
307 other => {
308 return Err(arrow_index_error(
309 path_str,
310 index,
311 format!("Arrow {other:?}"),
312 ));
313 }
314 }
315
316 tokio::task::yield_now().await;
317 }
318
319 Ok(bitmap)
320}
321
322pub async fn compute_segment_coverage(
341 location: &TableLocation,
342 rel_path: &Path,
343 index: &IndexSpec,
344) -> Result<Coverage, SegmentCoverageError> {
345 let path = rel_path.display().to_string();
346 let mut file = open_parquet_reader(location.as_ref(), rel_path)
347 .await
348 .map_err(|source| SegmentCoverageError::Storage {
349 path: path.clone(),
350 source,
351 })?;
352 let metadata = ArrowReaderMetadata::load_async(&mut file, ArrowReaderOptions::default())
353 .await
354 .map_err(|source| SegmentCoverageError::ParquetRead {
355 path: path.clone(),
356 source,
357 backtrace: Backtrace::capture(),
358 })?;
359 validate_parquet_index(&path, metadata.parquet_schema(), index)
360 .map_err(|source| SegmentCoverageError::OrderedIndexColumn { source })?;
361 drop(file);
362
363 let mask = ProjectionMask::columns(metadata.parquet_schema(), [index.column.as_str()]);
364 let row_groups = metadata.metadata().num_row_groups();
365 let (max_tasks, row_groups_per_task) = resolve_rg_settings(row_groups);
366 let row_groups = (0..row_groups).collect::<Vec<_>>();
367 let chunks = row_groups
368 .chunks(row_groups_per_task)
369 .map(<[usize]>::to_vec)
370 .collect::<Vec<_>>();
371 debug_assert!(chunks.len() <= max_tasks);
372
373 let mut tasks = JoinSet::new();
374 for chunk in chunks {
375 let location = location.clone();
376 let rel_path = rel_path.to_path_buf();
377 let path = path.clone();
378 let index = index.clone();
379 let metadata = metadata.clone();
380 let mask = mask.clone();
381
382 tasks.spawn(async move {
383 let file = open_parquet_reader(location.as_ref(), &rel_path)
384 .await
385 .map_err(|source| SegmentCoverageError::Storage {
386 path: path.clone(),
387 source,
388 })?;
389 let reader = ParquetRecordBatchStreamBuilder::new_with_metadata(file, metadata)
390 .with_projection(mask)
391 .with_row_groups(chunk)
392 .with_batch_size(INSPECTION_BATCH_SIZE)
393 .build()
394 .map_err(|source| SegmentCoverageError::ParquetRead {
395 path: path.clone(),
396 source,
397 backtrace: Backtrace::capture(),
398 })?;
399 compute_bitmap_from_stream(reader, &path, &index).await
400 });
401 }
402
403 let mut merged = RoaringTreemap::new();
404 while let Some(result) = tasks.join_next().await {
405 let bitmap = result.map_err(|source| SegmentCoverageError::ParquetRead {
406 path: path.clone(),
407 source: ParquetError::General(format!("row-group scan task failed: {source}")),
408 backtrace: Backtrace::capture(),
409 })??;
410 merged |= bitmap;
411 }
412
413 Ok(Coverage::from_treemap(merged))
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use std::{fs::File, io::SeekFrom, num::NonZeroU64, sync::Arc};
420
421 use crate::metadata::table_metadata::TimeBucket;
422 use arrow::{
423 datatypes::{Field, Schema},
424 record_batch::RecordBatch,
425 };
426 use arrow_array::builder::{
427 BinaryBuilder, Int32Builder, StringBuilder, TimestampMillisecondBuilder,
428 };
429 use parquet::arrow::ArrowWriter;
430 use parquet::{
431 basic::Compression,
432 file::{
433 properties::WriterProperties,
434 reader::{FileReader, SerializedFileReader},
435 },
436 };
437 use tempfile::TempDir;
438 use tokio::io::{AsyncSeekExt, AsyncWriteExt};
439
440 type TestResult = Result<(), Box<dyn std::error::Error>>;
441 const EPOCH_BUCKET: u64 = 0x8000_0000_0000_0000;
442
443 fn timestamp_index(column: &str, bucket: TimeBucket) -> IndexSpec {
444 IndexSpec {
445 column: column.to_string(),
446 entity_columns: Vec::new(),
447 kind: IndexKind::Timestamp {
448 bucket,
449 timezone: None,
450 },
451 }
452 }
453
454 fn int64_index(column: &str, bucket_width: u64) -> IndexSpec {
455 IndexSpec {
456 column: column.to_string(),
457 entity_columns: Vec::new(),
458 kind: IndexKind::Int64 {
459 bucket_width: NonZeroU64::new(bucket_width).expect("nonzero test bucket"),
460 },
461 }
462 }
463
464 fn uint64_index(column: &str, bucket_width: u64) -> IndexSpec {
465 IndexSpec {
466 column: column.to_string(),
467 entity_columns: Vec::new(),
468 kind: IndexKind::UInt64 {
469 bucket_width: NonZeroU64::new(bucket_width).expect("nonzero test bucket"),
470 },
471 }
472 }
473
474 fn write_parquet_batch(
475 path: &Path,
476 schema: Schema,
477 columns: Vec<Arc<dyn Array>>,
478 ) -> TestResult {
479 let schema = Arc::new(schema);
480 let batch = RecordBatch::try_new(Arc::clone(&schema), columns)?;
481 write_parquet_batches(
482 path,
483 schema,
484 vec![batch],
485 WriterProperties::builder().build(),
486 )
487 }
488
489 fn write_parquet_batches(
490 path: &Path,
491 schema: Arc<Schema>,
492 batches: Vec<RecordBatch>,
493 props: WriterProperties,
494 ) -> TestResult {
495 if let Some(parent) = path.parent() {
496 std::fs::create_dir_all(parent)?;
497 }
498
499 let mut writer = ArrowWriter::try_new(File::create(path)?, schema, Some(props))?;
500 for batch in batches {
501 writer.write(&batch)?;
502 writer.flush()?;
503 }
504 writer.close()?;
505 Ok(())
506 }
507
508 fn write_parquet_with_timestamps(path: &Path, ts_values: &[Option<i64>]) -> TestResult {
509 let schema = Schema::new(vec![
510 Field::new("ts", DataType::Timestamp(TimeUnit::Millisecond, None), true),
511 Field::new("val", DataType::Int32, false),
512 ]);
513
514 let mut ts_builder = TimestampMillisecondBuilder::with_capacity(ts_values.len());
515 for v in ts_values {
516 match v {
517 Some(ts) => ts_builder.append_value(*ts),
518 None => ts_builder.append_null(),
519 }
520 }
521 let ts_array = Arc::new(ts_builder.finish()) as Arc<dyn Array>;
522
523 let mut val_builder = Int32Builder::with_capacity(ts_values.len());
524 for i in 0..ts_values.len() {
525 val_builder.append_value(i as i32);
526 }
527 let val_array = Arc::new(val_builder.finish()) as Arc<dyn Array>;
528
529 write_parquet_batch(path, schema, vec![ts_array, val_array])
530 }
531
532 fn timestamp_batch(schema: Arc<Schema>, values: &[Option<i64>]) -> RecordBatch {
533 let timestamps = Arc::new(TimestampMillisecondArray::from(values.to_vec()));
534 RecordBatch::try_new(schema, vec![timestamps]).expect("timestamp batch")
535 }
536
537 fn int64_batch(schema: Arc<Schema>, values: &[Option<i64>]) -> RecordBatch {
538 RecordBatch::try_new(schema, vec![Arc::new(Int64Array::from(values.to_vec()))])
539 .expect("int64 batch")
540 }
541
542 fn uint64_batch(schema: Arc<Schema>, values: &[Option<u64>]) -> RecordBatch {
543 RecordBatch::try_new(schema, vec![Arc::new(UInt64Array::from(values.to_vec()))])
544 .expect("uint64 batch")
545 }
546
547 fn expected_buckets(
548 index: &IndexSpec,
549 values: impl IntoIterator<Item = IndexValue>,
550 ) -> Vec<u64> {
551 let mut buckets = values
552 .into_iter()
553 .map(|value| bucket_id(&index.kind, &value).expect("valid test index value"))
554 .collect::<Vec<_>>();
555 buckets.sort_unstable();
556 buckets.dedup();
557 buckets
558 }
559
560 #[tokio::test]
561 async fn compute_coverage_supports_nulls_and_dedup_and_multiple_specs() -> TestResult {
562 let tmp = TempDir::new()?;
563 let rel_path = Path::new("data/seg.parquet");
564 let abs_path = tmp.path().join(rel_path);
565
566 let ts_values = vec![Some(1_000), Some(30_000), Some(3_600_000), None];
568 write_parquet_with_timestamps(&abs_path, &ts_values)?;
569
570 let location = TableLocation::local(tmp.path());
571
572 let cov_min = compute_segment_coverage(
574 &location,
575 rel_path,
576 ×tamp_index("ts", TimeBucket::Minutes(1)),
577 )
578 .await?;
579 let buckets_min: Vec<u64> = cov_min.present().iter().collect();
580 assert_eq!(buckets_min, vec![EPOCH_BUCKET, EPOCH_BUCKET + 60]);
581
582 let cov_hr = compute_segment_coverage(
584 &location,
585 rel_path,
586 ×tamp_index("ts", TimeBucket::Hours(1)),
587 )
588 .await?;
589 let buckets_hr: Vec<u64> = cov_hr.present().iter().collect();
590 assert_eq!(buckets_hr, vec![EPOCH_BUCKET, EPOCH_BUCKET + 1]);
591
592 Ok(())
593 }
594
595 #[tokio::test]
596 async fn compute_coverage_merges_multiple_row_groups() -> TestResult {
597 let tmp = TempDir::new()?;
598 let rel_path = Path::new("data/row_groups.parquet");
599 let schema = Arc::new(Schema::new(vec![Field::new(
600 "ts",
601 DataType::Timestamp(TimeUnit::Millisecond, None),
602 true,
603 )]));
604 let batches = vec![
605 timestamp_batch(Arc::clone(&schema), &[Some(1_000), Some(61_000)]),
606 timestamp_batch(Arc::clone(&schema), &[Some(121_000), None]),
607 timestamp_batch(Arc::clone(&schema), &[Some(181_000), Some(1_000)]),
608 ];
609 write_parquet_batches(
610 &tmp.path().join(rel_path),
611 schema,
612 batches,
613 WriterProperties::builder().build(),
614 )?;
615
616 let coverage = compute_segment_coverage(
617 &TableLocation::local(tmp.path()),
618 rel_path,
619 ×tamp_index("ts", TimeBucket::Minutes(1)),
620 )
621 .await?;
622 assert_eq!(
623 coverage.present().iter().collect::<Vec<_>>(),
624 vec![
625 EPOCH_BUCKET,
626 EPOCH_BUCKET + 1,
627 EPOCH_BUCKET + 2,
628 EPOCH_BUCKET + 3
629 ]
630 );
631 Ok(())
632 }
633
634 #[tokio::test]
635 async fn compute_coverage_supports_integer_indexes_across_row_groups() -> TestResult {
636 let tmp = TempDir::new()?;
637 let location = TableLocation::local(tmp.path());
638
639 let signed_path = Path::new("data/int64-row-groups.parquet");
640 let signed_schema = Arc::new(Schema::new(vec![Field::new(
641 "index",
642 DataType::Int64,
643 true,
644 )]));
645 let signed_values = [i64::MIN, -11, -1, 0, 9, 10, i64::MAX];
646 write_parquet_batches(
647 &tmp.path().join(signed_path),
648 Arc::clone(&signed_schema),
649 vec![
650 int64_batch(Arc::clone(&signed_schema), &[Some(i64::MIN), Some(-11)]),
651 int64_batch(Arc::clone(&signed_schema), &[None, Some(-1), Some(0)]),
652 int64_batch(
653 Arc::clone(&signed_schema),
654 &[Some(9), Some(10), Some(i64::MAX)],
655 ),
656 ],
657 WriterProperties::builder().build(),
658 )?;
659 let signed_index = int64_index("index", 10);
660 let signed = compute_segment_coverage(&location, signed_path, &signed_index).await?;
661 assert_eq!(
662 signed.present().iter().collect::<Vec<_>>(),
663 expected_buckets(
664 &signed_index,
665 signed_values.into_iter().map(IndexValue::Int64)
666 )
667 );
668
669 let unsigned_path = Path::new("data/uint64-row-groups.parquet");
670 let unsigned_schema = Arc::new(Schema::new(vec![Field::new(
671 "index",
672 DataType::UInt64,
673 true,
674 )]));
675 let unsigned_values = [0, 9, 10, i64::MAX as u64 + 1, u64::MAX];
676 write_parquet_batches(
677 &tmp.path().join(unsigned_path),
678 Arc::clone(&unsigned_schema),
679 vec![
680 uint64_batch(Arc::clone(&unsigned_schema), &[Some(0), Some(9)]),
681 uint64_batch(Arc::clone(&unsigned_schema), &[None, Some(10)]),
682 uint64_batch(
683 Arc::clone(&unsigned_schema),
684 &[Some(i64::MAX as u64 + 1), Some(u64::MAX)],
685 ),
686 ],
687 WriterProperties::builder().build(),
688 )?;
689 let unsigned_index = uint64_index("index", 10);
690 let unsigned = compute_segment_coverage(&location, unsigned_path, &unsigned_index).await?;
691 assert_eq!(
692 unsigned.present().iter().collect::<Vec<_>>(),
693 expected_buckets(
694 &unsigned_index,
695 unsigned_values.into_iter().map(IndexValue::UInt64)
696 )
697 );
698 Ok(())
699 }
700
701 #[tokio::test]
702 async fn compute_coverage_scans_multiple_bounded_batches() -> TestResult {
703 let tmp = TempDir::new()?;
704 let rel_path = Path::new("data/batches.parquet");
705 let row_count = INSPECTION_BATCH_SIZE * 2 + 17;
706 let values = (0..row_count)
707 .map(|value| Some(value as i64 * 1_000))
708 .collect::<Vec<_>>();
709 write_parquet_with_timestamps(&tmp.path().join(rel_path), &values)?;
710
711 let coverage = compute_segment_coverage(
712 &TableLocation::local(tmp.path()),
713 rel_path,
714 ×tamp_index("ts", TimeBucket::Seconds(1)),
715 )
716 .await?;
717 assert_eq!(coverage.cardinality(), row_count as u64);
718 assert_eq!(coverage.present().min(), Some(EPOCH_BUCKET));
719 assert_eq!(
720 coverage.present().max(),
721 Some(EPOCH_BUCKET + row_count as u64 - 1)
722 );
723 Ok(())
724 }
725
726 #[tokio::test]
727 async fn compute_coverage_supports_every_parquet_timestamp_unit() -> TestResult {
728 let tmp = TempDir::new()?;
729 let cases: Vec<(&str, DataType, Arc<dyn Array>)> = vec![
730 (
731 "milliseconds.parquet",
732 DataType::Timestamp(TimeUnit::Millisecond, None),
733 Arc::new(TimestampMillisecondArray::from(vec![
734 Some(1_000),
735 Some(60_000),
736 ])),
737 ),
738 (
739 "microseconds.parquet",
740 DataType::Timestamp(TimeUnit::Microsecond, None),
741 Arc::new(TimestampMicrosecondArray::from(vec![
742 Some(1_000_000),
743 Some(60_000_000),
744 ])),
745 ),
746 (
747 "nanoseconds.parquet",
748 DataType::Timestamp(TimeUnit::Nanosecond, None),
749 Arc::new(TimestampNanosecondArray::from(vec![
750 Some(1_000_000_000),
751 Some(60_000_000_000),
752 ])),
753 ),
754 ];
755
756 for (file_name, data_type, array) in cases {
757 let rel_path = Path::new("data").join(file_name);
758 write_parquet_batch(
759 &tmp.path().join(&rel_path),
760 Schema::new(vec![Field::new("ts", data_type, true)]),
761 vec![array],
762 )?;
763 let coverage = compute_segment_coverage(
764 &TableLocation::local(tmp.path()),
765 &rel_path,
766 ×tamp_index("ts", TimeBucket::Seconds(1)),
767 )
768 .await?;
769 assert_eq!(
770 coverage.present().iter().collect::<Vec<_>>(),
771 vec![EPOCH_BUCKET + 1, EPOCH_BUCKET + 60]
772 );
773 }
774 Ok(())
775 }
776
777 #[tokio::test]
778 async fn compute_coverage_returns_empty_for_empty_and_all_null_files() -> TestResult {
779 let tmp = TempDir::new()?;
780 for (file_name, values) in [
781 ("empty.parquet", Vec::new()),
782 ("all_null.parquet", vec![None, None, None]),
783 ] {
784 let rel_path = Path::new("data").join(file_name);
785 write_parquet_with_timestamps(&tmp.path().join(&rel_path), &values)?;
786 let coverage = compute_segment_coverage(
787 &TableLocation::local(tmp.path()),
788 &rel_path,
789 ×tamp_index("ts", TimeBucket::Minutes(1)),
790 )
791 .await?;
792 assert!(coverage.present().is_empty());
793 }
794 Ok(())
795 }
796
797 #[tokio::test]
798 async fn compute_coverage_ignores_large_unprojected_payload() -> TestResult {
799 let tmp = TempDir::new()?;
800 let rel_path = Path::new("data/payload.parquet");
801 let abs_path = tmp.path().join(rel_path);
802 let schema = Arc::new(Schema::new(vec![
803 Field::new(
804 "ts",
805 DataType::Timestamp(TimeUnit::Millisecond, None),
806 false,
807 ),
808 Field::new("payload", DataType::Binary, false),
809 ]));
810 let timestamps = Arc::new(TimestampMillisecondArray::from(vec![
811 1_000, 61_000, 121_000, 181_000,
812 ]));
813 let payload = vec![0xA5; 1024 * 1024];
814 let mut payloads = BinaryBuilder::with_capacity(4, 4 * payload.len());
815 for _ in 0..4 {
816 payloads.append_value(&payload);
817 }
818 let batch = RecordBatch::try_new(
819 Arc::clone(&schema),
820 vec![timestamps, Arc::new(payloads.finish())],
821 )?;
822 let props = WriterProperties::builder()
823 .set_compression(Compression::UNCOMPRESSED)
824 .set_dictionary_enabled(false)
825 .build();
826 write_parquet_batches(&abs_path, schema, vec![batch], props)?;
827
828 let reader = SerializedFileReader::new(File::open(&abs_path)?)?;
829 let payload_page = reader.metadata().row_group(0).column(1).data_page_offset() as u64;
830 drop(reader);
831 let mut file = tokio::fs::OpenOptions::new()
832 .read(true)
833 .write(true)
834 .open(&abs_path)
835 .await?;
836 file.seek(SeekFrom::Start(payload_page)).await?;
837 file.write_all(&[0xFF; 32]).await?;
838 file.flush().await?;
839 drop(file);
840
841 assert!(tokio::fs::metadata(&abs_path).await?.len() > 4 * 1024 * 1024);
842 let coverage = compute_segment_coverage(
843 &TableLocation::local(tmp.path()),
844 rel_path,
845 ×tamp_index("ts", TimeBucket::Minutes(1)),
846 )
847 .await?;
848 assert_eq!(
849 coverage.present().iter().collect::<Vec<_>>(),
850 vec![
851 EPOCH_BUCKET,
852 EPOCH_BUCKET + 1,
853 EPOCH_BUCKET + 2,
854 EPOCH_BUCKET + 3
855 ]
856 );
857 Ok(())
858 }
859
860 #[tokio::test]
861 async fn compute_coverage_errors_on_missing_time_column() -> TestResult {
862 let tmp = TempDir::new()?;
863 let rel_path = Path::new("data/seg.parquet");
864 let abs_path = tmp.path().join(rel_path);
865 write_parquet_with_timestamps(&abs_path, &[Some(1_000)])?;
866
867 let location = TableLocation::local(tmp.path());
868 let err = compute_segment_coverage(
869 &location,
870 rel_path,
871 ×tamp_index("missing_ts", TimeBucket::Minutes(1)),
872 )
873 .await
874 .expect_err("expected missing column error");
875
876 assert!(matches!(
877 err,
878 SegmentCoverageError::OrderedIndexColumn {
879 source: ParquetIndexColumnError {
880 ref column,
881 expected_domain: "timestamp",
882 ref observed_type,
883 ..
884 }
885 } if column == "missing_ts" && observed_type == "missing"
886 ));
887 Ok(())
888 }
889
890 #[tokio::test]
891 async fn compute_coverage_rejects_unsupported_time_type() -> TestResult {
892 let tmp = TempDir::new()?;
893 let rel_path = Path::new("data/string_ts.parquet");
894 let abs_path = tmp.path().join(rel_path);
895
896 let schema = Schema::new(vec![
897 Field::new("ts", DataType::Utf8, false),
898 Field::new("val", DataType::Int32, false),
899 ]);
900 let mut ts_builder = StringBuilder::with_capacity(2, 8);
901 ts_builder.append_value("a");
902 ts_builder.append_value("b");
903 let ts_array = Arc::new(ts_builder.finish()) as Arc<dyn Array>;
904
905 let mut val_builder = Int32Builder::with_capacity(2);
906 val_builder.append_value(1);
907 val_builder.append_value(2);
908 let val_array = Arc::new(val_builder.finish()) as Arc<dyn Array>;
909
910 write_parquet_batch(&abs_path, schema, vec![ts_array, val_array])?;
911
912 let location = TableLocation::local(tmp.path());
913 let err = compute_segment_coverage(
914 &location,
915 rel_path,
916 ×tamp_index("ts", TimeBucket::Minutes(1)),
917 )
918 .await
919 .expect_err("expected unsupported arrow type");
920
921 assert!(matches!(
922 err,
923 SegmentCoverageError::OrderedIndexColumn {
924 source: ParquetIndexColumnError {
925 expected_domain: "timestamp",
926 ref observed_type,
927 ..
928 }
929 } if observed_type.contains("BYTE_ARRAY")
930 ));
931 Ok(())
932 }
933
934 #[tokio::test]
935 async fn compute_coverage_rejects_signed_unsigned_mismatch() -> TestResult {
936 let tmp = TempDir::new()?;
937 let rel_path = Path::new("data/signed.parquet");
938 write_parquet_batch(
939 &tmp.path().join(rel_path),
940 Schema::new(vec![Field::new("index", DataType::Int64, false)]),
941 vec![Arc::new(Int64Array::from(vec![1]))],
942 )?;
943
944 let error = compute_segment_coverage(
945 &TableLocation::local(tmp.path()),
946 rel_path,
947 &uint64_index("index", 1),
948 )
949 .await
950 .expect_err("signed column must not be read as uint64");
951
952 assert!(matches!(
953 error,
954 SegmentCoverageError::OrderedIndexColumn {
955 source: ParquetIndexColumnError {
956 expected_domain: "uint64",
957 observed_type,
958 ..
959 }
960 } if observed_type.contains("logical=None")
961 ));
962 Ok(())
963 }
964
965 #[tokio::test]
966 async fn compute_coverage_supports_buckets_above_u32() -> TestResult {
967 let tmp = TempDir::new()?;
968 let rel_path = Path::new("data/overflow.parquet");
969 let abs_path = tmp.path().join(rel_path);
970 let overflow_ms = ((u32::MAX as i64) + 1) * 1_000;
971 write_parquet_with_timestamps(&abs_path, &[Some(overflow_ms)])?;
972
973 let location = TableLocation::local(tmp.path());
974 let coverage = compute_segment_coverage(
975 &location,
976 rel_path,
977 ×tamp_index("ts", TimeBucket::Seconds(1)),
978 )
979 .await?;
980
981 assert!(
982 coverage
983 .present()
984 .contains(0x8000_0000_0000_0000 + u64::from(u32::MAX) + 1)
985 );
986 Ok(())
987 }
988
989 #[tokio::test]
990 async fn compute_coverage_bubbles_up_storage_errors() -> TestResult {
991 let tmp = TempDir::new()?;
992 let rel_path = Path::new("missing/seg.parquet");
993 let location = TableLocation::local(tmp.path());
994
995 let err = compute_segment_coverage(
996 &location,
997 rel_path,
998 ×tamp_index("ts", TimeBucket::Minutes(1)),
999 )
1000 .await
1001 .expect_err("expected storage error");
1002
1003 assert!(matches!(
1004 err,
1005 SegmentCoverageError::Storage {
1006 source: StorageError::NotFound { .. },
1007 ..
1008 }
1009 ));
1010 Ok(())
1011 }
1012
1013 #[tokio::test]
1014 async fn compute_coverage_surfaces_parquet_read_errors() -> TestResult {
1015 let tmp = TempDir::new()?;
1016 let rel_path = Path::new("data/corrupt.parquet");
1017 let abs_path = tmp.path().join(rel_path);
1018 if let Some(parent) = abs_path.parent() {
1019 std::fs::create_dir_all(parent)?;
1020 }
1021 std::fs::write(&abs_path, b"not a parquet file")?;
1022
1023 let location = TableLocation::local(tmp.path());
1024 let err = compute_segment_coverage(
1025 &location,
1026 rel_path,
1027 ×tamp_index("ts", TimeBucket::Minutes(1)),
1028 )
1029 .await
1030 .expect_err("expected parquet read error");
1031
1032 assert!(matches!(err, SegmentCoverageError::ParquetRead { .. }));
1033 Ok(())
1034 }
1035
1036 #[tokio::test]
1037 async fn compute_coverage_surfaces_projected_column_corruption() -> TestResult {
1038 let tmp = TempDir::new()?;
1039 let rel_path = Path::new("data/corrupt_timestamp.parquet");
1040 let abs_path = tmp.path().join(rel_path);
1041 let schema = Arc::new(Schema::new(vec![Field::new(
1042 "ts",
1043 DataType::Timestamp(TimeUnit::Millisecond, None),
1044 false,
1045 )]));
1046 let batch = RecordBatch::try_new(
1047 Arc::clone(&schema),
1048 vec![Arc::new(TimestampMillisecondArray::from(vec![
1049 1_000, 2_000,
1050 ]))],
1051 )?;
1052 let props = WriterProperties::builder()
1053 .set_compression(Compression::UNCOMPRESSED)
1054 .set_dictionary_enabled(false)
1055 .build();
1056 write_parquet_batches(&abs_path, schema, vec![batch], props)?;
1057
1058 let reader = SerializedFileReader::new(File::open(&abs_path)?)?;
1059 let timestamp_page = reader.metadata().row_group(0).column(0).data_page_offset() as u64;
1060 drop(reader);
1061 let mut file = tokio::fs::OpenOptions::new()
1062 .read(true)
1063 .write(true)
1064 .open(&abs_path)
1065 .await?;
1066 file.seek(SeekFrom::Start(timestamp_page)).await?;
1067 file.write_all(&[0xFF; 16]).await?;
1068 file.flush().await?;
1069 drop(file);
1070
1071 let err = compute_segment_coverage(
1072 &TableLocation::local(tmp.path()),
1073 rel_path,
1074 ×tamp_index("ts", TimeBucket::Minutes(1)),
1075 )
1076 .await
1077 .unwrap_err();
1078 assert!(matches!(err, SegmentCoverageError::ParquetRead { .. }));
1079 Ok(())
1080 }
1081}