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