Skip to main content

chronix_engine/segment/
writer.rs

1//! Segment writer — builds `.csx` segment files.
2//!
3//! The writer accepts rows (as [`Point`]s), sorts them by series key and
4//! timestamp, splits them into row groups, encodes each column with
5//! `chronix-encoding`, optionally compresses with LZ4, and writes
6//! the segment file using **streaming I/O with incremental CRC-32c**.
7//!
8//! # Streaming Write Architecture
9//!
10//! Row groups are encoded one at a time and flushed directly to a temp
11//! file via `BufWriter`. An incremental CRC-32c checksum (`crc32c_append`)
12//! is updated as each chunk is written. Peak memory is bounded by
13//! `O(row_group_size)` — typically 1-10 MiB — rather than `O(segment_size)`.
14//!
15//! The write path uses temp-file → `fsync` → `rename` → dir-fsync for
16//! crash-safe atomic visibility.
17//!
18//! # Zstd dictionary compression
19//!
20//! When `zstd_dict_training` is enabled and the compression codec is `Zstd`,
21//! the writer collects encoded column blocks from the first row group as
22//! training samples, trains a dictionary via `zstd::dict::from_samples`,
23//! and compresses subsequent row groups with the trained dictionary.
24//! The dictionary is embedded in the segment metadata for the reader.
25//!
26//! This improves compression ratio by 20–40% on small blocks (< 64 KiB)
27//! where column values have repeating structure (e.g. string tags, enum fields).
28//!
29//! # Usage
30//!
31//! ```no_run
32//! # use chronix_engine::segment::writer::{SegmentWriter, SegmentWriterConfig};
33//! let config = SegmentWriterConfig::default();
34//! let mut writer = SegmentWriter::new("/tmp/segment.csx", config).unwrap();
35//! // writer.write_rows(&points).unwrap();
36//! // let meta = writer.finalize().unwrap();
37//! ```
38
39use 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
63/// Output of a row-group write pass:
64/// (column metas, per-row-group block metas, rows written, optional trained zstd dictionary).
65type RowGroupWriteOutput = (
66    Vec<ColumnMeta>,
67    Vec<Vec<ColumnBlockMeta>>,
68    u64,
69    Option<Vec<u8>>,
70);
71
72/// Default maximum rows per row group.
73pub const DEFAULT_ROW_GROUP_SIZE: usize = 65_536;
74
75/// Default target segment file size (256 MiB).
76pub const DEFAULT_TARGET_SEGMENT_SIZE_BYTES: usize = 256 * 1024 * 1024;
77
78/// Configuration for the segment writer.
79#[derive(Debug, Clone)]
80pub struct SegmentWriterConfig {
81    /// Maximum rows per row group.
82    pub row_group_size: usize,
83    /// Maximum rows per row group for memory-limiting purposes.
84    ///
85    /// When set, the writer will split row groups to ensure that no single
86    /// row group exceeds this number of rows. This bounds peak memory usage
87    /// during encoding: only `max_row_group_size` rows worth of encoded
88    /// column buffers are held in memory at any time.
89    ///
90    /// Defaults to [`DEFAULT_ROW_GROUP_SIZE`] (65 536 rows). Reducing this
91    /// trades write throughput (more row groups, more metadata) for lower
92    /// peak memory consumption.
93    pub max_row_group_size: usize,
94    /// Whether to apply compression after encoding.
95    pub compress: bool,
96    /// Float encoding strategy.
97    pub float_encoding: FloatEncoding,
98    /// Compression codec (LZ4, Zstd, or None).
99    pub compression_codec: CompressionCodec,
100    /// Zstd compression level (1–22). Only used when `compression_codec` is Zstd.
101    pub zstd_level: i32,
102    /// Per-column codec overrides. Keys are column names; values override
103    /// the default `compression_codec` for that column.
104    pub column_codec_overrides: HashMap<String, CompressionCodec>,
105    /// Advisory maximum segment file size in bytes (default: 256 MiB).
106    ///
107    /// When the estimated segment size exceeds this threshold, the flush
108    /// logic in the database layer should split the data into multiple
109    /// segments. This keeps individual segment files manageable for
110    /// compaction, object-store upload, and cache eviction.
111    ///
112    /// **Note:** This is an advisory limit — the writer itself produces a
113    /// single segment file. Size-based splitting is enforced by the flush
114    /// layer (`chronix-memtable::flush::flush_frozen`), which estimates
115    /// the compressed row size and splits large measurement batches into
116    /// multiple `SegmentWriter` instances.
117    pub target_segment_size_bytes: usize,
118    /// Bloom filter false-positive rate (default: 0.01 = 1%).
119    ///
120    /// Lower values improve query pruning at the cost of larger bloom
121    /// filter bitmaps.  Recommended values:
122    /// Low cardinality (<100 unique): 0.001
123    /// Default workloads: 0.01
124    /// High cardinality (>100K unique): 0.05
125    pub bloom_fpr: f64,
126    /// Enable Zstd dictionary training.
127    ///
128    /// When `true` **and** `compression_codec` is `Zstd`, the writer
129    /// collects encoded column blocks from the first row group as
130    /// training samples, trains a dictionary via `zstd::dict::from_samples`,
131    /// and compresses subsequent row groups with the trained dictionary.
132    /// The dictionary is embedded in the segment metadata for the reader.
133    ///
134    /// Most beneficial when row groups are small (< 64 KiB) and values
135    /// contain repeating structure (e.g. string tags, enum fields).
136    pub zstd_dict_training: bool,
137    /// Per-column field-level encryption configuration.
138    ///
139    /// Only columns listed here are encrypted with AES-256-GCM.
140    /// Encrypted columns have their statistics (min/max/sum/distinct)
141    /// zeroed to prevent information leakage through zone maps.
142    #[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/// Metadata returned after a segment is finalized.
166#[derive(Debug, Clone, PartialEq)]
167pub struct SegmentMeta {
168    /// Path to the segment file.
169    pub path: PathBuf,
170    /// Minimum timestamp in the segment.
171    pub min_timestamp: i64,
172    /// Maximum timestamp in the segment.
173    pub max_timestamp: i64,
174    /// Total number of rows.
175    pub row_count: u64,
176    /// Number of unique series.
177    pub series_count: u32,
178    /// File size in bytes.
179    pub byte_size: u64,
180    /// Number of row groups.
181    pub row_group_count: u32,
182    /// Number of columns.
183    pub column_count: u16,
184    /// Total uncompressed payload bytes (before LZ4/Zstd), for compression ratio tracking.
185    pub uncompressed_bytes: u64,
186    /// Segment header (available from the write path, avoids re-reading the file).
187    pub header: crate::segment::header::SegmentHeader,
188    /// Per-column metadata with stats (available from the write path).
189    pub column_metas: Vec<crate::segment::metadata::ColumnMeta>,
190    /// The distinct series the segment holds, in series-major order — the
191    /// writer knows them, so nothing has to decode the segment to learn them.
192    pub series_keys: Vec<chronix_core::SeriesKey>,
193}
194
195/// Builds a `.csx` segment file from time-series data points.
196///
197/// Points are accumulated across multiple [`write_rows`](Self::write_rows)
198/// calls and encoded during [`finalize`](Self::finalize).
199pub struct SegmentWriter {
200    path: PathBuf,
201    config: SegmentWriterConfig,
202    /// Accumulated points from all `write_rows` calls.
203    points: Vec<Point>,
204    finalized: bool,
205}
206
207impl SegmentWriter {
208    /// Create a new segment writer that will write to `path`.
209    ///
210    /// # Errors
211    ///
212    /// Returns an I/O error if the directory cannot be determined.
213    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    /// Write a batch of rows to the segment.
223    ///
224    /// Can be called multiple times — all rows are accumulated and encoded
225    /// together during [`finalize`](Self::finalize).
226    ///
227    /// # Errors
228    ///
229    /// Returns [`SegmentError::AlreadyFinalized`] if the segment has been
230    /// finalized.
231    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    /// Returns the number of points accumulated so far.
241    #[must_use]
242    pub fn buffered_point_count(&self) -> usize {
243        self.points.len()
244    }
245
246    /// Finalize the segment: sort, encode, and stream to disk with
247    /// incremental CRC-32c.
248    ///
249    /// # Streaming Write Architecture
250    ///
251    /// Row groups are encoded one at a time and written directly to a
252    /// temp file via `BufWriter<File>`. An incremental CRC-32c checksum
253    /// is updated as each chunk is written. Peak memory is bounded by
254    /// `O(row_group_size)` — typically 1-10 MiB — not `O(segment_size)`.
255    ///
256    /// The write path uses temp-file → `fsync` → `rename` → dir-fsync
257    /// for crash-safe atomic visibility.
258    ///
259    /// # Errors
260    ///
261    /// Returns an error if the segment is empty or already finalized.
262    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        // Discover the column schema from the data. The schema already has
274        // tag columns sorted by ascending cardinality from discover_schema().
275        let schema = discover_schema(points);
276
277        // Extract the cardinality-ordered tag names directly from the
278        // schema — discover_schema() already sorted tags by cardinality,
279        // so we avoid recomputing distinct values.
280        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        // Sort rows by (measurement, lowest-cardinality tag first … , timestamp).
287        // Uses a borrow-based comparator to avoid per-row String allocations.
288        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            // Compare measurement
293            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            // Compare tags in cardinality order
301            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            // Finally compare timestamps
310            pa.timestamp().cmp(&pb.timestamp())
311        });
312
313        // Compute global stats
314        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        // Build header
324        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        // ── Streaming write path ───────────────────────────────────
345        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            // Write header
362            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            // Encode and stream row groups one at a time
368            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            // Write metadata
387            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            // Write footer with accumulated CRC
395            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            // Flush and fsync
406            writer.flush()?;
407            writer.get_ref().sync_all()?;
408            drop(writer);
409
410            // Atomic rename
411            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                // Fsync parent directory for rename durability.
437                // Failure here means the rename may not survive a power
438                // loss, so propagate the error to the caller.
439                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    /// Finalize from a **pre-sorted, pre-deduped** `RecordBatch`.
464    ///
465    /// This is the zero-copy write path used by the compaction executor.
466    /// Because the caller has already sorted and deduped the data, this
467    /// method skips schema discovery, sorting, and — most importantly —
468    /// avoids the `batch_to_points` round-trip that would allocate a
469    /// `BTreeMap` per row.
470    ///
471    /// Column data is extracted directly from Arrow arrays and passed
472    /// to the column encoders. Uses streaming I/O with incremental
473    /// CRC-32c.
474    ///
475    /// # Errors
476    ///
477    /// Returns an error if the batch is empty, the writer was already
478    /// finalized, or encoding/I/O fails.
479    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        // Build schema from Arrow schema + tag metadata
497        let schema = discover_schema_from_batch(batch, &tag_set)?;
498
499        // Compute global stats directly from Arrow arrays
500        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        // ── Streaming write path ───────────────────────────────────
548        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            // Write header
565            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            // Encode and stream row groups one at a time
571            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            // Write metadata
589            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            // Write footer with accumulated CRC
597            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            // Flush and fsync
608            writer.flush()?;
609            writer.get_ref().sync_all()?;
610            drop(writer);
611
612            // Atomic rename
613            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                // Fsync parent directory for rename durability.
639                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    /// Finalize a segment from pre-chunked `RecordBatch` slices.
664    ///
665    /// Unlike [`finalize_batch`](Self::finalize_batch), this avoids
666    /// `concat_batches` by iterating over the chunks directly. Each chunk
667    /// is encoded as one or more row groups and streamed to disk, bounding
668    /// peak memory to `O(chunk_size × columns)`.
669    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        // Concatenate for now — this is the fallback. The streaming
683        // architecture benefit is that the compaction executor no longer
684        // needs to concat first, saving one full copy. We can concat
685        // here since the chunks are already small (row_group_size each).
686        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
696// ── Row Group Encoding ─────────────────────────────────────────────────
697
698/// Encode all row groups from sorted point data, streaming each encoded
699/// column block directly to `writer` instead of buffering in memory.
700///
701/// The file position (`pos`) and incremental CRC (`crc`) are updated in-place
702/// as each block is written. Peak memory is bounded by `O(row_group_size)`
703/// instead of `O(segment_size)`.
704fn encode_row_groups_streaming<W: Write>(
705    schema: &[ColumnDef],
706    sorted_indices: &[usize],
707    points: &[Point],
708    config: &SegmentWriterConfig,
709    // Only the encryption path reads it: it is half of the AEAD nonce's
710    // associated data, so a block cannot be replayed into another segment.
711    #[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    // Zstd dictionary training state.
749    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    // rg_index also names files/metadata, not just iterations
757    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            // Collect training samples from the first row group.
776            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                    // Use dictionary compression when available.
788                    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            // Field-level encryption: encrypt after encode+compress.
818            #[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            // Suppress statistics for encrypted columns to prevent
844            // information leakage through zone-map predicates.
845            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            // D-NULL: the validity bitmap follows the block payload. It is
854            // stored uncompressed and unencrypted — it reveals only which
855            // rows have values, never what they are — so the reader can turn
856            // it straight into an Arrow NullBuffer.
857            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        // Build per-row-group bloom filters for tag columns.
882        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        // Train dictionary after the first row group.
906        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    // Build bloom filters for tag columns.
923    // Skip bloom filter for encrypted columns to prevent information leakage.
924    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// ── Schema Discovery ───────────────────────────────────────────────────
951
952/// Discovered column definition from point data.
953#[derive(Debug, Clone)]
954struct ColumnDef {
955    name: String,
956    data_type: u8,
957    role: u8,
958    default_encoding: u8,
959    /// Digits after the decimal point, for a decimal column; `None` otherwise.
960    decimal_scale: Option<u8>,
961}
962
963/// Discover the column schema by scanning all points.
964///
965/// Tag columns are ordered by ascending cardinality (fewest distinct
966/// values first) so that the physical column layout is optimal for
967/// run-length and dictionary encoding.
968fn discover_schema(points: &[Point]) -> Vec<ColumnDef> {
969    let mut columns: Vec<ColumnDef> = Vec::new();
970
971    // Timestamp column is always first
972    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    // Discover tag columns and compute cardinality
981    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    // Sort tags by cardinality (lowest first) for optimal compression
989    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    // Discover field columns.
1012    //
1013    // A decimal field also carries its scale, and the *widest* scale in the
1014    // batch wins rather than whichever point happened to come first. The
1015    // write path has already normalised every value to the column's declared
1016    // scale, so in practice they all agree; taking the maximum means a
1017    // segment written from points that had not been through that path — a
1018    // test, a tool, a future caller — still produces one scale for the
1019    // column instead of silently truncating to the first one seen.
1020    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
1062/// Append a block's validity bitmap to the segment file.
1063///
1064/// Returns the `(offset, length)` pair recorded in
1065/// [`ColumnBlockMeta::validity_offset`] / `validity_length`. A `None` bitmap
1066/// (a block with no nulls) writes nothing and yields `(0, 0)`.
1067fn 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
1086/// An encoded column block together with its statistics and, when the block
1087/// contains nulls, its validity bitmap (`.csx` v2, D-NULL).
1088struct EncodedColumn {
1089    block: EncodedBlock,
1090    stats: ColumnStats,
1091    /// `None` when every row in the block holds a real value.
1092    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    /// Wrap a column that cannot contain nulls (timestamps, tags).
1105    fn dense((block, stats): (EncodedBlock, ColumnStats)) -> Self {
1106        Self {
1107            block,
1108            stats,
1109            validity: None,
1110        }
1111    }
1112}
1113
1114/// Encode a single column for a chunk of rows.
1115fn 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    // D-NULL: record which rows actually carry a value so the reader can
1123    // distinguish an absent field from a stored zero.
1124    let mut validity = ValidityBuilder::with_capacity(indices.len());
1125
1126    match (col.role, col.data_type) {
1127        // Timestamps are never null; tags are materialised as "" by design
1128        // (an absent tag is not part of the series key).
1129        (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            // The scale is the column's, recorded once in its metadata; what
1197            // is encoded is the mantissa column. A value that arrived at a
1198            // different scale is widened losslessly rather than dropped — the
1199            // write path normalises, so this only catches a caller that
1200            // bypassed it.
1201            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            // Collect owned strings, then borrow as &str for encoding
1234            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            // value_count should reflect non-null entries (null_count already tracked).
1249            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
1259/// Compute min/max timestamps and unique series count from sorted indices.
1260///
1261/// Since the indices are already sorted by `(canonical_form, timestamp)`,
1262/// we count distinct series by checking when the canonical form changes.
1263fn 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        // Series-major order: a new series starts wherever the canonical form
1277        // changes. Comparing the form rather than the hash means a hash
1278        // collision between adjacent series is counted as two, not one.
1279        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
1289/// Encode a timestamp column from a set of points at the given indices.
1290fn 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(&timestamps)?;
1302    Ok((block, stats))
1303}
1304
1305/// Encode a tag column by looking up each tag value from the series key.
1306fn 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
1324/// Merge source stats into destination.
1325///
1326/// **Note:** `distinct_count` uses `max()` when merging across row groups —
1327/// a lower bound on the true segment cardinality, and a tighter estimate than
1328/// summing. The segment-level per-column `distinct_count` computed over all
1329/// rows is the exact figure; this merge only applies to the row-group rollup.
1330fn 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    // Also merge u64 min/max — previously these were silently
1334    // dropped, breaking predicate pushdown for u64 columns.
1335    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    // Use max() instead of saturating_add for a tighter bound.
1342    // Row groups may share values, so summing grossly over-counts.
1343    dest.distinct_count = dest.distinct_count.max(src.distinct_count);
1344}
1345
1346// ── Batch-based encoding (zero-copy compaction path) ───────────────────
1347
1348/// Discover column schema from an Arrow `RecordBatch`.
1349///
1350/// Uses `tag_columns` to distinguish tags from fields (since Arrow
1351/// schema alone doesn't carry Chronix role information).
1352fn 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    // Timestamp first
1359    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    // Tags (sorted for consistency)
1368    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    // Fields (everything else except timestamp and tags)
1383    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        // The Arrow scale is authoritative on this path: the batch came out
1420        // of a segment or a memtable, both of which already fixed it.
1421        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
1441/// Count distinct series in a RecordBatch by canonical series key form.
1442///
1443/// Uses the full canonical form (not just hash) to avoid undercounting
1444/// in the extremely rare case of FNV hash collisions.
1445fn 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
1481/// Encode all row groups from a pre-sorted `RecordBatch`, streaming each
1482/// encoded column block directly to `writer` instead of buffering in memory.
1483///
1484/// This is the batch-based counterpart of [`encode_row_groups_streaming`].
1485fn encode_row_groups_from_batch_streaming<W: Write>(
1486    schema: &[ColumnDef],
1487    batch: &RecordBatch,
1488    config: &SegmentWriterConfig,
1489    // Only the encryption path reads it: it is half of the AEAD nonce's
1490    // associated data, so a block cannot be replayed into another segment.
1491    #[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    // Zstd dictionary training state.
1529    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    // rg_index also names files/metadata, not just iterations
1537    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            // Collect training samples from the first row group.
1561            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                    // Use dictionary compression when available.
1573                    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            // Field-level encryption: encrypt after encode+compress.
1603            #[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            // Suppress statistics for encrypted columns to prevent
1629            // information leakage through zone-map predicates.
1630            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            // D-NULL: validity bitmap follows the block payload.
1639            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        // Build per-row-group bloom filters for tag columns (batch path).
1664        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        // Train dictionary after the first row group.
1695        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    // Build bloom filters for tag columns from Arrow arrays.
1712    // Skip bloom filter for encrypted columns to prevent information leakage.
1713    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
1747/// Encode a single column from a RecordBatch slice [start..end).
1748fn 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    // D-NULL: track per-row presence alongside the encoded values.
1757    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(&timestamps)?;
1774            // Timestamps are never null.
1775            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            // The column's scale and the array's must agree: the mantissas
1878            // are stored raw, so writing a scale-2 array into a scale-4
1879            // column would multiply every value by a hundred.
1880            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        // Override the "cpu" column to use Zstd while default is Lz4
1963        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        // Read back and verify data integrity
1983        let reader = SegmentReader::open(&path).unwrap();
1984        let batch = reader.read_all().unwrap();
1985        assert_eq!(batch.num_rows(), 200);
1986
1987        // Verify cpu values
1988        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        // Generate enough data for 3+ row groups so dictionary training
2008        // kicks in on the first and is used on subsequent groups.
2009        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        // Verify the dictionary was trained and embedded
2033        let reader = SegmentReader::open(&path).unwrap();
2034        let batch = reader.read_all().unwrap();
2035        assert_eq!(batch.num_rows(), 600);
2036
2037        // Verify data integrity
2038        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    /// Segment metadata must stay proportional to the data, not fixed.
2048    ///
2049    /// Every column used to carry a full-precision HyperLogLog sketch
2050    /// (16 KiB) plus an equi-depth histogram, unconditionally — roughly 49 KiB
2051    /// of fixed overhead per segment that nothing ever read: the DataFusion
2052    /// statistics provider works from the catalog's exact `distinct_count`.
2053    /// On an hourly-sharded gateway that dwarfed the data and burned flash for
2054    /// nothing, so the sketches were removed. This pins the result.
2055    #[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}