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| -> Result<ColumnMeta> {
721 #[cfg(feature = "field-encryption")]
722 let (encrypted, key_id) = if let Some(k) = config.field_encryption.key_for(&col.name) {
723 if col.role != crate::segment::metadata::roles::FIELD {
731 return Err(SegmentError::InvalidConfiguration {
732 detail: format!(
733 "column '{}' is declared encrypted but is a {} — only a field can \
734 be encrypted, because a tag's value is also written in plaintext \
735 to the segment's series sidecar, the inverted tag index and its \
736 bloom filter, and the time column is what every scan ranges on",
737 col.name,
738 if col.role == crate::segment::metadata::roles::TAG {
739 "tag"
740 } else {
741 "timestamp"
742 }
743 ),
744 });
745 }
746 (true, Some(k.key_id.clone()))
747 } else {
748 (false, None)
749 };
750 #[cfg(not(feature = "field-encryption"))]
751 let (encrypted, key_id) = (false, None);
752
753 Ok(ColumnMeta {
754 name: col.name.clone(),
755 data_type: col.data_type,
756 role: col.role,
757 default_encoding: col.default_encoding,
758 stats: ColumnStats::empty(),
759 bloom_filter: None,
760 encrypted,
761 key_id,
762 decimal_scale: col.decimal_scale,
763 row_group_blooms: None,
764 })
765 })
766 .collect::<Result<Vec<_>>>()?;
767
768 let mut row_group_blocks: Vec<Vec<ColumnBlockMeta>> = Vec::new();
769 let mut total_uncompressed: u64 = 0;
770
771 let use_dict =
773 config.zstd_dict_training && matches!(config.compression_codec, CompressionCodec::Zstd);
774 let mut dict_samples: Vec<Vec<u8>> = Vec::new();
775 let mut trained_dict: Option<Vec<u8>> = None;
776 let mut rg_index: usize = 0;
777
778 #[allow(clippy::explicit_counter_loop)]
779 for chunk_start in (0..total_rows).step_by(config.row_group_size) {
781 let chunk_end = (chunk_start + config.row_group_size).min(total_rows);
782 let chunk_indices = &sorted_indices[chunk_start..chunk_end];
783 let chunk_size = chunk_end - chunk_start;
784
785 let mut rg_blocks = Vec::with_capacity(schema.len());
786
787 for (col_idx, col) in schema.iter().enumerate() {
788 let EncodedColumn {
789 block,
790 stats,
791 validity,
792 } = encode_column(col, chunk_indices, points, config.float_encoding)?;
793
794 let raw_encoded = block.to_bytes();
795 let raw_size = chunk_size * 8;
796 total_uncompressed += raw_encoded.len() as u64;
797
798 if use_dict && rg_index == 0 {
800 dict_samples.push(raw_encoded.clone());
801 }
802
803 let codec = config
804 .column_codec_overrides
805 .get(&col.name)
806 .copied()
807 .unwrap_or(config.compression_codec);
808 let (final_data, compressed) =
809 if config.compress && !should_skip_compression(raw_encoded.len(), raw_size) {
810 if let Some(ref dict) = trained_dict {
812 if matches!(codec, CompressionCodec::Zstd) {
813 let compressed_data =
814 compress_block_zstd_dict(&raw_encoded, config.zstd_level, dict)?;
815 if compressed_data.len() < raw_encoded.len() {
816 (compressed_data, true)
817 } else {
818 (raw_encoded, false)
819 }
820 } else {
821 let compressed_data = compress_block_with_codec(&raw_encoded, codec)?;
822 if compressed_data.len() < raw_encoded.len() {
823 (compressed_data, true)
824 } else {
825 (raw_encoded, false)
826 }
827 }
828 } else {
829 let compressed_data = compress_block_with_codec(&raw_encoded, codec)?;
830 if compressed_data.len() < raw_encoded.len() {
831 (compressed_data, true)
832 } else {
833 (raw_encoded, false)
834 }
835 }
836 } else {
837 (raw_encoded, false)
838 };
839
840 #[cfg(feature = "field-encryption")]
842 let (final_data, block_encrypted) =
843 if let Some(enc_key) = config.field_encryption.key_for(&col.name) {
844 let encrypted = crate::segment::field_encryption::encrypt_block(
845 &final_data,
846 enc_key.key_bytes(),
847 crate::segment::field_encryption::BlockContext {
848 column: &col.name,
849 segment_created_at,
850 },
851 )?;
852 (encrypted, true)
853 } else {
854 (final_data, false)
855 };
856 #[cfg(not(feature = "field-encryption"))]
857 let block_encrypted = false;
858
859 let offset = *pos;
860 writer.write_all(&final_data)?;
861 *crc = crc32c::crc32c_append(*crc, &final_data);
862 *pos += final_data.len() as u64;
863
864 let block_crc = crc32c::crc32c(&final_data);
865
866 let final_stats = if block_encrypted {
869 ColumnStats::empty()
870 } else {
871 stats
872 };
873
874 merge_stats(&mut column_metas[col_idx].stats, &final_stats);
875
876 let (validity_offset, validity_length) =
881 write_validity(writer, validity.as_deref(), pos, crc)?;
882
883 rg_blocks.push(ColumnBlockMeta {
884 column_index: col_idx as u16,
885 encoding: block.encoding,
886 compressed,
887 offset,
888 length: u32::try_from(final_data.len()).map_err(|_| SegmentError::CorruptFile {
889 detail: format!("block length {} exceeds u32::MAX", final_data.len()),
890 })?,
891 value_count: u32::try_from(chunk_size).map_err(|_| SegmentError::CorruptFile {
892 detail: format!("value_count {chunk_size} exceeds u32::MAX"),
893 })?,
894 block_crc,
895 encrypted: block_encrypted,
896 validity_offset,
897 validity_length,
898 stats: final_stats,
899 });
900 }
901
902 row_group_blocks.push(rg_blocks);
903
904 for (col_idx, col) in schema.iter().enumerate() {
906 if col.role == roles::TAG && !column_metas[col_idx].encrypted {
907 let mut distinct: std::collections::HashSet<&str> =
908 std::collections::HashSet::new();
909 for &idx in chunk_indices {
910 let val = points[idx].series_key().tag(&col.name).unwrap_or("");
911 if !val.is_empty() {
912 distinct.insert(val);
913 }
914 }
915 let bloom = if distinct.is_empty() {
916 Vec::new()
917 } else {
918 let vals: Vec<&str> = distinct.into_iter().collect();
919 crate::segment::bloom::bloom_filter_build(&vals, config.bloom_fpr)
920 };
921 column_metas[col_idx]
922 .row_group_blooms
923 .get_or_insert_with(Vec::new)
924 .push(bloom);
925 }
926 }
927
928 if use_dict && rg_index == 0 && !dict_samples.is_empty() {
930 let sample_refs: Vec<&[u8]> =
931 dict_samples.iter().map(std::vec::Vec::as_slice).collect();
932 if let Ok(Some(dict)) = train_zstd_dictionary(&sample_refs) {
933 tracing::debug!(
934 dict_size = dict.len(),
935 samples = dict_samples.len(),
936 "trained Zstd dictionary from first row group"
937 );
938 trained_dict = Some(dict);
939 }
940 dict_samples.clear();
941 }
942 rg_index += 1;
943 }
944
945 for (col_idx, col) in schema.iter().enumerate() {
948 if col.role == roles::TAG && !column_metas[col_idx].encrypted {
949 let mut distinct: std::collections::HashSet<&str> = std::collections::HashSet::new();
950 for &idx in sorted_indices {
951 let val = points[idx].series_key().tag(&col.name).unwrap_or("");
952 if !val.is_empty() {
953 distinct.insert(val);
954 }
955 }
956 if !distinct.is_empty() {
957 let values: Vec<&str> = distinct.into_iter().collect();
958 column_metas[col_idx].bloom_filter = Some(
959 crate::segment::bloom::bloom_filter_build(&values, config.bloom_fpr),
960 );
961 }
962 }
963 }
964
965 Ok((
966 column_metas,
967 row_group_blocks,
968 total_uncompressed,
969 trained_dict,
970 ))
971}
972
973#[derive(Debug, Clone)]
977struct ColumnDef {
978 name: String,
979 data_type: u8,
980 role: u8,
981 default_encoding: u8,
982 decimal_scale: Option<u8>,
984}
985
986fn discover_schema(points: &[Point]) -> Vec<ColumnDef> {
992 let mut columns: Vec<ColumnDef> = Vec::new();
993
994 columns.push(ColumnDef {
996 name: chronix_core::TIME_COLUMN.to_string(),
997 data_type: data_types::TIMESTAMP,
998 role: roles::TIMESTAMP,
999 default_encoding: chronix_encoding::EncodingType::DeltaOfDelta.tag(),
1000 decimal_scale: None,
1001 });
1002
1003 let mut tag_names = BTreeSet::new();
1005 for p in points {
1006 for key in p.series_key().tag_keys() {
1007 tag_names.insert(key.to_string());
1008 }
1009 }
1010
1011 let mut tag_with_card: Vec<(String, usize)> = tag_names
1013 .into_iter()
1014 .map(|name: String| {
1015 let distinct: std::collections::HashSet<&str> = points
1016 .iter()
1017 .filter_map(|p| p.series_key().tag(&name))
1018 .collect();
1019 (name, distinct.len())
1020 })
1021 .collect();
1022 tag_with_card.sort_by_key(|(_, card)| *card);
1023
1024 for (name, _) in tag_with_card {
1025 columns.push(ColumnDef {
1026 name,
1027 data_type: data_types::STRING,
1028 role: roles::TAG,
1029 default_encoding: chronix_encoding::EncodingType::Dictionary.tag(),
1030 decimal_scale: None,
1031 });
1032 }
1033
1034 let mut field_defs: BTreeMap<String, (u8, Option<u8>)> = BTreeMap::new();
1044 for p in points {
1045 for (key, val) in p.fields() {
1046 let entry = field_defs.entry(key.to_string()).or_insert_with(|| {
1047 let dt = match val {
1048 FieldValue::F64(_) => data_types::F64,
1049 FieldValue::I64(_) => data_types::I64,
1050 FieldValue::U64(_) => data_types::U64,
1051 FieldValue::Bool(_) => data_types::BOOL,
1052 FieldValue::String(_) => data_types::STRING,
1053 FieldValue::Decimal(_) => data_types::DECIMAL,
1054 };
1055 (dt, None)
1056 });
1057 if let FieldValue::Decimal(d) = val {
1058 if entry.0 == data_types::DECIMAL {
1059 entry.1 = Some(entry.1.unwrap_or(0).max(d.scale()));
1060 }
1061 }
1062 }
1063 }
1064 for (name, (dt, scale)) in field_defs {
1065 let enc = match dt {
1066 data_types::F64 => chronix_encoding::EncodingType::Chimp.tag(),
1067 data_types::I64 => chronix_encoding::EncodingType::IntegerI64.tag(),
1068 data_types::U64 => chronix_encoding::EncodingType::IntegerU64.tag(),
1069 data_types::BOOL => chronix_encoding::EncodingType::Bitmap.tag(),
1070 data_types::DECIMAL => chronix_encoding::EncodingType::DecimalI128.tag(),
1071 _ => chronix_encoding::EncodingType::Dictionary.tag(),
1072 };
1073 columns.push(ColumnDef {
1074 name,
1075 data_type: dt,
1076 role: roles::FIELD,
1077 default_encoding: enc,
1078 decimal_scale: scale,
1079 });
1080 }
1081
1082 columns
1083}
1084
1085fn write_validity<W: Write>(
1091 writer: &mut W,
1092 validity: Option<&[u8]>,
1093 pos: &mut u64,
1094 crc: &mut u32,
1095) -> Result<(u64, u32)> {
1096 let Some(bytes) = validity else {
1097 return Ok((0, 0));
1098 };
1099 let offset = *pos;
1100 writer.write_all(bytes)?;
1101 *crc = crc32c::crc32c_append(*crc, bytes);
1102 *pos += bytes.len() as u64;
1103 let length = u32::try_from(bytes.len()).map_err(|_| SegmentError::CorruptFile {
1104 detail: format!("validity bitmap length {} exceeds u32::MAX", bytes.len()),
1105 })?;
1106 Ok((offset, length))
1107}
1108
1109struct EncodedColumn {
1112 block: EncodedBlock,
1113 stats: ColumnStats,
1114 validity: Option<Vec<u8>>,
1116}
1117
1118impl EncodedColumn {
1119 fn new(block: EncodedBlock, stats: ColumnStats, validity: ValidityBuilder) -> Self {
1120 Self {
1121 block,
1122 stats,
1123 validity: validity.finish(),
1124 }
1125 }
1126
1127 fn dense((block, stats): (EncodedBlock, ColumnStats)) -> Self {
1129 Self {
1130 block,
1131 stats,
1132 validity: None,
1133 }
1134 }
1135}
1136
1137fn encode_column(
1139 col: &ColumnDef,
1140 indices: &[usize],
1141 points: &[Point],
1142 float_encoding: FloatEncoding,
1143) -> Result<EncodedColumn> {
1144 let mut stats = ColumnStats::empty();
1145 let mut validity = ValidityBuilder::with_capacity(indices.len());
1148
1149 match (col.role, col.data_type) {
1150 (roles::TIMESTAMP, _) => encode_timestamp_column(indices, points).map(EncodedColumn::dense),
1153 (roles::TAG, _) => encode_tag_column(col, indices, points).map(EncodedColumn::dense),
1154 (_, data_types::F64) => {
1155 let mut values = Vec::with_capacity(indices.len());
1156 for &idx in indices {
1157 if let Some(FieldValue::F64(v)) = points[idx].field(&col.name) {
1158 values.push(*v);
1159 stats.update_f64(*v);
1160 validity.push(true);
1161 } else {
1162 values.push(0.0);
1163 stats.record_null();
1164 validity.push(false);
1165 }
1166 }
1167 let block = ColumnEncoder::encode_f64(&values, float_encoding)?;
1168 Ok(EncodedColumn::new(block, stats, validity))
1169 }
1170 (_, data_types::I64) => {
1171 let mut values = Vec::with_capacity(indices.len());
1172 for &idx in indices {
1173 if let Some(FieldValue::I64(v)) = points[idx].field(&col.name) {
1174 values.push(*v);
1175 stats.update_i64(*v);
1176 validity.push(true);
1177 } else {
1178 values.push(0);
1179 stats.record_null();
1180 validity.push(false);
1181 }
1182 }
1183 let block = ColumnEncoder::encode_i64(&values)?;
1184 Ok(EncodedColumn::new(block, stats, validity))
1185 }
1186 (_, data_types::U64) => {
1187 let mut values = Vec::with_capacity(indices.len());
1188 for &idx in indices {
1189 if let Some(FieldValue::U64(v)) = points[idx].field(&col.name) {
1190 values.push(*v);
1191 stats.update_u64(*v);
1192 validity.push(true);
1193 } else {
1194 values.push(0);
1195 stats.record_null();
1196 validity.push(false);
1197 }
1198 }
1199 let block = ColumnEncoder::encode_u64(&values)?;
1200 Ok(EncodedColumn::new(block, stats, validity))
1201 }
1202 (_, data_types::BOOL) => {
1203 let mut values = Vec::with_capacity(indices.len());
1204 for &idx in indices {
1205 if let Some(FieldValue::Bool(v)) = points[idx].field(&col.name) {
1206 values.push(*v);
1207 stats.update_bool(*v);
1208 validity.push(true);
1209 } else {
1210 values.push(false);
1211 stats.record_null();
1212 validity.push(false);
1213 }
1214 }
1215 let block = ColumnEncoder::encode_bool(&values)?;
1216 Ok(EncodedColumn::new(block, stats, validity))
1217 }
1218 (_, data_types::DECIMAL) => {
1219 let scale = col.decimal_scale.unwrap_or(0);
1225 let mut values = Vec::with_capacity(indices.len());
1226 for &idx in indices {
1227 match points[idx].field(&col.name) {
1228 Some(FieldValue::Decimal(d)) => match d.rescale(scale) {
1229 Ok(v) => {
1230 values.push(v.mantissa());
1231 stats.update_decimal(v.mantissa());
1232 validity.push(true);
1233 }
1234 Err(e) => {
1235 return Err(SegmentError::CorruptFile {
1236 detail: format!(
1237 "decimal field '{}' at scale {} cannot be stored in a \
1238 scale-{scale} column: {e}",
1239 col.name,
1240 d.scale()
1241 ),
1242 })
1243 }
1244 },
1245 _ => {
1246 values.push(0);
1247 stats.record_null();
1248 validity.push(false);
1249 }
1250 }
1251 }
1252 let block = ColumnEncoder::encode_decimal(&values)?;
1253 Ok(EncodedColumn::new(block, stats, validity))
1254 }
1255 (_, data_types::STRING) => {
1256 let mut owned: Vec<String> = Vec::with_capacity(indices.len());
1258 for &idx in indices {
1259 if let Some(FieldValue::String(v)) = points[idx].field(&col.name) {
1260 owned.push(v.clone());
1261 validity.push(true);
1262 } else {
1263 owned.push(String::new());
1264 stats.record_null();
1265 validity.push(false);
1266 }
1267 }
1268 let values: Vec<&str> = owned.iter().map(String::as_str).collect();
1269 let distinct: std::collections::HashSet<&str> = values.iter().copied().collect();
1270 stats.distinct_count = distinct.len() as u32;
1271 stats.value_count = (values.len() as u64).saturating_sub(stats.null_count);
1273 let block = ColumnEncoder::encode_string(&values)?;
1274 Ok(EncodedColumn::new(block, stats, validity))
1275 }
1276 _ => Err(SegmentError::CorruptFile {
1277 detail: format!("unknown column type: {}", col.data_type),
1278 }),
1279 }
1280}
1281
1282fn compute_global_stats(
1287 points: &[Point],
1288 sorted_indices: &[usize],
1289) -> (i64, i64, Vec<chronix_core::SeriesKey>) {
1290 let mut min_ts = i64::MAX;
1291 let mut max_ts = i64::MIN;
1292 let mut series_keys: Vec<chronix_core::SeriesKey> = Vec::new();
1293
1294 for &idx in sorted_indices {
1295 let p = &points[idx];
1296 let ts = p.timestamp();
1297 min_ts = min_ts.min(ts);
1298 max_ts = max_ts.max(ts);
1299 let is_new = series_keys
1303 .last()
1304 .is_none_or(|prev| prev.canonical_form() != p.series_key().canonical_form());
1305 if is_new {
1306 series_keys.push(p.series_key().clone());
1307 }
1308 }
1309 (min_ts, max_ts, series_keys)
1310}
1311
1312fn encode_timestamp_column(
1314 indices: &[usize],
1315 points: &[Point],
1316) -> Result<(EncodedBlock, ColumnStats)> {
1317 let mut stats = ColumnStats::empty();
1318 let mut timestamps = Vec::with_capacity(indices.len());
1319 for &idx in indices {
1320 let ts = points[idx].timestamp();
1321 timestamps.push(ts);
1322 stats.update_i64(ts);
1323 }
1324 let block = ColumnEncoder::encode_timestamps(×tamps)?;
1325 Ok((block, stats))
1326}
1327
1328fn encode_tag_column(
1330 col: &ColumnDef,
1331 indices: &[usize],
1332 points: &[Point],
1333) -> Result<(EncodedBlock, ColumnStats)> {
1334 let mut stats = ColumnStats::empty();
1335 let mut values = Vec::with_capacity(indices.len());
1336 for &idx in indices {
1337 let val = points[idx].series_key().tag(&col.name).unwrap_or("");
1338 values.push(val);
1339 }
1340 let distinct: std::collections::HashSet<&str> = values.iter().copied().collect();
1341 stats.distinct_count = distinct.len() as u32;
1342 stats.value_count = values.len() as u64;
1343 let block = ColumnEncoder::encode_string(&values)?;
1344 Ok((block, stats))
1345}
1346
1347fn merge_stats(dest: &mut ColumnStats, src: &ColumnStats) {
1354 dest.min_value = dest.min_value.min(src.min_value);
1355 dest.max_value = dest.max_value.max(src.max_value);
1356 dest.min_value_u64 = dest.min_value_u64.min(src.min_value_u64);
1359 dest.max_value_u64 = dest.max_value_u64.max(src.max_value_u64);
1360 dest.null_count += src.null_count;
1361 dest.value_count += src.value_count;
1362 dest.sum += src.sum;
1363 dest.sum_i128 += src.sum_i128;
1364 dest.distinct_count = dest.distinct_count.max(src.distinct_count);
1367}
1368
1369fn discover_schema_from_batch(
1376 batch: &RecordBatch,
1377 tag_columns: &std::collections::HashSet<&str>,
1378) -> Result<Vec<ColumnDef>> {
1379 let mut columns = Vec::new();
1380
1381 columns.push(ColumnDef {
1383 name: chronix_core::TIME_COLUMN.to_string(),
1384 data_type: data_types::TIMESTAMP,
1385 role: roles::TIMESTAMP,
1386 default_encoding: chronix_encoding::EncodingType::DeltaOfDelta.tag(),
1387 decimal_scale: None,
1388 });
1389
1390 let mut tags: Vec<&str> = tag_columns.iter().copied().collect();
1392 tags.sort_unstable();
1393 for tag in tags {
1394 if batch.column_by_name(tag).is_some() {
1395 columns.push(ColumnDef {
1396 name: tag.to_string(),
1397 data_type: data_types::STRING,
1398 role: roles::TAG,
1399 default_encoding: chronix_encoding::EncodingType::Dictionary.tag(),
1400 decimal_scale: None,
1401 });
1402 }
1403 }
1404
1405 for field in batch.schema().fields() {
1407 let name = field.name().as_str();
1408 if name == chronix_core::TIME_COLUMN || tag_columns.contains(name) {
1409 continue;
1410 }
1411 let (dt, enc) = match field.data_type() {
1412 DataType::Float64 => (data_types::F64, chronix_encoding::EncodingType::Chimp.tag()),
1413 DataType::Int64 => (
1414 data_types::I64,
1415 chronix_encoding::EncodingType::IntegerI64.tag(),
1416 ),
1417 DataType::UInt64 => (
1418 data_types::U64,
1419 chronix_encoding::EncodingType::IntegerU64.tag(),
1420 ),
1421 DataType::Boolean => (
1422 data_types::BOOL,
1423 chronix_encoding::EncodingType::Bitmap.tag(),
1424 ),
1425 DataType::Utf8 | DataType::LargeUtf8 => (
1426 data_types::STRING,
1427 chronix_encoding::EncodingType::Dictionary.tag(),
1428 ),
1429 DataType::Decimal128(_, _) => (
1430 data_types::DECIMAL,
1431 chronix_encoding::EncodingType::DecimalI128.tag(),
1432 ),
1433 other => {
1434 return Err(SegmentError::CorruptFile {
1435 detail: format!(
1436 "unsupported Arrow data type {:?} for field '{}'",
1437 other, name
1438 ),
1439 });
1440 }
1441 };
1442 let decimal_scale = match field.data_type() {
1445 DataType::Decimal128(_, scale) => {
1446 Some(u8::try_from(*scale).map_err(|_| SegmentError::CorruptFile {
1447 detail: format!("negative decimal scale {scale} for field '{name}'"),
1448 })?)
1449 }
1450 _ => None,
1451 };
1452 columns.push(ColumnDef {
1453 name: name.to_string(),
1454 data_type: dt,
1455 role: roles::FIELD,
1456 default_encoding: enc,
1457 decimal_scale,
1458 });
1459 }
1460
1461 Ok(columns)
1462}
1463
1464fn series_keys_from_batch(
1469 batch: &RecordBatch,
1470 measurement: &str,
1471 tag_columns: &[String],
1472) -> Vec<chronix_core::SeriesKey> {
1473 let mut seen = std::collections::HashSet::new();
1474 let mut keys = Vec::new();
1475 let num_rows = batch.num_rows();
1476
1477 let tag_arrays: Vec<Option<&StringArray>> = tag_columns
1478 .iter()
1479 .map(|name| {
1480 batch
1481 .column_by_name(name)
1482 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
1483 })
1484 .collect();
1485
1486 for row in 0..num_rows {
1487 let mut tags = BTreeMap::new();
1488 for (i, tag_name) in tag_columns.iter().enumerate() {
1489 if let Some(arr) = tag_arrays[i] {
1490 if !arr.is_null(row) {
1491 tags.insert(tag_name.clone(), arr.value(row).to_string());
1492 }
1493 }
1494 }
1495 if let Ok(key) = chronix_core::SeriesKey::new(measurement, tags) {
1496 if seen.insert(key.canonical_form().to_string()) {
1497 keys.push(key);
1498 }
1499 }
1500 }
1501 keys
1502}
1503
1504fn encode_row_groups_from_batch_streaming<W: Write>(
1509 schema: &[ColumnDef],
1510 batch: &RecordBatch,
1511 config: &SegmentWriterConfig,
1512 #[cfg_attr(not(feature = "field-encryption"), allow(unused_variables))] segment_created_at: i64,
1515 writer: &mut W,
1516 crc: &mut u32,
1517 pos: &mut u64,
1518) -> Result<RowGroupWriteOutput> {
1519 let total_rows = batch.num_rows();
1520
1521 let mut column_metas: Vec<ColumnMeta> = schema
1522 .iter()
1523 .map(|col| -> Result<ColumnMeta> {
1524 #[cfg(feature = "field-encryption")]
1525 let (encrypted, key_id) = if let Some(k) = config.field_encryption.key_for(&col.name) {
1526 if col.role != crate::segment::metadata::roles::FIELD {
1534 return Err(SegmentError::InvalidConfiguration {
1535 detail: format!(
1536 "column '{}' is declared encrypted but is a {} — only a field can \
1537 be encrypted, because a tag's value is also written in plaintext \
1538 to the segment's series sidecar, the inverted tag index and its \
1539 bloom filter, and the time column is what every scan ranges on",
1540 col.name,
1541 if col.role == crate::segment::metadata::roles::TAG {
1542 "tag"
1543 } else {
1544 "timestamp"
1545 }
1546 ),
1547 });
1548 }
1549 (true, Some(k.key_id.clone()))
1550 } else {
1551 (false, None)
1552 };
1553 #[cfg(not(feature = "field-encryption"))]
1554 let (encrypted, key_id) = (false, None);
1555
1556 Ok(ColumnMeta {
1557 name: col.name.clone(),
1558 data_type: col.data_type,
1559 role: col.role,
1560 default_encoding: col.default_encoding,
1561 stats: ColumnStats::empty(),
1562 bloom_filter: None,
1563 encrypted,
1564 key_id,
1565 decimal_scale: col.decimal_scale,
1566 row_group_blooms: None,
1567 })
1568 })
1569 .collect::<Result<Vec<_>>>()?;
1570
1571 let mut row_group_blocks: Vec<Vec<ColumnBlockMeta>> = Vec::new();
1572 let mut total_uncompressed: u64 = 0;
1573
1574 let use_dict =
1576 config.zstd_dict_training && matches!(config.compression_codec, CompressionCodec::Zstd);
1577 let mut dict_samples: Vec<Vec<u8>> = Vec::new();
1578 let mut trained_dict: Option<Vec<u8>> = None;
1579 let mut rg_index: usize = 0;
1580
1581 #[allow(clippy::explicit_counter_loop)]
1582 for chunk_start in (0..total_rows).step_by(config.row_group_size) {
1584 let chunk_end = (chunk_start + config.row_group_size).min(total_rows);
1585 let chunk_size = chunk_end - chunk_start;
1586
1587 let mut rg_blocks = Vec::with_capacity(schema.len());
1588
1589 for (col_idx, col) in schema.iter().enumerate() {
1590 let EncodedColumn {
1591 block,
1592 stats,
1593 validity,
1594 } = encode_column_from_batch(
1595 col,
1596 batch,
1597 chunk_start,
1598 chunk_end,
1599 config.float_encoding,
1600 )?;
1601
1602 let raw_encoded = block.to_bytes();
1603 let raw_size = chunk_size * 8;
1604 total_uncompressed += raw_encoded.len() as u64;
1605
1606 if use_dict && rg_index == 0 {
1608 dict_samples.push(raw_encoded.clone());
1609 }
1610
1611 let codec = config
1612 .column_codec_overrides
1613 .get(&col.name)
1614 .copied()
1615 .unwrap_or(config.compression_codec);
1616 let (final_data, compressed) =
1617 if config.compress && !should_skip_compression(raw_encoded.len(), raw_size) {
1618 if let Some(ref dict) = trained_dict {
1620 if matches!(codec, CompressionCodec::Zstd) {
1621 let compressed_data =
1622 compress_block_zstd_dict(&raw_encoded, config.zstd_level, dict)?;
1623 if compressed_data.len() < raw_encoded.len() {
1624 (compressed_data, true)
1625 } else {
1626 (raw_encoded, false)
1627 }
1628 } else {
1629 let compressed_data = compress_block_with_codec(&raw_encoded, codec)?;
1630 if compressed_data.len() < raw_encoded.len() {
1631 (compressed_data, true)
1632 } else {
1633 (raw_encoded, false)
1634 }
1635 }
1636 } else {
1637 let compressed_data = compress_block_with_codec(&raw_encoded, codec)?;
1638 if compressed_data.len() < raw_encoded.len() {
1639 (compressed_data, true)
1640 } else {
1641 (raw_encoded, false)
1642 }
1643 }
1644 } else {
1645 (raw_encoded, false)
1646 };
1647
1648 #[cfg(feature = "field-encryption")]
1650 let (final_data, block_encrypted) =
1651 if let Some(enc_key) = config.field_encryption.key_for(&col.name) {
1652 let encrypted = crate::segment::field_encryption::encrypt_block(
1653 &final_data,
1654 enc_key.key_bytes(),
1655 crate::segment::field_encryption::BlockContext {
1656 column: &col.name,
1657 segment_created_at,
1658 },
1659 )?;
1660 (encrypted, true)
1661 } else {
1662 (final_data, false)
1663 };
1664 #[cfg(not(feature = "field-encryption"))]
1665 let block_encrypted = false;
1666
1667 let offset = *pos;
1668 writer.write_all(&final_data)?;
1669 *crc = crc32c::crc32c_append(*crc, &final_data);
1670 *pos += final_data.len() as u64;
1671
1672 let block_crc = crc32c::crc32c(&final_data);
1673
1674 let final_stats = if block_encrypted {
1677 ColumnStats::empty()
1678 } else {
1679 stats
1680 };
1681
1682 merge_stats(&mut column_metas[col_idx].stats, &final_stats);
1683
1684 let (validity_offset, validity_length) =
1686 write_validity(writer, validity.as_deref(), pos, crc)?;
1687
1688 rg_blocks.push(ColumnBlockMeta {
1689 column_index: col_idx as u16,
1690 encoding: block.encoding,
1691 compressed,
1692 offset,
1693 length: u32::try_from(final_data.len()).map_err(|_| SegmentError::CorruptFile {
1694 detail: format!("block length {} exceeds u32::MAX", final_data.len()),
1695 })?,
1696 value_count: u32::try_from(chunk_size).map_err(|_| SegmentError::CorruptFile {
1697 detail: format!("value_count {chunk_size} exceeds u32::MAX"),
1698 })?,
1699 block_crc,
1700 encrypted: block_encrypted,
1701 validity_offset,
1702 validity_length,
1703 stats: final_stats,
1704 });
1705 }
1706
1707 row_group_blocks.push(rg_blocks);
1708
1709 for (col_idx, col) in schema.iter().enumerate() {
1711 if col.role == roles::TAG && !column_metas[col_idx].encrypted {
1712 if let Some(arr) = batch
1713 .column_by_name(&col.name)
1714 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
1715 {
1716 let mut distinct: std::collections::HashSet<&str> =
1717 std::collections::HashSet::new();
1718 for i in chunk_start..chunk_end {
1719 if !arr.is_null(i) {
1720 let val = arr.value(i);
1721 if !val.is_empty() {
1722 distinct.insert(val);
1723 }
1724 }
1725 }
1726 let bloom = if distinct.is_empty() {
1727 Vec::new()
1728 } else {
1729 let vals: Vec<&str> = distinct.into_iter().collect();
1730 crate::segment::bloom::bloom_filter_build(&vals, config.bloom_fpr)
1731 };
1732 column_metas[col_idx]
1733 .row_group_blooms
1734 .get_or_insert_with(Vec::new)
1735 .push(bloom);
1736 }
1737 }
1738 }
1739
1740 if use_dict && rg_index == 0 && !dict_samples.is_empty() {
1742 let sample_refs: Vec<&[u8]> =
1743 dict_samples.iter().map(std::vec::Vec::as_slice).collect();
1744 if let Ok(Some(dict)) = train_zstd_dictionary(&sample_refs) {
1745 tracing::debug!(
1746 dict_size = dict.len(),
1747 samples = dict_samples.len(),
1748 "trained Zstd dictionary from first row group (batch path)"
1749 );
1750 trained_dict = Some(dict);
1751 }
1752 dict_samples.clear();
1753 }
1754 rg_index += 1;
1755 }
1756
1757 for (col_idx, col) in schema.iter().enumerate() {
1760 if col.role == roles::TAG && !column_metas[col_idx].encrypted {
1761 if let Some(arr) = batch
1762 .column_by_name(&col.name)
1763 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
1764 {
1765 let mut distinct: std::collections::HashSet<&str> =
1766 std::collections::HashSet::new();
1767 for i in 0..arr.len() {
1768 if !arr.is_null(i) {
1769 let val = arr.value(i);
1770 if !val.is_empty() {
1771 distinct.insert(val);
1772 }
1773 }
1774 }
1775 if !distinct.is_empty() {
1776 let values: Vec<&str> = distinct.into_iter().collect();
1777 column_metas[col_idx].bloom_filter = Some(
1778 crate::segment::bloom::bloom_filter_build(&values, config.bloom_fpr),
1779 );
1780 }
1781 }
1782 }
1783 }
1784
1785 Ok((
1786 column_metas,
1787 row_group_blocks,
1788 total_uncompressed,
1789 trained_dict,
1790 ))
1791}
1792
1793fn encode_column_from_batch(
1795 col: &ColumnDef,
1796 batch: &RecordBatch,
1797 start: usize,
1798 end: usize,
1799 float_encoding: FloatEncoding,
1800) -> Result<EncodedColumn> {
1801 let mut stats = ColumnStats::empty();
1802 let mut validity = ValidityBuilder::with_capacity(end.saturating_sub(start));
1804
1805 match (col.role, col.data_type) {
1806 (roles::TIMESTAMP, _) => {
1807 let arr = batch
1808 .column_by_name(chronix_core::TIME_COLUMN)
1809 .and_then(|c| c.as_any().downcast_ref::<Int64Array>())
1810 .ok_or_else(|| SegmentError::CorruptFile {
1811 detail: "missing timestamp".into(),
1812 })?;
1813 let mut timestamps = Vec::with_capacity(end - start);
1814 for i in start..end {
1815 let t = arr.value(i);
1816 timestamps.push(t);
1817 stats.update_i64(t);
1818 }
1819 let block = ColumnEncoder::encode_timestamps(×tamps)?;
1820 Ok(EncodedColumn::dense((block, stats)))
1822 }
1823 (roles::TAG, _) | (_, data_types::STRING) => {
1824 let arr = batch
1825 .column_by_name(&col.name)
1826 .and_then(|c| c.as_any().downcast_ref::<StringArray>())
1827 .ok_or_else(|| SegmentError::CorruptFile {
1828 detail: format!("missing string column '{}'", col.name),
1829 })?;
1830 let mut values = Vec::with_capacity(end - start);
1831 for i in start..end {
1832 if arr.is_null(i) {
1833 values.push("");
1834 stats.record_null();
1835 validity.push(false);
1836 } else {
1837 values.push(arr.value(i));
1838 validity.push(true);
1839 }
1840 }
1841 let distinct: std::collections::HashSet<&str> = values.iter().copied().collect();
1842 stats.distinct_count = distinct.len() as u32;
1843 stats.value_count = (values.len() as u64).saturating_sub(stats.null_count);
1844 let block = ColumnEncoder::encode_string(&values)?;
1845 Ok(EncodedColumn::new(block, stats, validity))
1846 }
1847 (_, data_types::F64) => {
1848 let arr = batch
1849 .column_by_name(&col.name)
1850 .and_then(|c| c.as_any().downcast_ref::<Float64Array>())
1851 .ok_or_else(|| SegmentError::CorruptFile {
1852 detail: format!("missing f64 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.0);
1858 stats.record_null();
1859 validity.push(false);
1860 } else {
1861 let v = arr.value(i);
1862 values.push(v);
1863 stats.update_f64(v);
1864 validity.push(true);
1865 }
1866 }
1867 let block = ColumnEncoder::encode_f64(&values, float_encoding)?;
1868 Ok(EncodedColumn::new(block, stats, validity))
1869 }
1870 (_, data_types::I64) => {
1871 let arr = batch
1872 .column_by_name(&col.name)
1873 .and_then(|c| c.as_any().downcast_ref::<Int64Array>())
1874 .ok_or_else(|| SegmentError::CorruptFile {
1875 detail: format!("missing i64 column '{}'", col.name),
1876 })?;
1877 let mut values = Vec::with_capacity(end - start);
1878 for i in start..end {
1879 if arr.is_null(i) {
1880 values.push(0);
1881 stats.record_null();
1882 validity.push(false);
1883 } else {
1884 let v = arr.value(i);
1885 values.push(v);
1886 stats.update_i64(v);
1887 validity.push(true);
1888 }
1889 }
1890 let block = ColumnEncoder::encode_i64(&values)?;
1891 Ok(EncodedColumn::new(block, stats, validity))
1892 }
1893 (_, data_types::U64) => {
1894 let arr = batch
1895 .column_by_name(&col.name)
1896 .and_then(|c| c.as_any().downcast_ref::<UInt64Array>())
1897 .ok_or_else(|| SegmentError::CorruptFile {
1898 detail: format!("missing u64 column '{}'", col.name),
1899 })?;
1900 let mut values = Vec::with_capacity(end - start);
1901 for i in start..end {
1902 if arr.is_null(i) {
1903 values.push(0);
1904 stats.record_null();
1905 validity.push(false);
1906 } else {
1907 let v = arr.value(i);
1908 values.push(v);
1909 stats.update_u64(v);
1910 validity.push(true);
1911 }
1912 }
1913 let block = ColumnEncoder::encode_u64(&values)?;
1914 Ok(EncodedColumn::new(block, stats, validity))
1915 }
1916 (_, data_types::DECIMAL) => {
1917 let arr = batch
1918 .column_by_name(&col.name)
1919 .and_then(|c| c.as_any().downcast_ref::<Decimal128Array>())
1920 .ok_or_else(|| SegmentError::CorruptFile {
1921 detail: format!("missing decimal column '{}'", col.name),
1922 })?;
1923 let column_scale = col.decimal_scale.unwrap_or(0);
1927 let array_scale = u8::try_from(arr.scale()).map_err(|_| SegmentError::CorruptFile {
1928 detail: format!("negative decimal scale for column '{}'", col.name),
1929 })?;
1930 if array_scale != column_scale {
1931 return Err(SegmentError::CorruptFile {
1932 detail: format!(
1933 "decimal column '{}' is scale {column_scale} but the batch is scale \
1934 {array_scale}",
1935 col.name
1936 ),
1937 });
1938 }
1939 let mut values = Vec::with_capacity(end - start);
1940 for i in start..end {
1941 if arr.is_null(i) {
1942 values.push(0);
1943 stats.record_null();
1944 validity.push(false);
1945 } else {
1946 let v = arr.value(i);
1947 values.push(v);
1948 stats.update_decimal(v);
1949 validity.push(true);
1950 }
1951 }
1952 let block = ColumnEncoder::encode_decimal(&values)?;
1953 Ok(EncodedColumn::new(block, stats, validity))
1954 }
1955 (_, data_types::BOOL) => {
1956 let arr = batch
1957 .column_by_name(&col.name)
1958 .and_then(|c| c.as_any().downcast_ref::<BooleanArray>())
1959 .ok_or_else(|| SegmentError::CorruptFile {
1960 detail: format!("missing bool column '{}'", col.name),
1961 })?;
1962 let mut values = Vec::with_capacity(end - start);
1963 for i in start..end {
1964 if arr.is_null(i) {
1965 values.push(false);
1966 stats.record_null();
1967 validity.push(false);
1968 } else {
1969 let v = arr.value(i);
1970 values.push(v);
1971 stats.update_bool(v);
1972 validity.push(true);
1973 }
1974 }
1975 let block = ColumnEncoder::encode_bool(&values)?;
1976 Ok(EncodedColumn::new(block, stats, validity))
1977 }
1978 _ => Err(SegmentError::CorruptFile {
1979 detail: format!("unknown column type: {}", col.data_type),
1980 }),
1981 }
1982}
1983
1984#[cfg(test)]
1985mod tests {
1986 use super::*;
1987 use crate::segment::reader::SegmentReader;
1988 use chronix_core::config::{CompressionCodec, FloatEncoding};
1989 use chronix_core::types::{FieldValue, Point, SeriesKey};
1990 use std::collections::{BTreeMap, HashMap};
1991
1992 fn make_point(host: &str, cpu: f64, ts: i64) -> Point {
1993 let tags = BTreeMap::from([("host".to_string(), host.to_string())]);
1994 let key = SeriesKey::new("cpu_usage", tags).unwrap();
1995 let fields = BTreeMap::from([("cpu".to_string(), FieldValue::F64(cpu))]);
1996 Point::new(key, fields, ts).unwrap()
1997 }
1998
1999 #[test]
2000 fn per_column_codec_mixed_lz4_zstd() {
2001 let dir = tempfile::tempdir().unwrap();
2002 let path = dir.path().join("mixed_codec.csx");
2003
2004 let points: Vec<Point> = (0..200)
2005 .map(|i| make_point("server-1", 50.0 + i as f64, 1_000_000 + i * 10_000))
2006 .collect();
2007
2008 let mut overrides = HashMap::new();
2010 overrides.insert("cpu".to_string(), CompressionCodec::Zstd);
2011
2012 let config = SegmentWriterConfig {
2013 row_group_size: 100,
2014 compress: true,
2015 float_encoding: FloatEncoding::Gorilla,
2016 compression_codec: CompressionCodec::Lz4,
2017 zstd_level: 3,
2018 column_codec_overrides: overrides,
2019 ..Default::default()
2020 };
2021
2022 let mut writer = SegmentWriter::new(&path, config).unwrap();
2023 writer.write_rows(&points).unwrap();
2024 let meta = writer.finalize().unwrap();
2025
2026 assert_eq!(meta.row_count, 200);
2027
2028 let reader = SegmentReader::open(&path).unwrap();
2030 let batch = reader.read_all().unwrap();
2031 assert_eq!(batch.num_rows(), 200);
2032
2033 let cpu_col = batch
2035 .column_by_name("cpu")
2036 .unwrap()
2037 .as_any()
2038 .downcast_ref::<arrow::array::Float64Array>()
2039 .unwrap();
2040 for i in 0..200 {
2041 assert!(
2042 (cpu_col.value(i) - (50.0 + i as f64)).abs() < 1e-9,
2043 "cpu mismatch at row {i}"
2044 );
2045 }
2046 }
2047
2048 #[test]
2049 fn p09_zstd_dict_training_roundtrip() {
2050 let dir = tempfile::tempdir().unwrap();
2051 let path = dir.path().join("dict_trained.csx");
2052
2053 let points: Vec<Point> = (0..600)
2056 .map(|i| {
2057 let host = format!("host-{}", i % 5);
2058 make_point(&host, 10.0 + (i as f64) * 0.1, 1_000_000 + i * 10_000)
2059 })
2060 .collect();
2061
2062 let config = SegmentWriterConfig {
2063 row_group_size: 100,
2064 compress: true,
2065 compression_codec: CompressionCodec::Zstd,
2066 zstd_level: 3,
2067 zstd_dict_training: true,
2068 ..Default::default()
2069 };
2070
2071 let mut writer = SegmentWriter::new(&path, config).unwrap();
2072 writer.write_rows(&points).unwrap();
2073 let meta = writer.finalize().unwrap();
2074
2075 assert_eq!(meta.row_count, 600);
2076 assert!(meta.row_group_count >= 3);
2077
2078 let reader = SegmentReader::open(&path).unwrap();
2080 let batch = reader.read_all().unwrap();
2081 assert_eq!(batch.num_rows(), 600);
2082
2083 let cpu_col = batch
2085 .column_by_name("cpu")
2086 .unwrap()
2087 .as_any()
2088 .downcast_ref::<arrow::array::Float64Array>()
2089 .unwrap();
2090 assert_eq!(cpu_col.len(), 600);
2091 }
2092
2093 #[test]
2102 fn segment_metadata_overhead_stays_proportional_to_data() {
2103 let dir = tempfile::tempdir().unwrap();
2104 let path = dir.path().join("overhead.csx");
2105
2106 let points: Vec<Point> = (0..500)
2107 .map(|i| {
2108 let key = SeriesKey::new(
2109 "m".to_string(),
2110 std::collections::BTreeMap::from([(
2111 "host".to_string(),
2112 format!("h{}", i % 10),
2113 )]),
2114 )
2115 .unwrap();
2116 Point::new(
2117 key,
2118 std::collections::BTreeMap::from([(
2119 "cpu".to_string(),
2120 FieldValue::F64(f64::from(i % 100) / 10.0),
2121 )]),
2122 i64::from(i) * 1_000_000,
2123 )
2124 .unwrap()
2125 })
2126 .collect();
2127
2128 let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
2129 writer.write_rows(&points).unwrap();
2130 writer.finalize().unwrap();
2131
2132 let file_size = std::fs::metadata(&path).unwrap().len();
2133 let block_bytes: u64 = {
2134 let reader = SegmentReader::open(&path).unwrap();
2135 (0..reader.row_group_count())
2136 .filter_map(|rg| reader.row_group_blocks(rg))
2137 .flatten()
2138 .map(|b| u64::from(b.length))
2139 .sum()
2140 };
2141 let overhead = file_size - block_bytes;
2142
2143 assert!(
2144 overhead < 8 * 1024,
2145 "segment carries {overhead} bytes of non-block overhead for {block_bytes} bytes of \
2146 data — fixed-size metadata has crept back in"
2147 );
2148 }
2149}