1use std::fs::File;
44use std::path::{Path, PathBuf};
45#[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#[derive(Debug, Clone, Copy, PartialEq)]
70pub enum ZoneMapOp {
71 Eq,
73 Gt,
75 GtEq,
77 Lt,
79 LtEq,
81}
82
83#[derive(Debug, Clone)]
87pub struct FieldPredicate {
88 pub column: String,
90 pub op: ZoneMapOp,
92 pub value: f64,
94}
95
96impl FieldPredicate {
97 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 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)] let (lo, hi) = (rg_min as f64, rg_max as f64);
135 match self.op {
136 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
148pub struct SegmentReader {
170 data: Mmap,
172 header: SegmentHeader,
174 metadata: SegmentMetadata,
176 path: PathBuf,
178 #[cfg(feature = "field-encryption")]
181 key_provider: Option<Arc<dyn crate::segment::field_encryption::FieldKeyProvider>>,
182}
183
184impl SegmentReader {
185 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 #[allow(unsafe_code)] let data = unsafe { Mmap::map(&file)? };
200 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 let header = SegmentHeader::from_bytes(&data[..HEADER_SIZE])?;
217
218 let footer_start = data.len() - FOOTER_SIZE;
220 let footer = SegmentFooter::from_bytes(&data[footer_start..])?;
221
222 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 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 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 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 #[must_use]
284 pub fn header(&self) -> &SegmentHeader {
285 &self.header
286 }
287
288 #[must_use]
290 pub fn column_metadata(&self) -> &[crate::segment::metadata::ColumnMeta] {
291 &self.metadata.columns
292 }
293
294 #[must_use]
296 pub fn row_group_count(&self) -> usize {
297 self.metadata.row_group_blocks.len()
298 }
299
300 #[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 #[must_use]
319 pub fn path(&self) -> &Path {
320 &self.path
321 }
322
323 #[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 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 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 if i + 1 < resolved.len() {
385 let (_, next_block) = resolved[i + 1];
386 self.prefetch_block(next_block);
387 }
388
389 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 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 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 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 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 pub fn read_projected(&self, column_names: &[&str]) -> Result<RecordBatch> {
513 self.read_projected_filtered(column_names, None)
514 }
515
516 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 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 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 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 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 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 let ts_col_idx = self
615 .metadata
616 .columns
617 .iter()
618 .position(|c| c.name == chronix_core::TIME_COLUMN);
619
620 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 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 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 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 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 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 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 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 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, }
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 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 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 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 #[allow(unsafe_code)]
931 let _ = unsafe { self.data.unchecked_advise(UncheckedAdvice::DontNeed) };
932
933 Ok(batch)
934 }
935
936 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 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 #[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 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 let encoded_block = EncodedBlock::from_bytes(&decompressed)?;
1042
1043 let decoded = ColumnDecoder::decode(&encoded_block)?;
1045
1046 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 let nulls = self.read_validity(block_meta)?;
1061
1062 let col_meta = &self.metadata.columns[col_idx];
1064 decoded_to_arrow(decoded, col_meta.data_type, nulls)
1065 }
1066
1067 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
1107fn 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 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
1141fn 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 #[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 assert!(!pred(ZoneMapOp::Lt, 1.0).may_match_i64(1, 3));
1205 assert!(!pred(ZoneMapOp::Gt, 3.0).may_match_i64(1, 3));
1206 assert!(!pred(ZoneMapOp::Eq, 2.5).may_match_i64(1, 3));
1208 }
1209
1210 #[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 #[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 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 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); let reader = SegmentReader::open(&path).unwrap();
1285 assert_eq!(reader.header().row_count, 100);
1286 assert_eq!(reader.row_group_count(), 2);
1287
1288 let columns = reader
1290 .read_columns(&[chronix_core::TIME_COLUMN, "cpu"], 0)
1291 .unwrap();
1292 assert_eq!(columns.len(), 2);
1293
1294 let batch = reader.read_all().unwrap();
1296 assert_eq!(batch.num_rows(), 100);
1297 assert_eq!(batch.num_columns(), 3); }
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 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 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 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 let batch = reader.read_projected(&["cpu"]).unwrap();
1441 assert_eq!(batch.num_rows(), 80);
1442 assert_eq!(batch.num_columns(), 2); 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 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 let batch = reader.read_projected(&["host"]).unwrap();
1471 assert_eq!(batch.num_columns(), 2); 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); }
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 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 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 let batch = reader
1618 .read_projected_filtered(&["cpu"], Some((100_000, 190_000)))
1619 .unwrap();
1620 assert_eq!(batch.num_rows(), 10); 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 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 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 write_segment_with_rg_size(&path, 30, 10_000, 10);
1664
1665 let reader = SegmentReader::open(&path).unwrap();
1666
1667 let batch = reader
1669 .read_projected_filtered(&["cpu"], Some((500_000, 600_000)))
1670 .unwrap();
1671 assert_eq!(batch.num_rows(), 0);
1672 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 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 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 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 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 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 let batch = reader
1817 .read_projected_filtered_with_predicates(
1818 &["cpu", "host"],
1819 None,
1820 &[("host", "server-1")],
1821 )
1822 .unwrap();
1823 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 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 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 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 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 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 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 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 #[test]
1946 fn corrupted_validity_bitmap_is_detected() {
1947 let dir = tempfile::tempdir().unwrap();
1948 let path = dir.path().join("nulls.csx");
1949
1950 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 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 #[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 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 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 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 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 #[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 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 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 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 let dir = tempfile::tempdir().unwrap();
2183 let path = dir.path().join("rg_bloom_prune.csx");
2184
2185 let mut points = Vec::new();
2186 for i in 0..10 {
2188 points.push(make_point("alpha", i as f64, i as i64 * 1000));
2189 }
2190 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 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 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 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 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}