Skip to main content

timeseries_table_format/formats/parquet/
coverage.rs

1//! Helpers for reading and computing segment-level ordered-index coverage.
2//!
3//! This module provides utilities for analyzing Parquet segments to extract
4//! coverage metadata: bucket assignments for ordered-index values within each
5//! segment. Coverage data is persisted in a RoaringTreemap sidecar
6//! file and referenced by the transaction log for efficient time-range queries.
7//!
8//! The error types in this module cover common failure points:
9//! - Storage I/O errors when accessing segment files.
10//! - Parquet format violations or missing/malformed metadata.
11//! - Unsupported or out-of-range ordered-index values.
12
13use 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/// Errors that can occur when reading or computing segment coverage.
48///
49/// Coverage computation typically:
50/// 1. Reads the Parquet segment file from storage.
51/// 2. Inspects the Parquet schema to locate the registered index column.
52/// 3. Validates that the column matches the registered index domain.
53/// 4. Streams projected index values and maps them to buckets.
54/// 5. Stores computed bucket IDs in a RoaringTreemap for efficient serialization.
55///
56/// Errors at any stage are captured here with context about the segment path,
57/// column name, and raw values involved.
58#[derive(Debug, Snafu)]
59pub enum SegmentCoverageError {
60    /// Storage layer failed to read the segment file at the given path.
61    ///
62    /// This may indicate the file is missing, inaccessible, or suffered an I/O error.
63    #[snafu(display("Storage error reading parquet file {path}: {source}"))]
64    Storage {
65        /// The path to the segment file that could not be read.
66        path: String,
67        /// The underlying storage error that caused this failure.
68        #[snafu(source, backtrace)]
69        source: StorageError,
70    },
71
72    /// Parquet format violation or metadata read error.
73    ///
74    /// This may indicate the file is corrupted, truncated, or uses an unsupported
75    /// Parquet feature.
76    #[snafu(display("Parquet read error for {path}: {source}"))]
77    ParquetRead {
78        /// The path to the segment file with a Parquet format error.
79        path: String,
80        /// The underlying Parquet library error.
81        #[snafu(source)]
82        source: ParquetError,
83        /// The backtrace at the time the error occurred.
84        backtrace: Backtrace,
85    },
86
87    /// The registered ordered-index column is missing or incompatible.
88    #[snafu(transparent)]
89    OrderedIndexColumn {
90        /// Exact registered and observed Parquet column details.
91        source: ParquetIndexColumnError,
92    },
93
94    /// A projected ordered-index value cannot be represented in its registered domain.
95    #[snafu(display(
96        "Invalid {expected_domain} value for ordered-index column {column} in segment at {path}: {detail}"
97    ))]
98    IndexValue {
99        /// Path to the segment file.
100        path: String,
101        /// Registered ordered-index column.
102        column: String,
103        /// Registered ordered-index domain.
104        expected_domain: &'static str,
105        /// Value decoding failure.
106        detail: String,
107    },
108
109    /// Ordered-index bucket mapping failed.
110    #[snafu(display("Bucket mapping failed for segment {path}: {source}"))]
111    Bucket {
112        /// The path to the segment file.
113        path: String,
114        /// Bucket mapping failure.
115        source: BucketError,
116    },
117
118    /// A configured entity column is missing from the segment.
119    #[snafu(display("Entity column not found in {path}: {column}"))]
120    EntityColumnNotFound {
121        /// Path to the segment file.
122        path: String,
123        /// Missing configured entity column.
124        column: String,
125    },
126
127    /// A configured entity column has an unsupported Arrow type.
128    #[snafu(display("Unsupported entity column type in {path}: {column} has {datatype}"))]
129    EntityColumnUnsupportedType {
130        /// Path to the segment file.
131        path: String,
132        /// Configured entity column.
133        column: String,
134        /// Observed Arrow type.
135        datatype: String,
136    },
137
138    /// A configured entity column contains a null value.
139    #[snafu(display("Entity column contains nulls in {path}: {column}"))]
140    EntityColumnHasNull {
141        /// Path to the segment file.
142        path: String,
143        /// Configured entity column.
144        column: String,
145    },
146
147    /// The segment has no rows from which to construct an entity identity.
148    #[snafu(display("Entity column has no values (empty segment) in {path}: {column}"))]
149    EntityColumnEmpty {
150        /// Path to the segment file.
151        path: String,
152        /// First configured entity column.
153        column: String,
154    },
155
156    /// Ordered entity components could not form a complete identity.
157    #[snafu(display("Invalid entity identity in segment {path}: {source}"))]
158    EntityIdentity {
159        /// Path to the segment file.
160        path: String,
161        /// Identity validation failure.
162        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
322/// Computes segment-level ordered-index coverage from a Parquet segment file.
323///
324/// This function:
325/// 1. Reads the Parquet segment file from storage.
326/// 2. Validates and projects the registered ordered-index column.
327/// 3. Iterates over non-null values and maps each through the shared bucket helper.
328/// 4. Returns a Coverage bitmap containing all bucket IDs found in the segment.
329///
330/// # Arguments
331///
332/// * `location` - The table location for accessing the storage layer.
333/// * `rel_path` - The relative path to the Parquet segment file.
334/// * `index` - The registered ordered-index column, domain, and bucket configuration.
335///
336/// # Returns
337///
338/// A `Coverage` bitmap containing the bucket IDs of all observed index values in
339/// the segment, or a `SegmentCoverageError` if any stage of the process fails.
340pub 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        // Two points in bucket 0, one point in bucket 60 (1 hour), and one null.
567        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        // Minutes bucket: 1 second and 30 seconds map to bucket 0; 3600s -> bucket 60.
573        let cov_min = compute_segment_coverage(
574            &location,
575            rel_path,
576            &timestamp_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        // Hours bucket: 1 second -> bucket 0; 3600s -> bucket 1.
583        let cov_hr = compute_segment_coverage(
584            &location,
585            rel_path,
586            &timestamp_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            &timestamp_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            &timestamp_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                &timestamp_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                &timestamp_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            &timestamp_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            &timestamp_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            &timestamp_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            &timestamp_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            &timestamp_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            &timestamp_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            &timestamp_index("ts", TimeBucket::Minutes(1)),
1075        )
1076        .await
1077        .unwrap_err();
1078        assert!(matches!(err, SegmentCoverageError::ParquetRead { .. }));
1079        Ok(())
1080    }
1081}