1use std::fs::File;
44use std::path::{Path, PathBuf};
45#[cfg(feature = "field-encryption")]
49use std::sync::Arc;
50
51use arrow::array::{
52 ArrayRef, BooleanArray, Decimal128Array, Float64Array, Int64Array, StringArray, UInt64Array,
53};
54use arrow::buffer::NullBuffer;
55use arrow::datatypes::{Field, Schema};
56use arrow::record_batch::RecordBatch;
57use memmap2::{Advice, Mmap, UncheckedAdvice};
58
59use chronix_encoding::{ColumnDecoder, DecodedColumn, EncodedBlock};
60
61use crate::segment::compression::decompress_block_with_optional_dict;
62use crate::segment::error::{Result, SegmentError};
63use crate::segment::header::{FOOTER_SIZE, HEADER_SIZE, SegmentFooter, SegmentHeader};
64use crate::segment::metadata::{ColumnBlockMeta, ColumnMeta, SegmentMetadata, data_types};
65use crate::segment::stats::ordered_i64_to_f64;
66
67#[derive(Debug, Clone, Copy, PartialEq)]
71pub enum StatsOp {
72 Eq,
74 Gt,
76 GtEq,
78 Lt,
80 LtEq,
82}
83
84#[derive(Debug, Clone)]
88pub struct FieldPredicate {
89 pub column: String,
91 pub op: StatsOp,
93 pub value: f64,
95}
96
97impl FieldPredicate {
98 fn may_match_f64(&self, rg_min: f64, rg_max: f64) -> bool {
106 if rg_min.is_nan() || rg_max.is_nan() || rg_min > rg_max {
107 return true;
108 }
109 match self.op {
110 StatsOp::Eq => self.value >= rg_min && self.value <= rg_max,
111 StatsOp::Gt => rg_max > self.value,
112 StatsOp::GtEq => rg_max >= self.value,
113 StatsOp::Lt => rg_min < self.value,
114 StatsOp::LtEq => rg_min <= self.value,
115 }
116 }
117
118 fn may_match_i64(&self, rg_min: i64, rg_max: i64) -> bool {
127 if rg_min > rg_max {
128 return true;
129 }
130 const EXACT: i64 = 1 << 53;
131 if rg_min <= -EXACT || rg_max >= EXACT {
132 return true;
133 }
134 #[allow(clippy::cast_precision_loss)] let (lo, hi) = (rg_min as f64, rg_max as f64);
136 match self.op {
137 StatsOp::Eq if self.value.fract() != 0.0 => false,
140 StatsOp::Eq => self.value >= lo && self.value <= hi,
141 StatsOp::Gt => hi > self.value,
142 StatsOp::GtEq => hi >= self.value,
143 StatsOp::Lt => lo < self.value,
144 StatsOp::LtEq => lo <= self.value,
145 }
146 }
147
148 fn may_match_decimal(&self, rg_min: i64, rg_max: i64, scale: u8) -> bool {
164 if rg_min > rg_max {
165 return true;
166 }
167 const EXACT: i64 = 1 << 53;
168 if rg_min <= -EXACT || rg_max >= EXACT {
169 return true;
170 }
171 let pow = 10f64.powi(i32::from(scale));
172 if !pow.is_finite() || pow <= 0.0 {
173 return true;
174 }
175 #[allow(clippy::cast_precision_loss)] let (lo, hi) = ((rg_min as f64 - 1.0) / pow, (rg_max as f64 + 1.0) / pow);
177 self.may_match_f64(lo, hi)
178 }
179}
180
181pub struct SegmentReader {
203 data: Mmap,
205 header: SegmentHeader,
207 metadata: SegmentMetadata,
209 path: PathBuf,
211 #[cfg(feature = "field-encryption")]
214 key_provider: Option<Arc<dyn crate::segment::field_encryption::FieldKeyProvider>>,
215}
216
217impl SegmentReader {
218 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
225 let path = path.as_ref().to_path_buf();
226 let file = File::open(&path)?;
227 #[allow(unsafe_code)] let data = unsafe { Mmap::map(&file)? };
233 data.advise(Advice::Sequential)?;
237
238 if data.len() < HEADER_SIZE + FOOTER_SIZE {
239 return Err(SegmentError::CorruptFile {
240 detail: format!(
241 "file too small: {} bytes (need at least {})",
242 data.len(),
243 HEADER_SIZE + FOOTER_SIZE
244 ),
245 });
246 }
247
248 let header = SegmentHeader::from_bytes(&data[..HEADER_SIZE])?;
250
251 let footer_start = data.len() - FOOTER_SIZE;
253 let footer = SegmentFooter::from_bytes(&data[footer_start..])?;
254
255 let computed_checksum = crc32c::crc32c(&data[..footer_start]);
257 if computed_checksum != footer.checksum {
258 return Err(SegmentError::ChecksumMismatch {
259 expected: footer.checksum,
260 actual: computed_checksum,
261 });
262 }
263
264 let metadata_start = footer.metadata_offset as usize;
266 if metadata_start < footer_start {
267 let metadata_bytes = &data[metadata_start..footer_start];
268 let computed_meta_crc = crc32c::crc32c(metadata_bytes);
269 if computed_meta_crc != footer.metadata_checksum {
270 return Err(SegmentError::ChecksumMismatch {
271 expected: footer.metadata_checksum,
272 actual: computed_meta_crc,
273 });
274 }
275 }
276
277 if metadata_start < HEADER_SIZE {
279 return Err(SegmentError::CorruptFile {
280 detail: format!(
281 "metadata_offset ({metadata_start}) is before end of header ({HEADER_SIZE})"
282 ),
283 });
284 }
285 if metadata_start > footer_start {
286 return Err(SegmentError::CorruptFile {
287 detail: format!(
288 "metadata_offset ({metadata_start}) exceeds footer start ({footer_start})"
289 ),
290 });
291 }
292 let metadata = SegmentMetadata::from_bytes(&data[metadata_start..footer_start])?;
293
294 if metadata.columns.len() != header.column_count as usize {
296 return Err(SegmentError::CorruptFile {
297 detail: format!(
298 "column count mismatch: header says {}, metadata has {}",
299 header.column_count,
300 metadata.columns.len()
301 ),
302 });
303 }
304
305 Ok(Self {
306 data,
307 header,
308 metadata,
309 path,
310 #[cfg(feature = "field-encryption")]
311 key_provider: None,
312 })
313 }
314
315 #[must_use]
317 pub fn header(&self) -> &SegmentHeader {
318 &self.header
319 }
320
321 #[must_use]
323 pub fn column_metadata(&self) -> &[crate::segment::metadata::ColumnMeta] {
324 &self.metadata.columns
325 }
326
327 #[must_use]
329 pub fn row_group_count(&self) -> usize {
330 self.metadata.row_group_blocks.len()
331 }
332
333 #[must_use]
343 pub fn row_group_blocks(&self, row_group: usize) -> Option<&[ColumnBlockMeta]> {
344 self.metadata
345 .row_group_blocks
346 .get(row_group)
347 .map(Vec::as_slice)
348 }
349
350 #[must_use]
352 pub fn path(&self) -> &Path {
353 &self.path
354 }
355
356 #[cfg(feature = "field-encryption")]
359 pub fn set_key_provider(
360 &mut self,
361 provider: Arc<dyn crate::segment::field_encryption::FieldKeyProvider>,
362 ) {
363 self.key_provider = Some(provider);
364 }
365
366 pub fn read_columns(&self, column_names: &[&str], row_group: usize) -> Result<Vec<ArrayRef>> {
381 if row_group >= self.metadata.row_group_blocks.len() {
382 return Err(SegmentError::RowGroupOutOfRange {
383 index: row_group,
384 max: self.metadata.row_group_blocks.len().saturating_sub(1),
385 });
386 }
387
388 let rg_blocks = &self.metadata.row_group_blocks[row_group];
389
390 let resolved: Vec<(usize, &ColumnBlockMeta)> = column_names
392 .iter()
393 .map(|&name| {
394 let col_idx = self
395 .metadata
396 .columns
397 .iter()
398 .position(|c| c.name == name)
399 .ok_or_else(|| SegmentError::ColumnNotFound {
400 name: name.to_string(),
401 })?;
402 let block_meta = rg_blocks
403 .iter()
404 .find(|b| b.column_index == col_idx as u16)
405 .ok_or_else(|| SegmentError::ColumnNotFound {
406 name: name.to_string(),
407 })?;
408 Ok((col_idx, block_meta))
409 })
410 .collect::<Result<Vec<_>>>()?;
411
412 let mut result = Vec::with_capacity(column_names.len());
413
414 for (i, &(col_idx, block_meta)) in resolved.iter().enumerate() {
415 if i + 1 < resolved.len() {
418 let (_, next_block) = resolved[i + 1];
419 self.prefetch_block(next_block);
420 }
421
422 if row_group + 1 < self.metadata.row_group_blocks.len() {
425 let next_rg_blocks = &self.metadata.row_group_blocks[row_group + 1];
426 if let Some(next_rg_block) = next_rg_blocks
427 .iter()
428 .find(|b| b.column_index == col_idx as u16)
429 {
430 self.prefetch_block(next_rg_block);
431 }
432 }
433
434 let array = self.read_column_block(block_meta, col_idx)?;
435 result.push(array);
436 }
437
438 Ok(result)
439 }
440
441 fn prefetch_block(&self, block_meta: &ColumnBlockMeta) {
451 let start = block_meta.offset as usize;
452 let len = block_meta.length as usize;
453 if start.saturating_add(len) <= self.data.len() {
454 if let Err(e) = self.data.advise_range(Advice::WillNeed, start, len) {
456 tracing::trace!(
457 offset = start,
458 length = len,
459 error = %e,
460 "prefetch advise failed (non-fatal)"
461 );
462 metrics::counter!("chronix_segment_prefetch_errors_total").increment(1);
463 } else {
464 metrics::counter!("chronix_segment_prefetch_hints_total").increment(1);
465 metrics::counter!("chronix_segment_prefetch_bytes_total").increment(len as u64);
466 }
467 }
468 }
469
470 pub fn read_all(&self) -> Result<RecordBatch> {
483 let column_names: Vec<&str> = self
484 .metadata
485 .columns
486 .iter()
487 .map(|c| c.name.as_str())
488 .collect();
489 self.read_projected(&column_names)
490 }
491
492 pub fn read_row_group(&self, row_group: usize) -> Result<RecordBatch> {
501 let column_names: Vec<&str> = self
502 .metadata
503 .columns
504 .iter()
505 .map(|c| c.name.as_str())
506 .collect();
507 let arrays = self.read_columns(&column_names, row_group)?;
508 let fields: Vec<Field> = column_names
509 .iter()
510 .map(|&name| {
511 let col_meta = self
512 .metadata
513 .columns
514 .iter()
515 .find(|c| c.name == name)
516 .ok_or_else(|| SegmentError::CorruptFile {
517 detail: format!("column '{name}' not in segment metadata"),
518 })?;
519 Ok(col_meta.arrow_field())
520 })
521 .collect::<Result<Vec<_>>>()?;
522 let schema = std::sync::Arc::new(Schema::new(fields));
523 RecordBatch::try_new(schema, arrays).map_err(|e| SegmentError::CorruptFile {
524 detail: format!("failed to build row-group RecordBatch: {e}"),
525 })
526 }
527
528 pub fn read_projected(&self, column_names: &[&str]) -> Result<RecordBatch> {
539 self.read_projected_filtered(column_names, None)
540 }
541
542 pub fn read_projected_filtered(
555 &self,
556 column_names: &[&str],
557 time_range: Option<(i64, i64)>,
558 ) -> Result<RecordBatch> {
559 self.read_projected_filtered_with_predicates(column_names, time_range, &[])
560 }
561
562 pub fn read_projected_filtered_with_predicates(
577 &self,
578 column_names: &[&str],
579 time_range: Option<(i64, i64)>,
580 tag_predicates: &[(&str, &str)],
581 ) -> Result<RecordBatch> {
582 self.read_projected_with_zone_maps(column_names, time_range, tag_predicates, &[])
583 }
584
585 pub fn read_projected_with_zone_maps(
600 &self,
601 column_names: &[&str],
602 time_range: Option<(i64, i64)>,
603 tag_predicates: &[(&str, &str)],
604 field_predicates: &[FieldPredicate],
605 ) -> Result<RecordBatch> {
606 let all_col_names: Vec<String> = if column_names.is_empty() {
608 self.metadata
609 .columns
610 .iter()
611 .map(|c| c.name.clone())
612 .collect()
613 } else {
614 Vec::new()
615 };
616
617 let mut names: Vec<&str> = Vec::with_capacity(if column_names.is_empty() {
619 all_col_names.len()
620 } else {
621 column_names.len() + 1
622 });
623 if column_names.is_empty() {
624 for c in &all_col_names {
626 names.push(c.as_str());
627 }
628 } else {
629 if !column_names.contains(&chronix_core::TIME_COLUMN) {
630 names.push(chronix_core::TIME_COLUMN);
631 }
632 for &n in column_names {
633 if !names.contains(&n) {
634 names.push(n);
635 }
636 }
637 }
638
639 let ts_col_idx = self
641 .metadata
642 .columns
643 .iter()
644 .position(|c| c.name == chronix_core::TIME_COLUMN);
645
646 let tag_col_indices: Vec<usize> = tag_predicates
648 .iter()
649 .filter_map(|(col_name, _)| {
650 self.metadata
651 .columns
652 .iter()
653 .position(|c| c.name == *col_name)
654 })
655 .collect();
656
657 let mut all_arrays: Vec<Vec<ArrayRef>> = vec![Vec::new(); names.len()];
658
659 if !tag_predicates.is_empty() {
664 for &(col_name, value) in tag_predicates {
665 if let Some(col_meta) = self.metadata.columns.iter().find(|c| c.name == col_name)
666 && let Some(ref bf) = col_meta.bloom_filter
667 && !crate::segment::bloom::bloom_filter_contains(bf, value)
668 {
669 metrics::counter!("chronix_segment_pruned_by_bloom_filter_total").increment(1);
670 let mut empty_arrays: Vec<ArrayRef> = Vec::with_capacity(names.len());
672 for &name in &names {
673 let cm = self
674 .metadata
675 .columns
676 .iter()
677 .find(|c| c.name == name)
678 .ok_or_else(|| SegmentError::CorruptFile {
679 detail: format!("column '{name}' not in metadata"),
680 })?;
681 empty_arrays.push(cm.empty_array());
682 }
683 let fields: Vec<Field> = names
684 .iter()
685 .map(|&n| {
686 let cm2 = self
687 .metadata
688 .columns
689 .iter()
690 .find(|c| c.name == n)
691 .ok_or_else(|| SegmentError::CorruptFile {
692 detail: format!("column '{n}' not in metadata"),
693 })?;
694 Ok(cm2.arrow_field())
695 })
696 .collect::<Result<Vec<_>>>()?;
697 let schema = Schema::new(fields);
698 return RecordBatch::try_new(std::sync::Arc::new(schema), empty_arrays)
699 .map_err(|e| SegmentError::CorruptFile {
700 detail: format!("failed to create empty RecordBatch: {e}"),
701 });
702 }
703 }
704 }
705
706 for rg_idx in 0..self.metadata.row_group_blocks.len() {
707 let rg_blocks = &self.metadata.row_group_blocks[rg_idx];
708
709 if let (Some((start_ns, end_ns)), Some(ts_idx)) = (time_range, ts_col_idx)
716 && let Some(ts_block) = rg_blocks.iter().find(|b| b.column_index == ts_idx as u16)
717 {
718 let rg_min = ts_block.stats.min_value;
719 let rg_max = ts_block.stats.max_value;
720 if rg_max < start_ns || rg_min > end_ns {
723 metrics::counter!("chronix_segment_rg_pruned_by_time_range_total").increment(1);
724 continue;
725 }
726 }
727
728 if !tag_predicates.is_empty() {
746 let mut skip = false;
747 for &tag_idx in &tag_col_indices {
748 if let Some(tag_block) =
749 rg_blocks.iter().find(|b| b.column_index == tag_idx as u16)
750 && tag_block.stats.value_count == 0
751 && !tag_block.stats.says_nothing()
752 {
753 skip = true;
754 break;
755 }
756 }
757 if skip {
758 metrics::counter!("chronix_segment_rg_pruned_by_tag_stats_total").increment(1);
759 continue;
760 }
761 }
762
763 if !tag_predicates.is_empty() {
768 let mut skip = false;
769 for &(col_name, value) in tag_predicates {
770 if let Some(col_meta) =
771 self.metadata.columns.iter().find(|c| c.name == col_name)
772 && let Some(ref rg_blooms) = col_meta.row_group_blooms
773 && let Some(bloom_bytes) = rg_blooms.get(rg_idx)
774 && !bloom_bytes.is_empty()
775 && !crate::segment::bloom::bloom_filter_contains(bloom_bytes, value)
776 {
777 skip = true;
778 break;
779 }
780 }
781 if skip {
782 metrics::counter!("chronix_segment_rg_pruned_by_bloom_total").increment(1);
783 continue;
784 }
785 }
786
787 if !field_predicates.is_empty() {
794 let mut skip = false;
795 for pred in field_predicates {
796 if let Some((col_idx, col_meta)) = self
797 .metadata
798 .columns
799 .iter()
800 .enumerate()
801 .find(|(_, c)| c.name == pred.column)
802 && let Some(block) =
803 rg_blocks.iter().find(|b| b.column_index == col_idx as u16)
804 {
805 let matches = if block.stats.says_nothing() {
809 true
810 } else {
811 match col_meta.data_type {
812 data_types::F64 => {
813 let rg_min = ordered_i64_to_f64(block.stats.min_value);
814 let rg_max = ordered_i64_to_f64(block.stats.max_value);
815 pred.may_match_f64(rg_min, rg_max)
816 }
817 data_types::TIMESTAMP | data_types::I64 => {
818 pred.may_match_i64(block.stats.min_value, block.stats.max_value)
819 }
820 data_types::DECIMAL => pred.may_match_decimal(
821 block.stats.min_value,
822 block.stats.max_value,
823 col_meta.decimal_scale.unwrap_or(0),
824 ),
825 _ => true, }
827 };
828 if !matches {
829 skip = true;
830 break;
831 }
832 }
833 }
834 if skip {
835 metrics::counter!("chronix_segment_rg_pruned_by_zone_map_total").increment(1);
836 continue;
837 }
838 }
839
840 let arrays = self.read_columns(&names, rg_idx)?;
841 for (col_idx, array) in arrays.into_iter().enumerate() {
842 all_arrays[col_idx].push(array);
843 }
844 }
845
846 let all_pruned = all_arrays.iter().all(std::vec::Vec::is_empty);
850
851 let mut final_arrays: Vec<ArrayRef> = Vec::with_capacity(names.len());
852
853 if all_pruned {
854 for &name in &names {
856 let col_meta = self
857 .metadata
858 .columns
859 .iter()
860 .find(|c| c.name == name)
861 .ok_or_else(|| SegmentError::CorruptFile {
862 detail: format!("projected column '{name}' not in segment metadata"),
863 })?;
864 final_arrays.push(col_meta.empty_array());
865 }
866 } else {
867 for (col_idx, arrays) in all_arrays.iter().enumerate() {
868 let refs: Vec<&dyn arrow::array::Array> =
869 arrays.iter().map(std::convert::AsRef::as_ref).collect();
870 let concatenated =
871 arrow::compute::concat(&refs).map_err(|e| SegmentError::CorruptFile {
872 detail: format!("failed to concatenate column {}: {e}", names[col_idx]),
873 })?;
874 final_arrays.push(concatenated);
875 }
876 }
877
878 let fields: Vec<Field> = names
880 .iter()
881 .map(|&name| {
882 let col_meta = self
883 .metadata
884 .columns
885 .iter()
886 .find(|c| c.name == name)
887 .ok_or_else(|| SegmentError::CorruptFile {
888 detail: format!("projected column '{name}' not in segment metadata"),
889 })?;
890 Ok(col_meta.arrow_field())
891 })
892 .collect::<Result<Vec<Field>>>()?;
893
894 let schema = Schema::new(fields);
895 let batch =
896 RecordBatch::try_new(std::sync::Arc::new(schema), final_arrays).map_err(|e| {
897 SegmentError::CorruptFile {
898 detail: format!("failed to create RecordBatch: {e}"),
899 }
900 })?;
901
902 #[allow(unsafe_code)]
910 let _ = unsafe { self.data.unchecked_advise(UncheckedAdvice::DontNeed) };
911
912 Ok(batch)
913 }
914
915 fn read_column_block(&self, block_meta: &ColumnBlockMeta, col_idx: usize) -> Result<ArrayRef> {
917 let start = block_meta.offset as usize;
918 let end = start
919 .checked_add(block_meta.length as usize)
920 .ok_or_else(|| SegmentError::CorruptFile {
921 detail: format!(
922 "column block offset+length overflow: offset={start}, length={}",
923 block_meta.length,
924 ),
925 })?;
926
927 if end > self.data.len() {
928 return Err(SegmentError::CorruptFile {
929 detail: format!(
930 "column block extends beyond file: offset={start}, \
931 length={}, file_size={}",
932 block_meta.length,
933 self.data.len()
934 ),
935 });
936 }
937
938 let raw_data = &self.data[start..end];
939
940 if block_meta.block_crc != 0 {
942 let actual_crc = crc32c::crc32c(raw_data);
943 if actual_crc != block_meta.block_crc {
944 return Err(SegmentError::CorruptFile {
945 detail: format!(
946 "block CRC mismatch for column {}: expected {:#010x}, got {:#010x}",
947 col_idx, block_meta.block_crc, actual_crc,
948 ),
949 });
950 }
951 } else {
952 tracing::warn!(
953 column = col_idx,
954 "block CRC is zero, skipping integrity check — this segment was written without per-block CRCs"
955 );
956 metrics::counter!("chronix_segment_crc_skip_total").increment(1);
957 }
958
959 #[cfg(feature = "field-encryption")]
962 let raw_data: std::borrow::Cow<'_, [u8]> = if block_meta.encrypted {
963 let col_meta = &self.metadata.columns[col_idx];
964 let key_id = col_meta
965 .key_id
966 .as_deref()
967 .ok_or_else(|| SegmentError::CorruptFile {
968 detail: format!(
969 "column {} is encrypted but has no key_id in metadata",
970 col_meta.name,
971 ),
972 })?;
973 let provider =
974 self.key_provider
975 .as_ref()
976 .ok_or_else(|| SegmentError::MissingEncryptionKey {
977 column: col_meta.name.clone(),
978 key_id: key_id.to_owned(),
979 })?;
980 let key =
981 provider
982 .get_key(key_id)
983 .ok_or_else(|| SegmentError::MissingEncryptionKey {
984 column: col_meta.name.clone(),
985 key_id: key_id.to_owned(),
986 })?;
987 let decrypted = crate::segment::field_encryption::decrypt_block(
988 raw_data,
989 &key,
990 crate::segment::field_encryption::BlockContext {
991 column: &col_meta.name,
992 segment_created_at: self.header.created_at,
993 },
994 )
995 .map_err(|e| SegmentError::CorruptFile {
996 detail: format!("failed to decrypt column {}: {e}", col_meta.name,),
997 })?;
998 std::borrow::Cow::Owned(decrypted)
999 } else {
1000 std::borrow::Cow::Borrowed(raw_data)
1001 };
1002
1003 #[cfg(not(feature = "field-encryption"))]
1010 let raw_data: std::borrow::Cow<'_, [u8]> = std::borrow::Cow::Borrowed(raw_data);
1011
1012 let decompressed = if block_meta.compressed {
1014 decompress_block_with_optional_dict(
1015 &raw_data,
1016 self.metadata.zstd_dictionary.as_deref(),
1017 )?
1018 } else {
1019 raw_data.to_vec()
1020 };
1021
1022 let encoded_block = EncodedBlock::from_bytes(&decompressed)?;
1024
1025 let decoded = ColumnDecoder::decode(&encoded_block)?;
1027
1028 let decoded_len = decoded.len();
1030 let expected = block_meta.value_count as usize;
1031 if decoded_len != expected {
1032 return Err(SegmentError::CorruptFile {
1033 detail: format!(
1034 "decoded value count mismatch for column {col_idx}: \
1035 metadata says {expected} values but decoded {decoded_len}",
1036 ),
1037 });
1038 }
1039
1040 let nulls = self.read_validity(block_meta)?;
1043
1044 let col_meta = &self.metadata.columns[col_idx];
1046 decoded_to_arrow(decoded, col_meta, nulls)
1047 }
1048
1049 fn read_validity(&self, block_meta: &ColumnBlockMeta) -> Result<Option<NullBuffer>> {
1053 if block_meta.validity_length == 0 {
1054 return Ok(None);
1055 }
1056 let start = block_meta.validity_offset as usize;
1057 let end = start
1058 .checked_add(block_meta.validity_length as usize)
1059 .ok_or_else(|| SegmentError::CorruptFile {
1060 detail: "validity bitmap offset+length overflow".to_string(),
1061 })?;
1062 if end > self.data.len() {
1063 return Err(SegmentError::CorruptFile {
1064 detail: format!(
1065 "validity bitmap extends beyond file: offset={start}, \
1066 length={}, file_size={}",
1067 block_meta.validity_length,
1068 self.data.len()
1069 ),
1070 });
1071 }
1072 let value_count = block_meta.value_count as usize;
1073 let bytes = &self.data[start..end];
1074 if bytes.len() < crate::segment::validity::bitmap_len(value_count) {
1075 return Err(SegmentError::CorruptFile {
1076 detail: format!(
1077 "validity bitmap too short: {} bytes for {value_count} values",
1078 bytes.len()
1079 ),
1080 });
1081 }
1082 Ok(crate::segment::validity::null_buffer_from_bytes(
1083 bytes,
1084 value_count,
1085 ))
1086 }
1087}
1088
1089fn apply_nulls<A>(array: A, nulls: Option<NullBuffer>) -> Result<A>
1095where
1096 A: arrow::array::Array + From<arrow::array::ArrayData>,
1097{
1098 let Some(nulls) = nulls else {
1099 return Ok(array);
1100 };
1101 let data = array.into_data();
1102 if nulls.len() != data.len() {
1103 return Err(SegmentError::CorruptFile {
1104 detail: format!(
1105 "validity bitmap length {} does not match column length {}",
1106 nulls.len(),
1107 data.len()
1108 ),
1109 });
1110 }
1111 let data = data
1114 .into_builder()
1115 .nulls(Some(nulls))
1116 .build()
1117 .map_err(|e| SegmentError::CorruptFile {
1118 detail: format!("failed to attach validity bitmap: {e}"),
1119 })?;
1120 Ok(A::from(data))
1121}
1122
1123fn decoded_to_arrow(
1127 decoded: DecodedColumn,
1128 col_meta: &ColumnMeta,
1129 nulls: Option<NullBuffer>,
1130) -> Result<ArrayRef> {
1131 let data_type = col_meta.data_type;
1132 match (decoded, data_type) {
1133 (DecodedColumn::I64(values), data_types::TIMESTAMP | data_types::I64) => Ok(
1134 std::sync::Arc::new(apply_nulls(Int64Array::from(values), nulls)?),
1135 ),
1136 (DecodedColumn::U64(values), data_types::U64) => Ok(std::sync::Arc::new(apply_nulls(
1137 UInt64Array::from(values),
1138 nulls,
1139 )?)),
1140 (DecodedColumn::F64(values), data_types::F64) => Ok(std::sync::Arc::new(apply_nulls(
1141 Float64Array::from(values),
1142 nulls,
1143 )?)),
1144 (DecodedColumn::Bool(values), data_types::BOOL) => Ok(std::sync::Arc::new(apply_nulls(
1145 BooleanArray::from(values),
1146 nulls,
1147 )?)),
1148 (DecodedColumn::String(values), data_types::STRING) => {
1149 let refs: Vec<&str> = values.iter().map(String::as_str).collect();
1150 Ok(std::sync::Arc::new(apply_nulls(
1151 StringArray::from(refs),
1152 nulls,
1153 )?))
1154 }
1155 (DecodedColumn::Decimal(values), data_types::DECIMAL) => {
1156 let scale = i8::try_from(col_meta.decimal_scale.unwrap_or(0)).map_err(|_| {
1159 SegmentError::CorruptFile {
1160 detail: format!(
1161 "decimal column '{}' declares an unrepresentable scale",
1162 col_meta.name
1163 ),
1164 }
1165 })?;
1166 let array = Decimal128Array::from(values)
1167 .with_precision_and_scale(chronix_core::DECIMAL_PRECISION, scale)
1168 .map_err(|e| SegmentError::CorruptFile {
1169 detail: format!(
1170 "decimal column '{}' does not fit decimal(38, {scale}): {e}",
1171 col_meta.name
1172 ),
1173 })?;
1174 Ok(std::sync::Arc::new(apply_nulls(array, nulls)?))
1175 }
1176 (DecodedColumn::Bytes(values), data_types::HISTOGRAM) => {
1177 let refs: Vec<&[u8]> = values.iter().map(Vec::as_slice).collect();
1181 Ok(std::sync::Arc::new(apply_nulls(
1182 arrow::array::BinaryArray::from(refs),
1183 nulls,
1184 )?))
1185 }
1186 (decoded, _) => Err(SegmentError::CorruptFile {
1187 detail: format!(
1188 "type mismatch: decoded variant {:?} does not match expected data_type {data_type}",
1189 std::mem::discriminant(&decoded),
1190 ),
1191 }),
1192 }
1193}
1194
1195#[cfg(test)]
1196mod zone_map_tests {
1197 use super::*;
1198
1199 fn pred(op: StatsOp, value: f64) -> FieldPredicate {
1200 FieldPredicate {
1201 column: "n".into(),
1202 op,
1203 value,
1204 }
1205 }
1206
1207 #[test]
1211 fn a_fractional_bound_does_not_prune_an_integer_block() {
1212 assert!(pred(StatsOp::Lt, 1.5).may_match_i64(1, 3));
1213 assert!(pred(StatsOp::LtEq, 1.5).may_match_i64(1, 3));
1214 assert!(pred(StatsOp::Gt, 2.5).may_match_i64(1, 3));
1215 assert!(pred(StatsOp::GtEq, 2.5).may_match_i64(1, 3));
1216 assert!(pred(StatsOp::Eq, 2.0).may_match_i64(1, 3));
1217 assert!(!pred(StatsOp::Lt, 1.0).may_match_i64(1, 3));
1219 assert!(!pred(StatsOp::Gt, 3.0).may_match_i64(1, 3));
1220 assert!(!pred(StatsOp::Eq, 2.5).may_match_i64(1, 3));
1222 }
1223
1224 #[test]
1227 fn a_bound_beyond_exact_float_range_never_prunes() {
1228 let big = (1_i64 << 53) + 1;
1229 assert!(pred(StatsOp::Eq, big as f64).may_match_i64(big, big));
1230 assert!(pred(StatsOp::Lt, 0.0).may_match_i64(big, big + 10));
1231 }
1232
1233 #[test]
1238 fn suppressed_statistics_never_prune() {
1239 let empty = crate::segment::stats::ColumnStats::empty();
1240 assert!(empty.says_nothing());
1241 assert!(
1242 pred(StatsOp::Gt, 0.0).may_match_i64(empty.min_value, empty.max_value),
1243 "an inverted sentinel range must not prune"
1244 );
1245 assert!(pred(StatsOp::Eq, 42.0).may_match_f64(f64::NAN, f64::NAN));
1246
1247 let mut all_null = crate::segment::stats::ColumnStats::empty();
1250 all_null.record_null();
1251 assert!(!all_null.says_nothing());
1252 }
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257 use super::*;
1258 use crate::segment::writer::{SegmentWriter, SegmentWriterConfig};
1259 use chronix_core::config::{CompressionCodec, FloatEncoding};
1260 use chronix_core::types::{FieldValue, Point, SeriesKey};
1261 use std::collections::BTreeMap;
1262
1263 fn make_point(host: &str, cpu: f64, ts: i64) -> Point {
1264 let tags = BTreeMap::from([("host".to_string(), host.to_string())]);
1265 let key = SeriesKey::new("cpu_usage", tags).unwrap();
1266 let fields = BTreeMap::from([("cpu".to_string(), FieldValue::F64(cpu))]);
1267 Point::new(key, fields, ts).unwrap()
1268 }
1269
1270 #[test]
1271 fn write_and_read_segment() {
1272 let dir = tempfile::tempdir().unwrap();
1273 let path = dir.path().join("test.csx");
1274
1275 let points: Vec<Point> = (0..100)
1277 .map(|i| make_point("server-1", 50.0 + i as f64, 1_000_000 + i * 10_000))
1278 .collect();
1279
1280 let config = SegmentWriterConfig {
1281 row_group_size: 50,
1282 compress: false,
1283 float_encoding: FloatEncoding::Gorilla,
1284 compression_codec: CompressionCodec::Lz4,
1285 zstd_level: 3,
1286 column_codec_overrides: std::collections::HashMap::new(),
1287 ..Default::default()
1288 };
1289
1290 let mut writer = SegmentWriter::new(&path, config).unwrap();
1291 writer.write_rows(&points).unwrap();
1292 let meta = writer.finalize().unwrap();
1293
1294 assert_eq!(meta.row_count, 100);
1295 assert_eq!(meta.row_group_count, 2); let reader = SegmentReader::open(&path).unwrap();
1299 assert_eq!(reader.header().row_count, 100);
1300 assert_eq!(reader.row_group_count(), 2);
1301
1302 let columns = reader
1304 .read_columns(&[chronix_core::TIME_COLUMN, "cpu"], 0)
1305 .unwrap();
1306 assert_eq!(columns.len(), 2);
1307
1308 let batch = reader.read_all().unwrap();
1310 assert_eq!(batch.num_rows(), 100);
1311 assert_eq!(batch.num_columns(), 3); }
1313
1314 #[test]
1315 fn write_and_read_compressed() {
1316 let dir = tempfile::tempdir().unwrap();
1317 let path = dir.path().join("compressed.csx");
1318
1319 let points: Vec<Point> = (0..200)
1320 .map(|i| make_point("server-1", 50.0 + (i % 10) as f64, 1_000_000 + i * 10_000))
1321 .collect();
1322
1323 let config = SegmentWriterConfig {
1324 row_group_size: 100,
1325 compress: true,
1326 float_encoding: FloatEncoding::Gorilla,
1327 compression_codec: CompressionCodec::Lz4,
1328 zstd_level: 3,
1329 column_codec_overrides: std::collections::HashMap::new(),
1330 ..Default::default()
1331 };
1332
1333 let mut writer = SegmentWriter::new(&path, config).unwrap();
1334 writer.write_rows(&points).unwrap();
1335 writer.finalize().unwrap();
1336
1337 let reader = SegmentReader::open(&path).unwrap();
1338 let batch = reader.read_all().unwrap();
1339 assert_eq!(batch.num_rows(), 200);
1340 }
1341
1342 #[test]
1343 fn checksum_validation() {
1344 let dir = tempfile::tempdir().unwrap();
1345 let path = dir.path().join("checksum.csx");
1346
1347 let points = vec![make_point("s1", 1.0, 1000)];
1348 let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1349 writer.write_rows(&points).unwrap();
1350 writer.finalize().unwrap();
1351
1352 let mut data = std::fs::read(&path).unwrap();
1354 let mid = data.len() / 2;
1355 data[mid] ^= 0xFF;
1356 std::fs::write(&path, &data).unwrap();
1357
1358 assert!(matches!(
1359 SegmentReader::open(&path),
1360 Err(SegmentError::ChecksumMismatch { .. })
1361 ));
1362 }
1363
1364 #[test]
1365 fn column_projection() {
1366 let dir = tempfile::tempdir().unwrap();
1367 let path = dir.path().join("projection.csx");
1368
1369 let points: Vec<Point> = (0..50)
1370 .map(|i| make_point("h1", i as f64, i * 1000))
1371 .collect();
1372
1373 let config = SegmentWriterConfig {
1374 row_group_size: 100,
1375 compress: false,
1376 float_encoding: FloatEncoding::Gorilla,
1377 compression_codec: CompressionCodec::Lz4,
1378 zstd_level: 3,
1379 column_codec_overrides: std::collections::HashMap::new(),
1380 ..Default::default()
1381 };
1382
1383 let mut writer = SegmentWriter::new(&path, config).unwrap();
1384 writer.write_rows(&points).unwrap();
1385 writer.finalize().unwrap();
1386
1387 let reader = SegmentReader::open(&path).unwrap();
1388
1389 let cols = reader.read_columns(&["cpu"], 0).unwrap();
1391 assert_eq!(cols.len(), 1);
1392
1393 let cpu_arr = cols[0].as_any().downcast_ref::<Float64Array>().unwrap();
1394 assert_eq!(cpu_arr.len(), 50);
1395 }
1396
1397 #[test]
1398 fn row_group_out_of_range() {
1399 let dir = tempfile::tempdir().unwrap();
1400 let path = dir.path().join("rg_oor.csx");
1401
1402 let points = vec![make_point("h1", 1.0, 1000)];
1403 let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1404 writer.write_rows(&points).unwrap();
1405 writer.finalize().unwrap();
1406
1407 let reader = SegmentReader::open(&path).unwrap();
1408 assert!(
1409 reader
1410 .read_columns(&[chronix_core::TIME_COLUMN], 999)
1411 .is_err()
1412 );
1413 }
1414
1415 #[test]
1416 fn column_not_found() {
1417 let dir = tempfile::tempdir().unwrap();
1418 let path = dir.path().join("col_404.csx");
1419
1420 let points = vec![make_point("h1", 1.0, 1000)];
1421 let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1422 writer.write_rows(&points).unwrap();
1423 writer.finalize().unwrap();
1424
1425 let reader = SegmentReader::open(&path).unwrap();
1426 assert!(reader.read_columns(&["nonexistent"], 0).is_err());
1427 }
1428
1429 #[test]
1430 fn read_projected_selects_subset() {
1431 let dir = tempfile::tempdir().unwrap();
1432 let path = dir.path().join("projected.csx");
1433
1434 let points: Vec<Point> = (0..80)
1436 .map(|i| make_point("server-1", 50.0 + i as f64, 1_000_000 + i * 10_000))
1437 .collect();
1438
1439 let config = SegmentWriterConfig {
1440 row_group_size: 50,
1441 compress: false,
1442 float_encoding: FloatEncoding::Gorilla,
1443 compression_codec: CompressionCodec::Lz4,
1444 zstd_level: 3,
1445 column_codec_overrides: std::collections::HashMap::new(),
1446 ..Default::default()
1447 };
1448
1449 let mut writer = SegmentWriter::new(&path, config).unwrap();
1450 writer.write_rows(&points).unwrap();
1451 writer.finalize().unwrap();
1452
1453 let reader = SegmentReader::open(&path).unwrap();
1454
1455 let batch = reader.read_projected(&["cpu"]).unwrap();
1457 assert_eq!(batch.num_rows(), 80);
1458 assert_eq!(batch.num_columns(), 2); assert!(batch.column_by_name(chronix_core::TIME_COLUMN).is_some());
1460 assert!(batch.column_by_name("cpu").is_some());
1461 assert!(batch.column_by_name("host").is_none());
1462
1463 let cpu_col = batch
1465 .column_by_name("cpu")
1466 .unwrap()
1467 .as_any()
1468 .downcast_ref::<Float64Array>()
1469 .unwrap();
1470 assert!((cpu_col.value(0) - 50.0).abs() < f64::EPSILON);
1471 }
1472
1473 #[test]
1474 fn read_projected_includes_timestamp_implicitly() {
1475 let dir = tempfile::tempdir().unwrap();
1476 let path = dir.path().join("proj_ts.csx");
1477
1478 let points = vec![make_point("h1", 42.0, 9999)];
1479 let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1480 writer.write_rows(&points).unwrap();
1481 writer.finalize().unwrap();
1482
1483 let reader = SegmentReader::open(&path).unwrap();
1484
1485 let batch = reader.read_projected(&["host"]).unwrap();
1487 assert_eq!(batch.num_columns(), 2); assert!(batch.column_by_name(chronix_core::TIME_COLUMN).is_some());
1489 }
1490
1491 #[test]
1492 fn read_projected_all_columns_matches_read_all() {
1493 let dir = tempfile::tempdir().unwrap();
1494 let path = dir.path().join("proj_all.csx");
1495
1496 let points: Vec<Point> = (0..30)
1497 .map(|i| make_point("h1", i as f64, i * 1000))
1498 .collect();
1499
1500 let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1501 writer.write_rows(&points).unwrap();
1502 writer.finalize().unwrap();
1503
1504 let reader = SegmentReader::open(&path).unwrap();
1505
1506 let all = reader.read_all().unwrap();
1507 let projected = reader
1508 .read_projected(&[chronix_core::TIME_COLUMN, "host", "cpu"])
1509 .unwrap();
1510
1511 assert_eq!(all.num_rows(), projected.num_rows());
1512 assert_eq!(all.num_columns(), projected.num_columns());
1513 }
1514
1515 #[test]
1516 fn read_projected_unknown_column_errors() {
1517 let dir = tempfile::tempdir().unwrap();
1518 let path = dir.path().join("proj_err.csx");
1519
1520 let points = vec![make_point("h1", 1.0, 1000)];
1521 let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1522 writer.write_rows(&points).unwrap();
1523 writer.finalize().unwrap();
1524
1525 let reader = SegmentReader::open(&path).unwrap();
1526 assert!(reader.read_projected(&["nonexistent"]).is_err());
1527 }
1528
1529 #[test]
1530 fn read_projected_across_multiple_row_groups() {
1531 let dir = tempfile::tempdir().unwrap();
1532 let path = dir.path().join("proj_multi_rg.csx");
1533
1534 let points: Vec<Point> = (0..200)
1535 .map(|i| make_point("server-1", i as f64, i * 1000))
1536 .collect();
1537
1538 let config = SegmentWriterConfig {
1539 row_group_size: 64,
1540 compress: true,
1541 float_encoding: FloatEncoding::Gorilla,
1542 compression_codec: CompressionCodec::Lz4,
1543 zstd_level: 3,
1544 column_codec_overrides: std::collections::HashMap::new(),
1545 ..Default::default()
1546 };
1547
1548 let mut writer = SegmentWriter::new(&path, config).unwrap();
1549 writer.write_rows(&points).unwrap();
1550 writer.finalize().unwrap();
1551
1552 let reader = SegmentReader::open(&path).unwrap();
1553 assert!(reader.row_group_count() > 1);
1554
1555 let batch = reader.read_projected(&["cpu"]).unwrap();
1556 assert_eq!(batch.num_rows(), 200);
1557 assert_eq!(batch.num_columns(), 2); }
1559
1560 #[test]
1561 fn multiple_series() {
1562 let dir = tempfile::tempdir().unwrap();
1563 let path = dir.path().join("multi_series.csx");
1564
1565 let mut points = Vec::new();
1566 for i in 0..20 {
1567 points.push(make_point("server-1", i as f64, i * 1000));
1568 points.push(make_point("server-2", i as f64 + 100.0, i * 1000));
1569 }
1570
1571 let config = SegmentWriterConfig {
1572 row_group_size: 100,
1573 compress: false,
1574 float_encoding: FloatEncoding::Gorilla,
1575 compression_codec: CompressionCodec::Lz4,
1576 zstd_level: 3,
1577 column_codec_overrides: std::collections::HashMap::new(),
1578 ..Default::default()
1579 };
1580
1581 let mut writer = SegmentWriter::new(&path, config).unwrap();
1582 writer.write_rows(&points).unwrap();
1583 let meta = writer.finalize().unwrap();
1584
1585 assert_eq!(meta.series_count, 2);
1586 assert_eq!(meta.row_count, 40);
1587
1588 let reader = SegmentReader::open(&path).unwrap();
1589 let batch = reader.read_all().unwrap();
1590 assert_eq!(batch.num_rows(), 40);
1591 }
1592
1593 fn write_segment_with_rg_size(path: &std::path::Path, n: usize, step: i64, rg_size: usize) {
1599 let points: Vec<Point> = (0..n)
1600 .map(|i| make_point("server-1", i as f64, i as i64 * step))
1601 .collect();
1602
1603 let config = SegmentWriterConfig {
1604 row_group_size: rg_size,
1605 compress: false,
1606 float_encoding: FloatEncoding::Gorilla,
1607 compression_codec: CompressionCodec::Lz4,
1608 zstd_level: 3,
1609 column_codec_overrides: std::collections::HashMap::new(),
1610 ..Default::default()
1611 };
1612
1613 let mut writer = SegmentWriter::new(path, config).unwrap();
1614 writer.write_rows(&points).unwrap();
1615 writer.finalize().unwrap();
1616 }
1617
1618 #[test]
1619 fn time_range_prunes_row_groups() {
1620 let dir = tempfile::tempdir().unwrap();
1626 let path = dir.path().join("prune.csx");
1627 write_segment_with_rg_size(&path, 30, 10_000, 10);
1628
1629 let reader = SegmentReader::open(&path).unwrap();
1630 assert_eq!(reader.row_group_count(), 3);
1631
1632 let batch = reader
1634 .read_projected_filtered(&["cpu"], Some((100_000, 190_000)))
1635 .unwrap();
1636 assert_eq!(batch.num_rows(), 10); let ts = batch
1640 .column_by_name(chronix_core::TIME_COLUMN)
1641 .unwrap()
1642 .as_any()
1643 .downcast_ref::<Int64Array>()
1644 .unwrap();
1645 for i in 0..ts.len() {
1646 assert!(ts.value(i) >= 100_000);
1647 assert!(ts.value(i) <= 190_000);
1648 }
1649 }
1650
1651 #[test]
1652 fn time_range_inclusive_end_boundary() {
1653 let dir = tempfile::tempdir().unwrap();
1661 let path = dir.path().join("boundary.csx");
1662 write_segment_with_rg_size(&path, 30, 10_000, 10);
1663
1664 let reader = SegmentReader::open(&path).unwrap();
1665
1666 let batch = reader
1667 .read_projected_filtered(&["cpu"], Some((0, 100_000)))
1668 .unwrap();
1669 assert_eq!(batch.num_rows(), 20);
1672 }
1673
1674 #[test]
1675 fn time_range_all_pruned_returns_empty() {
1676 let dir = tempfile::tempdir().unwrap();
1677 let path = dir.path().join("empty.csx");
1678 write_segment_with_rg_size(&path, 30, 10_000, 10);
1680
1681 let reader = SegmentReader::open(&path).unwrap();
1682
1683 let batch = reader
1685 .read_projected_filtered(&["cpu"], Some((500_000, 600_000)))
1686 .unwrap();
1687 assert_eq!(batch.num_rows(), 0);
1688 assert_eq!(batch.num_columns(), 2);
1690 assert!(batch.column_by_name(chronix_core::TIME_COLUMN).is_some());
1691 assert!(batch.column_by_name("cpu").is_some());
1692 }
1693
1694 #[test]
1695 fn time_range_none_reads_all() {
1696 let dir = tempfile::tempdir().unwrap();
1697 let path = dir.path().join("no_filter.csx");
1698 write_segment_with_rg_size(&path, 30, 10_000, 10);
1699
1700 let reader = SegmentReader::open(&path).unwrap();
1701
1702 let batch = reader.read_projected_filtered(&["cpu"], None).unwrap();
1703 assert_eq!(batch.num_rows(), 30);
1704 }
1705
1706 #[test]
1707 fn read_row_group_returns_single_group() {
1708 let dir = tempfile::tempdir().unwrap();
1709 let path = dir.path().join("single_rg.csx");
1710 write_segment_with_rg_size(&path, 30, 10_000, 10);
1711
1712 let reader = SegmentReader::open(&path).unwrap();
1713 assert_eq!(reader.row_group_count(), 3);
1714
1715 let rg1 = reader.read_row_group(1).unwrap();
1716 assert_eq!(rg1.num_rows(), 10);
1717
1718 let ts = rg1
1720 .column_by_name(chronix_core::TIME_COLUMN)
1721 .unwrap()
1722 .as_any()
1723 .downcast_ref::<Int64Array>()
1724 .unwrap();
1725 assert_eq!(ts.value(0), 100_000);
1726 assert_eq!(ts.value(9), 190_000);
1727 }
1728
1729 #[test]
1730 fn read_row_group_out_of_range_errors() {
1731 let dir = tempfile::tempdir().unwrap();
1732 let path = dir.path().join("rg_oor2.csx");
1733 write_segment_with_rg_size(&path, 10, 1000, 10);
1734
1735 let reader = SegmentReader::open(&path).unwrap();
1736 assert!(reader.read_row_group(5).is_err());
1737 }
1738
1739 fn write_segment_with_tags(path: &std::path::Path, hosts: &[&str], points_per_host: usize) {
1744 let mut points = Vec::new();
1745 for (h_idx, &host) in hosts.iter().enumerate() {
1746 for i in 0..points_per_host {
1747 points.push(make_point(
1748 host,
1749 (h_idx * 100 + i) as f64,
1750 (h_idx * points_per_host + i) as i64 * 1000,
1751 ));
1752 }
1753 }
1754
1755 let config = SegmentWriterConfig {
1756 row_group_size: 64,
1757 compress: false,
1758 float_encoding: FloatEncoding::Gorilla,
1759 compression_codec: CompressionCodec::Lz4,
1760 zstd_level: 3,
1761 column_codec_overrides: std::collections::HashMap::new(),
1762 ..Default::default()
1763 };
1764
1765 let mut writer = SegmentWriter::new(path, config).unwrap();
1766 writer.write_rows(&points).unwrap();
1767 writer.finalize().unwrap();
1768 }
1769
1770 #[test]
1771 fn bloom_filter_present_in_tag_columns() {
1772 let dir = tempfile::tempdir().unwrap();
1773 let path = dir.path().join("bloom_tag.csx");
1774 write_segment_with_tags(&path, &["server-1", "server-2", "server-3"], 10);
1775
1776 let reader = SegmentReader::open(&path).unwrap();
1777
1778 let host_meta = reader
1780 .column_metadata()
1781 .iter()
1782 .find(|c| c.name == "host")
1783 .unwrap();
1784 assert!(
1785 host_meta.bloom_filter.is_some(),
1786 "tag column should have a bloom filter"
1787 );
1788
1789 let ts_meta = reader
1791 .column_metadata()
1792 .iter()
1793 .find(|c| c.name == chronix_core::TIME_COLUMN)
1794 .unwrap();
1795 assert!(ts_meta.bloom_filter.is_none());
1796
1797 let cpu_meta = reader
1798 .column_metadata()
1799 .iter()
1800 .find(|c| c.name == "cpu")
1801 .unwrap();
1802 assert!(cpu_meta.bloom_filter.is_none());
1803 }
1804
1805 #[test]
1806 fn bloom_filter_prunes_segment_for_unknown_tag() {
1807 let dir = tempfile::tempdir().unwrap();
1808 let path = dir.path().join("bloom_prune.csx");
1809 write_segment_with_tags(&path, &["server-1", "server-2"], 20);
1810
1811 let reader = SegmentReader::open(&path).unwrap();
1812
1813 let batch = reader
1817 .read_projected_filtered_with_predicates(&["cpu"], None, &[("host", "server-999")])
1818 .unwrap();
1819 assert_eq!(batch.num_rows(), 0, "bloom filter should prune unknown tag");
1820 }
1821
1822 #[test]
1823 fn bloom_filter_passes_known_tag() {
1824 let dir = tempfile::tempdir().unwrap();
1825 let path = dir.path().join("bloom_pass.csx");
1826 write_segment_with_tags(&path, &["server-1", "server-2"], 20);
1827
1828 let reader = SegmentReader::open(&path).unwrap();
1829
1830 let batch = reader
1833 .read_projected_filtered_with_predicates(
1834 &["cpu", "host"],
1835 None,
1836 &[("host", "server-1")],
1837 )
1838 .unwrap();
1839 assert!(
1843 batch.num_rows() > 0,
1844 "bloom filter should not prune known tag"
1845 );
1846 }
1847
1848 #[test]
1849 fn bloom_filter_roundtrip_metadata() {
1850 let dir = tempfile::tempdir().unwrap();
1853 let path = dir.path().join("bloom_rt.csx");
1854 write_segment_with_tags(&path, &["alpha", "beta", "gamma"], 5);
1855
1856 let reader = SegmentReader::open(&path).unwrap();
1857 let host_meta = reader
1858 .column_metadata()
1859 .iter()
1860 .find(|c| c.name == "host")
1861 .unwrap();
1862
1863 let bf = host_meta.bloom_filter.as_ref().unwrap();
1864
1865 assert!(crate::segment::bloom::bloom_filter_contains(bf, "alpha"));
1867 assert!(crate::segment::bloom::bloom_filter_contains(bf, "beta"));
1868 assert!(crate::segment::bloom::bloom_filter_contains(bf, "gamma"));
1869
1870 assert!(!crate::segment::bloom::bloom_filter_contains(bf, "delta"));
1872 }
1873
1874 #[test]
1875 #[cfg(feature = "field-encryption")]
1876 fn field_encryption_roundtrip() {
1877 use crate::segment::field_encryption::{
1878 FieldEncryptionConfig, FieldEncryptionKey, StaticKeyProvider,
1879 };
1880
1881 let dir = tempfile::tempdir().unwrap();
1882 let path = dir.path().join("encrypted.csx");
1883
1884 let key_bytes: [u8; 32] = [0xAB; 32];
1885 let enc_key = FieldEncryptionKey::new("key-1".to_string(), key_bytes);
1886
1887 let mut enc_config = FieldEncryptionConfig::default();
1888 enc_config.encrypt_column("cpu", enc_key);
1889
1890 let points: Vec<Point> = (0..100)
1891 .map(|i| make_point("server-1", 50.0 + i as f64, 1_000_000 + i * 10_000))
1892 .collect();
1893
1894 let config = SegmentWriterConfig {
1895 row_group_size: 50,
1896 compress: true,
1897 field_encryption: enc_config,
1898 ..Default::default()
1899 };
1900
1901 let mut writer = SegmentWriter::new(&path, config).unwrap();
1902 writer.write_rows(&points).unwrap();
1903 let meta = writer.finalize().unwrap();
1904 assert_eq!(meta.row_count, 100);
1905
1906 let reader_no_key = SegmentReader::open(&path).unwrap();
1908 let cpu_meta = reader_no_key
1909 .column_metadata()
1910 .iter()
1911 .find(|c| c.name == "cpu")
1912 .unwrap();
1913 assert!(cpu_meta.encrypted);
1914 assert_eq!(cpu_meta.key_id.as_deref(), Some("key-1"));
1915
1916 let ts_meta = reader_no_key
1918 .column_metadata()
1919 .iter()
1920 .find(|c| c.name == chronix_core::TIME_COLUMN)
1921 .unwrap();
1922 assert!(!ts_meta.encrypted);
1923 assert!(ts_meta.stats.min_value != i64::MAX || ts_meta.stats.max_value != i64::MIN);
1924
1925 let host_meta = reader_no_key
1928 .column_metadata()
1929 .iter()
1930 .find(|c| c.name == "host")
1931 .unwrap();
1932 assert!(host_meta.bloom_filter.is_some());
1933
1934 let mut provider = StaticKeyProvider::new();
1936 provider.add_key("key-1", key_bytes);
1937
1938 let mut reader = SegmentReader::open(&path).unwrap();
1939 reader.set_key_provider(Arc::new(provider));
1940
1941 let batch = reader.read_all().unwrap();
1942 assert_eq!(batch.num_rows(), 100);
1943
1944 let cpu_col = batch
1945 .column_by_name("cpu")
1946 .unwrap()
1947 .as_any()
1948 .downcast_ref::<Float64Array>()
1949 .unwrap();
1950 for i in 0..100 {
1951 assert!(
1952 (cpu_col.value(i) - (50.0 + i as f64)).abs() < 1e-9,
1953 "cpu mismatch at row {i}"
1954 );
1955 }
1956 }
1957
1958 #[test]
1962 fn corrupted_validity_bitmap_is_detected() {
1963 let dir = tempfile::tempdir().unwrap();
1964 let path = dir.path().join("nulls.csx");
1965
1966 let tags = BTreeMap::from([("host".to_string(), "h".to_string())]);
1968 let key = SeriesKey::new("m", tags).unwrap();
1969 let points: Vec<Point> = (0..40i64)
1970 .map(|i| {
1971 let fields = if i % 2 == 0 {
1972 BTreeMap::from([("v".to_string(), FieldValue::F64(i as f64))])
1973 } else {
1974 BTreeMap::from([("w".to_string(), FieldValue::F64(1.0))])
1975 };
1976 Point::new(key.clone(), fields, 1_000 + i * 10).unwrap()
1977 })
1978 .collect();
1979
1980 let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1981 writer.write_rows(&points).unwrap();
1982 writer.finalize().unwrap();
1983
1984 let reader = SegmentReader::open(&path).unwrap();
1986 let (off, len) = reader
1987 .metadata
1988 .row_group_blocks
1989 .iter()
1990 .flatten()
1991 .find(|b| b.validity_length > 0)
1992 .map(|b| (b.validity_offset as usize, b.validity_length))
1993 .expect("a sparse column must have written a validity bitmap");
1994 assert!(len > 0);
1995 drop(reader);
1996
1997 let mut bytes = std::fs::read(&path).unwrap();
1998 bytes[off] ^= 0b0000_0001;
1999 std::fs::write(&path, &bytes).unwrap();
2000
2001 assert!(
2002 matches!(
2003 SegmentReader::open(&path),
2004 Err(SegmentError::ChecksumMismatch { .. })
2005 ),
2006 "a corrupted validity bitmap must fail the file checksum"
2007 );
2008 }
2009
2010 #[test]
2014 #[cfg(feature = "field-encryption")]
2015 fn encrypted_column_preserves_nulls() {
2016 use crate::segment::field_encryption::{
2017 FieldEncryptionConfig, FieldEncryptionKey, StaticKeyProvider,
2018 };
2019 use arrow::array::Array;
2020
2021 let dir = tempfile::tempdir().unwrap();
2022 let path = dir.path().join("encrypted_nulls.csx");
2023
2024 let enc_key = FieldEncryptionKey::new("key-1".to_string(), [0xCD; 32]);
2025 let mut enc_config = FieldEncryptionConfig::default();
2026 enc_config.encrypt_column("cpu", enc_key);
2027
2028 let tags = BTreeMap::from([("host".to_string(), "server-1".to_string())]);
2030 let key = SeriesKey::new("cpu_usage", tags).unwrap();
2031 let points: Vec<Point> = (0..40i64)
2032 .map(|i| {
2033 let fields = if i % 2 == 0 {
2034 BTreeMap::from([("cpu".to_string(), FieldValue::F64(i as f64))])
2035 } else {
2036 BTreeMap::from([("other".to_string(), FieldValue::F64(1.0))])
2037 };
2038 Point::new(key.clone(), fields, 1_000_000 + i * 10_000).unwrap()
2039 })
2040 .collect();
2041
2042 let config = SegmentWriterConfig {
2043 row_group_size: 16,
2044 compress: true,
2045 field_encryption: enc_config,
2046 ..Default::default()
2047 };
2048 let mut writer = SegmentWriter::new(&path, config).unwrap();
2049 writer.write_rows(&points).unwrap();
2050 writer.finalize().unwrap();
2051
2052 let mut provider = StaticKeyProvider::new();
2053 provider.add_key("key-1", [0xCD; 32]);
2054 let mut reader = SegmentReader::open(&path).unwrap();
2055 reader.set_key_provider(Arc::new(provider));
2056 let batch = reader.read_all().unwrap();
2057
2058 let cpu = batch
2059 .column_by_name("cpu")
2060 .expect("cpu column")
2061 .as_any()
2062 .downcast_ref::<arrow::array::Float64Array>()
2063 .expect("f64");
2064 assert_eq!(cpu.null_count(), 20, "half the rows have no cpu value");
2065 let present: Vec<f64> = (0..cpu.len())
2067 .filter(|i| cpu.is_valid(*i))
2068 .map(|i| cpu.value(i))
2069 .collect();
2070 assert_eq!(present.len(), 20);
2071 assert!(
2072 present.iter().all(|v| *v % 2.0 == 0.0),
2073 "values corrupted: {present:?}"
2074 );
2075 }
2076
2077 #[test]
2078 #[cfg(feature = "field-encryption")]
2079 fn field_encryption_wrong_key_fails() {
2080 use crate::segment::field_encryption::{
2081 FieldEncryptionConfig, FieldEncryptionKey, StaticKeyProvider,
2082 };
2083
2084 let dir = tempfile::tempdir().unwrap();
2085 let path = dir.path().join("enc_wrong_key.csx");
2086
2087 let key_bytes: [u8; 32] = [0xCD; 32];
2088 let enc_key = FieldEncryptionKey::new("key-2".to_string(), key_bytes);
2089
2090 let mut enc_config = FieldEncryptionConfig::default();
2091 enc_config.encrypt_column("cpu", enc_key);
2092
2093 let points: Vec<Point> = (0..20)
2094 .map(|i| make_point("server-1", 10.0 + i as f64, 1_000_000 + i * 10_000))
2095 .collect();
2096
2097 let config = SegmentWriterConfig {
2098 row_group_size: 100,
2099 compress: false,
2100 field_encryption: enc_config,
2101 ..Default::default()
2102 };
2103
2104 let mut writer = SegmentWriter::new(&path, config).unwrap();
2105 writer.write_rows(&points).unwrap();
2106 writer.finalize().unwrap();
2107
2108 let mut wrong_provider = StaticKeyProvider::new();
2110 wrong_provider.add_key("key-2", [0xFF; 32]);
2111
2112 let mut reader = SegmentReader::open(&path).unwrap();
2113 reader.set_key_provider(Arc::new(wrong_provider));
2114
2115 let result = reader.read_all();
2116 assert!(result.is_err(), "reading with wrong key should fail");
2117 }
2118
2119 #[test]
2120 #[cfg(feature = "field-encryption")]
2121 fn field_encryption_no_provider_fails() {
2122 use crate::segment::field_encryption::{FieldEncryptionConfig, FieldEncryptionKey};
2123
2124 let dir = tempfile::tempdir().unwrap();
2125 let path = dir.path().join("enc_no_provider.csx");
2126
2127 let key_bytes: [u8; 32] = [0xEF; 32];
2128 let enc_key = FieldEncryptionKey::new("key-3".to_string(), key_bytes);
2129
2130 let mut enc_config = FieldEncryptionConfig::default();
2131 enc_config.encrypt_column("cpu", enc_key);
2132
2133 let points: Vec<Point> = (0..10)
2134 .map(|i| make_point("server-1", 1.0, 1_000_000 + i * 10_000))
2135 .collect();
2136
2137 let config = SegmentWriterConfig {
2138 row_group_size: 100,
2139 compress: false,
2140 field_encryption: enc_config,
2141 ..Default::default()
2142 };
2143
2144 let mut writer = SegmentWriter::new(&path, config).unwrap();
2145 writer.write_rows(&points).unwrap();
2146 writer.finalize().unwrap();
2147
2148 let reader = SegmentReader::open(&path).unwrap();
2150 let result = reader.read_all();
2151 assert!(
2152 result.is_err(),
2153 "reading encrypted column without key provider should fail"
2154 );
2155 }
2156
2157 #[test]
2160 fn row_group_bloom_filters_present_for_tag_columns() {
2161 let dir = tempfile::tempdir().unwrap();
2162 let path = dir.path().join("rg_bloom.csx");
2163
2164 write_segment_with_tags(&path, &["alpha", "beta", "gamma"], 10);
2167
2168 let reader = SegmentReader::open(&path).unwrap();
2169 let host_meta = reader
2170 .column_metadata()
2171 .iter()
2172 .find(|c| c.name == "host")
2173 .unwrap();
2174
2175 assert!(
2177 host_meta.row_group_blooms.is_some(),
2178 "tag column should have per-row-group bloom filters"
2179 );
2180
2181 let rg_blooms = host_meta.row_group_blooms.as_ref().unwrap();
2182 assert_eq!(rg_blooms.len(), reader.row_group_count());
2183
2184 for (i, bloom) in rg_blooms.iter().enumerate() {
2186 assert!(!bloom.is_empty(), "bloom for rg{i} should be non-empty");
2187 }
2188 }
2189
2190 #[test]
2191 fn row_group_bloom_prunes_non_matching_row_groups() {
2192 let dir = tempfile::tempdir().unwrap();
2199 let path = dir.path().join("rg_bloom_prune.csx");
2200
2201 let mut points = Vec::new();
2202 for i in 0..10 {
2204 points.push(make_point("alpha", i as f64, i as i64 * 1000));
2205 }
2206 for i in 0..10 {
2208 points.push(make_point("beta", (100 + i) as f64, (10 + i) as i64 * 1000));
2209 }
2210
2211 let config = SegmentWriterConfig {
2212 row_group_size: 10,
2213 compress: false,
2214 float_encoding: FloatEncoding::Gorilla,
2215 compression_codec: CompressionCodec::Lz4,
2216 zstd_level: 3,
2217 column_codec_overrides: std::collections::HashMap::new(),
2218 ..Default::default()
2219 };
2220
2221 let mut writer = SegmentWriter::new(&path, config).unwrap();
2222 writer.write_rows(&points).unwrap();
2223 writer.finalize().unwrap();
2224
2225 let reader = SegmentReader::open(&path).unwrap();
2226 assert_eq!(reader.row_group_count(), 2);
2227
2228 let host_meta = reader
2230 .column_metadata()
2231 .iter()
2232 .find(|c| c.name == "host")
2233 .unwrap();
2234 let rg_blooms = host_meta.row_group_blooms.as_ref().unwrap();
2235
2236 assert!(crate::segment::bloom::bloom_filter_contains(
2237 &rg_blooms[0],
2238 "alpha"
2239 ));
2240 assert!(!crate::segment::bloom::bloom_filter_contains(
2241 &rg_blooms[0],
2242 "beta"
2243 ));
2244 assert!(crate::segment::bloom::bloom_filter_contains(
2245 &rg_blooms[1],
2246 "beta"
2247 ));
2248 assert!(!crate::segment::bloom::bloom_filter_contains(
2249 &rg_blooms[1],
2250 "alpha"
2251 ));
2252
2253 let batch = reader
2255 .read_projected_filtered_with_predicates(&["cpu", "host"], None, &[("host", "alpha")])
2256 .unwrap();
2257 assert_eq!(
2258 batch.num_rows(),
2259 10,
2260 "only rg0 should be returned for alpha"
2261 );
2262
2263 let batch = reader
2265 .read_projected_filtered_with_predicates(&["cpu", "host"], None, &[("host", "beta")])
2266 .unwrap();
2267 assert_eq!(batch.num_rows(), 10, "only rg1 should be returned for beta");
2268
2269 let batch = reader
2271 .read_projected_filtered_with_predicates(&["cpu"], None, &[("host", "gamma")])
2272 .unwrap();
2273 assert_eq!(batch.num_rows(), 0, "non-existent tag should prune all RGs");
2274 }
2275}