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| -> Result<ColumnMeta> {
721            #[cfg(feature = "field-encryption")]
722            let (encrypted, key_id) = if let Some(k) = config.field_encryption.key_for(&col.name) {
723                // Only a **field** can honestly be encrypted. A tag's value
724                // is part of the series key, and the series key is written
725                // in plaintext right beside this segment — in its `.series`
726                // sidecar, in the inverted tag index and in the bloom filter
727                // — so an "encrypted" tag column would publish the value it
728                // claims to hide, a foot away. The timestamp cannot be
729                // encrypted either: every scan ranges on it.
730                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    // Zstd dictionary training state.
772    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    // rg_index also names files/metadata, not just iterations
780    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            // Collect training samples from the first row group.
799            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                    // Use dictionary compression when available.
811                    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            // Field-level encryption: encrypt after encode+compress.
841            #[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            // Suppress statistics for encrypted columns to prevent
867            // information leakage through zone-map predicates.
868            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            // D-NULL: the validity bitmap follows the block payload. It is
877            // stored uncompressed and unencrypted — it reveals only which
878            // rows have values, never what they are — so the reader can turn
879            // it straight into an Arrow NullBuffer.
880            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        // Build per-row-group bloom filters for tag columns.
905        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        // Train dictionary after the first row group.
929        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    // Build bloom filters for tag columns.
946    // Skip bloom filter for encrypted columns to prevent information leakage.
947    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// ── Schema Discovery ───────────────────────────────────────────────────
974
975/// Discovered column definition from point data.
976#[derive(Debug, Clone)]
977struct ColumnDef {
978    name: String,
979    data_type: u8,
980    role: u8,
981    default_encoding: u8,
982    /// Digits after the decimal point, for a decimal column; `None` otherwise.
983    decimal_scale: Option<u8>,
984}
985
986/// Discover the column schema by scanning all points.
987///
988/// Tag columns are ordered by ascending cardinality (fewest distinct
989/// values first) so that the physical column layout is optimal for
990/// run-length and dictionary encoding.
991fn discover_schema(points: &[Point]) -> Vec<ColumnDef> {
992    let mut columns: Vec<ColumnDef> = Vec::new();
993
994    // Timestamp column is always first
995    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    // Discover tag columns and compute cardinality
1004    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    // Sort tags by cardinality (lowest first) for optimal compression
1012    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    // Discover field columns.
1035    //
1036    // A decimal field also carries its scale, and the *widest* scale in the
1037    // batch wins rather than whichever point happened to come first. The
1038    // write path has already normalised every value to the column's declared
1039    // scale, so in practice they all agree; taking the maximum means a
1040    // segment written from points that had not been through that path — a
1041    // test, a tool, a future caller — still produces one scale for the
1042    // column instead of silently truncating to the first one seen.
1043    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
1085/// Append a block's validity bitmap to the segment file.
1086///
1087/// Returns the `(offset, length)` pair recorded in
1088/// [`ColumnBlockMeta::validity_offset`] / `validity_length`. A `None` bitmap
1089/// (a block with no nulls) writes nothing and yields `(0, 0)`.
1090fn 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
1109/// An encoded column block together with its statistics and, when the block
1110/// contains nulls, its validity bitmap (`.csx` v2, D-NULL).
1111struct EncodedColumn {
1112    block: EncodedBlock,
1113    stats: ColumnStats,
1114    /// `None` when every row in the block holds a real value.
1115    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    /// Wrap a column that cannot contain nulls (timestamps, tags).
1128    fn dense((block, stats): (EncodedBlock, ColumnStats)) -> Self {
1129        Self {
1130            block,
1131            stats,
1132            validity: None,
1133        }
1134    }
1135}
1136
1137/// Encode a single column for a chunk of rows.
1138fn 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    // D-NULL: record which rows actually carry a value so the reader can
1146    // distinguish an absent field from a stored zero.
1147    let mut validity = ValidityBuilder::with_capacity(indices.len());
1148
1149    match (col.role, col.data_type) {
1150        // Timestamps are never null; tags are materialised as "" by design
1151        // (an absent tag is not part of the series key).
1152        (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            // The scale is the column's, recorded once in its metadata; what
1220            // is encoded is the mantissa column. A value that arrived at a
1221            // different scale is widened losslessly rather than dropped — the
1222            // write path normalises, so this only catches a caller that
1223            // bypassed it.
1224            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            // Collect owned strings, then borrow as &str for encoding
1257            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            // value_count should reflect non-null entries (null_count already tracked).
1272            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
1282/// Compute min/max timestamps and unique series count from sorted indices.
1283///
1284/// Since the indices are already sorted by `(canonical_form, timestamp)`,
1285/// we count distinct series by checking when the canonical form changes.
1286fn 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        // Series-major order: a new series starts wherever the canonical form
1300        // changes. Comparing the form rather than the hash means a hash
1301        // collision between adjacent series is counted as two, not one.
1302        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
1312/// Encode a timestamp column from a set of points at the given indices.
1313fn 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(&timestamps)?;
1325    Ok((block, stats))
1326}
1327
1328/// Encode a tag column by looking up each tag value from the series key.
1329fn 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
1347/// Merge source stats into destination.
1348///
1349/// **Note:** `distinct_count` uses `max()` when merging across row groups —
1350/// a lower bound on the true segment cardinality, and a tighter estimate than
1351/// summing. The segment-level per-column `distinct_count` computed over all
1352/// rows is the exact figure; this merge only applies to the row-group rollup.
1353fn 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    // Also merge u64 min/max — previously these were silently
1357    // dropped, breaking predicate pushdown for u64 columns.
1358    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    // Use max() instead of saturating_add for a tighter bound.
1365    // Row groups may share values, so summing grossly over-counts.
1366    dest.distinct_count = dest.distinct_count.max(src.distinct_count);
1367}
1368
1369// ── Batch-based encoding (zero-copy compaction path) ───────────────────
1370
1371/// Discover column schema from an Arrow `RecordBatch`.
1372///
1373/// Uses `tag_columns` to distinguish tags from fields (since Arrow
1374/// schema alone doesn't carry Chronix role information).
1375fn 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    // Timestamp first
1382    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    // Tags (sorted for consistency)
1391    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    // Fields (everything else except timestamp and tags)
1406    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        // The Arrow scale is authoritative on this path: the batch came out
1443        // of a segment or a memtable, both of which already fixed it.
1444        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
1464/// Count distinct series in a RecordBatch by canonical series key form.
1465///
1466/// Uses the full canonical form (not just hash) to avoid undercounting
1467/// in the extremely rare case of FNV hash collisions.
1468fn 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
1504/// Encode all row groups from a pre-sorted `RecordBatch`, streaming each
1505/// encoded column block directly to `writer` instead of buffering in memory.
1506///
1507/// This is the batch-based counterpart of [`encode_row_groups_streaming`].
1508fn encode_row_groups_from_batch_streaming<W: Write>(
1509    schema: &[ColumnDef],
1510    batch: &RecordBatch,
1511    config: &SegmentWriterConfig,
1512    // Only the encryption path reads it: it is half of the AEAD nonce's
1513    // associated data, so a block cannot be replayed into another segment.
1514    #[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                // Only a **field** can honestly be encrypted. A tag's value
1527                // is part of the series key, and the series key is written
1528                // in plaintext right beside this segment — in its `.series`
1529                // sidecar, in the inverted tag index and in the bloom filter
1530                // — so an "encrypted" tag column would publish the value it
1531                // claims to hide, a foot away. The timestamp cannot be
1532                // encrypted either: every scan ranges on it.
1533                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    // Zstd dictionary training state.
1575    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    // rg_index also names files/metadata, not just iterations
1583    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            // Collect training samples from the first row group.
1607            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                    // Use dictionary compression when available.
1619                    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            // Field-level encryption: encrypt after encode+compress.
1649            #[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            // Suppress statistics for encrypted columns to prevent
1675            // information leakage through zone-map predicates.
1676            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            // D-NULL: validity bitmap follows the block payload.
1685            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        // Build per-row-group bloom filters for tag columns (batch path).
1710        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        // Train dictionary after the first row group.
1741        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    // Build bloom filters for tag columns from Arrow arrays.
1758    // Skip bloom filter for encrypted columns to prevent information leakage.
1759    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
1793/// Encode a single column from a RecordBatch slice [start..end).
1794fn 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    // D-NULL: track per-row presence alongside the encoded values.
1803    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(&timestamps)?;
1820            // Timestamps are never null.
1821            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            // The column's scale and the array's must agree: the mantissas
1924            // are stored raw, so writing a scale-2 array into a scale-4
1925            // column would multiply every value by a hundred.
1926            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        // Override the "cpu" column to use Zstd while default is Lz4
2009        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        // Read back and verify data integrity
2029        let reader = SegmentReader::open(&path).unwrap();
2030        let batch = reader.read_all().unwrap();
2031        assert_eq!(batch.num_rows(), 200);
2032
2033        // Verify cpu values
2034        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        // Generate enough data for 3+ row groups so dictionary training
2054        // kicks in on the first and is used on subsequent groups.
2055        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        // Verify the dictionary was trained and embedded
2079        let reader = SegmentReader::open(&path).unwrap();
2080        let batch = reader.read_all().unwrap();
2081        assert_eq!(batch.num_rows(), 600);
2082
2083        // Verify data integrity
2084        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    /// Segment metadata must stay proportional to the data, not fixed.
2094    ///
2095    /// Every column used to carry a full-precision HyperLogLog sketch
2096    /// (16 KiB) plus an equi-depth histogram, unconditionally — roughly 49 KiB
2097    /// of fixed overhead per segment that nothing ever read: the DataFusion
2098    /// statistics provider works from the catalog's exact `distinct_count`.
2099    /// On an hourly-sharded gateway that dwarfed the data and burned flash for
2100    /// nothing, so the sketches were removed. This pins the result.
2101    #[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}