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::{SegmentFooter, SegmentHeader, FOOTER_SIZE, HEADER_SIZE};
64use crate::segment::metadata::{data_types, ColumnBlockMeta, ColumnMeta, SegmentMetadata};
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 ZoneMapOp {
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: ZoneMapOp,
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            ZoneMapOp::Eq => self.value >= rg_min && self.value <= rg_max,
111            ZoneMapOp::Gt => rg_max > self.value,
112            ZoneMapOp::GtEq => rg_max >= self.value,
113            ZoneMapOp::Lt => rg_min < self.value,
114            ZoneMapOp::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            ZoneMapOp::Eq if self.value.fract() != 0.0 => false,
140            ZoneMapOp::Eq => self.value >= lo && self.value <= hi,
141            ZoneMapOp::Gt => hi > self.value,
142            ZoneMapOp::GtEq => hi >= self.value,
143            ZoneMapOp::Lt => lo < self.value,
144            ZoneMapOp::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                    if let Some(ref bf) = col_meta.bloom_filter {
667                        if !crate::segment::bloom::bloom_filter_contains(bf, value) {
668                            metrics::counter!("chronix_segment_pruned_by_bloom_filter_total")
669                                .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        }
707
708        for rg_idx in 0..self.metadata.row_group_blocks.len() {
709            let rg_blocks = &self.metadata.row_group_blocks[rg_idx];
710
711            // Row-group-level time-range predicate pushdown:
712            // Compare the query window [start_ns, end_ns] against the row
713            // group's timestamp column stats [rg_min, rg_max].  If the
714            // intervals don't overlap, no rows in this row group can match
715            // so we skip it entirely — no I/O, no decompression, no
716            // decoding.
717            if let (Some((start_ns, end_ns)), Some(ts_idx)) = (time_range, ts_col_idx) {
718                if let Some(ts_block) = rg_blocks.iter().find(|b| b.column_index == ts_idx as u16) {
719                    let rg_min = ts_block.stats.min_value;
720                    let rg_max = ts_block.stats.max_value;
721                    // Skip if row group is entirely before or after query range.
722                    // Both boundaries are inclusive: [start_ns, end_ns]
723                    if rg_max < start_ns || rg_min > end_ns {
724                        metrics::counter!("chronix_segment_rg_pruned_by_time_range_total")
725                            .increment(1);
726                        continue;
727                    }
728                }
729            }
730
731            // Row-group-level tag predicate pushdown.
732            //
733            // For each requested tag column, inspect the per-block stats.
734            // If any tag column has `value_count == 0` (i.e. all values in
735            // this row group are NULL for that tag), the row group cannot
736            // contain a matching row — skip it without reading any data.
737            //
738            // This is conservative: a row group with non-zero value_count
739            // may still not match the equality value, but skipping it
740            // would require decoding the block (which is what we're trying
741            // to avoid).  Full value-level filtering is done post-decode
742            // by `filter_batch()`.
743            //
744            // An encrypted column's statistics are suppressed deliberately,
745            // which leaves `value_count` at zero — indistinguishable from
746            // an all-null block unless `null_count` is consulted too. Only
747            // a genuinely all-null block is impossible to match.
748            if !tag_predicates.is_empty() {
749                let mut skip = false;
750                for &tag_idx in &tag_col_indices {
751                    if let Some(tag_block) =
752                        rg_blocks.iter().find(|b| b.column_index == tag_idx as u16)
753                    {
754                        if tag_block.stats.value_count == 0 && !tag_block.stats.says_nothing() {
755                            skip = true;
756                            break;
757                        }
758                    }
759                }
760                if skip {
761                    metrics::counter!("chronix_segment_rg_pruned_by_tag_stats_total").increment(1);
762                    continue;
763                }
764            }
765
766            // Per-row-group bloom filter pruning for tag columns.
767            //
768            // If a tag column has a per-RG bloom filter and the queried value
769            // is definitely not present in this row group, skip it entirely.
770            if !tag_predicates.is_empty() {
771                let mut skip = false;
772                for &(col_name, value) in tag_predicates {
773                    if let Some(col_meta) =
774                        self.metadata.columns.iter().find(|c| c.name == col_name)
775                    {
776                        if let Some(ref rg_blooms) = col_meta.row_group_blooms {
777                            if let Some(bloom_bytes) = rg_blooms.get(rg_idx) {
778                                if !bloom_bytes.is_empty()
779                                    && !crate::segment::bloom::bloom_filter_contains(
780                                        bloom_bytes,
781                                        value,
782                                    )
783                                {
784                                    skip = true;
785                                    break;
786                                }
787                            }
788                        }
789                    }
790                }
791                if skip {
792                    metrics::counter!("chronix_segment_rg_pruned_by_bloom_total").increment(1);
793                    continue;
794                }
795            }
796
797            // Late materialisation — zone-map field predicate pushdown.
798            //
799            // For each numeric field predicate, retrieve the column's
800            // per-row-group min/max from ColumnBlockMeta.stats and check
801            // whether any row in this group could possibly match.  If the
802            // zone-map test rules out all rows, skip the entire row group.
803            if !field_predicates.is_empty() {
804                let mut skip = false;
805                for pred in field_predicates {
806                    if let Some((col_idx, col_meta)) = self
807                        .metadata
808                        .columns
809                        .iter()
810                        .enumerate()
811                        .find(|(_, c)| c.name == pred.column)
812                    {
813                        if let Some(block) =
814                            rg_blocks.iter().find(|b| b.column_index == col_idx as u16)
815                        {
816                            // Statistics that say nothing — an encrypted
817                            // block, whose stats are suppressed so the zone
818                            // map cannot leak its values — must not prune.
819                            let matches = if block.stats.says_nothing() {
820                                true
821                            } else {
822                                match col_meta.data_type {
823                                    data_types::F64 => {
824                                        let rg_min = ordered_i64_to_f64(block.stats.min_value);
825                                        let rg_max = ordered_i64_to_f64(block.stats.max_value);
826                                        pred.may_match_f64(rg_min, rg_max)
827                                    }
828                                    data_types::TIMESTAMP | data_types::I64 => pred.may_match_i64(
829                                        block.stats.min_value,
830                                        block.stats.max_value,
831                                    ),
832                                    data_types::DECIMAL => pred.may_match_decimal(
833                                        block.stats.min_value,
834                                        block.stats.max_value,
835                                        col_meta.decimal_scale.unwrap_or(0),
836                                    ),
837                                    _ => true, // non-numeric → can't prune
838                                }
839                            };
840                            if !matches {
841                                skip = true;
842                                break;
843                            }
844                        }
845                    }
846                }
847                if skip {
848                    metrics::counter!("chronix_segment_rg_pruned_by_zone_map_total").increment(1);
849                    continue;
850                }
851            }
852
853            let arrays = self.read_columns(&names, rg_idx)?;
854            for (col_idx, array) in arrays.into_iter().enumerate() {
855                all_arrays[col_idx].push(array);
856            }
857        }
858
859        // Concatenate row groups per column.
860        // If all row groups were pruned, return an empty RecordBatch
861        // with the correct schema.
862        let all_pruned = all_arrays.iter().all(std::vec::Vec::is_empty);
863
864        let mut final_arrays: Vec<ArrayRef> = Vec::with_capacity(names.len());
865
866        if all_pruned {
867            // Build empty arrays with correct types
868            for &name in &names {
869                let col_meta = self
870                    .metadata
871                    .columns
872                    .iter()
873                    .find(|c| c.name == name)
874                    .ok_or_else(|| SegmentError::CorruptFile {
875                        detail: format!("projected column '{name}' not in segment metadata"),
876                    })?;
877                final_arrays.push(col_meta.empty_array());
878            }
879        } else {
880            for (col_idx, arrays) in all_arrays.iter().enumerate() {
881                let refs: Vec<&dyn arrow::array::Array> =
882                    arrays.iter().map(std::convert::AsRef::as_ref).collect();
883                let concatenated =
884                    arrow::compute::concat(&refs).map_err(|e| SegmentError::CorruptFile {
885                        detail: format!("failed to concatenate column {}: {e}", names[col_idx]),
886                    })?;
887                final_arrays.push(concatenated);
888            }
889        }
890
891        // Build schema for only the projected columns
892        let fields: Vec<Field> = names
893            .iter()
894            .map(|&name| {
895                let col_meta = self
896                    .metadata
897                    .columns
898                    .iter()
899                    .find(|c| c.name == name)
900                    .ok_or_else(|| SegmentError::CorruptFile {
901                        detail: format!("projected column '{name}' not in segment metadata"),
902                    })?;
903                Ok(col_meta.arrow_field())
904            })
905            .collect::<Result<Vec<Field>>>()?;
906
907        let schema = Schema::new(fields);
908        let batch =
909            RecordBatch::try_new(std::sync::Arc::new(schema), final_arrays).map_err(|e| {
910                SegmentError::CorruptFile {
911                    detail: format!("failed to create RecordBatch: {e}"),
912                }
913            })?;
914
915        // All column data has been decoded into owned Arrow arrays.
916        // Hint the kernel to release the mmap pages back to the page cache.
917        // This reduces RSS during parallel scans where completed segments
918        // would otherwise hold pages until SegmentReader is dropped.
919        // SAFETY: We hold a read-only file-backed Mmap and all column data has
920        // already been decoded into owned Arrow arrays above.  No references into
921        // the mapped region remain, so releasing the pages is safe.
922        #[allow(unsafe_code)]
923        let _ = unsafe { self.data.unchecked_advise(UncheckedAdvice::DontNeed) };
924
925        Ok(batch)
926    }
927
928    /// Read and decode a single column block.
929    fn read_column_block(&self, block_meta: &ColumnBlockMeta, col_idx: usize) -> Result<ArrayRef> {
930        let start = block_meta.offset as usize;
931        let end = start
932            .checked_add(block_meta.length as usize)
933            .ok_or_else(|| SegmentError::CorruptFile {
934                detail: format!(
935                    "column block offset+length overflow: offset={start}, length={}",
936                    block_meta.length,
937                ),
938            })?;
939
940        if end > self.data.len() {
941            return Err(SegmentError::CorruptFile {
942                detail: format!(
943                    "column block extends beyond file: offset={start}, \
944                     length={}, file_size={}",
945                    block_meta.length,
946                    self.data.len()
947                ),
948            });
949        }
950
951        let raw_data = &self.data[start..end];
952
953        // Verify per-block CRC32c if present (block_crc == 0 means legacy/no checksum)
954        if block_meta.block_crc != 0 {
955            let actual_crc = crc32c::crc32c(raw_data);
956            if actual_crc != block_meta.block_crc {
957                return Err(SegmentError::CorruptFile {
958                    detail: format!(
959                        "block CRC mismatch for column {}: expected {:#010x}, got {:#010x}",
960                        col_idx, block_meta.block_crc, actual_crc,
961                    ),
962                });
963            }
964        } else {
965            tracing::warn!(
966                column = col_idx,
967                "block CRC is zero, skipping integrity check — this segment was written without per-block CRCs"
968            );
969            metrics::counter!("chronix_segment_crc_skip_total").increment(1);
970        }
971
972        // Field-level decryption: decrypt after CRC check (CRC covers the
973        // encrypted on-disk bytes) but before decompression.
974        #[cfg(feature = "field-encryption")]
975        let raw_data: std::borrow::Cow<'_, [u8]> = if block_meta.encrypted {
976            let col_meta = &self.metadata.columns[col_idx];
977            let key_id = col_meta
978                .key_id
979                .as_deref()
980                .ok_or_else(|| SegmentError::CorruptFile {
981                    detail: format!(
982                        "column {} is encrypted but has no key_id in metadata",
983                        col_meta.name,
984                    ),
985                })?;
986            let provider =
987                self.key_provider
988                    .as_ref()
989                    .ok_or_else(|| SegmentError::MissingEncryptionKey {
990                        column: col_meta.name.clone(),
991                        key_id: key_id.to_owned(),
992                    })?;
993            let key =
994                provider
995                    .get_key(key_id)
996                    .ok_or_else(|| SegmentError::MissingEncryptionKey {
997                        column: col_meta.name.clone(),
998                        key_id: key_id.to_owned(),
999                    })?;
1000            let decrypted = crate::segment::field_encryption::decrypt_block(
1001                raw_data,
1002                &key,
1003                crate::segment::field_encryption::BlockContext {
1004                    column: &col_meta.name,
1005                    segment_created_at: self.header.created_at,
1006                },
1007            )
1008            .map_err(|e| SegmentError::CorruptFile {
1009                detail: format!("failed to decrypt column {}: {e}", col_meta.name,),
1010            })?;
1011            std::borrow::Cow::Owned(decrypted)
1012        } else {
1013            std::borrow::Cow::Borrowed(raw_data)
1014        };
1015
1016        #[cfg(not(feature = "field-encryption"))]
1017        let raw_data: &[u8] = raw_data;
1018
1019        // Decompress if needed — the dictionary is passed for ZSTD_DICT blocks.
1020        let decompressed = if block_meta.compressed {
1021            decompress_block_with_optional_dict(
1022                &raw_data,
1023                self.metadata.zstd_dictionary.as_deref(),
1024            )?
1025        } else {
1026            raw_data.to_vec()
1027        };
1028
1029        // Parse the encoded block (first byte is encoding tag)
1030        let encoded_block = EncodedBlock::from_bytes(&decompressed)?;
1031
1032        // Decode to typed values
1033        let decoded = ColumnDecoder::decode(&encoded_block)?;
1034
1035        // Verify decoded value count matches metadata expectation.
1036        let decoded_len = decoded.len();
1037        let expected = block_meta.value_count as usize;
1038        if decoded_len != expected {
1039            return Err(SegmentError::CorruptFile {
1040                detail: format!(
1041                    "decoded value count mismatch for column {col_idx}: \
1042                     metadata says {expected} values but decoded {decoded_len}",
1043                ),
1044            });
1045        }
1046
1047        // D-NULL: attach the block's validity bitmap so absent values read
1048        // back as SQL NULL rather than as a 0/""/false sentinel.
1049        let nulls = self.read_validity(block_meta)?;
1050
1051        // Convert to Arrow array
1052        let col_meta = &self.metadata.columns[col_idx];
1053        decoded_to_arrow(decoded, col_meta, nulls)
1054    }
1055
1056    /// Load a block's validity bitmap, if it has one (`.csx` v2).
1057    ///
1058    /// Returns `None` for blocks with no nulls, which store no bitmap.
1059    fn read_validity(&self, block_meta: &ColumnBlockMeta) -> Result<Option<NullBuffer>> {
1060        if block_meta.validity_length == 0 {
1061            return Ok(None);
1062        }
1063        let start = block_meta.validity_offset as usize;
1064        let end = start
1065            .checked_add(block_meta.validity_length as usize)
1066            .ok_or_else(|| SegmentError::CorruptFile {
1067                detail: "validity bitmap offset+length overflow".to_string(),
1068            })?;
1069        if end > self.data.len() {
1070            return Err(SegmentError::CorruptFile {
1071                detail: format!(
1072                    "validity bitmap extends beyond file: offset={start}, \
1073                     length={}, file_size={}",
1074                    block_meta.validity_length,
1075                    self.data.len()
1076                ),
1077            });
1078        }
1079        let value_count = block_meta.value_count as usize;
1080        let bytes = &self.data[start..end];
1081        if bytes.len() < crate::segment::validity::bitmap_len(value_count) {
1082            return Err(SegmentError::CorruptFile {
1083                detail: format!(
1084                    "validity bitmap too short: {} bytes for {value_count} values",
1085                    bytes.len()
1086                ),
1087            });
1088        }
1089        Ok(crate::segment::validity::null_buffer_from_bytes(
1090            bytes,
1091            value_count,
1092        ))
1093    }
1094}
1095
1096/// Re-attach a validity bitmap to a freshly decoded Arrow array.
1097///
1098/// The decoder always produces a dense array (nulls were stored as
1099/// sentinels in the encoded payload); this stamps the v2 validity bitmap
1100/// back on so the sentinels become real SQL NULLs.
1101fn apply_nulls<A>(array: A, nulls: Option<NullBuffer>) -> Result<A>
1102where
1103    A: arrow::array::Array + From<arrow::array::ArrayData>,
1104{
1105    let Some(nulls) = nulls else {
1106        return Ok(array);
1107    };
1108    let data = array.into_data();
1109    if nulls.len() != data.len() {
1110        return Err(SegmentError::CorruptFile {
1111            detail: format!(
1112                "validity bitmap length {} does not match column length {}",
1113                nulls.len(),
1114                data.len()
1115            ),
1116        });
1117    }
1118    // SAFETY-equivalent: `into_builder`/`build` revalidates offsets and
1119    // buffers, and the null buffer length is checked above.
1120    let data = data
1121        .into_builder()
1122        .nulls(Some(nulls))
1123        .build()
1124        .map_err(|e| SegmentError::CorruptFile {
1125            detail: format!("failed to attach validity bitmap: {e}"),
1126        })?;
1127    Ok(A::from(data))
1128}
1129
1130/// Convert decoded column values to an Arrow `ArrayRef`.
1131///
1132/// Consumes the decoded column to avoid redundant cloning.
1133fn decoded_to_arrow(
1134    decoded: DecodedColumn,
1135    col_meta: &ColumnMeta,
1136    nulls: Option<NullBuffer>,
1137) -> Result<ArrayRef> {
1138    let data_type = col_meta.data_type;
1139    match (decoded, data_type) {
1140        (DecodedColumn::I64(values), data_types::TIMESTAMP | data_types::I64) => Ok(
1141            std::sync::Arc::new(apply_nulls(Int64Array::from(values), nulls)?),
1142        ),
1143        (DecodedColumn::U64(values), data_types::U64) => Ok(std::sync::Arc::new(apply_nulls(
1144            UInt64Array::from(values),
1145            nulls,
1146        )?)),
1147        (DecodedColumn::F64(values), data_types::F64) => Ok(std::sync::Arc::new(apply_nulls(
1148            Float64Array::from(values),
1149            nulls,
1150        )?)),
1151        (DecodedColumn::Bool(values), data_types::BOOL) => Ok(std::sync::Arc::new(apply_nulls(
1152            BooleanArray::from(values),
1153            nulls,
1154        )?)),
1155        (DecodedColumn::String(values), data_types::STRING) => {
1156            let refs: Vec<&str> = values.iter().map(String::as_str).collect();
1157            Ok(std::sync::Arc::new(apply_nulls(
1158                StringArray::from(refs),
1159                nulls,
1160            )?))
1161        }
1162        (DecodedColumn::Decimal(values), data_types::DECIMAL) => {
1163            // The scale is applied here and nowhere else: the mantissas were
1164            // stored raw, and this is where they become a number again.
1165            let scale = i8::try_from(col_meta.decimal_scale.unwrap_or(0)).map_err(|_| {
1166                SegmentError::CorruptFile {
1167                    detail: format!(
1168                        "decimal column '{}' declares an unrepresentable scale",
1169                        col_meta.name
1170                    ),
1171                }
1172            })?;
1173            let array = Decimal128Array::from(values)
1174                .with_precision_and_scale(chronix_core::DECIMAL_PRECISION, scale)
1175                .map_err(|e| SegmentError::CorruptFile {
1176                    detail: format!(
1177                        "decimal column '{}' does not fit decimal(38, {scale}): {e}",
1178                        col_meta.name
1179                    ),
1180                })?;
1181            Ok(std::sync::Arc::new(apply_nulls(array, nulls)?))
1182        }
1183        (decoded, _) => Err(SegmentError::CorruptFile {
1184            detail: format!(
1185                "type mismatch: decoded variant {:?} does not match expected data_type {data_type}",
1186                std::mem::discriminant(&decoded),
1187            ),
1188        }),
1189    }
1190}
1191
1192#[cfg(test)]
1193mod zone_map_tests {
1194    use super::*;
1195
1196    fn pred(op: ZoneMapOp, value: f64) -> FieldPredicate {
1197        FieldPredicate {
1198            column: "n".into(),
1199            op,
1200            value,
1201        }
1202    }
1203
1204    /// A fractional bound must not be truncated to an integer. `n < 1.5`
1205    /// used to become `n < 1`, which prunes a block holding `{1, 2, 3}`
1206    /// even though 1 matches — a pruning step that loses rows.
1207    #[test]
1208    fn a_fractional_bound_does_not_prune_an_integer_block() {
1209        assert!(pred(ZoneMapOp::Lt, 1.5).may_match_i64(1, 3));
1210        assert!(pred(ZoneMapOp::LtEq, 1.5).may_match_i64(1, 3));
1211        assert!(pred(ZoneMapOp::Gt, 2.5).may_match_i64(1, 3));
1212        assert!(pred(ZoneMapOp::GtEq, 2.5).may_match_i64(1, 3));
1213        assert!(pred(ZoneMapOp::Eq, 2.0).may_match_i64(1, 3));
1214        // And it still prunes what it should.
1215        assert!(!pred(ZoneMapOp::Lt, 1.0).may_match_i64(1, 3));
1216        assert!(!pred(ZoneMapOp::Gt, 3.0).may_match_i64(1, 3));
1217        // An integer column cannot hold 2.5 at all.
1218        assert!(!pred(ZoneMapOp::Eq, 2.5).may_match_i64(1, 3));
1219    }
1220
1221    /// Beyond 2^53 an `i64` bound cannot be compared as an `f64`, so the
1222    /// row group is kept rather than compared wrongly.
1223    #[test]
1224    fn a_bound_beyond_exact_float_range_never_prunes() {
1225        let big = (1_i64 << 53) + 1;
1226        assert!(pred(ZoneMapOp::Eq, big as f64).may_match_i64(big, big));
1227        assert!(pred(ZoneMapOp::Lt, 0.0).may_match_i64(big, big + 10));
1228    }
1229
1230    /// Statistics that say nothing — the sentinel range an encrypted block
1231    /// carries — must read as "unknown", not as an empty range. As an empty
1232    /// range every comparison is false, so a predicate on an encrypted
1233    /// column pruned every row group and the query returned nothing.
1234    #[test]
1235    fn suppressed_statistics_never_prune() {
1236        let empty = crate::segment::stats::ColumnStats::empty();
1237        assert!(empty.says_nothing());
1238        assert!(
1239            pred(ZoneMapOp::Gt, 0.0).may_match_i64(empty.min_value, empty.max_value),
1240            "an inverted sentinel range must not prune"
1241        );
1242        assert!(pred(ZoneMapOp::Eq, 42.0).may_match_f64(f64::NAN, f64::NAN));
1243
1244        // A genuinely all-null block *is* impossible to match, and pruning
1245        // it stays correct.
1246        let mut all_null = crate::segment::stats::ColumnStats::empty();
1247        all_null.record_null();
1248        assert!(!all_null.says_nothing());
1249    }
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254    use super::*;
1255    use crate::segment::writer::{SegmentWriter, SegmentWriterConfig};
1256    use chronix_core::config::{CompressionCodec, FloatEncoding};
1257    use chronix_core::types::{FieldValue, Point, SeriesKey};
1258    use std::collections::BTreeMap;
1259
1260    fn make_point(host: &str, cpu: f64, ts: i64) -> Point {
1261        let tags = BTreeMap::from([("host".to_string(), host.to_string())]);
1262        let key = SeriesKey::new("cpu_usage", tags).unwrap();
1263        let fields = BTreeMap::from([("cpu".to_string(), FieldValue::F64(cpu))]);
1264        Point::new(key, fields, ts).unwrap()
1265    }
1266
1267    #[test]
1268    fn write_and_read_segment() {
1269        let dir = tempfile::tempdir().unwrap();
1270        let path = dir.path().join("test.csx");
1271
1272        // Write
1273        let points: Vec<Point> = (0..100)
1274            .map(|i| make_point("server-1", 50.0 + i as f64, 1_000_000 + i * 10_000))
1275            .collect();
1276
1277        let config = SegmentWriterConfig {
1278            row_group_size: 50,
1279            compress: false,
1280            float_encoding: FloatEncoding::Gorilla,
1281            compression_codec: CompressionCodec::Lz4,
1282            zstd_level: 3,
1283            column_codec_overrides: std::collections::HashMap::new(),
1284            ..Default::default()
1285        };
1286
1287        let mut writer = SegmentWriter::new(&path, config).unwrap();
1288        writer.write_rows(&points).unwrap();
1289        let meta = writer.finalize().unwrap();
1290
1291        assert_eq!(meta.row_count, 100);
1292        assert_eq!(meta.row_group_count, 2); // 100 rows / 50 per group
1293
1294        // Read
1295        let reader = SegmentReader::open(&path).unwrap();
1296        assert_eq!(reader.header().row_count, 100);
1297        assert_eq!(reader.row_group_count(), 2);
1298
1299        // Read specific columns from first row group
1300        let columns = reader
1301            .read_columns(&[chronix_core::TIME_COLUMN, "cpu"], 0)
1302            .unwrap();
1303        assert_eq!(columns.len(), 2);
1304
1305        // Read all data
1306        let batch = reader.read_all().unwrap();
1307        assert_eq!(batch.num_rows(), 100);
1308        assert_eq!(batch.num_columns(), 3); // timestamp, host, cpu
1309    }
1310
1311    #[test]
1312    fn write_and_read_compressed() {
1313        let dir = tempfile::tempdir().unwrap();
1314        let path = dir.path().join("compressed.csx");
1315
1316        let points: Vec<Point> = (0..200)
1317            .map(|i| make_point("server-1", 50.0 + (i % 10) as f64, 1_000_000 + i * 10_000))
1318            .collect();
1319
1320        let config = SegmentWriterConfig {
1321            row_group_size: 100,
1322            compress: true,
1323            float_encoding: FloatEncoding::Gorilla,
1324            compression_codec: CompressionCodec::Lz4,
1325            zstd_level: 3,
1326            column_codec_overrides: std::collections::HashMap::new(),
1327            ..Default::default()
1328        };
1329
1330        let mut writer = SegmentWriter::new(&path, config).unwrap();
1331        writer.write_rows(&points).unwrap();
1332        writer.finalize().unwrap();
1333
1334        let reader = SegmentReader::open(&path).unwrap();
1335        let batch = reader.read_all().unwrap();
1336        assert_eq!(batch.num_rows(), 200);
1337    }
1338
1339    #[test]
1340    fn checksum_validation() {
1341        let dir = tempfile::tempdir().unwrap();
1342        let path = dir.path().join("checksum.csx");
1343
1344        let points = vec![make_point("s1", 1.0, 1000)];
1345        let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1346        writer.write_rows(&points).unwrap();
1347        writer.finalize().unwrap();
1348
1349        // Corrupt one byte in the middle
1350        let mut data = std::fs::read(&path).unwrap();
1351        let mid = data.len() / 2;
1352        data[mid] ^= 0xFF;
1353        std::fs::write(&path, &data).unwrap();
1354
1355        assert!(matches!(
1356            SegmentReader::open(&path),
1357            Err(SegmentError::ChecksumMismatch { .. })
1358        ));
1359    }
1360
1361    #[test]
1362    fn column_projection() {
1363        let dir = tempfile::tempdir().unwrap();
1364        let path = dir.path().join("projection.csx");
1365
1366        let points: Vec<Point> = (0..50)
1367            .map(|i| make_point("h1", i as f64, i * 1000))
1368            .collect();
1369
1370        let config = SegmentWriterConfig {
1371            row_group_size: 100,
1372            compress: false,
1373            float_encoding: FloatEncoding::Gorilla,
1374            compression_codec: CompressionCodec::Lz4,
1375            zstd_level: 3,
1376            column_codec_overrides: std::collections::HashMap::new(),
1377            ..Default::default()
1378        };
1379
1380        let mut writer = SegmentWriter::new(&path, config).unwrap();
1381        writer.write_rows(&points).unwrap();
1382        writer.finalize().unwrap();
1383
1384        let reader = SegmentReader::open(&path).unwrap();
1385
1386        // Read only the cpu column
1387        let cols = reader.read_columns(&["cpu"], 0).unwrap();
1388        assert_eq!(cols.len(), 1);
1389
1390        let cpu_arr = cols[0].as_any().downcast_ref::<Float64Array>().unwrap();
1391        assert_eq!(cpu_arr.len(), 50);
1392    }
1393
1394    #[test]
1395    fn row_group_out_of_range() {
1396        let dir = tempfile::tempdir().unwrap();
1397        let path = dir.path().join("rg_oor.csx");
1398
1399        let points = vec![make_point("h1", 1.0, 1000)];
1400        let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1401        writer.write_rows(&points).unwrap();
1402        writer.finalize().unwrap();
1403
1404        let reader = SegmentReader::open(&path).unwrap();
1405        assert!(reader
1406            .read_columns(&[chronix_core::TIME_COLUMN], 999)
1407            .is_err());
1408    }
1409
1410    #[test]
1411    fn column_not_found() {
1412        let dir = tempfile::tempdir().unwrap();
1413        let path = dir.path().join("col_404.csx");
1414
1415        let points = vec![make_point("h1", 1.0, 1000)];
1416        let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1417        writer.write_rows(&points).unwrap();
1418        writer.finalize().unwrap();
1419
1420        let reader = SegmentReader::open(&path).unwrap();
1421        assert!(reader.read_columns(&["nonexistent"], 0).is_err());
1422    }
1423
1424    #[test]
1425    fn read_projected_selects_subset() {
1426        let dir = tempfile::tempdir().unwrap();
1427        let path = dir.path().join("projected.csx");
1428
1429        // Write a segment with 3 columns: timestamp, host (tag), cpu (field)
1430        let points: Vec<Point> = (0..80)
1431            .map(|i| make_point("server-1", 50.0 + i as f64, 1_000_000 + i * 10_000))
1432            .collect();
1433
1434        let config = SegmentWriterConfig {
1435            row_group_size: 50,
1436            compress: false,
1437            float_encoding: FloatEncoding::Gorilla,
1438            compression_codec: CompressionCodec::Lz4,
1439            zstd_level: 3,
1440            column_codec_overrides: std::collections::HashMap::new(),
1441            ..Default::default()
1442        };
1443
1444        let mut writer = SegmentWriter::new(&path, config).unwrap();
1445        writer.write_rows(&points).unwrap();
1446        writer.finalize().unwrap();
1447
1448        let reader = SegmentReader::open(&path).unwrap();
1449
1450        // Project only "cpu" — timestamp is auto-included
1451        let batch = reader.read_projected(&["cpu"]).unwrap();
1452        assert_eq!(batch.num_rows(), 80);
1453        assert_eq!(batch.num_columns(), 2); // timestamp + cpu
1454        assert!(batch.column_by_name(chronix_core::TIME_COLUMN).is_some());
1455        assert!(batch.column_by_name("cpu").is_some());
1456        assert!(batch.column_by_name("host").is_none());
1457
1458        // Verify values are correct
1459        let cpu_col = batch
1460            .column_by_name("cpu")
1461            .unwrap()
1462            .as_any()
1463            .downcast_ref::<Float64Array>()
1464            .unwrap();
1465        assert!((cpu_col.value(0) - 50.0).abs() < f64::EPSILON);
1466    }
1467
1468    #[test]
1469    fn read_projected_includes_timestamp_implicitly() {
1470        let dir = tempfile::tempdir().unwrap();
1471        let path = dir.path().join("proj_ts.csx");
1472
1473        let points = vec![make_point("h1", 42.0, 9999)];
1474        let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1475        writer.write_rows(&points).unwrap();
1476        writer.finalize().unwrap();
1477
1478        let reader = SegmentReader::open(&path).unwrap();
1479
1480        // Only request "host" — should still get timestamp
1481        let batch = reader.read_projected(&["host"]).unwrap();
1482        assert_eq!(batch.num_columns(), 2); // timestamp + host
1483        assert!(batch.column_by_name(chronix_core::TIME_COLUMN).is_some());
1484    }
1485
1486    #[test]
1487    fn read_projected_all_columns_matches_read_all() {
1488        let dir = tempfile::tempdir().unwrap();
1489        let path = dir.path().join("proj_all.csx");
1490
1491        let points: Vec<Point> = (0..30)
1492            .map(|i| make_point("h1", i as f64, i * 1000))
1493            .collect();
1494
1495        let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1496        writer.write_rows(&points).unwrap();
1497        writer.finalize().unwrap();
1498
1499        let reader = SegmentReader::open(&path).unwrap();
1500
1501        let all = reader.read_all().unwrap();
1502        let projected = reader
1503            .read_projected(&[chronix_core::TIME_COLUMN, "host", "cpu"])
1504            .unwrap();
1505
1506        assert_eq!(all.num_rows(), projected.num_rows());
1507        assert_eq!(all.num_columns(), projected.num_columns());
1508    }
1509
1510    #[test]
1511    fn read_projected_unknown_column_errors() {
1512        let dir = tempfile::tempdir().unwrap();
1513        let path = dir.path().join("proj_err.csx");
1514
1515        let points = vec![make_point("h1", 1.0, 1000)];
1516        let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1517        writer.write_rows(&points).unwrap();
1518        writer.finalize().unwrap();
1519
1520        let reader = SegmentReader::open(&path).unwrap();
1521        assert!(reader.read_projected(&["nonexistent"]).is_err());
1522    }
1523
1524    #[test]
1525    fn read_projected_across_multiple_row_groups() {
1526        let dir = tempfile::tempdir().unwrap();
1527        let path = dir.path().join("proj_multi_rg.csx");
1528
1529        let points: Vec<Point> = (0..200)
1530            .map(|i| make_point("server-1", i as f64, i * 1000))
1531            .collect();
1532
1533        let config = SegmentWriterConfig {
1534            row_group_size: 64,
1535            compress: true,
1536            float_encoding: FloatEncoding::Gorilla,
1537            compression_codec: CompressionCodec::Lz4,
1538            zstd_level: 3,
1539            column_codec_overrides: std::collections::HashMap::new(),
1540            ..Default::default()
1541        };
1542
1543        let mut writer = SegmentWriter::new(&path, config).unwrap();
1544        writer.write_rows(&points).unwrap();
1545        writer.finalize().unwrap();
1546
1547        let reader = SegmentReader::open(&path).unwrap();
1548        assert!(reader.row_group_count() > 1);
1549
1550        let batch = reader.read_projected(&["cpu"]).unwrap();
1551        assert_eq!(batch.num_rows(), 200);
1552        assert_eq!(batch.num_columns(), 2); // timestamp + cpu
1553    }
1554
1555    #[test]
1556    fn multiple_series() {
1557        let dir = tempfile::tempdir().unwrap();
1558        let path = dir.path().join("multi_series.csx");
1559
1560        let mut points = Vec::new();
1561        for i in 0..20 {
1562            points.push(make_point("server-1", i as f64, i * 1000));
1563            points.push(make_point("server-2", i as f64 + 100.0, i * 1000));
1564        }
1565
1566        let config = SegmentWriterConfig {
1567            row_group_size: 100,
1568            compress: false,
1569            float_encoding: FloatEncoding::Gorilla,
1570            compression_codec: CompressionCodec::Lz4,
1571            zstd_level: 3,
1572            column_codec_overrides: std::collections::HashMap::new(),
1573            ..Default::default()
1574        };
1575
1576        let mut writer = SegmentWriter::new(&path, config).unwrap();
1577        writer.write_rows(&points).unwrap();
1578        let meta = writer.finalize().unwrap();
1579
1580        assert_eq!(meta.series_count, 2);
1581        assert_eq!(meta.row_count, 40);
1582
1583        let reader = SegmentReader::open(&path).unwrap();
1584        let batch = reader.read_all().unwrap();
1585        assert_eq!(batch.num_rows(), 40);
1586    }
1587
1588    // ── P1: time-range predicate pushdown tests ──────────────────────
1589
1590    /// Helper: create a segment with `n` points for a single series,
1591    /// timestamps `0, step, 2*step, …` and a small `row_group_size`
1592    /// so we get multiple row groups to prune.
1593    fn write_segment_with_rg_size(path: &std::path::Path, n: usize, step: i64, rg_size: usize) {
1594        let points: Vec<Point> = (0..n)
1595            .map(|i| make_point("server-1", i as f64, i as i64 * step))
1596            .collect();
1597
1598        let config = SegmentWriterConfig {
1599            row_group_size: rg_size,
1600            compress: false,
1601            float_encoding: FloatEncoding::Gorilla,
1602            compression_codec: CompressionCodec::Lz4,
1603            zstd_level: 3,
1604            column_codec_overrides: std::collections::HashMap::new(),
1605            ..Default::default()
1606        };
1607
1608        let mut writer = SegmentWriter::new(path, config).unwrap();
1609        writer.write_rows(&points).unwrap();
1610        writer.finalize().unwrap();
1611    }
1612
1613    #[test]
1614    fn time_range_prunes_row_groups() {
1615        // 30 points, ts = 0, 10_000, …, 290_000.
1616        // row_group_size = 10 → 3 row groups:
1617        //   rg0: ts [0, 90_000]
1618        //   rg1: ts [100_000, 190_000]
1619        //   rg2: ts [200_000, 290_000]
1620        let dir = tempfile::tempdir().unwrap();
1621        let path = dir.path().join("prune.csx");
1622        write_segment_with_rg_size(&path, 30, 10_000, 10);
1623
1624        let reader = SegmentReader::open(&path).unwrap();
1625        assert_eq!(reader.row_group_count(), 3);
1626
1627        // Query only the middle row group's time range.
1628        let batch = reader
1629            .read_projected_filtered(&["cpu"], Some((100_000, 190_000)))
1630            .unwrap();
1631        assert_eq!(batch.num_rows(), 10); // only rg1
1632
1633        // Verify timestamps are in the expected range.
1634        let ts = batch
1635            .column_by_name(chronix_core::TIME_COLUMN)
1636            .unwrap()
1637            .as_any()
1638            .downcast_ref::<Int64Array>()
1639            .unwrap();
1640        for i in 0..ts.len() {
1641            assert!(ts.value(i) >= 100_000);
1642            assert!(ts.value(i) <= 190_000);
1643        }
1644    }
1645
1646    #[test]
1647    fn time_range_inclusive_end_boundary() {
1648        // Same layout: 3 row groups.
1649        //   rg0: ts [0, 90_000]
1650        //   rg1: ts [100_000, 190_000]
1651        //   rg2: ts [200_000, 290_000]
1652        //
1653        // Query [0, 100_000] — end equals rg1.min.
1654        // With inclusive end, rg1 MUST be included (it contains ts=100_000).
1655        let dir = tempfile::tempdir().unwrap();
1656        let path = dir.path().join("boundary.csx");
1657        write_segment_with_rg_size(&path, 30, 10_000, 10);
1658
1659        let reader = SegmentReader::open(&path).unwrap();
1660
1661        let batch = reader
1662            .read_projected_filtered(&["cpu"], Some((0, 100_000)))
1663            .unwrap();
1664        // rg0 (10 rows) + rg1 (10 rows) = 20 rows
1665        // (rg1 is included because rg1.min == 100_000 <= end 100_000)
1666        assert_eq!(batch.num_rows(), 20);
1667    }
1668
1669    #[test]
1670    fn time_range_all_pruned_returns_empty() {
1671        let dir = tempfile::tempdir().unwrap();
1672        let path = dir.path().join("empty.csx");
1673        // ts range [0, 290_000]
1674        write_segment_with_rg_size(&path, 30, 10_000, 10);
1675
1676        let reader = SegmentReader::open(&path).unwrap();
1677
1678        // Query range completely after segment data.
1679        let batch = reader
1680            .read_projected_filtered(&["cpu"], Some((500_000, 600_000)))
1681            .unwrap();
1682        assert_eq!(batch.num_rows(), 0);
1683        // Schema should still have timestamp + cpu.
1684        assert_eq!(batch.num_columns(), 2);
1685        assert!(batch.column_by_name(chronix_core::TIME_COLUMN).is_some());
1686        assert!(batch.column_by_name("cpu").is_some());
1687    }
1688
1689    #[test]
1690    fn time_range_none_reads_all() {
1691        let dir = tempfile::tempdir().unwrap();
1692        let path = dir.path().join("no_filter.csx");
1693        write_segment_with_rg_size(&path, 30, 10_000, 10);
1694
1695        let reader = SegmentReader::open(&path).unwrap();
1696
1697        let batch = reader.read_projected_filtered(&["cpu"], None).unwrap();
1698        assert_eq!(batch.num_rows(), 30);
1699    }
1700
1701    #[test]
1702    fn read_row_group_returns_single_group() {
1703        let dir = tempfile::tempdir().unwrap();
1704        let path = dir.path().join("single_rg.csx");
1705        write_segment_with_rg_size(&path, 30, 10_000, 10);
1706
1707        let reader = SegmentReader::open(&path).unwrap();
1708        assert_eq!(reader.row_group_count(), 3);
1709
1710        let rg1 = reader.read_row_group(1).unwrap();
1711        assert_eq!(rg1.num_rows(), 10);
1712
1713        // Verify timestamps are from the second row group.
1714        let ts = rg1
1715            .column_by_name(chronix_core::TIME_COLUMN)
1716            .unwrap()
1717            .as_any()
1718            .downcast_ref::<Int64Array>()
1719            .unwrap();
1720        assert_eq!(ts.value(0), 100_000);
1721        assert_eq!(ts.value(9), 190_000);
1722    }
1723
1724    #[test]
1725    fn read_row_group_out_of_range_errors() {
1726        let dir = tempfile::tempdir().unwrap();
1727        let path = dir.path().join("rg_oor2.csx");
1728        write_segment_with_rg_size(&path, 10, 1000, 10);
1729
1730        let reader = SegmentReader::open(&path).unwrap();
1731        assert!(reader.read_row_group(5).is_err());
1732    }
1733
1734    // ── Bloom filter integration tests ─────────────────────────
1735
1736    /// Helper: create a segment with multiple hosts (tag values) for
1737    /// bloom filter testing.
1738    fn write_segment_with_tags(path: &std::path::Path, hosts: &[&str], points_per_host: usize) {
1739        let mut points = Vec::new();
1740        for (h_idx, &host) in hosts.iter().enumerate() {
1741            for i in 0..points_per_host {
1742                points.push(make_point(
1743                    host,
1744                    (h_idx * 100 + i) as f64,
1745                    (h_idx * points_per_host + i) as i64 * 1000,
1746                ));
1747            }
1748        }
1749
1750        let config = SegmentWriterConfig {
1751            row_group_size: 64,
1752            compress: false,
1753            float_encoding: FloatEncoding::Gorilla,
1754            compression_codec: CompressionCodec::Lz4,
1755            zstd_level: 3,
1756            column_codec_overrides: std::collections::HashMap::new(),
1757            ..Default::default()
1758        };
1759
1760        let mut writer = SegmentWriter::new(path, config).unwrap();
1761        writer.write_rows(&points).unwrap();
1762        writer.finalize().unwrap();
1763    }
1764
1765    #[test]
1766    fn bloom_filter_present_in_tag_columns() {
1767        let dir = tempfile::tempdir().unwrap();
1768        let path = dir.path().join("bloom_tag.csx");
1769        write_segment_with_tags(&path, &["server-1", "server-2", "server-3"], 10);
1770
1771        let reader = SegmentReader::open(&path).unwrap();
1772
1773        // The "host" column (role=TAG) should have a bloom filter
1774        let host_meta = reader
1775            .column_metadata()
1776            .iter()
1777            .find(|c| c.name == "host")
1778            .unwrap();
1779        assert!(
1780            host_meta.bloom_filter.is_some(),
1781            "tag column should have a bloom filter"
1782        );
1783
1784        // Non-tag columns should NOT have bloom filters
1785        let ts_meta = reader
1786            .column_metadata()
1787            .iter()
1788            .find(|c| c.name == chronix_core::TIME_COLUMN)
1789            .unwrap();
1790        assert!(ts_meta.bloom_filter.is_none());
1791
1792        let cpu_meta = reader
1793            .column_metadata()
1794            .iter()
1795            .find(|c| c.name == "cpu")
1796            .unwrap();
1797        assert!(cpu_meta.bloom_filter.is_none());
1798    }
1799
1800    #[test]
1801    fn bloom_filter_prunes_segment_for_unknown_tag() {
1802        let dir = tempfile::tempdir().unwrap();
1803        let path = dir.path().join("bloom_prune.csx");
1804        write_segment_with_tags(&path, &["server-1", "server-2"], 20);
1805
1806        let reader = SegmentReader::open(&path).unwrap();
1807
1808        // Query with a tag value that does NOT exist in the segment.
1809        // The bloom filter should prune the entire segment, returning
1810        // zero rows.
1811        let batch = reader
1812            .read_projected_filtered_with_predicates(&["cpu"], None, &[("host", "server-999")])
1813            .unwrap();
1814        assert_eq!(batch.num_rows(), 0, "bloom filter should prune unknown tag");
1815    }
1816
1817    #[test]
1818    fn bloom_filter_passes_known_tag() {
1819        let dir = tempfile::tempdir().unwrap();
1820        let path = dir.path().join("bloom_pass.csx");
1821        write_segment_with_tags(&path, &["server-1", "server-2"], 20);
1822
1823        let reader = SegmentReader::open(&path).unwrap();
1824
1825        // Query with a tag value that DOES exist — bloom filter must
1826        // not prune it (no false negatives).
1827        let batch = reader
1828            .read_projected_filtered_with_predicates(
1829                &["cpu", "host"],
1830                None,
1831                &[("host", "server-1")],
1832            )
1833            .unwrap();
1834        // Should return rows (bloom filter passes, all row groups read).
1835        // The batch has all rows (not just server-1) because row-group
1836        // level filtering doesn't do value-level matching.
1837        assert!(
1838            batch.num_rows() > 0,
1839            "bloom filter should not prune known tag"
1840        );
1841    }
1842
1843    #[test]
1844    fn bloom_filter_roundtrip_metadata() {
1845        // Verify that bloom filter data survives the write → read
1846        // round-trip through segment metadata serialization.
1847        let dir = tempfile::tempdir().unwrap();
1848        let path = dir.path().join("bloom_rt.csx");
1849        write_segment_with_tags(&path, &["alpha", "beta", "gamma"], 5);
1850
1851        let reader = SegmentReader::open(&path).unwrap();
1852        let host_meta = reader
1853            .column_metadata()
1854            .iter()
1855            .find(|c| c.name == "host")
1856            .unwrap();
1857
1858        let bf = host_meta.bloom_filter.as_ref().unwrap();
1859
1860        // All written tag values must be present
1861        assert!(crate::segment::bloom::bloom_filter_contains(bf, "alpha"));
1862        assert!(crate::segment::bloom::bloom_filter_contains(bf, "beta"));
1863        assert!(crate::segment::bloom::bloom_filter_contains(bf, "gamma"));
1864
1865        // An unwritten value should (almost certainly) be absent
1866        assert!(!crate::segment::bloom::bloom_filter_contains(bf, "delta"));
1867    }
1868
1869    #[test]
1870    #[cfg(feature = "field-encryption")]
1871    fn field_encryption_roundtrip() {
1872        use crate::segment::field_encryption::{
1873            FieldEncryptionConfig, FieldEncryptionKey, StaticKeyProvider,
1874        };
1875
1876        let dir = tempfile::tempdir().unwrap();
1877        let path = dir.path().join("encrypted.csx");
1878
1879        let key_bytes: [u8; 32] = [0xAB; 32];
1880        let enc_key = FieldEncryptionKey::new("key-1".to_string(), key_bytes);
1881
1882        let mut enc_config = FieldEncryptionConfig::default();
1883        enc_config.encrypt_column("cpu", enc_key);
1884
1885        let points: Vec<Point> = (0..100)
1886            .map(|i| make_point("server-1", 50.0 + i as f64, 1_000_000 + i * 10_000))
1887            .collect();
1888
1889        let config = SegmentWriterConfig {
1890            row_group_size: 50,
1891            compress: true,
1892            field_encryption: enc_config,
1893            ..Default::default()
1894        };
1895
1896        let mut writer = SegmentWriter::new(&path, config).unwrap();
1897        writer.write_rows(&points).unwrap();
1898        let meta = writer.finalize().unwrap();
1899        assert_eq!(meta.row_count, 100);
1900
1901        // Verify column metadata flags
1902        let reader_no_key = SegmentReader::open(&path).unwrap();
1903        let cpu_meta = reader_no_key
1904            .column_metadata()
1905            .iter()
1906            .find(|c| c.name == "cpu")
1907            .unwrap();
1908        assert!(cpu_meta.encrypted);
1909        assert_eq!(cpu_meta.key_id.as_deref(), Some("key-1"));
1910
1911        // Stats should be suppressed for encrypted columns
1912        let ts_meta = reader_no_key
1913            .column_metadata()
1914            .iter()
1915            .find(|c| c.name == chronix_core::TIME_COLUMN)
1916            .unwrap();
1917        assert!(!ts_meta.encrypted);
1918        assert!(ts_meta.stats.min_value != i64::MAX || ts_meta.stats.max_value != i64::MIN);
1919
1920        // Bloom filter should NOT exist for encrypted tag columns — but
1921        // "cpu" is a field, not a tag, so check that "host" still has a bloom.
1922        let host_meta = reader_no_key
1923            .column_metadata()
1924            .iter()
1925            .find(|c| c.name == "host")
1926            .unwrap();
1927        assert!(host_meta.bloom_filter.is_some());
1928
1929        // Read back with a key provider
1930        let mut provider = StaticKeyProvider::new();
1931        provider.add_key("key-1", key_bytes);
1932
1933        let mut reader = SegmentReader::open(&path).unwrap();
1934        reader.set_key_provider(Arc::new(provider));
1935
1936        let batch = reader.read_all().unwrap();
1937        assert_eq!(batch.num_rows(), 100);
1938
1939        let cpu_col = batch
1940            .column_by_name("cpu")
1941            .unwrap()
1942            .as_any()
1943            .downcast_ref::<Float64Array>()
1944            .unwrap();
1945        for i in 0..100 {
1946            assert!(
1947                (cpu_col.value(i) - (50.0 + i as f64)).abs() < 1e-9,
1948                "cpu mismatch at row {i}"
1949            );
1950        }
1951    }
1952
1953    /// The validity bitmap lives in the data region, so the whole-file CRC32c
1954    /// verified at `open()` must cover it — otherwise a flipped bit would
1955    /// silently turn a real value into a NULL (or vice versa) with no error.
1956    #[test]
1957    fn corrupted_validity_bitmap_is_detected() {
1958        let dir = tempfile::tempdir().unwrap();
1959        let path = dir.path().join("nulls.csx");
1960
1961        // Sparse column => at least one block carries a validity bitmap.
1962        let tags = BTreeMap::from([("host".to_string(), "h".to_string())]);
1963        let key = SeriesKey::new("m", tags).unwrap();
1964        let points: Vec<Point> = (0..40i64)
1965            .map(|i| {
1966                let fields = if i % 2 == 0 {
1967                    BTreeMap::from([("v".to_string(), FieldValue::F64(i as f64))])
1968                } else {
1969                    BTreeMap::from([("w".to_string(), FieldValue::F64(1.0))])
1970                };
1971                Point::new(key.clone(), fields, 1_000 + i * 10).unwrap()
1972            })
1973            .collect();
1974
1975        let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1976        writer.write_rows(&points).unwrap();
1977        writer.finalize().unwrap();
1978
1979        // Locate a bitmap and flip a bit inside it.
1980        let reader = SegmentReader::open(&path).unwrap();
1981        let (off, len) = reader
1982            .metadata
1983            .row_group_blocks
1984            .iter()
1985            .flatten()
1986            .find(|b| b.validity_length > 0)
1987            .map(|b| (b.validity_offset as usize, b.validity_length))
1988            .expect("a sparse column must have written a validity bitmap");
1989        assert!(len > 0);
1990        drop(reader);
1991
1992        let mut bytes = std::fs::read(&path).unwrap();
1993        bytes[off] ^= 0b0000_0001;
1994        std::fs::write(&path, &bytes).unwrap();
1995
1996        assert!(
1997            matches!(
1998                SegmentReader::open(&path),
1999                Err(SegmentError::ChecksumMismatch { .. })
2000            ),
2001            "a corrupted validity bitmap must fail the file checksum"
2002        );
2003    }
2004
2005    /// D-NULL + field encryption: the validity bitmap is stored *outside*
2006    /// the encrypted payload, so a reader without the key must still be
2007    /// blocked on values while a reader with the key sees correct nulls.
2008    #[test]
2009    #[cfg(feature = "field-encryption")]
2010    fn encrypted_column_preserves_nulls() {
2011        use crate::segment::field_encryption::{
2012            FieldEncryptionConfig, FieldEncryptionKey, StaticKeyProvider,
2013        };
2014        use arrow::array::Array;
2015
2016        let dir = tempfile::tempdir().unwrap();
2017        let path = dir.path().join("encrypted_nulls.csx");
2018
2019        let enc_key = FieldEncryptionKey::new("key-1".to_string(), [0xCD; 32]);
2020        let mut enc_config = FieldEncryptionConfig::default();
2021        enc_config.encrypt_column("cpu", enc_key);
2022
2023        // Every other point omits `cpu` entirely.
2024        let tags = BTreeMap::from([("host".to_string(), "server-1".to_string())]);
2025        let key = SeriesKey::new("cpu_usage", tags).unwrap();
2026        let points: Vec<Point> = (0..40i64)
2027            .map(|i| {
2028                let fields = if i % 2 == 0 {
2029                    BTreeMap::from([("cpu".to_string(), FieldValue::F64(i as f64))])
2030                } else {
2031                    BTreeMap::from([("other".to_string(), FieldValue::F64(1.0))])
2032                };
2033                Point::new(key.clone(), fields, 1_000_000 + i * 10_000).unwrap()
2034            })
2035            .collect();
2036
2037        let config = SegmentWriterConfig {
2038            row_group_size: 16,
2039            compress: true,
2040            field_encryption: enc_config,
2041            ..Default::default()
2042        };
2043        let mut writer = SegmentWriter::new(&path, config).unwrap();
2044        writer.write_rows(&points).unwrap();
2045        writer.finalize().unwrap();
2046
2047        let mut provider = StaticKeyProvider::new();
2048        provider.add_key("key-1", [0xCD; 32]);
2049        let mut reader = SegmentReader::open(&path).unwrap();
2050        reader.set_key_provider(Arc::new(provider));
2051        let batch = reader.read_all().unwrap();
2052
2053        let cpu = batch
2054            .column_by_name("cpu")
2055            .expect("cpu column")
2056            .as_any()
2057            .downcast_ref::<arrow::array::Float64Array>()
2058            .expect("f64");
2059        assert_eq!(cpu.null_count(), 20, "half the rows have no cpu value");
2060        // And the surviving values must be intact, not shifted by the bitmap.
2061        let present: Vec<f64> = (0..cpu.len())
2062            .filter(|i| cpu.is_valid(*i))
2063            .map(|i| cpu.value(i))
2064            .collect();
2065        assert_eq!(present.len(), 20);
2066        assert!(
2067            present.iter().all(|v| *v % 2.0 == 0.0),
2068            "values corrupted: {present:?}"
2069        );
2070    }
2071
2072    #[test]
2073    #[cfg(feature = "field-encryption")]
2074    fn field_encryption_wrong_key_fails() {
2075        use crate::segment::field_encryption::{
2076            FieldEncryptionConfig, FieldEncryptionKey, StaticKeyProvider,
2077        };
2078
2079        let dir = tempfile::tempdir().unwrap();
2080        let path = dir.path().join("enc_wrong_key.csx");
2081
2082        let key_bytes: [u8; 32] = [0xCD; 32];
2083        let enc_key = FieldEncryptionKey::new("key-2".to_string(), key_bytes);
2084
2085        let mut enc_config = FieldEncryptionConfig::default();
2086        enc_config.encrypt_column("cpu", enc_key);
2087
2088        let points: Vec<Point> = (0..20)
2089            .map(|i| make_point("server-1", 10.0 + i as f64, 1_000_000 + i * 10_000))
2090            .collect();
2091
2092        let config = SegmentWriterConfig {
2093            row_group_size: 100,
2094            compress: false,
2095            field_encryption: enc_config,
2096            ..Default::default()
2097        };
2098
2099        let mut writer = SegmentWriter::new(&path, config).unwrap();
2100        writer.write_rows(&points).unwrap();
2101        writer.finalize().unwrap();
2102
2103        // Wrong key — decryption should fail
2104        let mut wrong_provider = StaticKeyProvider::new();
2105        wrong_provider.add_key("key-2", [0xFF; 32]);
2106
2107        let mut reader = SegmentReader::open(&path).unwrap();
2108        reader.set_key_provider(Arc::new(wrong_provider));
2109
2110        let result = reader.read_all();
2111        assert!(result.is_err(), "reading with wrong key should fail");
2112    }
2113
2114    #[test]
2115    #[cfg(feature = "field-encryption")]
2116    fn field_encryption_no_provider_fails() {
2117        use crate::segment::field_encryption::{FieldEncryptionConfig, FieldEncryptionKey};
2118
2119        let dir = tempfile::tempdir().unwrap();
2120        let path = dir.path().join("enc_no_provider.csx");
2121
2122        let key_bytes: [u8; 32] = [0xEF; 32];
2123        let enc_key = FieldEncryptionKey::new("key-3".to_string(), key_bytes);
2124
2125        let mut enc_config = FieldEncryptionConfig::default();
2126        enc_config.encrypt_column("cpu", enc_key);
2127
2128        let points: Vec<Point> = (0..10)
2129            .map(|i| make_point("server-1", 1.0, 1_000_000 + i * 10_000))
2130            .collect();
2131
2132        let config = SegmentWriterConfig {
2133            row_group_size: 100,
2134            compress: false,
2135            field_encryption: enc_config,
2136            ..Default::default()
2137        };
2138
2139        let mut writer = SegmentWriter::new(&path, config).unwrap();
2140        writer.write_rows(&points).unwrap();
2141        writer.finalize().unwrap();
2142
2143        // No key provider at all
2144        let reader = SegmentReader::open(&path).unwrap();
2145        let result = reader.read_all();
2146        assert!(
2147            result.is_err(),
2148            "reading encrypted column without key provider should fail"
2149        );
2150    }
2151
2152    // ── Per-row-group bloom filter tests ─────────────────
2153
2154    #[test]
2155    fn row_group_bloom_filters_present_for_tag_columns() {
2156        let dir = tempfile::tempdir().unwrap();
2157        let path = dir.path().join("rg_bloom.csx");
2158
2159        // 30 points across 3 hosts, sorted by time → hosts interleave across
2160        // row groups when row_group_size=10.
2161        write_segment_with_tags(&path, &["alpha", "beta", "gamma"], 10);
2162
2163        let reader = SegmentReader::open(&path).unwrap();
2164        let host_meta = reader
2165            .column_metadata()
2166            .iter()
2167            .find(|c| c.name == "host")
2168            .unwrap();
2169
2170        // Per-RG blooms should exist for tag columns
2171        assert!(
2172            host_meta.row_group_blooms.is_some(),
2173            "tag column should have per-row-group bloom filters"
2174        );
2175
2176        let rg_blooms = host_meta.row_group_blooms.as_ref().unwrap();
2177        assert_eq!(rg_blooms.len(), reader.row_group_count());
2178
2179        // Each bloom should be non-empty
2180        for (i, bloom) in rg_blooms.iter().enumerate() {
2181            assert!(!bloom.is_empty(), "bloom for rg{i} should be non-empty");
2182        }
2183    }
2184
2185    #[test]
2186    fn row_group_bloom_prunes_non_matching_row_groups() {
2187        // Create a segment where host "alpha" is ONLY in the first row group
2188        // and host "beta" is ONLY in the second row group.
2189        //
2190        // Points are written in order (alpha first, beta second) with
2191        // increasing timestamps, so the writer's sort will keep them in
2192        // their respective row groups.
2193        let dir = tempfile::tempdir().unwrap();
2194        let path = dir.path().join("rg_bloom_prune.csx");
2195
2196        let mut points = Vec::new();
2197        // First 10 points: host=alpha, ts=[0, 9000]
2198        for i in 0..10 {
2199            points.push(make_point("alpha", i as f64, i as i64 * 1000));
2200        }
2201        // Next 10 points: host=beta, ts=[10000, 19000]
2202        for i in 0..10 {
2203            points.push(make_point("beta", (100 + i) as f64, (10 + i) as i64 * 1000));
2204        }
2205
2206        let config = SegmentWriterConfig {
2207            row_group_size: 10,
2208            compress: false,
2209            float_encoding: FloatEncoding::Gorilla,
2210            compression_codec: CompressionCodec::Lz4,
2211            zstd_level: 3,
2212            column_codec_overrides: std::collections::HashMap::new(),
2213            ..Default::default()
2214        };
2215
2216        let mut writer = SegmentWriter::new(&path, config).unwrap();
2217        writer.write_rows(&points).unwrap();
2218        writer.finalize().unwrap();
2219
2220        let reader = SegmentReader::open(&path).unwrap();
2221        assert_eq!(reader.row_group_count(), 2);
2222
2223        // Verify per-RG blooms: rg0 should contain "alpha" but NOT "beta"
2224        let host_meta = reader
2225            .column_metadata()
2226            .iter()
2227            .find(|c| c.name == "host")
2228            .unwrap();
2229        let rg_blooms = host_meta.row_group_blooms.as_ref().unwrap();
2230
2231        assert!(crate::segment::bloom::bloom_filter_contains(
2232            &rg_blooms[0],
2233            "alpha"
2234        ));
2235        assert!(!crate::segment::bloom::bloom_filter_contains(
2236            &rg_blooms[0],
2237            "beta"
2238        ));
2239        assert!(crate::segment::bloom::bloom_filter_contains(
2240            &rg_blooms[1],
2241            "beta"
2242        ));
2243        assert!(!crate::segment::bloom::bloom_filter_contains(
2244            &rg_blooms[1],
2245            "alpha"
2246        ));
2247
2248        // Query for "alpha" — should only return rg0 (10 rows)
2249        let batch = reader
2250            .read_projected_filtered_with_predicates(&["cpu", "host"], None, &[("host", "alpha")])
2251            .unwrap();
2252        assert_eq!(
2253            batch.num_rows(),
2254            10,
2255            "only rg0 should be returned for alpha"
2256        );
2257
2258        // Query for "beta" — should only return rg1 (10 rows)
2259        let batch = reader
2260            .read_projected_filtered_with_predicates(&["cpu", "host"], None, &[("host", "beta")])
2261            .unwrap();
2262        assert_eq!(batch.num_rows(), 10, "only rg1 should be returned for beta");
2263
2264        // Query for "gamma" (non-existent) — should return 0 rows
2265        let batch = reader
2266            .read_projected_filtered_with_predicates(&["cpu"], None, &[("host", "gamma")])
2267            .unwrap();
2268        assert_eq!(batch.num_rows(), 0, "non-existent tag should prune all RGs");
2269    }
2270}