1use std::collections::{BTreeMap, BTreeSet, HashMap};
40use std::io::{BufWriter, Write};
41use std::path::{Path, PathBuf};
42
43use chronix_core::config::{CompressionCodec, FloatEncoding};
44use chronix_core::types::{FieldValue, Point};
45use chronix_encoding::{ColumnEncoder, EncodedBlock};
46
47use arrow::array::{Array, BooleanArray, Float64Array, Int64Array, StringArray, UInt64Array};
48use arrow::datatypes::DataType;
49use arrow::record_batch::RecordBatch;
50
51use crate::segment::compression::{
52 compress_block_with_codec, compress_block_zstd_dict, should_skip_compression,
53 train_zstd_dictionary,
54};
55use crate::segment::error::{Result, SegmentError};
56use crate::segment::header::{SegmentFooter, SegmentHeader, VERSION};
57use crate::segment::metadata::{data_types, roles, ColumnBlockMeta, ColumnMeta, SegmentMetadata};
58use crate::segment::stats::ColumnStats;
59use crate::segment::validity::ValidityBuilder;
60
61type RowGroupWriteOutput = (
64 Vec<ColumnMeta>,
65 Vec<Vec<ColumnBlockMeta>>,
66 u64,
67 Option<Vec<u8>>,
68);
69
70pub const DEFAULT_ROW_GROUP_SIZE: usize = 65_536;
72
73pub const DEFAULT_TARGET_SEGMENT_SIZE_BYTES: usize = 256 * 1024 * 1024;
75
76#[derive(Debug, Clone)]
78pub struct SegmentWriterConfig {
79 pub row_group_size: usize,
81 pub max_row_group_size: usize,
92 pub compress: bool,
94 pub float_encoding: FloatEncoding,
96 pub compression_codec: CompressionCodec,
98 pub zstd_level: i32,
100 pub column_codec_overrides: HashMap<String, CompressionCodec>,
103 pub target_segment_size_bytes: usize,
116 pub bloom_fpr: f64,
124 pub zstd_dict_training: bool,
135 #[cfg(feature = "field-encryption")]
141 pub field_encryption: crate::segment::field_encryption::FieldEncryptionConfig,
142}
143
144impl Default for SegmentWriterConfig {
145 fn default() -> Self {
146 Self {
147 row_group_size: DEFAULT_ROW_GROUP_SIZE,
148 max_row_group_size: DEFAULT_ROW_GROUP_SIZE,
149 compress: true,
150 float_encoding: FloatEncoding::Chimp,
151 compression_codec: CompressionCodec::Lz4,
152 zstd_level: 3,
153 column_codec_overrides: HashMap::new(),
154 target_segment_size_bytes: DEFAULT_TARGET_SEGMENT_SIZE_BYTES,
155 bloom_fpr: 0.01,
156 zstd_dict_training: false,
157 #[cfg(feature = "field-encryption")]
158 field_encryption: crate::segment::field_encryption::FieldEncryptionConfig::default(),
159 }
160 }
161}
162
163#[derive(Debug, Clone, PartialEq)]
165pub struct SegmentMeta {
166 pub path: PathBuf,
168 pub min_timestamp: i64,
170 pub max_timestamp: i64,
172 pub row_count: u64,
174 pub series_count: u32,
176 pub byte_size: u64,
178 pub row_group_count: u32,
180 pub column_count: u16,
182 pub uncompressed_bytes: u64,
184 pub header: crate::segment::header::SegmentHeader,
186 pub column_metas: Vec<crate::segment::metadata::ColumnMeta>,
188 pub series_keys: Vec<chronix_core::SeriesKey>,
191}
192
193pub struct SegmentWriter {
198 path: PathBuf,
199 config: SegmentWriterConfig,
200 points: Vec<Point>,
202 finalized: bool,
203}
204
205impl SegmentWriter {
206 pub fn new(path: impl AsRef<Path>, config: SegmentWriterConfig) -> Result<Self> {
212 Ok(Self {
213 path: path.as_ref().to_path_buf(),
214 config,
215 points: Vec::new(),
216 finalized: false,
217 })
218 }
219
220 pub fn write_rows(&mut self, points: &[Point]) -> Result<()> {
230 if self.finalized {
231 return Err(SegmentError::AlreadyFinalized);
232 }
233
234 self.points.extend_from_slice(points);
235 Ok(())
236 }
237
238 #[must_use]
240 pub fn buffered_point_count(&self) -> usize {
241 self.points.len()
242 }
243
244 pub fn finalize(&mut self) -> Result<SegmentMeta> {
261 if self.finalized {
262 return Err(SegmentError::AlreadyFinalized);
263 }
264
265 if self.points.is_empty() {
266 return Err(SegmentError::EmptySegment);
267 }
268
269 let points = &self.points;
270
271 let schema = discover_schema(points);
274
275 let tag_cardinality_order: Vec<&str> = schema
279 .iter()
280 .filter(|c| c.role == roles::TAG)
281 .map(|c| c.name.as_str())
282 .collect();
283
284 let mut sorted_indices: Vec<usize> = (0..points.len()).collect();
287 sorted_indices.sort_unstable_by(|&a, &b| {
288 let pa = &points[a];
289 let pb = &points[b];
290 let cmp = pa
292 .series_key()
293 .measurement()
294 .cmp(pb.series_key().measurement());
295 if cmp != std::cmp::Ordering::Equal {
296 return cmp;
297 }
298 for tag_name in &tag_cardinality_order {
300 let va = pa.series_key().tag(tag_name).unwrap_or("");
301 let vb = pb.series_key().tag(tag_name).unwrap_or("");
302 let cmp = va.cmp(vb);
303 if cmp != std::cmp::Ordering::Equal {
304 return cmp;
305 }
306 }
307 pa.timestamp().cmp(&pb.timestamp())
309 });
310
311 let (min_ts, max_ts, series_keys) = compute_global_stats(points, &sorted_indices);
313 let series_count = u32::try_from(series_keys.len()).unwrap_or(u32::MAX);
314
315 let total_rows = sorted_indices.len();
316
317 let column_count = u16::try_from(schema.len()).map_err(|_| SegmentError::CorruptFile {
318 detail: format!("column count {} exceeds u16::MAX", schema.len()),
319 })?;
320
321 let header = SegmentHeader {
323 version: VERSION,
324 flags: 0,
325 created_at: std::time::SystemTime::now()
326 .duration_since(std::time::UNIX_EPOCH)
327 .map(|d| i64::try_from(d.as_nanos()).unwrap_or(i64::MAX))
328 .unwrap_or(0),
329 min_timestamp: min_ts,
330 max_timestamp: max_ts,
331 row_count: total_rows as u64,
332 column_count,
333 series_count,
334 compression: match self.config.compression_codec {
335 CompressionCodec::Lz4 => 0,
336 CompressionCodec::Zstd => 1,
337 CompressionCodec::None => 2,
338 },
339 sort_order: 1,
340 };
341
342 if let Some(parent) = self.path.parent() {
344 std::fs::create_dir_all(parent)?;
345 }
346
347 let temp_path = {
348 let mut p = self.path.clone().into_os_string();
349 p.push(".tmp");
350 PathBuf::from(p)
351 };
352
353 let result = (|| -> Result<(SegmentMeta, u64)> {
354 let file = std::fs::File::create(&temp_path)?;
355 let mut writer = BufWriter::new(file);
356 let mut crc: u32 = 0;
357 let mut pos: u64 = 0;
358
359 let header_bytes = header.to_bytes();
361 writer.write_all(&header_bytes)?;
362 crc = crc32c::crc32c_append(crc, &header_bytes);
363 pos += header_bytes.len() as u64;
364
365 let (column_metas, row_group_blocks, uncompressed_bytes, zstd_dictionary) =
367 encode_row_groups_streaming(
368 &schema,
369 &sorted_indices,
370 points,
371 &self.config,
372 header.created_at,
373 &mut writer,
374 &mut crc,
375 &mut pos,
376 )?;
377
378 let metadata = SegmentMetadata {
379 columns: column_metas,
380 row_group_blocks,
381 zstd_dictionary,
382 };
383
384 let metadata_offset = pos;
386 let metadata_bytes = metadata.to_bytes()?;
387 let metadata_checksum = crc32c::crc32c(&metadata_bytes);
388 writer.write_all(&metadata_bytes)?;
389 crc = crc32c::crc32c_append(crc, &metadata_bytes);
390 pos += metadata_bytes.len() as u64;
391
392 let footer = SegmentFooter {
394 metadata_offset,
395 row_group_count: metadata.row_group_blocks.len() as u32,
396 checksum: crc,
397 metadata_checksum,
398 };
399 let footer_bytes = footer.to_bytes();
400 writer.write_all(&footer_bytes)?;
401 pos += footer_bytes.len() as u64;
402
403 writer.flush()?;
405 writer.get_ref().sync_all()?;
406 drop(writer);
407
408 std::fs::rename(&temp_path, &self.path)?;
410
411 Ok((
412 SegmentMeta {
413 path: self.path.clone(),
414 min_timestamp: header.min_timestamp,
415 max_timestamp: header.max_timestamp,
416 row_count: header.row_count,
417 series_count: header.series_count,
418 byte_size: pos,
419 row_group_count: footer.row_group_count,
420 column_count: header.column_count,
421 uncompressed_bytes,
422 header,
423 column_metas: metadata.columns,
424 series_keys,
425 },
426 pos,
427 ))
428 })();
429
430 match result {
431 Ok((meta, _)) => {
432 self.finalized = true;
433
434 if let Some(parent) = self.path.parent() {
438 let dir = std::fs::File::open(parent).map_err(|e| {
439 SegmentError::Io(std::io::Error::new(
440 e.kind(),
441 format!("failed to open parent dir for fsync: {e}"),
442 ))
443 })?;
444 dir.sync_all().map_err(|e| {
445 SegmentError::Io(std::io::Error::new(
446 e.kind(),
447 format!("parent directory fsync failed: {e}"),
448 ))
449 })?;
450 }
451
452 Ok(meta)
453 }
454 Err(e) => {
455 let _ = std::fs::remove_file(&temp_path);
456 Err(e)
457 }
458 }
459 }
460
461 pub fn finalize_batch(
478 &mut self,
479 batch: &RecordBatch,
480 measurement: &str,
481 tag_columns: &[String],
482 ) -> Result<SegmentMeta> {
483 if self.finalized {
484 return Err(SegmentError::AlreadyFinalized);
485 }
486 if batch.num_rows() == 0 {
487 return Err(SegmentError::EmptySegment);
488 }
489
490 let total_rows = batch.num_rows();
491 let tag_set: std::collections::HashSet<&str> =
492 tag_columns.iter().map(String::as_str).collect();
493
494 let schema = discover_schema_from_batch(batch, &tag_set)?;
496
497 let ts_col = batch
499 .column_by_name(chronix_core::TIME_COLUMN)
500 .and_then(|c| c.as_any().downcast_ref::<Int64Array>())
501 .ok_or_else(|| SegmentError::CorruptFile {
502 detail: "missing or non-Int64 timestamp column".into(),
503 })?;
504
505 let mut min_ts = i64::MAX;
506 let mut max_ts = i64::MIN;
507 for i in 0..ts_col.len() {
508 if ts_col.is_null(i) {
509 return Err(SegmentError::CorruptFile {
510 detail: format!("null timestamp at row {i}"),
511 });
512 }
513 let t = ts_col.value(i);
514 min_ts = min_ts.min(t);
515 max_ts = max_ts.max(t);
516 }
517
518 let series_keys = series_keys_from_batch(batch, measurement, tag_columns);
519 let series_count = u32::try_from(series_keys.len()).unwrap_or(u32::MAX);
520
521 let column_count = u16::try_from(schema.len()).map_err(|_| SegmentError::CorruptFile {
522 detail: format!("column count {} exceeds u16::MAX", schema.len()),
523 })?;
524
525 let header = SegmentHeader {
526 version: VERSION,
527 flags: 0,
528 created_at: std::time::SystemTime::now()
529 .duration_since(std::time::UNIX_EPOCH)
530 .map(|d| i64::try_from(d.as_nanos()).unwrap_or(i64::MAX))
531 .unwrap_or(0),
532 min_timestamp: min_ts,
533 max_timestamp: max_ts,
534 row_count: total_rows as u64,
535 column_count,
536 series_count,
537 compression: match self.config.compression_codec {
538 CompressionCodec::Lz4 => 0,
539 CompressionCodec::Zstd => 1,
540 CompressionCodec::None => 2,
541 },
542 sort_order: 1,
543 };
544
545 if let Some(parent) = self.path.parent() {
547 std::fs::create_dir_all(parent)?;
548 }
549
550 let temp_path = {
551 let mut p = self.path.clone().into_os_string();
552 p.push(".tmp");
553 PathBuf::from(p)
554 };
555
556 let result = (|| -> Result<(SegmentMeta, u64)> {
557 let file = std::fs::File::create(&temp_path)?;
558 let mut writer = BufWriter::new(file);
559 let mut crc: u32 = 0;
560 let mut pos: u64 = 0;
561
562 let header_bytes = header.to_bytes();
564 writer.write_all(&header_bytes)?;
565 crc = crc32c::crc32c_append(crc, &header_bytes);
566 pos += header_bytes.len() as u64;
567
568 let (column_metas, row_group_blocks, uncompressed_bytes, zstd_dictionary) =
570 encode_row_groups_from_batch_streaming(
571 &schema,
572 batch,
573 &self.config,
574 header.created_at,
575 &mut writer,
576 &mut crc,
577 &mut pos,
578 )?;
579
580 let metadata = SegmentMetadata {
581 columns: column_metas,
582 row_group_blocks,
583 zstd_dictionary,
584 };
585
586 let metadata_offset = pos;
588 let metadata_bytes = metadata.to_bytes()?;
589 let metadata_checksum = crc32c::crc32c(&metadata_bytes);
590 writer.write_all(&metadata_bytes)?;
591 crc = crc32c::crc32c_append(crc, &metadata_bytes);
592 pos += metadata_bytes.len() as u64;
593
594 let footer = SegmentFooter {
596 metadata_offset,
597 row_group_count: metadata.row_group_blocks.len() as u32,
598 checksum: crc,
599 metadata_checksum,
600 };
601 let footer_bytes = footer.to_bytes();
602 writer.write_all(&footer_bytes)?;
603 pos += footer_bytes.len() as u64;
604
605 writer.flush()?;
607 writer.get_ref().sync_all()?;
608 drop(writer);
609
610 std::fs::rename(&temp_path, &self.path)?;
612
613 Ok((
614 SegmentMeta {
615 path: self.path.clone(),
616 min_timestamp: min_ts,
617 max_timestamp: max_ts,
618 row_count: total_rows as u64,
619 series_count,
620 byte_size: pos,
621 row_group_count: footer.row_group_count,
622 column_count,
623 uncompressed_bytes,
624 header,
625 column_metas: metadata.columns,
626 series_keys,
627 },
628 pos,
629 ))
630 })();
631
632 match result {
633 Ok((meta, _)) => {
634 self.finalized = true;
635
636 if let Some(parent) = self.path.parent() {
638 let dir = std::fs::File::open(parent).map_err(|e| {
639 SegmentError::Io(std::io::Error::new(
640 e.kind(),
641 format!("failed to open parent dir for fsync: {e}"),
642 ))
643 })?;
644 dir.sync_all().map_err(|e| {
645 SegmentError::Io(std::io::Error::new(
646 e.kind(),
647 format!("parent directory fsync failed: {e}"),
648 ))
649 })?;
650 }
651
652 Ok(meta)
653 }
654 Err(e) => {
655 let _ = std::fs::remove_file(&temp_path);
656 Err(e)
657 }
658 }
659 }
660
661 pub fn finalize_batches(
668 &mut self,
669 batches: &[RecordBatch],
670 measurement: &str,
671 tag_columns: &[String],
672 ) -> Result<SegmentMeta> {
673 if self.finalized {
674 return Err(SegmentError::AlreadyFinalized);
675 }
676 if batches.is_empty() {
677 return Err(SegmentError::EmptySegment);
678 }
679
680 let schema = batches[0].schema();
685 let batch = arrow::compute::concat_batches(&schema, batches).map_err(|e| {
686 SegmentError::CorruptFile {
687 detail: format!("concat batches failed: {e}"),
688 }
689 })?;
690 self.finalize_batch(&batch, measurement, tag_columns)
691 }
692}
693
694fn encode_row_groups_streaming<W: Write>(
703 schema: &[ColumnDef],
704 sorted_indices: &[usize],
705 points: &[Point],
706 config: &SegmentWriterConfig,
707 #[cfg_attr(not(feature = "field-encryption"), allow(unused_variables))] segment_created_at: i64,
710 writer: &mut W,
711 crc: &mut u32,
712 pos: &mut u64,
713) -> Result<RowGroupWriteOutput> {
714 let total_rows = sorted_indices.len();
715
716 let mut column_metas: Vec<ColumnMeta> = schema
717 .iter()
718 .map(|col| {
719 #[cfg(feature = "field-encryption")]
720 let (encrypted, key_id) = if let Some(k) = config.field_encryption.key_for(&col.name) {
721 (true, Some(k.key_id.clone()))
722 } else {
723 (false, None)
724 };
725 #[cfg(not(feature = "field-encryption"))]
726 let (encrypted, key_id) = (false, None);
727
728 ColumnMeta {
729 name: col.name.clone(),
730 data_type: col.data_type,
731 role: col.role,
732 default_encoding: col.default_encoding,
733 stats: ColumnStats::empty(),
734 bloom_filter: None,
735 encrypted,
736 key_id,
737 row_group_blooms: None,
738 }
739 })
740 .collect();
741
742 let mut row_group_blocks: Vec<Vec<ColumnBlockMeta>> = Vec::new();
743 let mut total_uncompressed: u64 = 0;
744
745 let use_dict =
747 config.zstd_dict_training && matches!(config.compression_codec, CompressionCodec::Zstd);
748 let mut dict_samples: Vec<Vec<u8>> = Vec::new();
749 let mut trained_dict: Option<Vec<u8>> = None;
750 let mut rg_index: usize = 0;
751
752 #[allow(clippy::explicit_counter_loop)]
753 for chunk_start in (0..total_rows).step_by(config.row_group_size) {
755 let chunk_end = (chunk_start + config.row_group_size).min(total_rows);
756 let chunk_indices = &sorted_indices[chunk_start..chunk_end];
757 let chunk_size = chunk_end - chunk_start;
758
759 let mut rg_blocks = Vec::with_capacity(schema.len());
760
761 for (col_idx, col) in schema.iter().enumerate() {
762 let EncodedColumn {
763 block,
764 stats,
765 validity,
766 } = encode_column(col, chunk_indices, points, config.float_encoding)?;
767
768 let raw_encoded = block.to_bytes();
769 let raw_size = chunk_size * 8;
770 total_uncompressed += raw_encoded.len() as u64;
771
772 if use_dict && rg_index == 0 {
774 dict_samples.push(raw_encoded.clone());
775 }
776
777 let codec = config
778 .column_codec_overrides
779 .get(&col.name)
780 .copied()
781 .unwrap_or(config.compression_codec);
782 let (final_data, compressed) =
783 if config.compress && !should_skip_compression(raw_encoded.len(), raw_size) {
784 if let Some(ref dict) = trained_dict {
786 if matches!(codec, CompressionCodec::Zstd) {
787 let compressed_data =
788 compress_block_zstd_dict(&raw_encoded, config.zstd_level, dict)?;
789 if compressed_data.len() < raw_encoded.len() {
790 (compressed_data, true)
791 } else {
792 (raw_encoded, false)
793 }
794 } else {
795 let compressed_data = compress_block_with_codec(&raw_encoded, codec)?;
796 if compressed_data.len() < raw_encoded.len() {
797 (compressed_data, true)
798 } else {
799 (raw_encoded, false)
800 }
801 }
802 } else {
803 let compressed_data = compress_block_with_codec(&raw_encoded, codec)?;
804 if compressed_data.len() < raw_encoded.len() {
805 (compressed_data, true)
806 } else {
807 (raw_encoded, false)
808 }
809 }
810 } else {
811 (raw_encoded, false)
812 };
813
814 #[cfg(feature = "field-encryption")]
816 let (final_data, block_encrypted) =
817 if let Some(enc_key) = config.field_encryption.key_for(&col.name) {
818 let encrypted = crate::segment::field_encryption::encrypt_block(
819 &final_data,
820 enc_key.key_bytes(),
821 crate::segment::field_encryption::BlockContext {
822 column: &col.name,
823 segment_created_at,
824 },
825 )?;
826 (encrypted, true)
827 } else {
828 (final_data, false)
829 };
830 #[cfg(not(feature = "field-encryption"))]
831 let block_encrypted = false;
832
833 let offset = *pos;
834 writer.write_all(&final_data)?;
835 *crc = crc32c::crc32c_append(*crc, &final_data);
836 *pos += final_data.len() as u64;
837
838 let block_crc = crc32c::crc32c(&final_data);
839
840 let final_stats = if block_encrypted {
843 ColumnStats::empty()
844 } else {
845 stats
846 };
847
848 merge_stats(&mut column_metas[col_idx].stats, &final_stats);
849
850 let (validity_offset, validity_length) =
855 write_validity(writer, validity.as_deref(), pos, crc)?;
856
857 rg_blocks.push(ColumnBlockMeta {
858 column_index: col_idx as u16,
859 encoding: block.encoding,
860 compressed,
861 offset,
862 length: u32::try_from(final_data.len()).map_err(|_| SegmentError::CorruptFile {
863 detail: format!("block length {} exceeds u32::MAX", final_data.len()),
864 })?,
865 value_count: u32::try_from(chunk_size).map_err(|_| SegmentError::CorruptFile {
866 detail: format!("value_count {chunk_size} exceeds u32::MAX"),
867 })?,
868 block_crc,
869 encrypted: block_encrypted,
870 validity_offset,
871 validity_length,
872 stats: final_stats,
873 });
874 }
875
876 row_group_blocks.push(rg_blocks);
877
878 for (col_idx, col) in schema.iter().enumerate() {
880 if col.role == roles::TAG && !column_metas[col_idx].encrypted {
881 let mut distinct: std::collections::HashSet<&str> =
882 std::collections::HashSet::new();
883 for &idx in chunk_indices {
884 let val = points[idx].series_key().tag(&col.name).unwrap_or("");
885 if !val.is_empty() {
886 distinct.insert(val);
887 }
888 }
889 let bloom = if distinct.is_empty() {
890 Vec::new()
891 } else {
892 let vals: Vec<&str> = distinct.into_iter().collect();
893 crate::segment::bloom::bloom_filter_build(&vals, config.bloom_fpr)
894 };
895 column_metas[col_idx]
896 .row_group_blooms
897 .get_or_insert_with(Vec::new)
898 .push(bloom);
899 }
900 }
901
902 if use_dict && rg_index == 0 && !dict_samples.is_empty() {
904 let sample_refs: Vec<&[u8]> =
905 dict_samples.iter().map(std::vec::Vec::as_slice).collect();
906 if let Ok(Some(dict)) = train_zstd_dictionary(&sample_refs) {
907 tracing::debug!(
908 dict_size = dict.len(),
909 samples = dict_samples.len(),
910 "trained Zstd dictionary from first row group"
911 );
912 trained_dict = Some(dict);
913 }
914 dict_samples.clear();
915 }
916 rg_index += 1;
917 }
918
919 for (col_idx, col) in schema.iter().enumerate() {
922 if col.role == roles::TAG && !column_metas[col_idx].encrypted {
923 let mut distinct: std::collections::HashSet<&str> = std::collections::HashSet::new();
924 for &idx in sorted_indices {
925 let val = points[idx].series_key().tag(&col.name).unwrap_or("");
926 if !val.is_empty() {
927 distinct.insert(val);
928 }
929 }
930 if !distinct.is_empty() {
931 let values: Vec<&str> = distinct.into_iter().collect();
932 column_metas[col_idx].bloom_filter = Some(
933 crate::segment::bloom::bloom_filter_build(&values, config.bloom_fpr),
934 );
935 }
936 }
937 }
938
939 Ok((
940 column_metas,
941 row_group_blocks,
942 total_uncompressed,
943 trained_dict,
944 ))
945}
946
947#[derive(Debug, Clone)]
951struct ColumnDef {
952 name: String,
953 data_type: u8,
954 role: u8,
955 default_encoding: u8,
956}
957
958fn discover_schema(points: &[Point]) -> Vec<ColumnDef> {
964 let mut columns: Vec<ColumnDef> = Vec::new();
965
966 columns.push(ColumnDef {
968 name: chronix_core::TIME_COLUMN.to_string(),
969 data_type: data_types::TIMESTAMP,
970 role: roles::TIMESTAMP,
971 default_encoding: chronix_encoding::EncodingType::DeltaOfDelta.tag(),
972 });
973
974 let mut tag_names = BTreeSet::new();
976 for p in points {
977 for key in p.series_key().tag_keys() {
978 tag_names.insert(key.to_string());
979 }
980 }
981
982 let mut tag_with_card: Vec<(String, usize)> = tag_names
984 .into_iter()
985 .map(|name: String| {
986 let distinct: std::collections::HashSet<&str> = points
987 .iter()
988 .filter_map(|p| p.series_key().tag(&name))
989 .collect();
990 (name, distinct.len())
991 })
992 .collect();
993 tag_with_card.sort_by_key(|(_, card)| *card);
994
995 for (name, _) in tag_with_card {
996 columns.push(ColumnDef {
997 name,
998 data_type: data_types::STRING,
999 role: roles::TAG,
1000 default_encoding: chronix_encoding::EncodingType::Dictionary.tag(),
1001 });
1002 }
1003
1004 let mut field_defs: BTreeMap<String, u8> = BTreeMap::new();
1006 for p in points {
1007 for (key, val) in p.fields() {
1008 field_defs
1009 .entry(key.to_string())
1010 .or_insert_with(|| match val {
1011 FieldValue::F64(_) => data_types::F64,
1012 FieldValue::I64(_) => data_types::I64,
1013 FieldValue::U64(_) => data_types::U64,
1014 FieldValue::Bool(_) => data_types::BOOL,
1015 FieldValue::String(_) => data_types::STRING,
1016 });
1017 }
1018 }
1019 for (name, dt) in field_defs {
1020 let enc = match dt {
1021 data_types::F64 => chronix_encoding::EncodingType::Chimp.tag(),
1022 data_types::I64 => chronix_encoding::EncodingType::IntegerI64.tag(),
1023 data_types::U64 => chronix_encoding::EncodingType::IntegerU64.tag(),
1024 data_types::BOOL => chronix_encoding::EncodingType::Bitmap.tag(),
1025 _ => chronix_encoding::EncodingType::Dictionary.tag(),
1026 };
1027 columns.push(ColumnDef {
1028 name,
1029 data_type: dt,
1030 role: roles::FIELD,
1031 default_encoding: enc,
1032 });
1033 }
1034
1035 columns
1036}
1037
1038fn write_validity<W: Write>(
1044 writer: &mut W,
1045 validity: Option<&[u8]>,
1046 pos: &mut u64,
1047 crc: &mut u32,
1048) -> Result<(u64, u32)> {
1049 let Some(bytes) = validity else {
1050 return Ok((0, 0));
1051 };
1052 let offset = *pos;
1053 writer.write_all(bytes)?;
1054 *crc = crc32c::crc32c_append(*crc, bytes);
1055 *pos += bytes.len() as u64;
1056 let length = u32::try_from(bytes.len()).map_err(|_| SegmentError::CorruptFile {
1057 detail: format!("validity bitmap length {} exceeds u32::MAX", bytes.len()),
1058 })?;
1059 Ok((offset, length))
1060}
1061
1062struct EncodedColumn {
1065 block: EncodedBlock,
1066 stats: ColumnStats,
1067 validity: Option<Vec<u8>>,
1069}
1070
1071impl EncodedColumn {
1072 fn new(block: EncodedBlock, stats: ColumnStats, validity: ValidityBuilder) -> Self {
1073 Self {
1074 block,
1075 stats,
1076 validity: validity.finish(),
1077 }
1078 }
1079
1080 fn dense((block, stats): (EncodedBlock, ColumnStats)) -> Self {
1082 Self {
1083 block,
1084 stats,
1085 validity: None,
1086 }
1087 }
1088}
1089
1090fn encode_column(
1092 col: &ColumnDef,
1093 indices: &[usize],
1094 points: &[Point],
1095 float_encoding: FloatEncoding,
1096) -> Result<EncodedColumn> {
1097 let mut stats = ColumnStats::empty();
1098 let mut validity = ValidityBuilder::with_capacity(indices.len());
1101
1102 match (col.role, col.data_type) {
1103 (roles::TIMESTAMP, _) => encode_timestamp_column(indices, points).map(EncodedColumn::dense),
1106 (roles::TAG, _) => encode_tag_column(col, indices, points).map(EncodedColumn::dense),
1107 (_, data_types::F64) => {
1108 let mut values = Vec::with_capacity(indices.len());
1109 for &idx in indices {
1110 if let Some(FieldValue::F64(v)) = points[idx].field(&col.name) {
1111 values.push(*v);
1112 stats.update_f64(*v);
1113 validity.push(true);
1114 } else {
1115 values.push(0.0);
1116 stats.record_null();
1117 validity.push(false);
1118 }
1119 }
1120 let block = ColumnEncoder::encode_f64(&values, float_encoding)?;
1121 Ok(EncodedColumn::new(block, stats, validity))
1122 }
1123 (_, data_types::I64) => {
1124 let mut values = Vec::with_capacity(indices.len());
1125 for &idx in indices {
1126 if let Some(FieldValue::I64(v)) = points[idx].field(&col.name) {
1127 values.push(*v);
1128 stats.update_i64(*v);
1129 validity.push(true);
1130 } else {
1131 values.push(0);
1132 stats.record_null();
1133 validity.push(false);
1134 }
1135 }
1136 let block = ColumnEncoder::encode_i64(&values)?;
1137 Ok(EncodedColumn::new(block, stats, validity))
1138 }
1139 (_, data_types::U64) => {
1140 let mut values = Vec::with_capacity(indices.len());
1141 for &idx in indices {
1142 if let Some(FieldValue::U64(v)) = points[idx].field(&col.name) {
1143 values.push(*v);
1144 stats.update_u64(*v);
1145 validity.push(true);
1146 } else {
1147 values.push(0);
1148 stats.record_null();
1149 validity.push(false);
1150 }
1151 }
1152 let block = ColumnEncoder::encode_u64(&values)?;
1153 Ok(EncodedColumn::new(block, stats, validity))
1154 }
1155 (_, data_types::BOOL) => {
1156 let mut values = Vec::with_capacity(indices.len());
1157 for &idx in indices {
1158 if let Some(FieldValue::Bool(v)) = points[idx].field(&col.name) {
1159 values.push(*v);
1160 stats.update_bool(*v);
1161 validity.push(true);
1162 } else {
1163 values.push(false);
1164 stats.record_null();
1165 validity.push(false);
1166 }
1167 }
1168 let block = ColumnEncoder::encode_bool(&values)?;
1169 Ok(EncodedColumn::new(block, stats, validity))
1170 }
1171 (_, data_types::STRING) => {
1172 let mut owned: Vec<String> = Vec::with_capacity(indices.len());
1174 for &idx in indices {
1175 if let Some(FieldValue::String(v)) = points[idx].field(&col.name) {
1176 owned.push(v.clone());
1177 validity.push(true);
1178 } else {
1179 owned.push(String::new());
1180 stats.record_null();
1181 validity.push(false);
1182 }
1183 }
1184 let values: Vec<&str> = owned.iter().map(String::as_str).collect();
1185 let distinct: std::collections::HashSet<&str> = values.iter().copied().collect();
1186 stats.distinct_count = distinct.len() as u32;
1187 stats.value_count = (values.len() as u64).saturating_sub(stats.null_count);
1189 let block = ColumnEncoder::encode_string(&values)?;
1190 Ok(EncodedColumn::new(block, stats, validity))
1191 }
1192 _ => Err(SegmentError::CorruptFile {
1193 detail: format!("unknown column type: {}", col.data_type),
1194 }),
1195 }
1196}
1197
1198fn compute_global_stats(
1203 points: &[Point],
1204 sorted_indices: &[usize],
1205) -> (i64, i64, Vec<chronix_core::SeriesKey>) {
1206 let mut min_ts = i64::MAX;
1207 let mut max_ts = i64::MIN;
1208 let mut series_keys: Vec<chronix_core::SeriesKey> = Vec::new();
1209
1210 for &idx in sorted_indices {
1211 let p = &points[idx];
1212 let ts = p.timestamp();
1213 min_ts = min_ts.min(ts);
1214 max_ts = max_ts.max(ts);
1215 let is_new = series_keys
1219 .last()
1220 .is_none_or(|prev| prev.canonical_form() != p.series_key().canonical_form());
1221 if is_new {
1222 series_keys.push(p.series_key().clone());
1223 }
1224 }
1225 (min_ts, max_ts, series_keys)
1226}
1227
1228fn encode_timestamp_column(
1230 indices: &[usize],
1231 points: &[Point],
1232) -> Result<(EncodedBlock, ColumnStats)> {
1233 let mut stats = ColumnStats::empty();
1234 let mut timestamps = Vec::with_capacity(indices.len());
1235 for &idx in indices {
1236 let ts = points[idx].timestamp();
1237 timestamps.push(ts);
1238 stats.update_i64(ts);
1239 }
1240 let block = ColumnEncoder::encode_timestamps(×tamps)?;
1241 Ok((block, stats))
1242}
1243
1244fn encode_tag_column(
1246 col: &ColumnDef,
1247 indices: &[usize],
1248 points: &[Point],
1249) -> Result<(EncodedBlock, ColumnStats)> {
1250 let mut stats = ColumnStats::empty();
1251 let mut values = Vec::with_capacity(indices.len());
1252 for &idx in indices {
1253 let val = points[idx].series_key().tag(&col.name).unwrap_or("");
1254 values.push(val);
1255 }
1256 let distinct: std::collections::HashSet<&str> = values.iter().copied().collect();
1257 stats.distinct_count = distinct.len() as u32;
1258 stats.value_count = values.len() as u64;
1259 let block = ColumnEncoder::encode_string(&values)?;
1260 Ok((block, stats))
1261}
1262
1263fn merge_stats(dest: &mut ColumnStats, src: &ColumnStats) {
1270 dest.min_value = dest.min_value.min(src.min_value);
1271 dest.max_value = dest.max_value.max(src.max_value);
1272 dest.min_value_u64 = dest.min_value_u64.min(src.min_value_u64);
1275 dest.max_value_u64 = dest.max_value_u64.max(src.max_value_u64);
1276 dest.null_count += src.null_count;
1277 dest.value_count += src.value_count;
1278 dest.sum += src.sum;
1279 dest.sum_i128 += src.sum_i128;
1280 dest.distinct_count = dest.distinct_count.max(src.distinct_count);
1283}
1284
1285fn discover_schema_from_batch(
1292 batch: &RecordBatch,
1293 tag_columns: &std::collections::HashSet<&str>,
1294) -> Result<Vec<ColumnDef>> {
1295 let mut columns = Vec::new();
1296
1297 columns.push(ColumnDef {
1299 name: chronix_core::TIME_COLUMN.to_string(),
1300 data_type: data_types::TIMESTAMP,
1301 role: roles::TIMESTAMP,
1302 default_encoding: chronix_encoding::EncodingType::DeltaOfDelta.tag(),
1303 });
1304
1305 let mut tags: Vec<&str> = tag_columns.iter().copied().collect();
1307 tags.sort_unstable();
1308 for tag in tags {
1309 if batch.column_by_name(tag).is_some() {
1310 columns.push(ColumnDef {
1311 name: tag.to_string(),
1312 data_type: data_types::STRING,
1313 role: roles::TAG,
1314 default_encoding: chronix_encoding::EncodingType::Dictionary.tag(),
1315 });
1316 }
1317 }
1318
1319 for field in batch.schema().fields() {
1321 let name = field.name().as_str();
1322 if name == chronix_core::TIME_COLUMN || tag_columns.contains(name) {
1323 continue;
1324 }
1325 let (dt, enc) = match field.data_type() {
1326 DataType::Float64 => (data_types::F64, chronix_encoding::EncodingType::Chimp.tag()),
1327 DataType::Int64 => (
1328 data_types::I64,
1329 chronix_encoding::EncodingType::IntegerI64.tag(),
1330 ),
1331 DataType::UInt64 => (
1332 data_types::U64,
1333 chronix_encoding::EncodingType::IntegerU64.tag(),
1334 ),
1335 DataType::Boolean => (
1336 data_types::BOOL,
1337 chronix_encoding::EncodingType::Bitmap.tag(),
1338 ),
1339 DataType::Utf8 | DataType::LargeUtf8 => (
1340 data_types::STRING,
1341 chronix_encoding::EncodingType::Dictionary.tag(),
1342 ),
1343 other => {
1344 return Err(SegmentError::CorruptFile {
1345 detail: format!(
1346 "unsupported Arrow data type {:?} for field '{}'",
1347 other, name
1348 ),
1349 });
1350 }
1351 };
1352 columns.push(ColumnDef {
1353 name: name.to_string(),
1354 data_type: dt,
1355 role: roles::FIELD,
1356 default_encoding: enc,
1357 });
1358 }
1359
1360 Ok(columns)
1361}
1362
1363fn series_keys_from_batch(
1368 batch: &RecordBatch,
1369 measurement: &str,
1370 tag_columns: &[String],
1371) -> Vec<chronix_core::SeriesKey> {
1372 let mut seen = std::collections::HashSet::new();
1373 let mut keys = Vec::new();
1374 let num_rows = batch.num_rows();
1375
1376 let tag_arrays: Vec<Option<&StringArray>> = tag_columns
1377 .iter()
1378 .map(|name| {
1379 batch
1380 .column_by_name(name)
1381 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
1382 })
1383 .collect();
1384
1385 for row in 0..num_rows {
1386 let mut tags = BTreeMap::new();
1387 for (i, tag_name) in tag_columns.iter().enumerate() {
1388 if let Some(arr) = tag_arrays[i] {
1389 if !arr.is_null(row) {
1390 tags.insert(tag_name.clone(), arr.value(row).to_string());
1391 }
1392 }
1393 }
1394 if let Ok(key) = chronix_core::SeriesKey::new(measurement, tags) {
1395 if seen.insert(key.canonical_form().to_string()) {
1396 keys.push(key);
1397 }
1398 }
1399 }
1400 keys
1401}
1402
1403fn encode_row_groups_from_batch_streaming<W: Write>(
1408 schema: &[ColumnDef],
1409 batch: &RecordBatch,
1410 config: &SegmentWriterConfig,
1411 #[cfg_attr(not(feature = "field-encryption"), allow(unused_variables))] segment_created_at: i64,
1414 writer: &mut W,
1415 crc: &mut u32,
1416 pos: &mut u64,
1417) -> Result<RowGroupWriteOutput> {
1418 let total_rows = batch.num_rows();
1419
1420 let mut column_metas: Vec<ColumnMeta> = schema
1421 .iter()
1422 .map(|col| {
1423 #[cfg(feature = "field-encryption")]
1424 let (encrypted, key_id) = if let Some(k) = config.field_encryption.key_for(&col.name) {
1425 (true, Some(k.key_id.clone()))
1426 } else {
1427 (false, None)
1428 };
1429 #[cfg(not(feature = "field-encryption"))]
1430 let (encrypted, key_id) = (false, None);
1431
1432 ColumnMeta {
1433 name: col.name.clone(),
1434 data_type: col.data_type,
1435 role: col.role,
1436 default_encoding: col.default_encoding,
1437 stats: ColumnStats::empty(),
1438 bloom_filter: None,
1439 encrypted,
1440 key_id,
1441 row_group_blooms: None,
1442 }
1443 })
1444 .collect();
1445
1446 let mut row_group_blocks: Vec<Vec<ColumnBlockMeta>> = Vec::new();
1447 let mut total_uncompressed: u64 = 0;
1448
1449 let use_dict =
1451 config.zstd_dict_training && matches!(config.compression_codec, CompressionCodec::Zstd);
1452 let mut dict_samples: Vec<Vec<u8>> = Vec::new();
1453 let mut trained_dict: Option<Vec<u8>> = None;
1454 let mut rg_index: usize = 0;
1455
1456 #[allow(clippy::explicit_counter_loop)]
1457 for chunk_start in (0..total_rows).step_by(config.row_group_size) {
1459 let chunk_end = (chunk_start + config.row_group_size).min(total_rows);
1460 let chunk_size = chunk_end - chunk_start;
1461
1462 let mut rg_blocks = Vec::with_capacity(schema.len());
1463
1464 for (col_idx, col) in schema.iter().enumerate() {
1465 let EncodedColumn {
1466 block,
1467 stats,
1468 validity,
1469 } = encode_column_from_batch(
1470 col,
1471 batch,
1472 chunk_start,
1473 chunk_end,
1474 config.float_encoding,
1475 )?;
1476
1477 let raw_encoded = block.to_bytes();
1478 let raw_size = chunk_size * 8;
1479 total_uncompressed += raw_encoded.len() as u64;
1480
1481 if use_dict && rg_index == 0 {
1483 dict_samples.push(raw_encoded.clone());
1484 }
1485
1486 let codec = config
1487 .column_codec_overrides
1488 .get(&col.name)
1489 .copied()
1490 .unwrap_or(config.compression_codec);
1491 let (final_data, compressed) =
1492 if config.compress && !should_skip_compression(raw_encoded.len(), raw_size) {
1493 if let Some(ref dict) = trained_dict {
1495 if matches!(codec, CompressionCodec::Zstd) {
1496 let compressed_data =
1497 compress_block_zstd_dict(&raw_encoded, config.zstd_level, dict)?;
1498 if compressed_data.len() < raw_encoded.len() {
1499 (compressed_data, true)
1500 } else {
1501 (raw_encoded, false)
1502 }
1503 } else {
1504 let compressed_data = compress_block_with_codec(&raw_encoded, codec)?;
1505 if compressed_data.len() < raw_encoded.len() {
1506 (compressed_data, true)
1507 } else {
1508 (raw_encoded, false)
1509 }
1510 }
1511 } else {
1512 let compressed_data = compress_block_with_codec(&raw_encoded, codec)?;
1513 if compressed_data.len() < raw_encoded.len() {
1514 (compressed_data, true)
1515 } else {
1516 (raw_encoded, false)
1517 }
1518 }
1519 } else {
1520 (raw_encoded, false)
1521 };
1522
1523 #[cfg(feature = "field-encryption")]
1525 let (final_data, block_encrypted) =
1526 if let Some(enc_key) = config.field_encryption.key_for(&col.name) {
1527 let encrypted = crate::segment::field_encryption::encrypt_block(
1528 &final_data,
1529 enc_key.key_bytes(),
1530 crate::segment::field_encryption::BlockContext {
1531 column: &col.name,
1532 segment_created_at,
1533 },
1534 )?;
1535 (encrypted, true)
1536 } else {
1537 (final_data, false)
1538 };
1539 #[cfg(not(feature = "field-encryption"))]
1540 let block_encrypted = false;
1541
1542 let offset = *pos;
1543 writer.write_all(&final_data)?;
1544 *crc = crc32c::crc32c_append(*crc, &final_data);
1545 *pos += final_data.len() as u64;
1546
1547 let block_crc = crc32c::crc32c(&final_data);
1548
1549 let final_stats = if block_encrypted {
1552 ColumnStats::empty()
1553 } else {
1554 stats
1555 };
1556
1557 merge_stats(&mut column_metas[col_idx].stats, &final_stats);
1558
1559 let (validity_offset, validity_length) =
1561 write_validity(writer, validity.as_deref(), pos, crc)?;
1562
1563 rg_blocks.push(ColumnBlockMeta {
1564 column_index: col_idx as u16,
1565 encoding: block.encoding,
1566 compressed,
1567 offset,
1568 length: u32::try_from(final_data.len()).map_err(|_| SegmentError::CorruptFile {
1569 detail: format!("block length {} exceeds u32::MAX", final_data.len()),
1570 })?,
1571 value_count: u32::try_from(chunk_size).map_err(|_| SegmentError::CorruptFile {
1572 detail: format!("value_count {chunk_size} exceeds u32::MAX"),
1573 })?,
1574 block_crc,
1575 encrypted: block_encrypted,
1576 validity_offset,
1577 validity_length,
1578 stats: final_stats,
1579 });
1580 }
1581
1582 row_group_blocks.push(rg_blocks);
1583
1584 for (col_idx, col) in schema.iter().enumerate() {
1586 if col.role == roles::TAG && !column_metas[col_idx].encrypted {
1587 if let Some(arr) = batch
1588 .column_by_name(&col.name)
1589 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
1590 {
1591 let mut distinct: std::collections::HashSet<&str> =
1592 std::collections::HashSet::new();
1593 for i in chunk_start..chunk_end {
1594 if !arr.is_null(i) {
1595 let val = arr.value(i);
1596 if !val.is_empty() {
1597 distinct.insert(val);
1598 }
1599 }
1600 }
1601 let bloom = if distinct.is_empty() {
1602 Vec::new()
1603 } else {
1604 let vals: Vec<&str> = distinct.into_iter().collect();
1605 crate::segment::bloom::bloom_filter_build(&vals, config.bloom_fpr)
1606 };
1607 column_metas[col_idx]
1608 .row_group_blooms
1609 .get_or_insert_with(Vec::new)
1610 .push(bloom);
1611 }
1612 }
1613 }
1614
1615 if use_dict && rg_index == 0 && !dict_samples.is_empty() {
1617 let sample_refs: Vec<&[u8]> =
1618 dict_samples.iter().map(std::vec::Vec::as_slice).collect();
1619 if let Ok(Some(dict)) = train_zstd_dictionary(&sample_refs) {
1620 tracing::debug!(
1621 dict_size = dict.len(),
1622 samples = dict_samples.len(),
1623 "trained Zstd dictionary from first row group (batch path)"
1624 );
1625 trained_dict = Some(dict);
1626 }
1627 dict_samples.clear();
1628 }
1629 rg_index += 1;
1630 }
1631
1632 for (col_idx, col) in schema.iter().enumerate() {
1635 if col.role == roles::TAG && !column_metas[col_idx].encrypted {
1636 if let Some(arr) = batch
1637 .column_by_name(&col.name)
1638 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
1639 {
1640 let mut distinct: std::collections::HashSet<&str> =
1641 std::collections::HashSet::new();
1642 for i in 0..arr.len() {
1643 if !arr.is_null(i) {
1644 let val = arr.value(i);
1645 if !val.is_empty() {
1646 distinct.insert(val);
1647 }
1648 }
1649 }
1650 if !distinct.is_empty() {
1651 let values: Vec<&str> = distinct.into_iter().collect();
1652 column_metas[col_idx].bloom_filter = Some(
1653 crate::segment::bloom::bloom_filter_build(&values, config.bloom_fpr),
1654 );
1655 }
1656 }
1657 }
1658 }
1659
1660 Ok((
1661 column_metas,
1662 row_group_blocks,
1663 total_uncompressed,
1664 trained_dict,
1665 ))
1666}
1667
1668fn encode_column_from_batch(
1670 col: &ColumnDef,
1671 batch: &RecordBatch,
1672 start: usize,
1673 end: usize,
1674 float_encoding: FloatEncoding,
1675) -> Result<EncodedColumn> {
1676 let mut stats = ColumnStats::empty();
1677 let mut validity = ValidityBuilder::with_capacity(end.saturating_sub(start));
1679
1680 match (col.role, col.data_type) {
1681 (roles::TIMESTAMP, _) => {
1682 let arr = batch
1683 .column_by_name(chronix_core::TIME_COLUMN)
1684 .and_then(|c| c.as_any().downcast_ref::<Int64Array>())
1685 .ok_or_else(|| SegmentError::CorruptFile {
1686 detail: "missing timestamp".into(),
1687 })?;
1688 let mut timestamps = Vec::with_capacity(end - start);
1689 for i in start..end {
1690 let t = arr.value(i);
1691 timestamps.push(t);
1692 stats.update_i64(t);
1693 }
1694 let block = ColumnEncoder::encode_timestamps(×tamps)?;
1695 Ok(EncodedColumn::dense((block, stats)))
1697 }
1698 (roles::TAG, _) | (_, data_types::STRING) => {
1699 let arr = batch
1700 .column_by_name(&col.name)
1701 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
1702 .ok_or_else(|| SegmentError::CorruptFile {
1703 detail: format!("missing string column '{}'", col.name),
1704 })?;
1705 let mut values = Vec::with_capacity(end - start);
1706 for i in start..end {
1707 if arr.is_null(i) {
1708 values.push("");
1709 stats.record_null();
1710 validity.push(false);
1711 } else {
1712 values.push(arr.value(i));
1713 validity.push(true);
1714 }
1715 }
1716 let distinct: std::collections::HashSet<&str> = values.iter().copied().collect();
1717 stats.distinct_count = distinct.len() as u32;
1718 stats.value_count = (values.len() as u64).saturating_sub(stats.null_count);
1719 let block = ColumnEncoder::encode_string(&values)?;
1720 Ok(EncodedColumn::new(block, stats, validity))
1721 }
1722 (_, data_types::F64) => {
1723 let arr = batch
1724 .column_by_name(&col.name)
1725 .and_then(|c| c.as_any().downcast_ref::<Float64Array>())
1726 .ok_or_else(|| SegmentError::CorruptFile {
1727 detail: format!("missing f64 column '{}'", col.name),
1728 })?;
1729 let mut values = Vec::with_capacity(end - start);
1730 for i in start..end {
1731 if arr.is_null(i) {
1732 values.push(0.0);
1733 stats.record_null();
1734 validity.push(false);
1735 } else {
1736 let v = arr.value(i);
1737 values.push(v);
1738 stats.update_f64(v);
1739 validity.push(true);
1740 }
1741 }
1742 let block = ColumnEncoder::encode_f64(&values, float_encoding)?;
1743 Ok(EncodedColumn::new(block, stats, validity))
1744 }
1745 (_, data_types::I64) => {
1746 let arr = batch
1747 .column_by_name(&col.name)
1748 .and_then(|c| c.as_any().downcast_ref::<Int64Array>())
1749 .ok_or_else(|| SegmentError::CorruptFile {
1750 detail: format!("missing i64 column '{}'", col.name),
1751 })?;
1752 let mut values = Vec::with_capacity(end - start);
1753 for i in start..end {
1754 if arr.is_null(i) {
1755 values.push(0);
1756 stats.record_null();
1757 validity.push(false);
1758 } else {
1759 let v = arr.value(i);
1760 values.push(v);
1761 stats.update_i64(v);
1762 validity.push(true);
1763 }
1764 }
1765 let block = ColumnEncoder::encode_i64(&values)?;
1766 Ok(EncodedColumn::new(block, stats, validity))
1767 }
1768 (_, data_types::U64) => {
1769 let arr = batch
1770 .column_by_name(&col.name)
1771 .and_then(|c| c.as_any().downcast_ref::<UInt64Array>())
1772 .ok_or_else(|| SegmentError::CorruptFile {
1773 detail: format!("missing u64 column '{}'", col.name),
1774 })?;
1775 let mut values = Vec::with_capacity(end - start);
1776 for i in start..end {
1777 if arr.is_null(i) {
1778 values.push(0);
1779 stats.record_null();
1780 validity.push(false);
1781 } else {
1782 let v = arr.value(i);
1783 values.push(v);
1784 stats.update_u64(v);
1785 validity.push(true);
1786 }
1787 }
1788 let block = ColumnEncoder::encode_u64(&values)?;
1789 Ok(EncodedColumn::new(block, stats, validity))
1790 }
1791 (_, data_types::BOOL) => {
1792 let arr = batch
1793 .column_by_name(&col.name)
1794 .and_then(|c| c.as_any().downcast_ref::<BooleanArray>())
1795 .ok_or_else(|| SegmentError::CorruptFile {
1796 detail: format!("missing bool column '{}'", col.name),
1797 })?;
1798 let mut values = Vec::with_capacity(end - start);
1799 for i in start..end {
1800 if arr.is_null(i) {
1801 values.push(false);
1802 stats.record_null();
1803 validity.push(false);
1804 } else {
1805 let v = arr.value(i);
1806 values.push(v);
1807 stats.update_bool(v);
1808 validity.push(true);
1809 }
1810 }
1811 let block = ColumnEncoder::encode_bool(&values)?;
1812 Ok(EncodedColumn::new(block, stats, validity))
1813 }
1814 _ => Err(SegmentError::CorruptFile {
1815 detail: format!("unknown column type: {}", col.data_type),
1816 }),
1817 }
1818}
1819
1820#[cfg(test)]
1821mod tests {
1822 use super::*;
1823 use crate::segment::reader::SegmentReader;
1824 use chronix_core::config::{CompressionCodec, FloatEncoding};
1825 use chronix_core::types::{FieldValue, Point, SeriesKey};
1826 use std::collections::{BTreeMap, HashMap};
1827
1828 fn make_point(host: &str, cpu: f64, ts: i64) -> Point {
1829 let tags = BTreeMap::from([("host".to_string(), host.to_string())]);
1830 let key = SeriesKey::new("cpu_usage", tags).unwrap();
1831 let fields = BTreeMap::from([("cpu".to_string(), FieldValue::F64(cpu))]);
1832 Point::new(key, fields, ts).unwrap()
1833 }
1834
1835 #[test]
1836 fn per_column_codec_mixed_lz4_zstd() {
1837 let dir = tempfile::tempdir().unwrap();
1838 let path = dir.path().join("mixed_codec.csx");
1839
1840 let points: Vec<Point> = (0..200)
1841 .map(|i| make_point("server-1", 50.0 + i as f64, 1_000_000 + i * 10_000))
1842 .collect();
1843
1844 let mut overrides = HashMap::new();
1846 overrides.insert("cpu".to_string(), CompressionCodec::Zstd);
1847
1848 let config = SegmentWriterConfig {
1849 row_group_size: 100,
1850 compress: true,
1851 float_encoding: FloatEncoding::Gorilla,
1852 compression_codec: CompressionCodec::Lz4,
1853 zstd_level: 3,
1854 column_codec_overrides: overrides,
1855 ..Default::default()
1856 };
1857
1858 let mut writer = SegmentWriter::new(&path, config).unwrap();
1859 writer.write_rows(&points).unwrap();
1860 let meta = writer.finalize().unwrap();
1861
1862 assert_eq!(meta.row_count, 200);
1863
1864 let reader = SegmentReader::open(&path).unwrap();
1866 let batch = reader.read_all().unwrap();
1867 assert_eq!(batch.num_rows(), 200);
1868
1869 let cpu_col = batch
1871 .column_by_name("cpu")
1872 .unwrap()
1873 .as_any()
1874 .downcast_ref::<arrow::array::Float64Array>()
1875 .unwrap();
1876 for i in 0..200 {
1877 assert!(
1878 (cpu_col.value(i) - (50.0 + i as f64)).abs() < 1e-9,
1879 "cpu mismatch at row {i}"
1880 );
1881 }
1882 }
1883
1884 #[test]
1885 fn p09_zstd_dict_training_roundtrip() {
1886 let dir = tempfile::tempdir().unwrap();
1887 let path = dir.path().join("dict_trained.csx");
1888
1889 let points: Vec<Point> = (0..600)
1892 .map(|i| {
1893 let host = format!("host-{}", i % 5);
1894 make_point(&host, 10.0 + (i as f64) * 0.1, 1_000_000 + i * 10_000)
1895 })
1896 .collect();
1897
1898 let config = SegmentWriterConfig {
1899 row_group_size: 100,
1900 compress: true,
1901 compression_codec: CompressionCodec::Zstd,
1902 zstd_level: 3,
1903 zstd_dict_training: true,
1904 ..Default::default()
1905 };
1906
1907 let mut writer = SegmentWriter::new(&path, config).unwrap();
1908 writer.write_rows(&points).unwrap();
1909 let meta = writer.finalize().unwrap();
1910
1911 assert_eq!(meta.row_count, 600);
1912 assert!(meta.row_group_count >= 3);
1913
1914 let reader = SegmentReader::open(&path).unwrap();
1916 let batch = reader.read_all().unwrap();
1917 assert_eq!(batch.num_rows(), 600);
1918
1919 let cpu_col = batch
1921 .column_by_name("cpu")
1922 .unwrap()
1923 .as_any()
1924 .downcast_ref::<arrow::array::Float64Array>()
1925 .unwrap();
1926 assert_eq!(cpu_col.len(), 600);
1927 }
1928
1929 #[test]
1938 fn segment_metadata_overhead_stays_proportional_to_data() {
1939 let dir = tempfile::tempdir().unwrap();
1940 let path = dir.path().join("overhead.csx");
1941
1942 let points: Vec<Point> = (0..500)
1943 .map(|i| {
1944 let key = SeriesKey::new(
1945 "m".to_string(),
1946 std::collections::BTreeMap::from([(
1947 "host".to_string(),
1948 format!("h{}", i % 10),
1949 )]),
1950 )
1951 .unwrap();
1952 Point::new(
1953 key,
1954 std::collections::BTreeMap::from([(
1955 "cpu".to_string(),
1956 FieldValue::F64(f64::from(i % 100) / 10.0),
1957 )]),
1958 i64::from(i) * 1_000_000,
1959 )
1960 .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 file_size = std::fs::metadata(&path).unwrap().len();
1969 let block_bytes: u64 = {
1970 let reader = SegmentReader::open(&path).unwrap();
1971 (0..reader.row_group_count())
1972 .filter_map(|rg| reader.row_group_blocks(rg))
1973 .flatten()
1974 .map(|b| u64::from(b.length))
1975 .sum()
1976 };
1977 let overhead = file_size - block_bytes;
1978
1979 assert!(
1980 overhead < 8 * 1024,
1981 "segment carries {overhead} bytes of non-block overhead for {block_bytes} bytes of \
1982 data — fixed-size metadata has crept back in"
1983 );
1984 }
1985}