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