Skip to main content

chronix_engine/segment/
reader.rs

1//! Segment reader — reads `.csx` segment files.
2//!
3//! The reader validates the file integrity (magic bytes, `CRC32c` checksum),
4//! parses header/footer/metadata, and provides methods to read individual
5//! columns or entire row groups as Apache Arrow arrays.
6//!
7//! # I/O strategy — memory-mapped prefetch
8//!
9//! The entire segment file is **memory-mapped** via [`memmap2::Mmap`].  No
10//! per-column `seek()`/`read()` system calls are issued; instead, column data
11//! is accessed by slicing the mapped byte range, and the OS page cache
12//! transparently pages data in from disk on first access.
13//!
14//! Two advisory hints accelerate sequential scans:
15//!
16//! 1. **`madvise(MADV_SEQUENTIAL)`** — set once at open time, tells the kernel
17//!    the file will be read front-to-back so it can perform aggressive
18//!    read-ahead (typically 128–256 KiB on Linux).
19//! 2. **`madvise(MADV_WILLNEED)`** — issued per-column in
20//!    [`SegmentReader::read_columns`]: while decoding column *N*, the reader
21//!    hints the kernel to begin paging in column *N+1* (and the same column
22//!    in the next row group for multi-row-group scans).  This overlaps I/O
23//!    with CPU decoding.
24//!
25//! Because the `Mmap` holds the entire file mapping, **there is no separate
26//! read-ahead buffer or prefetch thread**; the OS page cache *is* the buffer.
27//! This is the most efficient approach for columnar segment files that fit in
28//! the page cache — it avoids double-copying into user-space buffers and lets
29//! the kernel coalesce I/O for adjacent columns.
30//!
31//! # Predicate pushdown
32//!
33//! The reader supports multi-level predicate pushdown to minimise the amount
34//! of data that is read and decoded:
35//!
36//! | Level | Mechanism | Method |
37//! |---|---|---|
38//! | **Segment** | Catalog-level time-range + tag-index + bloom filter | `Chronix::prune_segments()` |
39//! | **Row group — time** | `min_ts`/`max_ts` from timestamp column stats | `read_projected_filtered` |
40//! | **Row group — tags** | `value_count == 0` with a non-zero `null_count` (a genuinely all-null block → impossible match) | `read_projected_filtered_with_predicates` |
41//! | **Column** | projection pushdown (only requested columns decoded) | `read_projected` |
42
43use std::fs::File;
44use std::path::{Path, PathBuf};
45// The only bare `Arc` in this file is the key provider, which is gated; every
46// other use is written out as `std::sync::Arc`. Without the same gate the
47// import is unused under `--no-default-features`.
48#[cfg(feature = "field-encryption")]
49use std::sync::Arc;
50
51use arrow::array::{
52    ArrayRef, BooleanArray, Decimal128Array, Float64Array, Int64Array, StringArray, UInt64Array,
53};
54use arrow::buffer::NullBuffer;
55use arrow::datatypes::{Field, Schema};
56use arrow::record_batch::RecordBatch;
57use memmap2::{Advice, Mmap, UncheckedAdvice};
58
59use chronix_encoding::{ColumnDecoder, DecodedColumn, EncodedBlock};
60
61use crate::segment::compression::decompress_block_with_optional_dict;
62use crate::segment::error::{Result, SegmentError};
63use crate::segment::header::{FOOTER_SIZE, HEADER_SIZE, SegmentFooter, SegmentHeader};
64use crate::segment::metadata::{ColumnBlockMeta, ColumnMeta, SegmentMetadata, data_types};
65use crate::segment::stats::ordered_i64_to_f64;
66
67// ── Zone-map field predicates for late-materialisation ─────────────────
68
69/// A numeric comparison operator for zone-map row-group pruning.
70#[derive(Debug, Clone, Copy, PartialEq)]
71pub enum StatsOp {
72    /// Equals.
73    Eq,
74    /// Greater than.
75    Gt,
76    /// Greater than or equal.
77    GtEq,
78    /// Less than.
79    Lt,
80    /// Less than or equal.
81    LtEq,
82}
83
84/// A field predicate that can be evaluated against per-row-group
85/// [`ColumnStats`](crate::segment::stats::ColumnStats) min/max to skip row groups
86/// that cannot possibly match.
87#[derive(Debug, Clone)]
88pub struct FieldPredicate {
89    /// Column name.
90    pub column: String,
91    /// Comparison operator.
92    pub op: StatsOp,
93    /// Predicate value as `f64`.  Integer predicates are cast to `f64`.
94    pub value: f64,
95}
96
97impl FieldPredicate {
98    /// Test whether a row group with the given float min/max statistics
99    /// could possibly contain rows matching this predicate.
100    ///
101    /// A NaN bound makes every comparison false, which would prune the
102    /// group — so an unusable bound is reported as "may match" instead.
103    /// **A pruning step may only ever be wrong in the direction of doing
104    /// more work**; one that can drop rows is a wrong answer, not a slow one.
105    fn may_match_f64(&self, rg_min: f64, rg_max: f64) -> bool {
106        if rg_min.is_nan() || rg_max.is_nan() || rg_min > rg_max {
107            return true;
108        }
109        match self.op {
110            StatsOp::Eq => self.value >= rg_min && self.value <= rg_max,
111            StatsOp::Gt => rg_max > self.value,
112            StatsOp::GtEq => rg_max >= self.value,
113            StatsOp::Lt => rg_min < self.value,
114            StatsOp::LtEq => rg_min <= self.value,
115        }
116    }
117
118    /// Test whether a row group with i64 min/max statistics could
119    /// possibly match.
120    ///
121    /// The predicate value is an `f64`, so it is compared as one: casting
122    /// it to `i64` first truncated the bound, and `n < 1.5` then pruned a
123    /// row group holding `{1, 2, 3}` because `1 < 1` is false. Above 2^53 an
124    /// `i64` bound is not representable as an `f64` at all, and the group is
125    /// kept rather than compared wrongly.
126    fn may_match_i64(&self, rg_min: i64, rg_max: i64) -> bool {
127        if rg_min > rg_max {
128            return true;
129        }
130        const EXACT: i64 = 1 << 53;
131        if rg_min <= -EXACT || rg_max >= EXACT {
132            return true;
133        }
134        #[allow(clippy::cast_precision_loss)] // bounded above by 2^53
135        let (lo, hi) = (rg_min as f64, rg_max as f64);
136        match self.op {
137            // The column is integral, so an equality against a fractional
138            // value cannot match any row in it, whatever the range says.
139            StatsOp::Eq if self.value.fract() != 0.0 => false,
140            StatsOp::Eq => self.value >= lo && self.value <= hi,
141            StatsOp::Gt => hi > self.value,
142            StatsOp::GtEq => hi >= self.value,
143            StatsOp::Lt => lo < self.value,
144            StatsOp::LtEq => lo <= self.value,
145        }
146    }
147
148    /// Test whether a row group of decimal *mantissas* could match.
149    ///
150    /// The zone map holds mantissas; the predicate holds an `f64`. Rather
151    /// than pretend those are the same number, the bounds are widened by a
152    /// whole mantissa unit before the comparison, so a predicate value that
153    /// binary floating point cannot represent exactly — which is most of the
154    /// values anyone writes against a decimal column — can never prune a row
155    /// group that contains it. The rule from
156    /// [`may_match_f64`](Self::may_match_f64) holds here too: wrong in the
157    /// direction of more work, never in the direction of fewer rows.
158    ///
159    /// Above 2⁵³ the `i64 → f64` conversion loses more than the widening
160    /// covers, so the group is kept rather than compared wrongly. Saturated
161    /// bounds — what a mantissa beyond `i64` records — land there by
162    /// construction.
163    fn may_match_decimal(&self, rg_min: i64, rg_max: i64, scale: u8) -> bool {
164        if rg_min > rg_max {
165            return true;
166        }
167        const EXACT: i64 = 1 << 53;
168        if rg_min <= -EXACT || rg_max >= EXACT {
169            return true;
170        }
171        let pow = 10f64.powi(i32::from(scale));
172        if !pow.is_finite() || pow <= 0.0 {
173            return true;
174        }
175        #[allow(clippy::cast_precision_loss)] // bounded above by 2^53
176        let (lo, hi) = ((rg_min as f64 - 1.0) / pow, (rg_max as f64 + 1.0) / pow);
177        self.may_match_f64(lo, hi)
178    }
179}
180
181/// Reads data from a `.csx` segment file.
182///
183/// # I/O model
184///
185/// The segment file is **memory-mapped** ([`Mmap`]) so all column block
186/// reads are zero-copy slices into the OS page cache.  No per-column
187/// `File::read()` calls are issued; instead the kernel pages data in on
188/// first access and the `MADV_WILLNEED` advisory hint triggers
189/// asynchronous prefetch of upcoming blocks while the current block is
190/// being decoded.  This means sequential scans benefit from kernel-level
191/// read-ahead without any user-space prefetch thread or buffer.
192///
193/// # Predicate pushdown
194///
195/// [`read_projected_filtered_with_predicates`](Self::read_projected_filtered_with_predicates)
196/// evaluates predicates at row-group granularity using per-block
197/// [`ColumnStats`](crate::segment::stats::ColumnStats) before touching any
198/// column bytes.  This can skip entire row groups when the query's
199/// time window doesn't overlap a row group's timestamp range, or when
200/// a tag column's `value_count` proves it cannot match an equality
201/// filter.
202pub struct SegmentReader {
203    /// Memory-mapped file data (zero-copy, backed by OS page cache).
204    data: Mmap,
205    /// Parsed segment header.
206    header: SegmentHeader,
207    /// Parsed segment metadata.
208    metadata: SegmentMetadata,
209    /// File path (for error messages).
210    path: PathBuf,
211    /// Optional field-level encryption key provider for decrypting
212    /// encrypted column blocks at read time.
213    #[cfg(feature = "field-encryption")]
214    key_provider: Option<Arc<dyn crate::segment::field_encryption::FieldKeyProvider>>,
215}
216
217impl SegmentReader {
218    /// Open a segment file, validate integrity, and parse metadata.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if the file doesn't exist, is corrupt, or has an
223    /// invalid checksum.
224    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
225        let path = path.as_ref().to_path_buf();
226        let file = File::open(&path)?;
227        // SAFETY: The file is opened read-only and the mapping lives as long
228        // as `SegmentReader`. External mutation of the underlying file while
229        // mapped is undefined behaviour, but segment files are immutable
230        // after write.
231        #[allow(unsafe_code)] // audited: read-only mmap of an immutable segment file
232        let data = unsafe { Mmap::map(&file)? };
233        // Hint the OS that we will read sequentially through the
234        // mapping.  On Linux this sets MADV_SEQUENTIAL which doubles the
235        // default read-ahead window, reducing page faults on sequential scans.
236        data.advise(Advice::Sequential)?;
237
238        if data.len() < HEADER_SIZE + FOOTER_SIZE {
239            return Err(SegmentError::CorruptFile {
240                detail: format!(
241                    "file too small: {} bytes (need at least {})",
242                    data.len(),
243                    HEADER_SIZE + FOOTER_SIZE
244                ),
245            });
246        }
247
248        // Parse header
249        let header = SegmentHeader::from_bytes(&data[..HEADER_SIZE])?;
250
251        // Parse footer
252        let footer_start = data.len() - FOOTER_SIZE;
253        let footer = SegmentFooter::from_bytes(&data[footer_start..])?;
254
255        // Verify CRC32c checksum (over everything except footer)
256        let computed_checksum = crc32c::crc32c(&data[..footer_start]);
257        if computed_checksum != footer.checksum {
258            return Err(SegmentError::ChecksumMismatch {
259                expected: footer.checksum,
260                actual: computed_checksum,
261            });
262        }
263
264        // Verify independent metadata checksum
265        let metadata_start = footer.metadata_offset as usize;
266        if metadata_start < footer_start {
267            let metadata_bytes = &data[metadata_start..footer_start];
268            let computed_meta_crc = crc32c::crc32c(metadata_bytes);
269            if computed_meta_crc != footer.metadata_checksum {
270                return Err(SegmentError::ChecksumMismatch {
271                    expected: footer.metadata_checksum,
272                    actual: computed_meta_crc,
273                });
274            }
275        }
276
277        // Parse metadata section
278        if metadata_start < HEADER_SIZE {
279            return Err(SegmentError::CorruptFile {
280                detail: format!(
281                    "metadata_offset ({metadata_start}) is before end of header ({HEADER_SIZE})"
282                ),
283            });
284        }
285        if metadata_start > footer_start {
286            return Err(SegmentError::CorruptFile {
287                detail: format!(
288                    "metadata_offset ({metadata_start}) exceeds footer start ({footer_start})"
289                ),
290            });
291        }
292        let metadata = SegmentMetadata::from_bytes(&data[metadata_start..footer_start])?;
293
294        // Validate that header column_count matches the actual metadata.
295        if metadata.columns.len() != header.column_count as usize {
296            return Err(SegmentError::CorruptFile {
297                detail: format!(
298                    "column count mismatch: header says {}, metadata has {}",
299                    header.column_count,
300                    metadata.columns.len()
301                ),
302            });
303        }
304
305        Ok(Self {
306            data,
307            header,
308            metadata,
309            path,
310            #[cfg(feature = "field-encryption")]
311            key_provider: None,
312        })
313    }
314
315    /// Returns the segment header.
316    #[must_use]
317    pub fn header(&self) -> &SegmentHeader {
318        &self.header
319    }
320
321    /// Returns the column metadata.
322    #[must_use]
323    pub fn column_metadata(&self) -> &[crate::segment::metadata::ColumnMeta] {
324        &self.metadata.columns
325    }
326
327    /// Returns the number of row groups.
328    #[must_use]
329    pub fn row_group_count(&self) -> usize {
330        self.metadata.row_group_blocks.len()
331    }
332
333    /// Returns the per-column block metadata for one row group.
334    ///
335    /// Each entry carries the codec that was actually used for that block —
336    /// [`ColumnMeta::default_encoding`](crate::segment::metadata::ColumnMeta::default_encoding)
337    /// is only the writer's initial guess — along with its on-disk length.
338    /// Together they answer "which encoding won, and what did it cost", which
339    /// is what compression regressions and codec benchmarks need to see.
340    ///
341    /// Returns `None` if `row_group` is out of range.
342    #[must_use]
343    pub fn row_group_blocks(&self, row_group: usize) -> Option<&[ColumnBlockMeta]> {
344        self.metadata
345            .row_group_blocks
346            .get(row_group)
347            .map(Vec::as_slice)
348    }
349
350    /// Returns the file path.
351    #[must_use]
352    pub fn path(&self) -> &Path {
353        &self.path
354    }
355
356    /// Set a field-level encryption key provider for decrypting encrypted
357    /// column blocks.  Must be called before reading any encrypted columns.
358    #[cfg(feature = "field-encryption")]
359    pub fn set_key_provider(
360        &mut self,
361        provider: Arc<dyn crate::segment::field_encryption::FieldKeyProvider>,
362    ) {
363        self.key_provider = Some(provider);
364    }
365
366    /// Read specific columns from a specific row group as Arrow arrays.
367    ///
368    /// # Read-ahead / prefetch
369    ///
370    /// When reading column N, the reader issues an `madvise(MADV_WILLNEED)`
371    /// hint for column N+1's byte range so the OS can begin paging it in
372    /// from disk while the current column is being decoded.  Combined with
373    /// the `Sequential` advice set at open time, this reduces I/O stalls
374    /// on sequential scans.
375    ///
376    /// # Errors
377    ///
378    /// Returns an error if the row group index is out of range or a column
379    /// is not found.
380    pub fn read_columns(&self, column_names: &[&str], row_group: usize) -> Result<Vec<ArrayRef>> {
381        if row_group >= self.metadata.row_group_blocks.len() {
382            return Err(SegmentError::RowGroupOutOfRange {
383                index: row_group,
384                max: self.metadata.row_group_blocks.len().saturating_sub(1),
385            });
386        }
387
388        let rg_blocks = &self.metadata.row_group_blocks[row_group];
389
390        // Resolve all column indices and block metas upfront for prefetching.
391        let resolved: Vec<(usize, &ColumnBlockMeta)> = column_names
392            .iter()
393            .map(|&name| {
394                let col_idx = self
395                    .metadata
396                    .columns
397                    .iter()
398                    .position(|c| c.name == name)
399                    .ok_or_else(|| SegmentError::ColumnNotFound {
400                        name: name.to_string(),
401                    })?;
402                let block_meta = rg_blocks
403                    .iter()
404                    .find(|b| b.column_index == col_idx as u16)
405                    .ok_or_else(|| SegmentError::ColumnNotFound {
406                        name: name.to_string(),
407                    })?;
408                Ok((col_idx, block_meta))
409            })
410            .collect::<Result<Vec<_>>>()?;
411
412        let mut result = Vec::with_capacity(column_names.len());
413
414        for (i, &(col_idx, block_meta)) in resolved.iter().enumerate() {
415            // Prefetch the next column block via madvise(WillNeed)
416            // so the OS starts paging it in while we decode the current one.
417            if i + 1 < resolved.len() {
418                let (_, next_block) = resolved[i + 1];
419                self.prefetch_block(next_block);
420            }
421
422            // Also prefetch the same columns in the next row group if
423            // we're doing a sequential multi-row-group scan.
424            if row_group + 1 < self.metadata.row_group_blocks.len() {
425                let next_rg_blocks = &self.metadata.row_group_blocks[row_group + 1];
426                if let Some(next_rg_block) = next_rg_blocks
427                    .iter()
428                    .find(|b| b.column_index == col_idx as u16)
429                {
430                    self.prefetch_block(next_rg_block);
431                }
432            }
433
434            let array = self.read_column_block(block_meta, col_idx)?;
435            result.push(array);
436        }
437
438        Ok(result)
439    }
440
441    /// Issue an `madvise(MADV_WILLNEED)` hint for a column block's byte
442    /// range, asking the OS to begin paging the data into the page cache.
443    ///
444    /// This is a non-blocking kernel hint — the data may already be in the
445    /// page cache (in which case this is a no-op) or the kernel may begin
446    /// an asynchronous read from disk.  Either way, the call returns
447    /// immediately and never blocks the caller.
448    ///
449    /// Errors are silently ignored — prefetch is purely advisory.
450    fn prefetch_block(&self, block_meta: &ColumnBlockMeta) {
451        let start = block_meta.offset as usize;
452        let len = block_meta.length as usize;
453        if start.saturating_add(len) <= self.data.len() {
454            // madvise(MADV_WILLNEED) — non-blocking hint to the kernel.
455            if let Err(e) = self.data.advise_range(Advice::WillNeed, start, len) {
456                tracing::trace!(
457                    offset = start,
458                    length = len,
459                    error = %e,
460                    "prefetch advise failed (non-fatal)"
461                );
462                metrics::counter!("chronix_segment_prefetch_errors_total").increment(1);
463            } else {
464                metrics::counter!("chronix_segment_prefetch_hints_total").increment(1);
465                metrics::counter!("chronix_segment_prefetch_bytes_total").increment(len as u64);
466            }
467        }
468    }
469
470    /// Read all data as a single Arrow `RecordBatch`.
471    ///
472    /// This is a convenience wrapper around [`read_projected`](Self::read_projected)
473    /// that passes every column name.  When only a subset of columns is
474    /// needed, prefer calling [`read_projected`](Self::read_projected) or
475    /// [`read_projected_filtered`](Self::read_projected_filtered) directly
476    /// — they skip I/O and decompression for unrequested columns (
477    /// column pruning).
478    ///
479    /// # Errors
480    ///
481    /// Returns an error if any column data is corrupt.
482    pub fn read_all(&self) -> Result<RecordBatch> {
483        let column_names: Vec<&str> = self
484            .metadata
485            .columns
486            .iter()
487            .map(|c| c.name.as_str())
488            .collect();
489        self.read_projected(&column_names)
490    }
491
492    /// Read all columns from a single row group as a `RecordBatch`.
493    ///
494    /// This is useful for reverse-order scanning (e.g. `last_value`)
495    /// where only one row group at a time needs to be materialised.
496    ///
497    /// # Errors
498    ///
499    /// Returns an error if `row_group` is out of range or column data is corrupt.
500    pub fn read_row_group(&self, row_group: usize) -> Result<RecordBatch> {
501        let column_names: Vec<&str> = self
502            .metadata
503            .columns
504            .iter()
505            .map(|c| c.name.as_str())
506            .collect();
507        let arrays = self.read_columns(&column_names, row_group)?;
508        let fields: Vec<Field> = column_names
509            .iter()
510            .map(|&name| {
511                let col_meta = self
512                    .metadata
513                    .columns
514                    .iter()
515                    .find(|c| c.name == name)
516                    .ok_or_else(|| SegmentError::CorruptFile {
517                        detail: format!("column '{name}' not in segment metadata"),
518                    })?;
519                Ok(col_meta.arrow_field())
520            })
521            .collect::<Result<Vec<_>>>()?;
522        let schema = std::sync::Arc::new(Schema::new(fields));
523        RecordBatch::try_new(schema, arrays).map_err(|e| SegmentError::CorruptFile {
524            detail: format!("failed to build row-group RecordBatch: {e}"),
525        })
526    }
527
528    /// Read selected columns across all row groups as a single `RecordBatch`.
529    ///
530    /// Only the byte ranges for the requested columns are decoded and
531    /// decompressed — all other column data on disk is skipped entirely.
532    /// The `timestamp` column is always included even if not listed.
533    ///
534    /// # Errors
535    ///
536    /// Returns an error if any requested column name is not found in the
537    /// segment metadata, or if column data is corrupt.
538    pub fn read_projected(&self, column_names: &[&str]) -> Result<RecordBatch> {
539        self.read_projected_filtered(column_names, None)
540    }
541
542    /// Read selected columns with optional row-group-level time-range
543    /// predicate pushdown.
544    ///
545    /// When `time_range` is `Some((start_ns, end_ns))`, row groups whose
546    /// timestamp range does not overlap `[start_ns, end_ns]` are skipped
547    /// entirely — no I/O or decompression is performed for them.
548    ///
549    /// Both boundaries are **inclusive**, matching the semantics of
550    /// `chronix_query::plan::TimeRange`.
551    ///
552    /// This is the primary entry point for query-time segment reads where
553    /// the caller already knows the desired time window.
554    pub fn read_projected_filtered(
555        &self,
556        column_names: &[&str],
557        time_range: Option<(i64, i64)>,
558    ) -> Result<RecordBatch> {
559        self.read_projected_filtered_with_predicates(column_names, time_range, &[])
560    }
561
562    /// Read selected columns with row-group-level time-range and tag
563    /// predicate pushdown.
564    ///
565    /// In addition to time-range pruning, this method also pushes tag
566    /// equality predicates down to row-group level. A row group is
567    /// skipped when a tag column's per-block statistics indicate that
568    /// it has zero non-null values (`value_count == 0`), which means it
569    /// cannot possibly match an equality filter on that tag.
570    ///
571    /// # Arguments
572    ///
573    /// - `column_names` — Columns to read (empty = all).
574    /// - `time_range` — Optional inclusive time range `(start, end)`.
575    /// - `tag_predicates` — Tag equality filters as `(column_name, value)` pairs.
576    pub fn read_projected_filtered_with_predicates(
577        &self,
578        column_names: &[&str],
579        time_range: Option<(i64, i64)>,
580        tag_predicates: &[(&str, &str)],
581    ) -> Result<RecordBatch> {
582        self.read_projected_with_zone_maps(column_names, time_range, tag_predicates, &[])
583    }
584
585    /// Read selected columns with full predicate pushdown: time-range,
586    /// tag equality, and zone-map field predicates (late materialisation).
587    ///
588    /// Zone-map field predicates evaluate per-row-group column min/max
589    /// statistics to skip row groups that cannot possibly match numeric
590    /// comparisons.  This avoids decompressing and decoding column data
591    /// for entire row groups.
592    ///
593    /// # Arguments
594    ///
595    /// - `column_names` — Columns to read (empty = all).
596    /// - `time_range` — Optional inclusive time range `(start, end)`.
597    /// - `tag_predicates` — Tag equality filters as `(column_name, value)` pairs.
598    /// - `field_predicates` — Numeric field predicates for zone-map pruning.
599    pub fn read_projected_with_zone_maps(
600        &self,
601        column_names: &[&str],
602        time_range: Option<(i64, i64)>,
603        tag_predicates: &[(&str, &str)],
604        field_predicates: &[FieldPredicate],
605    ) -> Result<RecordBatch> {
606        // If column_names is empty, read ALL columns (equivalent to read_all).
607        let all_col_names: Vec<String> = if column_names.is_empty() {
608            self.metadata
609                .columns
610                .iter()
611                .map(|c| c.name.clone())
612                .collect()
613        } else {
614            Vec::new()
615        };
616
617        // Ensure timestamp is always included
618        let mut names: Vec<&str> = Vec::with_capacity(if column_names.is_empty() {
619            all_col_names.len()
620        } else {
621            column_names.len() + 1
622        });
623        if column_names.is_empty() {
624            // Read all columns
625            for c in &all_col_names {
626                names.push(c.as_str());
627            }
628        } else {
629            if !column_names.contains(&chronix_core::TIME_COLUMN) {
630                names.push(chronix_core::TIME_COLUMN);
631            }
632            for &n in column_names {
633                if !names.contains(&n) {
634                    names.push(n);
635                }
636            }
637        }
638
639        // Find the timestamp column index for row-group pruning.
640        let ts_col_idx = self
641            .metadata
642            .columns
643            .iter()
644            .position(|c| c.name == chronix_core::TIME_COLUMN);
645
646        // Resolve tag predicate column indices for row-group pushdown.
647        let tag_col_indices: Vec<usize> = tag_predicates
648            .iter()
649            .filter_map(|(col_name, _)| {
650                self.metadata
651                    .columns
652                    .iter()
653                    .position(|c| c.name == *col_name)
654            })
655            .collect();
656
657        let mut all_arrays: Vec<Vec<ArrayRef>> = vec![Vec::new(); names.len()];
658
659        // Segment-level bloom filter check.
660        // If any tag predicate value is definitely absent from the
661        // segment's bloom filter, the entire segment cannot match —
662        // return an empty RecordBatch immediately.
663        if !tag_predicates.is_empty() {
664            for &(col_name, value) in tag_predicates {
665                if let Some(col_meta) = self.metadata.columns.iter().find(|c| c.name == col_name)
666                    && let Some(ref bf) = col_meta.bloom_filter
667                    && !crate::segment::bloom::bloom_filter_contains(bf, value)
668                {
669                    metrics::counter!("chronix_segment_pruned_by_bloom_filter_total").increment(1);
670                    // Build empty RecordBatch with correct schema
671                    let mut empty_arrays: Vec<ArrayRef> = Vec::with_capacity(names.len());
672                    for &name in &names {
673                        let cm = self
674                            .metadata
675                            .columns
676                            .iter()
677                            .find(|c| c.name == name)
678                            .ok_or_else(|| SegmentError::CorruptFile {
679                                detail: format!("column '{name}' not in metadata"),
680                            })?;
681                        empty_arrays.push(cm.empty_array());
682                    }
683                    let fields: Vec<Field> = names
684                        .iter()
685                        .map(|&n| {
686                            let cm2 = self
687                                .metadata
688                                .columns
689                                .iter()
690                                .find(|c| c.name == n)
691                                .ok_or_else(|| SegmentError::CorruptFile {
692                                    detail: format!("column '{n}' not in metadata"),
693                                })?;
694                            Ok(cm2.arrow_field())
695                        })
696                        .collect::<Result<Vec<_>>>()?;
697                    let schema = Schema::new(fields);
698                    return RecordBatch::try_new(std::sync::Arc::new(schema), empty_arrays)
699                        .map_err(|e| SegmentError::CorruptFile {
700                            detail: format!("failed to create empty RecordBatch: {e}"),
701                        });
702                }
703            }
704        }
705
706        for rg_idx in 0..self.metadata.row_group_blocks.len() {
707            let rg_blocks = &self.metadata.row_group_blocks[rg_idx];
708
709            // Row-group-level time-range predicate pushdown:
710            // Compare the query window [start_ns, end_ns] against the row
711            // group's timestamp column stats [rg_min, rg_max].  If the
712            // intervals don't overlap, no rows in this row group can match
713            // so we skip it entirely — no I/O, no decompression, no
714            // decoding.
715            if let (Some((start_ns, end_ns)), Some(ts_idx)) = (time_range, ts_col_idx)
716                && let Some(ts_block) = rg_blocks.iter().find(|b| b.column_index == ts_idx as u16)
717            {
718                let rg_min = ts_block.stats.min_value;
719                let rg_max = ts_block.stats.max_value;
720                // Skip if row group is entirely before or after query range.
721                // Both boundaries are inclusive: [start_ns, end_ns]
722                if rg_max < start_ns || rg_min > end_ns {
723                    metrics::counter!("chronix_segment_rg_pruned_by_time_range_total").increment(1);
724                    continue;
725                }
726            }
727
728            // Row-group-level tag predicate pushdown.
729            //
730            // For each requested tag column, inspect the per-block stats.
731            // If any tag column has `value_count == 0` (i.e. all values in
732            // this row group are NULL for that tag), the row group cannot
733            // contain a matching row — skip it without reading any data.
734            //
735            // This is conservative: a row group with non-zero value_count
736            // may still not match the equality value, but skipping it
737            // would require decoding the block (which is what we're trying
738            // to avoid).  Full value-level filtering is done post-decode
739            // by `filter_batch()`.
740            //
741            // An encrypted column's statistics are suppressed deliberately,
742            // which leaves `value_count` at zero — indistinguishable from
743            // an all-null block unless `null_count` is consulted too. Only
744            // a genuinely all-null block is impossible to match.
745            if !tag_predicates.is_empty() {
746                let mut skip = false;
747                for &tag_idx in &tag_col_indices {
748                    if let Some(tag_block) =
749                        rg_blocks.iter().find(|b| b.column_index == tag_idx as u16)
750                        && tag_block.stats.value_count == 0
751                        && !tag_block.stats.says_nothing()
752                    {
753                        skip = true;
754                        break;
755                    }
756                }
757                if skip {
758                    metrics::counter!("chronix_segment_rg_pruned_by_tag_stats_total").increment(1);
759                    continue;
760                }
761            }
762
763            // Per-row-group bloom filter pruning for tag columns.
764            //
765            // If a tag column has a per-RG bloom filter and the queried value
766            // is definitely not present in this row group, skip it entirely.
767            if !tag_predicates.is_empty() {
768                let mut skip = false;
769                for &(col_name, value) in tag_predicates {
770                    if let Some(col_meta) =
771                        self.metadata.columns.iter().find(|c| c.name == col_name)
772                        && let Some(ref rg_blooms) = col_meta.row_group_blooms
773                        && let Some(bloom_bytes) = rg_blooms.get(rg_idx)
774                        && !bloom_bytes.is_empty()
775                        && !crate::segment::bloom::bloom_filter_contains(bloom_bytes, value)
776                    {
777                        skip = true;
778                        break;
779                    }
780                }
781                if skip {
782                    metrics::counter!("chronix_segment_rg_pruned_by_bloom_total").increment(1);
783                    continue;
784                }
785            }
786
787            // Late materialisation — zone-map field predicate pushdown.
788            //
789            // For each numeric field predicate, retrieve the column's
790            // per-row-group min/max from ColumnBlockMeta.stats and check
791            // whether any row in this group could possibly match.  If the
792            // zone-map test rules out all rows, skip the entire row group.
793            if !field_predicates.is_empty() {
794                let mut skip = false;
795                for pred in field_predicates {
796                    if let Some((col_idx, col_meta)) = self
797                        .metadata
798                        .columns
799                        .iter()
800                        .enumerate()
801                        .find(|(_, c)| c.name == pred.column)
802                        && let Some(block) =
803                            rg_blocks.iter().find(|b| b.column_index == col_idx as u16)
804                    {
805                        // Statistics that say nothing — an encrypted
806                        // block, whose stats are suppressed so the zone
807                        // map cannot leak its values — must not prune.
808                        let matches = if block.stats.says_nothing() {
809                            true
810                        } else {
811                            match col_meta.data_type {
812                                data_types::F64 => {
813                                    let rg_min = ordered_i64_to_f64(block.stats.min_value);
814                                    let rg_max = ordered_i64_to_f64(block.stats.max_value);
815                                    pred.may_match_f64(rg_min, rg_max)
816                                }
817                                data_types::TIMESTAMP | data_types::I64 => {
818                                    pred.may_match_i64(block.stats.min_value, block.stats.max_value)
819                                }
820                                data_types::DECIMAL => pred.may_match_decimal(
821                                    block.stats.min_value,
822                                    block.stats.max_value,
823                                    col_meta.decimal_scale.unwrap_or(0),
824                                ),
825                                _ => true, // non-numeric → can't prune
826                            }
827                        };
828                        if !matches {
829                            skip = true;
830                            break;
831                        }
832                    }
833                }
834                if skip {
835                    metrics::counter!("chronix_segment_rg_pruned_by_zone_map_total").increment(1);
836                    continue;
837                }
838            }
839
840            let arrays = self.read_columns(&names, rg_idx)?;
841            for (col_idx, array) in arrays.into_iter().enumerate() {
842                all_arrays[col_idx].push(array);
843            }
844        }
845
846        // Concatenate row groups per column.
847        // If all row groups were pruned, return an empty RecordBatch
848        // with the correct schema.
849        let all_pruned = all_arrays.iter().all(std::vec::Vec::is_empty);
850
851        let mut final_arrays: Vec<ArrayRef> = Vec::with_capacity(names.len());
852
853        if all_pruned {
854            // Build empty arrays with correct types
855            for &name in &names {
856                let col_meta = self
857                    .metadata
858                    .columns
859                    .iter()
860                    .find(|c| c.name == name)
861                    .ok_or_else(|| SegmentError::CorruptFile {
862                        detail: format!("projected column '{name}' not in segment metadata"),
863                    })?;
864                final_arrays.push(col_meta.empty_array());
865            }
866        } else {
867            for (col_idx, arrays) in all_arrays.iter().enumerate() {
868                let refs: Vec<&dyn arrow::array::Array> =
869                    arrays.iter().map(std::convert::AsRef::as_ref).collect();
870                let concatenated =
871                    arrow::compute::concat(&refs).map_err(|e| SegmentError::CorruptFile {
872                        detail: format!("failed to concatenate column {}: {e}", names[col_idx]),
873                    })?;
874                final_arrays.push(concatenated);
875            }
876        }
877
878        // Build schema for only the projected columns
879        let fields: Vec<Field> = names
880            .iter()
881            .map(|&name| {
882                let col_meta = self
883                    .metadata
884                    .columns
885                    .iter()
886                    .find(|c| c.name == name)
887                    .ok_or_else(|| SegmentError::CorruptFile {
888                        detail: format!("projected column '{name}' not in segment metadata"),
889                    })?;
890                Ok(col_meta.arrow_field())
891            })
892            .collect::<Result<Vec<Field>>>()?;
893
894        let schema = Schema::new(fields);
895        let batch =
896            RecordBatch::try_new(std::sync::Arc::new(schema), final_arrays).map_err(|e| {
897                SegmentError::CorruptFile {
898                    detail: format!("failed to create RecordBatch: {e}"),
899                }
900            })?;
901
902        // All column data has been decoded into owned Arrow arrays.
903        // Hint the kernel to release the mmap pages back to the page cache.
904        // This reduces RSS during parallel scans where completed segments
905        // would otherwise hold pages until SegmentReader is dropped.
906        // SAFETY: We hold a read-only file-backed Mmap and all column data has
907        // already been decoded into owned Arrow arrays above.  No references into
908        // the mapped region remain, so releasing the pages is safe.
909        #[allow(unsafe_code)]
910        let _ = unsafe { self.data.unchecked_advise(UncheckedAdvice::DontNeed) };
911
912        Ok(batch)
913    }
914
915    /// Read and decode a single column block.
916    fn read_column_block(&self, block_meta: &ColumnBlockMeta, col_idx: usize) -> Result<ArrayRef> {
917        let start = block_meta.offset as usize;
918        let end = start
919            .checked_add(block_meta.length as usize)
920            .ok_or_else(|| SegmentError::CorruptFile {
921                detail: format!(
922                    "column block offset+length overflow: offset={start}, length={}",
923                    block_meta.length,
924                ),
925            })?;
926
927        if end > self.data.len() {
928            return Err(SegmentError::CorruptFile {
929                detail: format!(
930                    "column block extends beyond file: offset={start}, \
931                     length={}, file_size={}",
932                    block_meta.length,
933                    self.data.len()
934                ),
935            });
936        }
937
938        let raw_data = &self.data[start..end];
939
940        // Verify per-block CRC32c if present (block_crc == 0 means legacy/no checksum)
941        if block_meta.block_crc != 0 {
942            let actual_crc = crc32c::crc32c(raw_data);
943            if actual_crc != block_meta.block_crc {
944                return Err(SegmentError::CorruptFile {
945                    detail: format!(
946                        "block CRC mismatch for column {}: expected {:#010x}, got {:#010x}",
947                        col_idx, block_meta.block_crc, actual_crc,
948                    ),
949                });
950            }
951        } else {
952            tracing::warn!(
953                column = col_idx,
954                "block CRC is zero, skipping integrity check — this segment was written without per-block CRCs"
955            );
956            metrics::counter!("chronix_segment_crc_skip_total").increment(1);
957        }
958
959        // Field-level decryption: decrypt after CRC check (CRC covers the
960        // encrypted on-disk bytes) but before decompression.
961        #[cfg(feature = "field-encryption")]
962        let raw_data: std::borrow::Cow<'_, [u8]> = if block_meta.encrypted {
963            let col_meta = &self.metadata.columns[col_idx];
964            let key_id = col_meta
965                .key_id
966                .as_deref()
967                .ok_or_else(|| SegmentError::CorruptFile {
968                    detail: format!(
969                        "column {} is encrypted but has no key_id in metadata",
970                        col_meta.name,
971                    ),
972                })?;
973            let provider =
974                self.key_provider
975                    .as_ref()
976                    .ok_or_else(|| SegmentError::MissingEncryptionKey {
977                        column: col_meta.name.clone(),
978                        key_id: key_id.to_owned(),
979                    })?;
980            let key =
981                provider
982                    .get_key(key_id)
983                    .ok_or_else(|| SegmentError::MissingEncryptionKey {
984                        column: col_meta.name.clone(),
985                        key_id: key_id.to_owned(),
986                    })?;
987            let decrypted = crate::segment::field_encryption::decrypt_block(
988                raw_data,
989                &key,
990                crate::segment::field_encryption::BlockContext {
991                    column: &col_meta.name,
992                    segment_created_at: self.header.created_at,
993                },
994            )
995            .map_err(|e| SegmentError::CorruptFile {
996                detail: format!("failed to decrypt column {}: {e}", col_meta.name,),
997            })?;
998            std::borrow::Cow::Owned(decrypted)
999        } else {
1000            std::borrow::Cow::Borrowed(raw_data)
1001        };
1002
1003        // Both `cfg` arms end at the same type on purpose. When they differed —
1004        // `Cow<[u8]>` with the feature, `&[u8]` without — no single spelling of
1005        // the argument below was clean in both: `&raw_data` is a needless
1006        // borrow of a reference, and `&*raw_data` is a needless deref of one.
1007        // Unifying the type is what makes the code after this point
1008        // feature-independent.
1009        #[cfg(not(feature = "field-encryption"))]
1010        let raw_data: std::borrow::Cow<'_, [u8]> = std::borrow::Cow::Borrowed(raw_data);
1011
1012        // Decompress if needed — the dictionary is passed for ZSTD_DICT blocks.
1013        let decompressed = if block_meta.compressed {
1014            decompress_block_with_optional_dict(
1015                &raw_data,
1016                self.metadata.zstd_dictionary.as_deref(),
1017            )?
1018        } else {
1019            raw_data.to_vec()
1020        };
1021
1022        // Parse the encoded block (first byte is encoding tag)
1023        let encoded_block = EncodedBlock::from_bytes(&decompressed)?;
1024
1025        // Decode to typed values
1026        let decoded = ColumnDecoder::decode(&encoded_block)?;
1027
1028        // Verify decoded value count matches metadata expectation.
1029        let decoded_len = decoded.len();
1030        let expected = block_meta.value_count as usize;
1031        if decoded_len != expected {
1032            return Err(SegmentError::CorruptFile {
1033                detail: format!(
1034                    "decoded value count mismatch for column {col_idx}: \
1035                     metadata says {expected} values but decoded {decoded_len}",
1036                ),
1037            });
1038        }
1039
1040        // D-NULL: attach the block's validity bitmap so absent values read
1041        // back as SQL NULL rather than as a 0/""/false sentinel.
1042        let nulls = self.read_validity(block_meta)?;
1043
1044        // Convert to Arrow array
1045        let col_meta = &self.metadata.columns[col_idx];
1046        decoded_to_arrow(decoded, col_meta, nulls)
1047    }
1048
1049    /// Load a block's validity bitmap, if it has one (`.csx` v2).
1050    ///
1051    /// Returns `None` for blocks with no nulls, which store no bitmap.
1052    fn read_validity(&self, block_meta: &ColumnBlockMeta) -> Result<Option<NullBuffer>> {
1053        if block_meta.validity_length == 0 {
1054            return Ok(None);
1055        }
1056        let start = block_meta.validity_offset as usize;
1057        let end = start
1058            .checked_add(block_meta.validity_length as usize)
1059            .ok_or_else(|| SegmentError::CorruptFile {
1060                detail: "validity bitmap offset+length overflow".to_string(),
1061            })?;
1062        if end > self.data.len() {
1063            return Err(SegmentError::CorruptFile {
1064                detail: format!(
1065                    "validity bitmap extends beyond file: offset={start}, \
1066                     length={}, file_size={}",
1067                    block_meta.validity_length,
1068                    self.data.len()
1069                ),
1070            });
1071        }
1072        let value_count = block_meta.value_count as usize;
1073        let bytes = &self.data[start..end];
1074        if bytes.len() < crate::segment::validity::bitmap_len(value_count) {
1075            return Err(SegmentError::CorruptFile {
1076                detail: format!(
1077                    "validity bitmap too short: {} bytes for {value_count} values",
1078                    bytes.len()
1079                ),
1080            });
1081        }
1082        Ok(crate::segment::validity::null_buffer_from_bytes(
1083            bytes,
1084            value_count,
1085        ))
1086    }
1087}
1088
1089/// Re-attach a validity bitmap to a freshly decoded Arrow array.
1090///
1091/// The decoder always produces a dense array (nulls were stored as
1092/// sentinels in the encoded payload); this stamps the v2 validity bitmap
1093/// back on so the sentinels become real SQL NULLs.
1094fn apply_nulls<A>(array: A, nulls: Option<NullBuffer>) -> Result<A>
1095where
1096    A: arrow::array::Array + From<arrow::array::ArrayData>,
1097{
1098    let Some(nulls) = nulls else {
1099        return Ok(array);
1100    };
1101    let data = array.into_data();
1102    if nulls.len() != data.len() {
1103        return Err(SegmentError::CorruptFile {
1104            detail: format!(
1105                "validity bitmap length {} does not match column length {}",
1106                nulls.len(),
1107                data.len()
1108            ),
1109        });
1110    }
1111    // SAFETY-equivalent: `into_builder`/`build` revalidates offsets and
1112    // buffers, and the null buffer length is checked above.
1113    let data = data
1114        .into_builder()
1115        .nulls(Some(nulls))
1116        .build()
1117        .map_err(|e| SegmentError::CorruptFile {
1118            detail: format!("failed to attach validity bitmap: {e}"),
1119        })?;
1120    Ok(A::from(data))
1121}
1122
1123/// Convert decoded column values to an Arrow `ArrayRef`.
1124///
1125/// Consumes the decoded column to avoid redundant cloning.
1126fn decoded_to_arrow(
1127    decoded: DecodedColumn,
1128    col_meta: &ColumnMeta,
1129    nulls: Option<NullBuffer>,
1130) -> Result<ArrayRef> {
1131    let data_type = col_meta.data_type;
1132    match (decoded, data_type) {
1133        (DecodedColumn::I64(values), data_types::TIMESTAMP | data_types::I64) => Ok(
1134            std::sync::Arc::new(apply_nulls(Int64Array::from(values), nulls)?),
1135        ),
1136        (DecodedColumn::U64(values), data_types::U64) => Ok(std::sync::Arc::new(apply_nulls(
1137            UInt64Array::from(values),
1138            nulls,
1139        )?)),
1140        (DecodedColumn::F64(values), data_types::F64) => Ok(std::sync::Arc::new(apply_nulls(
1141            Float64Array::from(values),
1142            nulls,
1143        )?)),
1144        (DecodedColumn::Bool(values), data_types::BOOL) => Ok(std::sync::Arc::new(apply_nulls(
1145            BooleanArray::from(values),
1146            nulls,
1147        )?)),
1148        (DecodedColumn::String(values), data_types::STRING) => {
1149            let refs: Vec<&str> = values.iter().map(String::as_str).collect();
1150            Ok(std::sync::Arc::new(apply_nulls(
1151                StringArray::from(refs),
1152                nulls,
1153            )?))
1154        }
1155        (DecodedColumn::Decimal(values), data_types::DECIMAL) => {
1156            // The scale is applied here and nowhere else: the mantissas were
1157            // stored raw, and this is where they become a number again.
1158            let scale = i8::try_from(col_meta.decimal_scale.unwrap_or(0)).map_err(|_| {
1159                SegmentError::CorruptFile {
1160                    detail: format!(
1161                        "decimal column '{}' declares an unrepresentable scale",
1162                        col_meta.name
1163                    ),
1164                }
1165            })?;
1166            let array = Decimal128Array::from(values)
1167                .with_precision_and_scale(chronix_core::DECIMAL_PRECISION, scale)
1168                .map_err(|e| SegmentError::CorruptFile {
1169                    detail: format!(
1170                        "decimal column '{}' does not fit decimal(38, {scale}): {e}",
1171                        col_meta.name
1172                    ),
1173                })?;
1174            Ok(std::sync::Arc::new(apply_nulls(array, nulls)?))
1175        }
1176        (DecodedColumn::Bytes(values), data_types::HISTOGRAM) => {
1177            // Straight back out as `Binary`: the bytes are the histogram's own
1178            // encoding, and decoding them here would mean every scan paid for
1179            // parsing a distribution that most queries only count.
1180            let refs: Vec<&[u8]> = values.iter().map(Vec::as_slice).collect();
1181            Ok(std::sync::Arc::new(apply_nulls(
1182                arrow::array::BinaryArray::from(refs),
1183                nulls,
1184            )?))
1185        }
1186        (decoded, _) => Err(SegmentError::CorruptFile {
1187            detail: format!(
1188                "type mismatch: decoded variant {:?} does not match expected data_type {data_type}",
1189                std::mem::discriminant(&decoded),
1190            ),
1191        }),
1192    }
1193}
1194
1195#[cfg(test)]
1196mod zone_map_tests {
1197    use super::*;
1198
1199    fn pred(op: StatsOp, value: f64) -> FieldPredicate {
1200        FieldPredicate {
1201            column: "n".into(),
1202            op,
1203            value,
1204        }
1205    }
1206
1207    /// A fractional bound must not be truncated to an integer. `n < 1.5`
1208    /// used to become `n < 1`, which prunes a block holding `{1, 2, 3}`
1209    /// even though 1 matches — a pruning step that loses rows.
1210    #[test]
1211    fn a_fractional_bound_does_not_prune_an_integer_block() {
1212        assert!(pred(StatsOp::Lt, 1.5).may_match_i64(1, 3));
1213        assert!(pred(StatsOp::LtEq, 1.5).may_match_i64(1, 3));
1214        assert!(pred(StatsOp::Gt, 2.5).may_match_i64(1, 3));
1215        assert!(pred(StatsOp::GtEq, 2.5).may_match_i64(1, 3));
1216        assert!(pred(StatsOp::Eq, 2.0).may_match_i64(1, 3));
1217        // And it still prunes what it should.
1218        assert!(!pred(StatsOp::Lt, 1.0).may_match_i64(1, 3));
1219        assert!(!pred(StatsOp::Gt, 3.0).may_match_i64(1, 3));
1220        // An integer column cannot hold 2.5 at all.
1221        assert!(!pred(StatsOp::Eq, 2.5).may_match_i64(1, 3));
1222    }
1223
1224    /// Beyond 2^53 an `i64` bound cannot be compared as an `f64`, so the
1225    /// row group is kept rather than compared wrongly.
1226    #[test]
1227    fn a_bound_beyond_exact_float_range_never_prunes() {
1228        let big = (1_i64 << 53) + 1;
1229        assert!(pred(StatsOp::Eq, big as f64).may_match_i64(big, big));
1230        assert!(pred(StatsOp::Lt, 0.0).may_match_i64(big, big + 10));
1231    }
1232
1233    /// Statistics that say nothing — the sentinel range an encrypted block
1234    /// carries — must read as "unknown", not as an empty range. As an empty
1235    /// range every comparison is false, so a predicate on an encrypted
1236    /// column pruned every row group and the query returned nothing.
1237    #[test]
1238    fn suppressed_statistics_never_prune() {
1239        let empty = crate::segment::stats::ColumnStats::empty();
1240        assert!(empty.says_nothing());
1241        assert!(
1242            pred(StatsOp::Gt, 0.0).may_match_i64(empty.min_value, empty.max_value),
1243            "an inverted sentinel range must not prune"
1244        );
1245        assert!(pred(StatsOp::Eq, 42.0).may_match_f64(f64::NAN, f64::NAN));
1246
1247        // A genuinely all-null block *is* impossible to match, and pruning
1248        // it stays correct.
1249        let mut all_null = crate::segment::stats::ColumnStats::empty();
1250        all_null.record_null();
1251        assert!(!all_null.says_nothing());
1252    }
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257    use super::*;
1258    use crate::segment::writer::{SegmentWriter, SegmentWriterConfig};
1259    use chronix_core::config::{CompressionCodec, FloatEncoding};
1260    use chronix_core::types::{FieldValue, Point, SeriesKey};
1261    use std::collections::BTreeMap;
1262
1263    fn make_point(host: &str, cpu: f64, ts: i64) -> Point {
1264        let tags = BTreeMap::from([("host".to_string(), host.to_string())]);
1265        let key = SeriesKey::new("cpu_usage", tags).unwrap();
1266        let fields = BTreeMap::from([("cpu".to_string(), FieldValue::F64(cpu))]);
1267        Point::new(key, fields, ts).unwrap()
1268    }
1269
1270    #[test]
1271    fn write_and_read_segment() {
1272        let dir = tempfile::tempdir().unwrap();
1273        let path = dir.path().join("test.csx");
1274
1275        // Write
1276        let points: Vec<Point> = (0..100)
1277            .map(|i| make_point("server-1", 50.0 + i as f64, 1_000_000 + i * 10_000))
1278            .collect();
1279
1280        let config = SegmentWriterConfig {
1281            row_group_size: 50,
1282            compress: false,
1283            float_encoding: FloatEncoding::Gorilla,
1284            compression_codec: CompressionCodec::Lz4,
1285            zstd_level: 3,
1286            column_codec_overrides: std::collections::HashMap::new(),
1287            ..Default::default()
1288        };
1289
1290        let mut writer = SegmentWriter::new(&path, config).unwrap();
1291        writer.write_rows(&points).unwrap();
1292        let meta = writer.finalize().unwrap();
1293
1294        assert_eq!(meta.row_count, 100);
1295        assert_eq!(meta.row_group_count, 2); // 100 rows / 50 per group
1296
1297        // Read
1298        let reader = SegmentReader::open(&path).unwrap();
1299        assert_eq!(reader.header().row_count, 100);
1300        assert_eq!(reader.row_group_count(), 2);
1301
1302        // Read specific columns from first row group
1303        let columns = reader
1304            .read_columns(&[chronix_core::TIME_COLUMN, "cpu"], 0)
1305            .unwrap();
1306        assert_eq!(columns.len(), 2);
1307
1308        // Read all data
1309        let batch = reader.read_all().unwrap();
1310        assert_eq!(batch.num_rows(), 100);
1311        assert_eq!(batch.num_columns(), 3); // timestamp, host, cpu
1312    }
1313
1314    #[test]
1315    fn write_and_read_compressed() {
1316        let dir = tempfile::tempdir().unwrap();
1317        let path = dir.path().join("compressed.csx");
1318
1319        let points: Vec<Point> = (0..200)
1320            .map(|i| make_point("server-1", 50.0 + (i % 10) as f64, 1_000_000 + i * 10_000))
1321            .collect();
1322
1323        let config = SegmentWriterConfig {
1324            row_group_size: 100,
1325            compress: true,
1326            float_encoding: FloatEncoding::Gorilla,
1327            compression_codec: CompressionCodec::Lz4,
1328            zstd_level: 3,
1329            column_codec_overrides: std::collections::HashMap::new(),
1330            ..Default::default()
1331        };
1332
1333        let mut writer = SegmentWriter::new(&path, config).unwrap();
1334        writer.write_rows(&points).unwrap();
1335        writer.finalize().unwrap();
1336
1337        let reader = SegmentReader::open(&path).unwrap();
1338        let batch = reader.read_all().unwrap();
1339        assert_eq!(batch.num_rows(), 200);
1340    }
1341
1342    #[test]
1343    fn checksum_validation() {
1344        let dir = tempfile::tempdir().unwrap();
1345        let path = dir.path().join("checksum.csx");
1346
1347        let points = vec![make_point("s1", 1.0, 1000)];
1348        let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1349        writer.write_rows(&points).unwrap();
1350        writer.finalize().unwrap();
1351
1352        // Corrupt one byte in the middle
1353        let mut data = std::fs::read(&path).unwrap();
1354        let mid = data.len() / 2;
1355        data[mid] ^= 0xFF;
1356        std::fs::write(&path, &data).unwrap();
1357
1358        assert!(matches!(
1359            SegmentReader::open(&path),
1360            Err(SegmentError::ChecksumMismatch { .. })
1361        ));
1362    }
1363
1364    #[test]
1365    fn column_projection() {
1366        let dir = tempfile::tempdir().unwrap();
1367        let path = dir.path().join("projection.csx");
1368
1369        let points: Vec<Point> = (0..50)
1370            .map(|i| make_point("h1", i as f64, i * 1000))
1371            .collect();
1372
1373        let config = SegmentWriterConfig {
1374            row_group_size: 100,
1375            compress: false,
1376            float_encoding: FloatEncoding::Gorilla,
1377            compression_codec: CompressionCodec::Lz4,
1378            zstd_level: 3,
1379            column_codec_overrides: std::collections::HashMap::new(),
1380            ..Default::default()
1381        };
1382
1383        let mut writer = SegmentWriter::new(&path, config).unwrap();
1384        writer.write_rows(&points).unwrap();
1385        writer.finalize().unwrap();
1386
1387        let reader = SegmentReader::open(&path).unwrap();
1388
1389        // Read only the cpu column
1390        let cols = reader.read_columns(&["cpu"], 0).unwrap();
1391        assert_eq!(cols.len(), 1);
1392
1393        let cpu_arr = cols[0].as_any().downcast_ref::<Float64Array>().unwrap();
1394        assert_eq!(cpu_arr.len(), 50);
1395    }
1396
1397    #[test]
1398    fn row_group_out_of_range() {
1399        let dir = tempfile::tempdir().unwrap();
1400        let path = dir.path().join("rg_oor.csx");
1401
1402        let points = vec![make_point("h1", 1.0, 1000)];
1403        let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1404        writer.write_rows(&points).unwrap();
1405        writer.finalize().unwrap();
1406
1407        let reader = SegmentReader::open(&path).unwrap();
1408        assert!(
1409            reader
1410                .read_columns(&[chronix_core::TIME_COLUMN], 999)
1411                .is_err()
1412        );
1413    }
1414
1415    #[test]
1416    fn column_not_found() {
1417        let dir = tempfile::tempdir().unwrap();
1418        let path = dir.path().join("col_404.csx");
1419
1420        let points = vec![make_point("h1", 1.0, 1000)];
1421        let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1422        writer.write_rows(&points).unwrap();
1423        writer.finalize().unwrap();
1424
1425        let reader = SegmentReader::open(&path).unwrap();
1426        assert!(reader.read_columns(&["nonexistent"], 0).is_err());
1427    }
1428
1429    #[test]
1430    fn read_projected_selects_subset() {
1431        let dir = tempfile::tempdir().unwrap();
1432        let path = dir.path().join("projected.csx");
1433
1434        // Write a segment with 3 columns: timestamp, host (tag), cpu (field)
1435        let points: Vec<Point> = (0..80)
1436            .map(|i| make_point("server-1", 50.0 + i as f64, 1_000_000 + i * 10_000))
1437            .collect();
1438
1439        let config = SegmentWriterConfig {
1440            row_group_size: 50,
1441            compress: false,
1442            float_encoding: FloatEncoding::Gorilla,
1443            compression_codec: CompressionCodec::Lz4,
1444            zstd_level: 3,
1445            column_codec_overrides: std::collections::HashMap::new(),
1446            ..Default::default()
1447        };
1448
1449        let mut writer = SegmentWriter::new(&path, config).unwrap();
1450        writer.write_rows(&points).unwrap();
1451        writer.finalize().unwrap();
1452
1453        let reader = SegmentReader::open(&path).unwrap();
1454
1455        // Project only "cpu" — timestamp is auto-included
1456        let batch = reader.read_projected(&["cpu"]).unwrap();
1457        assert_eq!(batch.num_rows(), 80);
1458        assert_eq!(batch.num_columns(), 2); // timestamp + cpu
1459        assert!(batch.column_by_name(chronix_core::TIME_COLUMN).is_some());
1460        assert!(batch.column_by_name("cpu").is_some());
1461        assert!(batch.column_by_name("host").is_none());
1462
1463        // Verify values are correct
1464        let cpu_col = batch
1465            .column_by_name("cpu")
1466            .unwrap()
1467            .as_any()
1468            .downcast_ref::<Float64Array>()
1469            .unwrap();
1470        assert!((cpu_col.value(0) - 50.0).abs() < f64::EPSILON);
1471    }
1472
1473    #[test]
1474    fn read_projected_includes_timestamp_implicitly() {
1475        let dir = tempfile::tempdir().unwrap();
1476        let path = dir.path().join("proj_ts.csx");
1477
1478        let points = vec![make_point("h1", 42.0, 9999)];
1479        let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1480        writer.write_rows(&points).unwrap();
1481        writer.finalize().unwrap();
1482
1483        let reader = SegmentReader::open(&path).unwrap();
1484
1485        // Only request "host" — should still get timestamp
1486        let batch = reader.read_projected(&["host"]).unwrap();
1487        assert_eq!(batch.num_columns(), 2); // timestamp + host
1488        assert!(batch.column_by_name(chronix_core::TIME_COLUMN).is_some());
1489    }
1490
1491    #[test]
1492    fn read_projected_all_columns_matches_read_all() {
1493        let dir = tempfile::tempdir().unwrap();
1494        let path = dir.path().join("proj_all.csx");
1495
1496        let points: Vec<Point> = (0..30)
1497            .map(|i| make_point("h1", i as f64, i * 1000))
1498            .collect();
1499
1500        let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1501        writer.write_rows(&points).unwrap();
1502        writer.finalize().unwrap();
1503
1504        let reader = SegmentReader::open(&path).unwrap();
1505
1506        let all = reader.read_all().unwrap();
1507        let projected = reader
1508            .read_projected(&[chronix_core::TIME_COLUMN, "host", "cpu"])
1509            .unwrap();
1510
1511        assert_eq!(all.num_rows(), projected.num_rows());
1512        assert_eq!(all.num_columns(), projected.num_columns());
1513    }
1514
1515    #[test]
1516    fn read_projected_unknown_column_errors() {
1517        let dir = tempfile::tempdir().unwrap();
1518        let path = dir.path().join("proj_err.csx");
1519
1520        let points = vec![make_point("h1", 1.0, 1000)];
1521        let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1522        writer.write_rows(&points).unwrap();
1523        writer.finalize().unwrap();
1524
1525        let reader = SegmentReader::open(&path).unwrap();
1526        assert!(reader.read_projected(&["nonexistent"]).is_err());
1527    }
1528
1529    #[test]
1530    fn read_projected_across_multiple_row_groups() {
1531        let dir = tempfile::tempdir().unwrap();
1532        let path = dir.path().join("proj_multi_rg.csx");
1533
1534        let points: Vec<Point> = (0..200)
1535            .map(|i| make_point("server-1", i as f64, i * 1000))
1536            .collect();
1537
1538        let config = SegmentWriterConfig {
1539            row_group_size: 64,
1540            compress: true,
1541            float_encoding: FloatEncoding::Gorilla,
1542            compression_codec: CompressionCodec::Lz4,
1543            zstd_level: 3,
1544            column_codec_overrides: std::collections::HashMap::new(),
1545            ..Default::default()
1546        };
1547
1548        let mut writer = SegmentWriter::new(&path, config).unwrap();
1549        writer.write_rows(&points).unwrap();
1550        writer.finalize().unwrap();
1551
1552        let reader = SegmentReader::open(&path).unwrap();
1553        assert!(reader.row_group_count() > 1);
1554
1555        let batch = reader.read_projected(&["cpu"]).unwrap();
1556        assert_eq!(batch.num_rows(), 200);
1557        assert_eq!(batch.num_columns(), 2); // timestamp + cpu
1558    }
1559
1560    #[test]
1561    fn multiple_series() {
1562        let dir = tempfile::tempdir().unwrap();
1563        let path = dir.path().join("multi_series.csx");
1564
1565        let mut points = Vec::new();
1566        for i in 0..20 {
1567            points.push(make_point("server-1", i as f64, i * 1000));
1568            points.push(make_point("server-2", i as f64 + 100.0, i * 1000));
1569        }
1570
1571        let config = SegmentWriterConfig {
1572            row_group_size: 100,
1573            compress: false,
1574            float_encoding: FloatEncoding::Gorilla,
1575            compression_codec: CompressionCodec::Lz4,
1576            zstd_level: 3,
1577            column_codec_overrides: std::collections::HashMap::new(),
1578            ..Default::default()
1579        };
1580
1581        let mut writer = SegmentWriter::new(&path, config).unwrap();
1582        writer.write_rows(&points).unwrap();
1583        let meta = writer.finalize().unwrap();
1584
1585        assert_eq!(meta.series_count, 2);
1586        assert_eq!(meta.row_count, 40);
1587
1588        let reader = SegmentReader::open(&path).unwrap();
1589        let batch = reader.read_all().unwrap();
1590        assert_eq!(batch.num_rows(), 40);
1591    }
1592
1593    // ── P1: time-range predicate pushdown tests ──────────────────────
1594
1595    /// Helper: create a segment with `n` points for a single series,
1596    /// timestamps `0, step, 2*step, …` and a small `row_group_size`
1597    /// so we get multiple row groups to prune.
1598    fn write_segment_with_rg_size(path: &std::path::Path, n: usize, step: i64, rg_size: usize) {
1599        let points: Vec<Point> = (0..n)
1600            .map(|i| make_point("server-1", i as f64, i as i64 * step))
1601            .collect();
1602
1603        let config = SegmentWriterConfig {
1604            row_group_size: rg_size,
1605            compress: false,
1606            float_encoding: FloatEncoding::Gorilla,
1607            compression_codec: CompressionCodec::Lz4,
1608            zstd_level: 3,
1609            column_codec_overrides: std::collections::HashMap::new(),
1610            ..Default::default()
1611        };
1612
1613        let mut writer = SegmentWriter::new(path, config).unwrap();
1614        writer.write_rows(&points).unwrap();
1615        writer.finalize().unwrap();
1616    }
1617
1618    #[test]
1619    fn time_range_prunes_row_groups() {
1620        // 30 points, ts = 0, 10_000, …, 290_000.
1621        // row_group_size = 10 → 3 row groups:
1622        //   rg0: ts [0, 90_000]
1623        //   rg1: ts [100_000, 190_000]
1624        //   rg2: ts [200_000, 290_000]
1625        let dir = tempfile::tempdir().unwrap();
1626        let path = dir.path().join("prune.csx");
1627        write_segment_with_rg_size(&path, 30, 10_000, 10);
1628
1629        let reader = SegmentReader::open(&path).unwrap();
1630        assert_eq!(reader.row_group_count(), 3);
1631
1632        // Query only the middle row group's time range.
1633        let batch = reader
1634            .read_projected_filtered(&["cpu"], Some((100_000, 190_000)))
1635            .unwrap();
1636        assert_eq!(batch.num_rows(), 10); // only rg1
1637
1638        // Verify timestamps are in the expected range.
1639        let ts = batch
1640            .column_by_name(chronix_core::TIME_COLUMN)
1641            .unwrap()
1642            .as_any()
1643            .downcast_ref::<Int64Array>()
1644            .unwrap();
1645        for i in 0..ts.len() {
1646            assert!(ts.value(i) >= 100_000);
1647            assert!(ts.value(i) <= 190_000);
1648        }
1649    }
1650
1651    #[test]
1652    fn time_range_inclusive_end_boundary() {
1653        // Same layout: 3 row groups.
1654        //   rg0: ts [0, 90_000]
1655        //   rg1: ts [100_000, 190_000]
1656        //   rg2: ts [200_000, 290_000]
1657        //
1658        // Query [0, 100_000] — end equals rg1.min.
1659        // With inclusive end, rg1 MUST be included (it contains ts=100_000).
1660        let dir = tempfile::tempdir().unwrap();
1661        let path = dir.path().join("boundary.csx");
1662        write_segment_with_rg_size(&path, 30, 10_000, 10);
1663
1664        let reader = SegmentReader::open(&path).unwrap();
1665
1666        let batch = reader
1667            .read_projected_filtered(&["cpu"], Some((0, 100_000)))
1668            .unwrap();
1669        // rg0 (10 rows) + rg1 (10 rows) = 20 rows
1670        // (rg1 is included because rg1.min == 100_000 <= end 100_000)
1671        assert_eq!(batch.num_rows(), 20);
1672    }
1673
1674    #[test]
1675    fn time_range_all_pruned_returns_empty() {
1676        let dir = tempfile::tempdir().unwrap();
1677        let path = dir.path().join("empty.csx");
1678        // ts range [0, 290_000]
1679        write_segment_with_rg_size(&path, 30, 10_000, 10);
1680
1681        let reader = SegmentReader::open(&path).unwrap();
1682
1683        // Query range completely after segment data.
1684        let batch = reader
1685            .read_projected_filtered(&["cpu"], Some((500_000, 600_000)))
1686            .unwrap();
1687        assert_eq!(batch.num_rows(), 0);
1688        // Schema should still have timestamp + cpu.
1689        assert_eq!(batch.num_columns(), 2);
1690        assert!(batch.column_by_name(chronix_core::TIME_COLUMN).is_some());
1691        assert!(batch.column_by_name("cpu").is_some());
1692    }
1693
1694    #[test]
1695    fn time_range_none_reads_all() {
1696        let dir = tempfile::tempdir().unwrap();
1697        let path = dir.path().join("no_filter.csx");
1698        write_segment_with_rg_size(&path, 30, 10_000, 10);
1699
1700        let reader = SegmentReader::open(&path).unwrap();
1701
1702        let batch = reader.read_projected_filtered(&["cpu"], None).unwrap();
1703        assert_eq!(batch.num_rows(), 30);
1704    }
1705
1706    #[test]
1707    fn read_row_group_returns_single_group() {
1708        let dir = tempfile::tempdir().unwrap();
1709        let path = dir.path().join("single_rg.csx");
1710        write_segment_with_rg_size(&path, 30, 10_000, 10);
1711
1712        let reader = SegmentReader::open(&path).unwrap();
1713        assert_eq!(reader.row_group_count(), 3);
1714
1715        let rg1 = reader.read_row_group(1).unwrap();
1716        assert_eq!(rg1.num_rows(), 10);
1717
1718        // Verify timestamps are from the second row group.
1719        let ts = rg1
1720            .column_by_name(chronix_core::TIME_COLUMN)
1721            .unwrap()
1722            .as_any()
1723            .downcast_ref::<Int64Array>()
1724            .unwrap();
1725        assert_eq!(ts.value(0), 100_000);
1726        assert_eq!(ts.value(9), 190_000);
1727    }
1728
1729    #[test]
1730    fn read_row_group_out_of_range_errors() {
1731        let dir = tempfile::tempdir().unwrap();
1732        let path = dir.path().join("rg_oor2.csx");
1733        write_segment_with_rg_size(&path, 10, 1000, 10);
1734
1735        let reader = SegmentReader::open(&path).unwrap();
1736        assert!(reader.read_row_group(5).is_err());
1737    }
1738
1739    // ── Bloom filter integration tests ─────────────────────────
1740
1741    /// Helper: create a segment with multiple hosts (tag values) for
1742    /// bloom filter testing.
1743    fn write_segment_with_tags(path: &std::path::Path, hosts: &[&str], points_per_host: usize) {
1744        let mut points = Vec::new();
1745        for (h_idx, &host) in hosts.iter().enumerate() {
1746            for i in 0..points_per_host {
1747                points.push(make_point(
1748                    host,
1749                    (h_idx * 100 + i) as f64,
1750                    (h_idx * points_per_host + i) as i64 * 1000,
1751                ));
1752            }
1753        }
1754
1755        let config = SegmentWriterConfig {
1756            row_group_size: 64,
1757            compress: false,
1758            float_encoding: FloatEncoding::Gorilla,
1759            compression_codec: CompressionCodec::Lz4,
1760            zstd_level: 3,
1761            column_codec_overrides: std::collections::HashMap::new(),
1762            ..Default::default()
1763        };
1764
1765        let mut writer = SegmentWriter::new(path, config).unwrap();
1766        writer.write_rows(&points).unwrap();
1767        writer.finalize().unwrap();
1768    }
1769
1770    #[test]
1771    fn bloom_filter_present_in_tag_columns() {
1772        let dir = tempfile::tempdir().unwrap();
1773        let path = dir.path().join("bloom_tag.csx");
1774        write_segment_with_tags(&path, &["server-1", "server-2", "server-3"], 10);
1775
1776        let reader = SegmentReader::open(&path).unwrap();
1777
1778        // The "host" column (role=TAG) should have a bloom filter
1779        let host_meta = reader
1780            .column_metadata()
1781            .iter()
1782            .find(|c| c.name == "host")
1783            .unwrap();
1784        assert!(
1785            host_meta.bloom_filter.is_some(),
1786            "tag column should have a bloom filter"
1787        );
1788
1789        // Non-tag columns should NOT have bloom filters
1790        let ts_meta = reader
1791            .column_metadata()
1792            .iter()
1793            .find(|c| c.name == chronix_core::TIME_COLUMN)
1794            .unwrap();
1795        assert!(ts_meta.bloom_filter.is_none());
1796
1797        let cpu_meta = reader
1798            .column_metadata()
1799            .iter()
1800            .find(|c| c.name == "cpu")
1801            .unwrap();
1802        assert!(cpu_meta.bloom_filter.is_none());
1803    }
1804
1805    #[test]
1806    fn bloom_filter_prunes_segment_for_unknown_tag() {
1807        let dir = tempfile::tempdir().unwrap();
1808        let path = dir.path().join("bloom_prune.csx");
1809        write_segment_with_tags(&path, &["server-1", "server-2"], 20);
1810
1811        let reader = SegmentReader::open(&path).unwrap();
1812
1813        // Query with a tag value that does NOT exist in the segment.
1814        // The bloom filter should prune the entire segment, returning
1815        // zero rows.
1816        let batch = reader
1817            .read_projected_filtered_with_predicates(&["cpu"], None, &[("host", "server-999")])
1818            .unwrap();
1819        assert_eq!(batch.num_rows(), 0, "bloom filter should prune unknown tag");
1820    }
1821
1822    #[test]
1823    fn bloom_filter_passes_known_tag() {
1824        let dir = tempfile::tempdir().unwrap();
1825        let path = dir.path().join("bloom_pass.csx");
1826        write_segment_with_tags(&path, &["server-1", "server-2"], 20);
1827
1828        let reader = SegmentReader::open(&path).unwrap();
1829
1830        // Query with a tag value that DOES exist — bloom filter must
1831        // not prune it (no false negatives).
1832        let batch = reader
1833            .read_projected_filtered_with_predicates(
1834                &["cpu", "host"],
1835                None,
1836                &[("host", "server-1")],
1837            )
1838            .unwrap();
1839        // Should return rows (bloom filter passes, all row groups read).
1840        // The batch has all rows (not just server-1) because row-group
1841        // level filtering doesn't do value-level matching.
1842        assert!(
1843            batch.num_rows() > 0,
1844            "bloom filter should not prune known tag"
1845        );
1846    }
1847
1848    #[test]
1849    fn bloom_filter_roundtrip_metadata() {
1850        // Verify that bloom filter data survives the write → read
1851        // round-trip through segment metadata serialization.
1852        let dir = tempfile::tempdir().unwrap();
1853        let path = dir.path().join("bloom_rt.csx");
1854        write_segment_with_tags(&path, &["alpha", "beta", "gamma"], 5);
1855
1856        let reader = SegmentReader::open(&path).unwrap();
1857        let host_meta = reader
1858            .column_metadata()
1859            .iter()
1860            .find(|c| c.name == "host")
1861            .unwrap();
1862
1863        let bf = host_meta.bloom_filter.as_ref().unwrap();
1864
1865        // All written tag values must be present
1866        assert!(crate::segment::bloom::bloom_filter_contains(bf, "alpha"));
1867        assert!(crate::segment::bloom::bloom_filter_contains(bf, "beta"));
1868        assert!(crate::segment::bloom::bloom_filter_contains(bf, "gamma"));
1869
1870        // An unwritten value should (almost certainly) be absent
1871        assert!(!crate::segment::bloom::bloom_filter_contains(bf, "delta"));
1872    }
1873
1874    #[test]
1875    #[cfg(feature = "field-encryption")]
1876    fn field_encryption_roundtrip() {
1877        use crate::segment::field_encryption::{
1878            FieldEncryptionConfig, FieldEncryptionKey, StaticKeyProvider,
1879        };
1880
1881        let dir = tempfile::tempdir().unwrap();
1882        let path = dir.path().join("encrypted.csx");
1883
1884        let key_bytes: [u8; 32] = [0xAB; 32];
1885        let enc_key = FieldEncryptionKey::new("key-1".to_string(), key_bytes);
1886
1887        let mut enc_config = FieldEncryptionConfig::default();
1888        enc_config.encrypt_column("cpu", enc_key);
1889
1890        let points: Vec<Point> = (0..100)
1891            .map(|i| make_point("server-1", 50.0 + i as f64, 1_000_000 + i * 10_000))
1892            .collect();
1893
1894        let config = SegmentWriterConfig {
1895            row_group_size: 50,
1896            compress: true,
1897            field_encryption: enc_config,
1898            ..Default::default()
1899        };
1900
1901        let mut writer = SegmentWriter::new(&path, config).unwrap();
1902        writer.write_rows(&points).unwrap();
1903        let meta = writer.finalize().unwrap();
1904        assert_eq!(meta.row_count, 100);
1905
1906        // Verify column metadata flags
1907        let reader_no_key = SegmentReader::open(&path).unwrap();
1908        let cpu_meta = reader_no_key
1909            .column_metadata()
1910            .iter()
1911            .find(|c| c.name == "cpu")
1912            .unwrap();
1913        assert!(cpu_meta.encrypted);
1914        assert_eq!(cpu_meta.key_id.as_deref(), Some("key-1"));
1915
1916        // Stats should be suppressed for encrypted columns
1917        let ts_meta = reader_no_key
1918            .column_metadata()
1919            .iter()
1920            .find(|c| c.name == chronix_core::TIME_COLUMN)
1921            .unwrap();
1922        assert!(!ts_meta.encrypted);
1923        assert!(ts_meta.stats.min_value != i64::MAX || ts_meta.stats.max_value != i64::MIN);
1924
1925        // Bloom filter should NOT exist for encrypted tag columns — but
1926        // "cpu" is a field, not a tag, so check that "host" still has a bloom.
1927        let host_meta = reader_no_key
1928            .column_metadata()
1929            .iter()
1930            .find(|c| c.name == "host")
1931            .unwrap();
1932        assert!(host_meta.bloom_filter.is_some());
1933
1934        // Read back with a key provider
1935        let mut provider = StaticKeyProvider::new();
1936        provider.add_key("key-1", key_bytes);
1937
1938        let mut reader = SegmentReader::open(&path).unwrap();
1939        reader.set_key_provider(Arc::new(provider));
1940
1941        let batch = reader.read_all().unwrap();
1942        assert_eq!(batch.num_rows(), 100);
1943
1944        let cpu_col = batch
1945            .column_by_name("cpu")
1946            .unwrap()
1947            .as_any()
1948            .downcast_ref::<Float64Array>()
1949            .unwrap();
1950        for i in 0..100 {
1951            assert!(
1952                (cpu_col.value(i) - (50.0 + i as f64)).abs() < 1e-9,
1953                "cpu mismatch at row {i}"
1954            );
1955        }
1956    }
1957
1958    /// The validity bitmap lives in the data region, so the whole-file CRC32c
1959    /// verified at `open()` must cover it — otherwise a flipped bit would
1960    /// silently turn a real value into a NULL (or vice versa) with no error.
1961    #[test]
1962    fn corrupted_validity_bitmap_is_detected() {
1963        let dir = tempfile::tempdir().unwrap();
1964        let path = dir.path().join("nulls.csx");
1965
1966        // Sparse column => at least one block carries a validity bitmap.
1967        let tags = BTreeMap::from([("host".to_string(), "h".to_string())]);
1968        let key = SeriesKey::new("m", tags).unwrap();
1969        let points: Vec<Point> = (0..40i64)
1970            .map(|i| {
1971                let fields = if i % 2 == 0 {
1972                    BTreeMap::from([("v".to_string(), FieldValue::F64(i as f64))])
1973                } else {
1974                    BTreeMap::from([("w".to_string(), FieldValue::F64(1.0))])
1975                };
1976                Point::new(key.clone(), fields, 1_000 + i * 10).unwrap()
1977            })
1978            .collect();
1979
1980        let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1981        writer.write_rows(&points).unwrap();
1982        writer.finalize().unwrap();
1983
1984        // Locate a bitmap and flip a bit inside it.
1985        let reader = SegmentReader::open(&path).unwrap();
1986        let (off, len) = reader
1987            .metadata
1988            .row_group_blocks
1989            .iter()
1990            .flatten()
1991            .find(|b| b.validity_length > 0)
1992            .map(|b| (b.validity_offset as usize, b.validity_length))
1993            .expect("a sparse column must have written a validity bitmap");
1994        assert!(len > 0);
1995        drop(reader);
1996
1997        let mut bytes = std::fs::read(&path).unwrap();
1998        bytes[off] ^= 0b0000_0001;
1999        std::fs::write(&path, &bytes).unwrap();
2000
2001        assert!(
2002            matches!(
2003                SegmentReader::open(&path),
2004                Err(SegmentError::ChecksumMismatch { .. })
2005            ),
2006            "a corrupted validity bitmap must fail the file checksum"
2007        );
2008    }
2009
2010    /// D-NULL + field encryption: the validity bitmap is stored *outside*
2011    /// the encrypted payload, so a reader without the key must still be
2012    /// blocked on values while a reader with the key sees correct nulls.
2013    #[test]
2014    #[cfg(feature = "field-encryption")]
2015    fn encrypted_column_preserves_nulls() {
2016        use crate::segment::field_encryption::{
2017            FieldEncryptionConfig, FieldEncryptionKey, StaticKeyProvider,
2018        };
2019        use arrow::array::Array;
2020
2021        let dir = tempfile::tempdir().unwrap();
2022        let path = dir.path().join("encrypted_nulls.csx");
2023
2024        let enc_key = FieldEncryptionKey::new("key-1".to_string(), [0xCD; 32]);
2025        let mut enc_config = FieldEncryptionConfig::default();
2026        enc_config.encrypt_column("cpu", enc_key);
2027
2028        // Every other point omits `cpu` entirely.
2029        let tags = BTreeMap::from([("host".to_string(), "server-1".to_string())]);
2030        let key = SeriesKey::new("cpu_usage", tags).unwrap();
2031        let points: Vec<Point> = (0..40i64)
2032            .map(|i| {
2033                let fields = if i % 2 == 0 {
2034                    BTreeMap::from([("cpu".to_string(), FieldValue::F64(i as f64))])
2035                } else {
2036                    BTreeMap::from([("other".to_string(), FieldValue::F64(1.0))])
2037                };
2038                Point::new(key.clone(), fields, 1_000_000 + i * 10_000).unwrap()
2039            })
2040            .collect();
2041
2042        let config = SegmentWriterConfig {
2043            row_group_size: 16,
2044            compress: true,
2045            field_encryption: enc_config,
2046            ..Default::default()
2047        };
2048        let mut writer = SegmentWriter::new(&path, config).unwrap();
2049        writer.write_rows(&points).unwrap();
2050        writer.finalize().unwrap();
2051
2052        let mut provider = StaticKeyProvider::new();
2053        provider.add_key("key-1", [0xCD; 32]);
2054        let mut reader = SegmentReader::open(&path).unwrap();
2055        reader.set_key_provider(Arc::new(provider));
2056        let batch = reader.read_all().unwrap();
2057
2058        let cpu = batch
2059            .column_by_name("cpu")
2060            .expect("cpu column")
2061            .as_any()
2062            .downcast_ref::<arrow::array::Float64Array>()
2063            .expect("f64");
2064        assert_eq!(cpu.null_count(), 20, "half the rows have no cpu value");
2065        // And the surviving values must be intact, not shifted by the bitmap.
2066        let present: Vec<f64> = (0..cpu.len())
2067            .filter(|i| cpu.is_valid(*i))
2068            .map(|i| cpu.value(i))
2069            .collect();
2070        assert_eq!(present.len(), 20);
2071        assert!(
2072            present.iter().all(|v| *v % 2.0 == 0.0),
2073            "values corrupted: {present:?}"
2074        );
2075    }
2076
2077    #[test]
2078    #[cfg(feature = "field-encryption")]
2079    fn field_encryption_wrong_key_fails() {
2080        use crate::segment::field_encryption::{
2081            FieldEncryptionConfig, FieldEncryptionKey, StaticKeyProvider,
2082        };
2083
2084        let dir = tempfile::tempdir().unwrap();
2085        let path = dir.path().join("enc_wrong_key.csx");
2086
2087        let key_bytes: [u8; 32] = [0xCD; 32];
2088        let enc_key = FieldEncryptionKey::new("key-2".to_string(), key_bytes);
2089
2090        let mut enc_config = FieldEncryptionConfig::default();
2091        enc_config.encrypt_column("cpu", enc_key);
2092
2093        let points: Vec<Point> = (0..20)
2094            .map(|i| make_point("server-1", 10.0 + i as f64, 1_000_000 + i * 10_000))
2095            .collect();
2096
2097        let config = SegmentWriterConfig {
2098            row_group_size: 100,
2099            compress: false,
2100            field_encryption: enc_config,
2101            ..Default::default()
2102        };
2103
2104        let mut writer = SegmentWriter::new(&path, config).unwrap();
2105        writer.write_rows(&points).unwrap();
2106        writer.finalize().unwrap();
2107
2108        // Wrong key — decryption should fail
2109        let mut wrong_provider = StaticKeyProvider::new();
2110        wrong_provider.add_key("key-2", [0xFF; 32]);
2111
2112        let mut reader = SegmentReader::open(&path).unwrap();
2113        reader.set_key_provider(Arc::new(wrong_provider));
2114
2115        let result = reader.read_all();
2116        assert!(result.is_err(), "reading with wrong key should fail");
2117    }
2118
2119    #[test]
2120    #[cfg(feature = "field-encryption")]
2121    fn field_encryption_no_provider_fails() {
2122        use crate::segment::field_encryption::{FieldEncryptionConfig, FieldEncryptionKey};
2123
2124        let dir = tempfile::tempdir().unwrap();
2125        let path = dir.path().join("enc_no_provider.csx");
2126
2127        let key_bytes: [u8; 32] = [0xEF; 32];
2128        let enc_key = FieldEncryptionKey::new("key-3".to_string(), key_bytes);
2129
2130        let mut enc_config = FieldEncryptionConfig::default();
2131        enc_config.encrypt_column("cpu", enc_key);
2132
2133        let points: Vec<Point> = (0..10)
2134            .map(|i| make_point("server-1", 1.0, 1_000_000 + i * 10_000))
2135            .collect();
2136
2137        let config = SegmentWriterConfig {
2138            row_group_size: 100,
2139            compress: false,
2140            field_encryption: enc_config,
2141            ..Default::default()
2142        };
2143
2144        let mut writer = SegmentWriter::new(&path, config).unwrap();
2145        writer.write_rows(&points).unwrap();
2146        writer.finalize().unwrap();
2147
2148        // No key provider at all
2149        let reader = SegmentReader::open(&path).unwrap();
2150        let result = reader.read_all();
2151        assert!(
2152            result.is_err(),
2153            "reading encrypted column without key provider should fail"
2154        );
2155    }
2156
2157    // ── Per-row-group bloom filter tests ─────────────────
2158
2159    #[test]
2160    fn row_group_bloom_filters_present_for_tag_columns() {
2161        let dir = tempfile::tempdir().unwrap();
2162        let path = dir.path().join("rg_bloom.csx");
2163
2164        // 30 points across 3 hosts, sorted by time → hosts interleave across
2165        // row groups when row_group_size=10.
2166        write_segment_with_tags(&path, &["alpha", "beta", "gamma"], 10);
2167
2168        let reader = SegmentReader::open(&path).unwrap();
2169        let host_meta = reader
2170            .column_metadata()
2171            .iter()
2172            .find(|c| c.name == "host")
2173            .unwrap();
2174
2175        // Per-RG blooms should exist for tag columns
2176        assert!(
2177            host_meta.row_group_blooms.is_some(),
2178            "tag column should have per-row-group bloom filters"
2179        );
2180
2181        let rg_blooms = host_meta.row_group_blooms.as_ref().unwrap();
2182        assert_eq!(rg_blooms.len(), reader.row_group_count());
2183
2184        // Each bloom should be non-empty
2185        for (i, bloom) in rg_blooms.iter().enumerate() {
2186            assert!(!bloom.is_empty(), "bloom for rg{i} should be non-empty");
2187        }
2188    }
2189
2190    #[test]
2191    fn row_group_bloom_prunes_non_matching_row_groups() {
2192        // Create a segment where host "alpha" is ONLY in the first row group
2193        // and host "beta" is ONLY in the second row group.
2194        //
2195        // Points are written in order (alpha first, beta second) with
2196        // increasing timestamps, so the writer's sort will keep them in
2197        // their respective row groups.
2198        let dir = tempfile::tempdir().unwrap();
2199        let path = dir.path().join("rg_bloom_prune.csx");
2200
2201        let mut points = Vec::new();
2202        // First 10 points: host=alpha, ts=[0, 9000]
2203        for i in 0..10 {
2204            points.push(make_point("alpha", i as f64, i as i64 * 1000));
2205        }
2206        // Next 10 points: host=beta, ts=[10000, 19000]
2207        for i in 0..10 {
2208            points.push(make_point("beta", (100 + i) as f64, (10 + i) as i64 * 1000));
2209        }
2210
2211        let config = SegmentWriterConfig {
2212            row_group_size: 10,
2213            compress: false,
2214            float_encoding: FloatEncoding::Gorilla,
2215            compression_codec: CompressionCodec::Lz4,
2216            zstd_level: 3,
2217            column_codec_overrides: std::collections::HashMap::new(),
2218            ..Default::default()
2219        };
2220
2221        let mut writer = SegmentWriter::new(&path, config).unwrap();
2222        writer.write_rows(&points).unwrap();
2223        writer.finalize().unwrap();
2224
2225        let reader = SegmentReader::open(&path).unwrap();
2226        assert_eq!(reader.row_group_count(), 2);
2227
2228        // Verify per-RG blooms: rg0 should contain "alpha" but NOT "beta"
2229        let host_meta = reader
2230            .column_metadata()
2231            .iter()
2232            .find(|c| c.name == "host")
2233            .unwrap();
2234        let rg_blooms = host_meta.row_group_blooms.as_ref().unwrap();
2235
2236        assert!(crate::segment::bloom::bloom_filter_contains(
2237            &rg_blooms[0],
2238            "alpha"
2239        ));
2240        assert!(!crate::segment::bloom::bloom_filter_contains(
2241            &rg_blooms[0],
2242            "beta"
2243        ));
2244        assert!(crate::segment::bloom::bloom_filter_contains(
2245            &rg_blooms[1],
2246            "beta"
2247        ));
2248        assert!(!crate::segment::bloom::bloom_filter_contains(
2249            &rg_blooms[1],
2250            "alpha"
2251        ));
2252
2253        // Query for "alpha" — should only return rg0 (10 rows)
2254        let batch = reader
2255            .read_projected_filtered_with_predicates(&["cpu", "host"], None, &[("host", "alpha")])
2256            .unwrap();
2257        assert_eq!(
2258            batch.num_rows(),
2259            10,
2260            "only rg0 should be returned for alpha"
2261        );
2262
2263        // Query for "beta" — should only return rg1 (10 rows)
2264        let batch = reader
2265            .read_projected_filtered_with_predicates(&["cpu", "host"], None, &[("host", "beta")])
2266            .unwrap();
2267        assert_eq!(batch.num_rows(), 10, "only rg1 should be returned for beta");
2268
2269        // Query for "gamma" (non-existent) — should return 0 rows
2270        let batch = reader
2271            .read_projected_filtered_with_predicates(&["cpu"], None, &[("host", "gamma")])
2272            .unwrap();
2273        assert_eq!(batch.num_rows(), 0, "non-existent tag should prune all RGs");
2274    }
2275}