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::{Array, BooleanArray, Float64Array, Int64Array, StringArray, UInt64Array};
48use arrow::datatypes::DataType;
49use arrow::record_batch::RecordBatch;
50
51use crate::segment::compression::{
52    compress_block_with_codec, compress_block_zstd_dict, should_skip_compression,
53    train_zstd_dictionary,
54};
55use crate::segment::error::{Result, SegmentError};
56use crate::segment::header::{SegmentFooter, SegmentHeader, VERSION};
57use crate::segment::metadata::{data_types, roles, ColumnBlockMeta, ColumnMeta, SegmentMetadata};
58use crate::segment::stats::ColumnStats;
59use crate::segment::validity::ValidityBuilder;
60
61/// Output of a row-group write pass:
62/// (column metas, per-row-group block metas, rows written, optional trained zstd dictionary).
63type RowGroupWriteOutput = (
64    Vec<ColumnMeta>,
65    Vec<Vec<ColumnBlockMeta>>,
66    u64,
67    Option<Vec<u8>>,
68);
69
70/// Default maximum rows per row group.
71pub const DEFAULT_ROW_GROUP_SIZE: usize = 65_536;
72
73/// Default target segment file size (256 MiB).
74pub const DEFAULT_TARGET_SEGMENT_SIZE_BYTES: usize = 256 * 1024 * 1024;
75
76/// Configuration for the segment writer.
77#[derive(Debug, Clone)]
78pub struct SegmentWriterConfig {
79    /// Maximum rows per row group.
80    pub row_group_size: usize,
81    /// Maximum rows per row group for memory-limiting purposes.
82    ///
83    /// When set, the writer will split row groups to ensure that no single
84    /// row group exceeds this number of rows. This bounds peak memory usage
85    /// during encoding: only `max_row_group_size` rows worth of encoded
86    /// column buffers are held in memory at any time.
87    ///
88    /// Defaults to [`DEFAULT_ROW_GROUP_SIZE`] (65 536 rows). Reducing this
89    /// trades write throughput (more row groups, more metadata) for lower
90    /// peak memory consumption.
91    pub max_row_group_size: usize,
92    /// Whether to apply compression after encoding.
93    pub compress: bool,
94    /// Float encoding strategy.
95    pub float_encoding: FloatEncoding,
96    /// Compression codec (LZ4, Zstd, or None).
97    pub compression_codec: CompressionCodec,
98    /// Zstd compression level (1–22). Only used when `compression_codec` is Zstd.
99    pub zstd_level: i32,
100    /// Per-column codec overrides. Keys are column names; values override
101    /// the default `compression_codec` for that column.
102    pub column_codec_overrides: HashMap<String, CompressionCodec>,
103    /// Advisory maximum segment file size in bytes (default: 256 MiB).
104    ///
105    /// When the estimated segment size exceeds this threshold, the flush
106    /// logic in the database layer should split the data into multiple
107    /// segments. This keeps individual segment files manageable for
108    /// compaction, object-store upload, and cache eviction.
109    ///
110    /// **Note:** This is an advisory limit — the writer itself produces a
111    /// single segment file. Size-based splitting is enforced by the flush
112    /// layer (`chronix-memtable::flush::flush_frozen`), which estimates
113    /// the compressed row size and splits large measurement batches into
114    /// multiple `SegmentWriter` instances.
115    pub target_segment_size_bytes: usize,
116    /// Bloom filter false-positive rate (default: 0.01 = 1%).
117    ///
118    /// Lower values improve query pruning at the cost of larger bloom
119    /// filter bitmaps.  Recommended values:
120    /// Low cardinality (<100 unique): 0.001
121    /// Default workloads: 0.01
122    /// High cardinality (>100K unique): 0.05
123    pub bloom_fpr: f64,
124    /// Enable Zstd dictionary training.
125    ///
126    /// When `true` **and** `compression_codec` is `Zstd`, the writer
127    /// collects encoded column blocks from the first row group as
128    /// training samples, trains a dictionary via `zstd::dict::from_samples`,
129    /// and compresses subsequent row groups with the trained dictionary.
130    /// The dictionary is embedded in the segment metadata for the reader.
131    ///
132    /// Most beneficial when row groups are small (< 64 KiB) and values
133    /// contain repeating structure (e.g. string tags, enum fields).
134    pub zstd_dict_training: bool,
135    /// Per-column field-level encryption configuration.
136    ///
137    /// Only columns listed here are encrypted with AES-256-GCM.
138    /// Encrypted columns have their statistics (min/max/sum/distinct)
139    /// zeroed to prevent information leakage through zone maps.
140    #[cfg(feature = "field-encryption")]
141    pub field_encryption: crate::segment::field_encryption::FieldEncryptionConfig,
142}
143
144impl Default for SegmentWriterConfig {
145    fn default() -> Self {
146        Self {
147            row_group_size: DEFAULT_ROW_GROUP_SIZE,
148            max_row_group_size: DEFAULT_ROW_GROUP_SIZE,
149            compress: true,
150            float_encoding: FloatEncoding::Chimp,
151            compression_codec: CompressionCodec::Lz4,
152            zstd_level: 3,
153            column_codec_overrides: HashMap::new(),
154            target_segment_size_bytes: DEFAULT_TARGET_SEGMENT_SIZE_BYTES,
155            bloom_fpr: 0.01,
156            zstd_dict_training: false,
157            #[cfg(feature = "field-encryption")]
158            field_encryption: crate::segment::field_encryption::FieldEncryptionConfig::default(),
159        }
160    }
161}
162
163/// Metadata returned after a segment is finalized.
164#[derive(Debug, Clone, PartialEq)]
165pub struct SegmentMeta {
166    /// Path to the segment file.
167    pub path: PathBuf,
168    /// Minimum timestamp in the segment.
169    pub min_timestamp: i64,
170    /// Maximum timestamp in the segment.
171    pub max_timestamp: i64,
172    /// Total number of rows.
173    pub row_count: u64,
174    /// Number of unique series.
175    pub series_count: u32,
176    /// File size in bytes.
177    pub byte_size: u64,
178    /// Number of row groups.
179    pub row_group_count: u32,
180    /// Number of columns.
181    pub column_count: u16,
182    /// Total uncompressed payload bytes (before LZ4/Zstd), for compression ratio tracking.
183    pub uncompressed_bytes: u64,
184    /// Segment header (available from the write path, avoids re-reading the file).
185    pub header: crate::segment::header::SegmentHeader,
186    /// Per-column metadata with stats (available from the write path).
187    pub column_metas: Vec<crate::segment::metadata::ColumnMeta>,
188    /// The distinct series the segment holds, in series-major order — the
189    /// writer knows them, so nothing has to decode the segment to learn them.
190    pub series_keys: Vec<chronix_core::SeriesKey>,
191}
192
193/// Builds a `.csx` segment file from time-series data points.
194///
195/// Points are accumulated across multiple [`write_rows`](Self::write_rows)
196/// calls and encoded during [`finalize`](Self::finalize).
197pub struct SegmentWriter {
198    path: PathBuf,
199    config: SegmentWriterConfig,
200    /// Accumulated points from all `write_rows` calls.
201    points: Vec<Point>,
202    finalized: bool,
203}
204
205impl SegmentWriter {
206    /// Create a new segment writer that will write to `path`.
207    ///
208    /// # Errors
209    ///
210    /// Returns an I/O error if the directory cannot be determined.
211    pub fn new(path: impl AsRef<Path>, config: SegmentWriterConfig) -> Result<Self> {
212        Ok(Self {
213            path: path.as_ref().to_path_buf(),
214            config,
215            points: Vec::new(),
216            finalized: false,
217        })
218    }
219
220    /// Write a batch of rows to the segment.
221    ///
222    /// Can be called multiple times — all rows are accumulated and encoded
223    /// together during [`finalize`](Self::finalize).
224    ///
225    /// # Errors
226    ///
227    /// Returns [`SegmentError::AlreadyFinalized`] if the segment has been
228    /// finalized.
229    pub fn write_rows(&mut self, points: &[Point]) -> Result<()> {
230        if self.finalized {
231            return Err(SegmentError::AlreadyFinalized);
232        }
233
234        self.points.extend_from_slice(points);
235        Ok(())
236    }
237
238    /// Returns the number of points accumulated so far.
239    #[must_use]
240    pub fn buffered_point_count(&self) -> usize {
241        self.points.len()
242    }
243
244    /// Finalize the segment: sort, encode, and stream to disk with
245    /// incremental CRC-32c.
246    ///
247    /// # Streaming Write Architecture
248    ///
249    /// Row groups are encoded one at a time and written directly to a
250    /// temp file via `BufWriter<File>`. An incremental CRC-32c checksum
251    /// is updated as each chunk is written. Peak memory is bounded by
252    /// `O(row_group_size)` — typically 1-10 MiB — not `O(segment_size)`.
253    ///
254    /// The write path uses temp-file → `fsync` → `rename` → dir-fsync
255    /// for crash-safe atomic visibility.
256    ///
257    /// # Errors
258    ///
259    /// Returns an error if the segment is empty or already finalized.
260    pub fn finalize(&mut self) -> Result<SegmentMeta> {
261        if self.finalized {
262            return Err(SegmentError::AlreadyFinalized);
263        }
264
265        if self.points.is_empty() {
266            return Err(SegmentError::EmptySegment);
267        }
268
269        let points = &self.points;
270
271        // Discover the column schema from the data. The schema already has
272        // tag columns sorted by ascending cardinality from discover_schema().
273        let schema = discover_schema(points);
274
275        // Extract the cardinality-ordered tag names directly from the
276        // schema — discover_schema() already sorted tags by cardinality,
277        // so we avoid recomputing distinct values.
278        let tag_cardinality_order: Vec<&str> = schema
279            .iter()
280            .filter(|c| c.role == roles::TAG)
281            .map(|c| c.name.as_str())
282            .collect();
283
284        // Sort rows by (measurement, lowest-cardinality tag first … , timestamp).
285        // Uses a borrow-based comparator to avoid per-row String allocations.
286        let mut sorted_indices: Vec<usize> = (0..points.len()).collect();
287        sorted_indices.sort_unstable_by(|&a, &b| {
288            let pa = &points[a];
289            let pb = &points[b];
290            // Compare measurement
291            let cmp = pa
292                .series_key()
293                .measurement()
294                .cmp(pb.series_key().measurement());
295            if cmp != std::cmp::Ordering::Equal {
296                return cmp;
297            }
298            // Compare tags in cardinality order
299            for tag_name in &tag_cardinality_order {
300                let va = pa.series_key().tag(tag_name).unwrap_or("");
301                let vb = pb.series_key().tag(tag_name).unwrap_or("");
302                let cmp = va.cmp(vb);
303                if cmp != std::cmp::Ordering::Equal {
304                    return cmp;
305                }
306            }
307            // Finally compare timestamps
308            pa.timestamp().cmp(&pb.timestamp())
309        });
310
311        // Compute global stats
312        let (min_ts, max_ts, series_keys) = compute_global_stats(points, &sorted_indices);
313        let series_count = u32::try_from(series_keys.len()).unwrap_or(u32::MAX);
314
315        let total_rows = sorted_indices.len();
316
317        let column_count = u16::try_from(schema.len()).map_err(|_| SegmentError::CorruptFile {
318            detail: format!("column count {} exceeds u16::MAX", schema.len()),
319        })?;
320
321        // Build header
322        let header = SegmentHeader {
323            version: VERSION,
324            flags: 0,
325            created_at: std::time::SystemTime::now()
326                .duration_since(std::time::UNIX_EPOCH)
327                .map(|d| i64::try_from(d.as_nanos()).unwrap_or(i64::MAX))
328                .unwrap_or(0),
329            min_timestamp: min_ts,
330            max_timestamp: max_ts,
331            row_count: total_rows as u64,
332            column_count,
333            series_count,
334            compression: match self.config.compression_codec {
335                CompressionCodec::Lz4 => 0,
336                CompressionCodec::Zstd => 1,
337                CompressionCodec::None => 2,
338            },
339            sort_order: 1,
340        };
341
342        // ── Streaming write path ───────────────────────────────────
343        if let Some(parent) = self.path.parent() {
344            std::fs::create_dir_all(parent)?;
345        }
346
347        let temp_path = {
348            let mut p = self.path.clone().into_os_string();
349            p.push(".tmp");
350            PathBuf::from(p)
351        };
352
353        let result = (|| -> Result<(SegmentMeta, u64)> {
354            let file = std::fs::File::create(&temp_path)?;
355            let mut writer = BufWriter::new(file);
356            let mut crc: u32 = 0;
357            let mut pos: u64 = 0;
358
359            // Write header
360            let header_bytes = header.to_bytes();
361            writer.write_all(&header_bytes)?;
362            crc = crc32c::crc32c_append(crc, &header_bytes);
363            pos += header_bytes.len() as u64;
364
365            // Encode and stream row groups one at a time
366            let (column_metas, row_group_blocks, uncompressed_bytes, zstd_dictionary) =
367                encode_row_groups_streaming(
368                    &schema,
369                    &sorted_indices,
370                    points,
371                    &self.config,
372                    header.created_at,
373                    &mut writer,
374                    &mut crc,
375                    &mut pos,
376                )?;
377
378            let metadata = SegmentMetadata {
379                columns: column_metas,
380                row_group_blocks,
381                zstd_dictionary,
382            };
383
384            // Write metadata
385            let metadata_offset = pos;
386            let metadata_bytes = metadata.to_bytes()?;
387            let metadata_checksum = crc32c::crc32c(&metadata_bytes);
388            writer.write_all(&metadata_bytes)?;
389            crc = crc32c::crc32c_append(crc, &metadata_bytes);
390            pos += metadata_bytes.len() as u64;
391
392            // Write footer with accumulated CRC
393            let footer = SegmentFooter {
394                metadata_offset,
395                row_group_count: metadata.row_group_blocks.len() as u32,
396                checksum: crc,
397                metadata_checksum,
398            };
399            let footer_bytes = footer.to_bytes();
400            writer.write_all(&footer_bytes)?;
401            pos += footer_bytes.len() as u64;
402
403            // Flush and fsync
404            writer.flush()?;
405            writer.get_ref().sync_all()?;
406            drop(writer);
407
408            // Atomic rename
409            std::fs::rename(&temp_path, &self.path)?;
410
411            Ok((
412                SegmentMeta {
413                    path: self.path.clone(),
414                    min_timestamp: header.min_timestamp,
415                    max_timestamp: header.max_timestamp,
416                    row_count: header.row_count,
417                    series_count: header.series_count,
418                    byte_size: pos,
419                    row_group_count: footer.row_group_count,
420                    column_count: header.column_count,
421                    uncompressed_bytes,
422                    header,
423                    column_metas: metadata.columns,
424                    series_keys,
425                },
426                pos,
427            ))
428        })();
429
430        match result {
431            Ok((meta, _)) => {
432                self.finalized = true;
433
434                // Fsync parent directory for rename durability.
435                // Failure here means the rename may not survive a power
436                // loss, so propagate the error to the caller.
437                if let Some(parent) = self.path.parent() {
438                    let dir = std::fs::File::open(parent).map_err(|e| {
439                        SegmentError::Io(std::io::Error::new(
440                            e.kind(),
441                            format!("failed to open parent dir for fsync: {e}"),
442                        ))
443                    })?;
444                    dir.sync_all().map_err(|e| {
445                        SegmentError::Io(std::io::Error::new(
446                            e.kind(),
447                            format!("parent directory fsync failed: {e}"),
448                        ))
449                    })?;
450                }
451
452                Ok(meta)
453            }
454            Err(e) => {
455                let _ = std::fs::remove_file(&temp_path);
456                Err(e)
457            }
458        }
459    }
460
461    /// Finalize from a **pre-sorted, pre-deduped** `RecordBatch`.
462    ///
463    /// This is the zero-copy write path used by the compaction executor.
464    /// Because the caller has already sorted and deduped the data, this
465    /// method skips schema discovery, sorting, and — most importantly —
466    /// avoids the `batch_to_points` round-trip that would allocate a
467    /// `BTreeMap` per row.
468    ///
469    /// Column data is extracted directly from Arrow arrays and passed
470    /// to the column encoders. Uses streaming I/O with incremental
471    /// CRC-32c.
472    ///
473    /// # Errors
474    ///
475    /// Returns an error if the batch is empty, the writer was already
476    /// finalized, or encoding/I/O fails.
477    pub fn finalize_batch(
478        &mut self,
479        batch: &RecordBatch,
480        measurement: &str,
481        tag_columns: &[String],
482    ) -> Result<SegmentMeta> {
483        if self.finalized {
484            return Err(SegmentError::AlreadyFinalized);
485        }
486        if batch.num_rows() == 0 {
487            return Err(SegmentError::EmptySegment);
488        }
489
490        let total_rows = batch.num_rows();
491        let tag_set: std::collections::HashSet<&str> =
492            tag_columns.iter().map(String::as_str).collect();
493
494        // Build schema from Arrow schema + tag metadata
495        let schema = discover_schema_from_batch(batch, &tag_set)?;
496
497        // Compute global stats directly from Arrow arrays
498        let ts_col = batch
499            .column_by_name(chronix_core::TIME_COLUMN)
500            .and_then(|c| c.as_any().downcast_ref::<Int64Array>())
501            .ok_or_else(|| SegmentError::CorruptFile {
502                detail: "missing or non-Int64 timestamp column".into(),
503            })?;
504
505        let mut min_ts = i64::MAX;
506        let mut max_ts = i64::MIN;
507        for i in 0..ts_col.len() {
508            if ts_col.is_null(i) {
509                return Err(SegmentError::CorruptFile {
510                    detail: format!("null timestamp at row {i}"),
511                });
512            }
513            let t = ts_col.value(i);
514            min_ts = min_ts.min(t);
515            max_ts = max_ts.max(t);
516        }
517
518        let series_keys = series_keys_from_batch(batch, measurement, tag_columns);
519        let series_count = u32::try_from(series_keys.len()).unwrap_or(u32::MAX);
520
521        let column_count = u16::try_from(schema.len()).map_err(|_| SegmentError::CorruptFile {
522            detail: format!("column count {} exceeds u16::MAX", schema.len()),
523        })?;
524
525        let header = SegmentHeader {
526            version: VERSION,
527            flags: 0,
528            created_at: std::time::SystemTime::now()
529                .duration_since(std::time::UNIX_EPOCH)
530                .map(|d| i64::try_from(d.as_nanos()).unwrap_or(i64::MAX))
531                .unwrap_or(0),
532            min_timestamp: min_ts,
533            max_timestamp: max_ts,
534            row_count: total_rows as u64,
535            column_count,
536            series_count,
537            compression: match self.config.compression_codec {
538                CompressionCodec::Lz4 => 0,
539                CompressionCodec::Zstd => 1,
540                CompressionCodec::None => 2,
541            },
542            sort_order: 1,
543        };
544
545        // ── Streaming write path ───────────────────────────────────
546        if let Some(parent) = self.path.parent() {
547            std::fs::create_dir_all(parent)?;
548        }
549
550        let temp_path = {
551            let mut p = self.path.clone().into_os_string();
552            p.push(".tmp");
553            PathBuf::from(p)
554        };
555
556        let result = (|| -> Result<(SegmentMeta, u64)> {
557            let file = std::fs::File::create(&temp_path)?;
558            let mut writer = BufWriter::new(file);
559            let mut crc: u32 = 0;
560            let mut pos: u64 = 0;
561
562            // Write header
563            let header_bytes = header.to_bytes();
564            writer.write_all(&header_bytes)?;
565            crc = crc32c::crc32c_append(crc, &header_bytes);
566            pos += header_bytes.len() as u64;
567
568            // Encode and stream row groups one at a time
569            let (column_metas, row_group_blocks, uncompressed_bytes, zstd_dictionary) =
570                encode_row_groups_from_batch_streaming(
571                    &schema,
572                    batch,
573                    &self.config,
574                    header.created_at,
575                    &mut writer,
576                    &mut crc,
577                    &mut pos,
578                )?;
579
580            let metadata = SegmentMetadata {
581                columns: column_metas,
582                row_group_blocks,
583                zstd_dictionary,
584            };
585
586            // Write metadata
587            let metadata_offset = pos;
588            let metadata_bytes = metadata.to_bytes()?;
589            let metadata_checksum = crc32c::crc32c(&metadata_bytes);
590            writer.write_all(&metadata_bytes)?;
591            crc = crc32c::crc32c_append(crc, &metadata_bytes);
592            pos += metadata_bytes.len() as u64;
593
594            // Write footer with accumulated CRC
595            let footer = SegmentFooter {
596                metadata_offset,
597                row_group_count: metadata.row_group_blocks.len() as u32,
598                checksum: crc,
599                metadata_checksum,
600            };
601            let footer_bytes = footer.to_bytes();
602            writer.write_all(&footer_bytes)?;
603            pos += footer_bytes.len() as u64;
604
605            // Flush and fsync
606            writer.flush()?;
607            writer.get_ref().sync_all()?;
608            drop(writer);
609
610            // Atomic rename
611            std::fs::rename(&temp_path, &self.path)?;
612
613            Ok((
614                SegmentMeta {
615                    path: self.path.clone(),
616                    min_timestamp: min_ts,
617                    max_timestamp: max_ts,
618                    row_count: total_rows as u64,
619                    series_count,
620                    byte_size: pos,
621                    row_group_count: footer.row_group_count,
622                    column_count,
623                    uncompressed_bytes,
624                    header,
625                    column_metas: metadata.columns,
626                    series_keys,
627                },
628                pos,
629            ))
630        })();
631
632        match result {
633            Ok((meta, _)) => {
634                self.finalized = true;
635
636                // Fsync parent directory for rename durability.
637                if let Some(parent) = self.path.parent() {
638                    let dir = std::fs::File::open(parent).map_err(|e| {
639                        SegmentError::Io(std::io::Error::new(
640                            e.kind(),
641                            format!("failed to open parent dir for fsync: {e}"),
642                        ))
643                    })?;
644                    dir.sync_all().map_err(|e| {
645                        SegmentError::Io(std::io::Error::new(
646                            e.kind(),
647                            format!("parent directory fsync failed: {e}"),
648                        ))
649                    })?;
650                }
651
652                Ok(meta)
653            }
654            Err(e) => {
655                let _ = std::fs::remove_file(&temp_path);
656                Err(e)
657            }
658        }
659    }
660
661    /// Finalize a segment from pre-chunked `RecordBatch` slices.
662    ///
663    /// Unlike [`finalize_batch`](Self::finalize_batch), this avoids
664    /// `concat_batches` by iterating over the chunks directly. Each chunk
665    /// is encoded as one or more row groups and streamed to disk, bounding
666    /// peak memory to `O(chunk_size × columns)`.
667    pub fn finalize_batches(
668        &mut self,
669        batches: &[RecordBatch],
670        measurement: &str,
671        tag_columns: &[String],
672    ) -> Result<SegmentMeta> {
673        if self.finalized {
674            return Err(SegmentError::AlreadyFinalized);
675        }
676        if batches.is_empty() {
677            return Err(SegmentError::EmptySegment);
678        }
679
680        // Concatenate for now — this is the fallback. The streaming
681        // architecture benefit is that the compaction executor no longer
682        // needs to concat first, saving one full copy. We can concat
683        // here since the chunks are already small (row_group_size each).
684        let schema = batches[0].schema();
685        let batch = arrow::compute::concat_batches(&schema, batches).map_err(|e| {
686            SegmentError::CorruptFile {
687                detail: format!("concat batches failed: {e}"),
688            }
689        })?;
690        self.finalize_batch(&batch, measurement, tag_columns)
691    }
692}
693
694// ── Row Group Encoding ─────────────────────────────────────────────────
695
696/// Encode all row groups from sorted point data, streaming each encoded
697/// column block directly to `writer` instead of buffering in memory.
698///
699/// The file position (`pos`) and incremental CRC (`crc`) are updated in-place
700/// as each block is written. Peak memory is bounded by `O(row_group_size)`
701/// instead of `O(segment_size)`.
702fn encode_row_groups_streaming<W: Write>(
703    schema: &[ColumnDef],
704    sorted_indices: &[usize],
705    points: &[Point],
706    config: &SegmentWriterConfig,
707    // Only the encryption path reads it: it is half of the AEAD nonce's
708    // associated data, so a block cannot be replayed into another segment.
709    #[cfg_attr(not(feature = "field-encryption"), allow(unused_variables))] segment_created_at: i64,
710    writer: &mut W,
711    crc: &mut u32,
712    pos: &mut u64,
713) -> Result<RowGroupWriteOutput> {
714    let total_rows = sorted_indices.len();
715
716    let mut column_metas: Vec<ColumnMeta> = schema
717        .iter()
718        .map(|col| {
719            #[cfg(feature = "field-encryption")]
720            let (encrypted, key_id) = if let Some(k) = config.field_encryption.key_for(&col.name) {
721                (true, Some(k.key_id.clone()))
722            } else {
723                (false, None)
724            };
725            #[cfg(not(feature = "field-encryption"))]
726            let (encrypted, key_id) = (false, None);
727
728            ColumnMeta {
729                name: col.name.clone(),
730                data_type: col.data_type,
731                role: col.role,
732                default_encoding: col.default_encoding,
733                stats: ColumnStats::empty(),
734                bloom_filter: None,
735                encrypted,
736                key_id,
737                row_group_blooms: None,
738            }
739        })
740        .collect();
741
742    let mut row_group_blocks: Vec<Vec<ColumnBlockMeta>> = Vec::new();
743    let mut total_uncompressed: u64 = 0;
744
745    // Zstd dictionary training state.
746    let use_dict =
747        config.zstd_dict_training && matches!(config.compression_codec, CompressionCodec::Zstd);
748    let mut dict_samples: Vec<Vec<u8>> = Vec::new();
749    let mut trained_dict: Option<Vec<u8>> = None;
750    let mut rg_index: usize = 0;
751
752    #[allow(clippy::explicit_counter_loop)]
753    // rg_index also names files/metadata, not just iterations
754    for chunk_start in (0..total_rows).step_by(config.row_group_size) {
755        let chunk_end = (chunk_start + config.row_group_size).min(total_rows);
756        let chunk_indices = &sorted_indices[chunk_start..chunk_end];
757        let chunk_size = chunk_end - chunk_start;
758
759        let mut rg_blocks = Vec::with_capacity(schema.len());
760
761        for (col_idx, col) in schema.iter().enumerate() {
762            let EncodedColumn {
763                block,
764                stats,
765                validity,
766            } = encode_column(col, chunk_indices, points, config.float_encoding)?;
767
768            let raw_encoded = block.to_bytes();
769            let raw_size = chunk_size * 8;
770            total_uncompressed += raw_encoded.len() as u64;
771
772            // Collect training samples from the first row group.
773            if use_dict && rg_index == 0 {
774                dict_samples.push(raw_encoded.clone());
775            }
776
777            let codec = config
778                .column_codec_overrides
779                .get(&col.name)
780                .copied()
781                .unwrap_or(config.compression_codec);
782            let (final_data, compressed) =
783                if config.compress && !should_skip_compression(raw_encoded.len(), raw_size) {
784                    // Use dictionary compression when available.
785                    if let Some(ref dict) = trained_dict {
786                        if matches!(codec, CompressionCodec::Zstd) {
787                            let compressed_data =
788                                compress_block_zstd_dict(&raw_encoded, config.zstd_level, dict)?;
789                            if compressed_data.len() < raw_encoded.len() {
790                                (compressed_data, true)
791                            } else {
792                                (raw_encoded, false)
793                            }
794                        } else {
795                            let compressed_data = compress_block_with_codec(&raw_encoded, codec)?;
796                            if compressed_data.len() < raw_encoded.len() {
797                                (compressed_data, true)
798                            } else {
799                                (raw_encoded, false)
800                            }
801                        }
802                    } else {
803                        let compressed_data = compress_block_with_codec(&raw_encoded, codec)?;
804                        if compressed_data.len() < raw_encoded.len() {
805                            (compressed_data, true)
806                        } else {
807                            (raw_encoded, false)
808                        }
809                    }
810                } else {
811                    (raw_encoded, false)
812                };
813
814            // Field-level encryption: encrypt after encode+compress.
815            #[cfg(feature = "field-encryption")]
816            let (final_data, block_encrypted) =
817                if let Some(enc_key) = config.field_encryption.key_for(&col.name) {
818                    let encrypted = crate::segment::field_encryption::encrypt_block(
819                        &final_data,
820                        enc_key.key_bytes(),
821                        crate::segment::field_encryption::BlockContext {
822                            column: &col.name,
823                            segment_created_at,
824                        },
825                    )?;
826                    (encrypted, true)
827                } else {
828                    (final_data, false)
829                };
830            #[cfg(not(feature = "field-encryption"))]
831            let block_encrypted = false;
832
833            let offset = *pos;
834            writer.write_all(&final_data)?;
835            *crc = crc32c::crc32c_append(*crc, &final_data);
836            *pos += final_data.len() as u64;
837
838            let block_crc = crc32c::crc32c(&final_data);
839
840            // Suppress statistics for encrypted columns to prevent
841            // information leakage through zone-map predicates.
842            let final_stats = if block_encrypted {
843                ColumnStats::empty()
844            } else {
845                stats
846            };
847
848            merge_stats(&mut column_metas[col_idx].stats, &final_stats);
849
850            // D-NULL: the validity bitmap follows the block payload. It is
851            // stored uncompressed and unencrypted — it reveals only which
852            // rows have values, never what they are — so the reader can turn
853            // it straight into an Arrow NullBuffer.
854            let (validity_offset, validity_length) =
855                write_validity(writer, validity.as_deref(), pos, crc)?;
856
857            rg_blocks.push(ColumnBlockMeta {
858                column_index: col_idx as u16,
859                encoding: block.encoding,
860                compressed,
861                offset,
862                length: u32::try_from(final_data.len()).map_err(|_| SegmentError::CorruptFile {
863                    detail: format!("block length {} exceeds u32::MAX", final_data.len()),
864                })?,
865                value_count: u32::try_from(chunk_size).map_err(|_| SegmentError::CorruptFile {
866                    detail: format!("value_count {chunk_size} exceeds u32::MAX"),
867                })?,
868                block_crc,
869                encrypted: block_encrypted,
870                validity_offset,
871                validity_length,
872                stats: final_stats,
873            });
874        }
875
876        row_group_blocks.push(rg_blocks);
877
878        // Build per-row-group bloom filters for tag columns.
879        for (col_idx, col) in schema.iter().enumerate() {
880            if col.role == roles::TAG && !column_metas[col_idx].encrypted {
881                let mut distinct: std::collections::HashSet<&str> =
882                    std::collections::HashSet::new();
883                for &idx in chunk_indices {
884                    let val = points[idx].series_key().tag(&col.name).unwrap_or("");
885                    if !val.is_empty() {
886                        distinct.insert(val);
887                    }
888                }
889                let bloom = if distinct.is_empty() {
890                    Vec::new()
891                } else {
892                    let vals: Vec<&str> = distinct.into_iter().collect();
893                    crate::segment::bloom::bloom_filter_build(&vals, config.bloom_fpr)
894                };
895                column_metas[col_idx]
896                    .row_group_blooms
897                    .get_or_insert_with(Vec::new)
898                    .push(bloom);
899            }
900        }
901
902        // Train dictionary after the first row group.
903        if use_dict && rg_index == 0 && !dict_samples.is_empty() {
904            let sample_refs: Vec<&[u8]> =
905                dict_samples.iter().map(std::vec::Vec::as_slice).collect();
906            if let Ok(Some(dict)) = train_zstd_dictionary(&sample_refs) {
907                tracing::debug!(
908                    dict_size = dict.len(),
909                    samples = dict_samples.len(),
910                    "trained Zstd dictionary from first row group"
911                );
912                trained_dict = Some(dict);
913            }
914            dict_samples.clear();
915        }
916        rg_index += 1;
917    }
918
919    // Build bloom filters for tag columns.
920    // Skip bloom filter for encrypted columns to prevent information leakage.
921    for (col_idx, col) in schema.iter().enumerate() {
922        if col.role == roles::TAG && !column_metas[col_idx].encrypted {
923            let mut distinct: std::collections::HashSet<&str> = std::collections::HashSet::new();
924            for &idx in sorted_indices {
925                let val = points[idx].series_key().tag(&col.name).unwrap_or("");
926                if !val.is_empty() {
927                    distinct.insert(val);
928                }
929            }
930            if !distinct.is_empty() {
931                let values: Vec<&str> = distinct.into_iter().collect();
932                column_metas[col_idx].bloom_filter = Some(
933                    crate::segment::bloom::bloom_filter_build(&values, config.bloom_fpr),
934                );
935            }
936        }
937    }
938
939    Ok((
940        column_metas,
941        row_group_blocks,
942        total_uncompressed,
943        trained_dict,
944    ))
945}
946
947// ── Schema Discovery ───────────────────────────────────────────────────
948
949/// Discovered column definition from point data.
950#[derive(Debug, Clone)]
951struct ColumnDef {
952    name: String,
953    data_type: u8,
954    role: u8,
955    default_encoding: u8,
956}
957
958/// Discover the column schema by scanning all points.
959///
960/// Tag columns are ordered by ascending cardinality (fewest distinct
961/// values first) so that the physical column layout is optimal for
962/// run-length and dictionary encoding.
963fn discover_schema(points: &[Point]) -> Vec<ColumnDef> {
964    let mut columns: Vec<ColumnDef> = Vec::new();
965
966    // Timestamp column is always first
967    columns.push(ColumnDef {
968        name: chronix_core::TIME_COLUMN.to_string(),
969        data_type: data_types::TIMESTAMP,
970        role: roles::TIMESTAMP,
971        default_encoding: chronix_encoding::EncodingType::DeltaOfDelta.tag(),
972    });
973
974    // Discover tag columns and compute cardinality
975    let mut tag_names = BTreeSet::new();
976    for p in points {
977        for key in p.series_key().tag_keys() {
978            tag_names.insert(key.to_string());
979        }
980    }
981
982    // Sort tags by cardinality (lowest first) for optimal compression
983    let mut tag_with_card: Vec<(String, usize)> = tag_names
984        .into_iter()
985        .map(|name: String| {
986            let distinct: std::collections::HashSet<&str> = points
987                .iter()
988                .filter_map(|p| p.series_key().tag(&name))
989                .collect();
990            (name, distinct.len())
991        })
992        .collect();
993    tag_with_card.sort_by_key(|(_, card)| *card);
994
995    for (name, _) in tag_with_card {
996        columns.push(ColumnDef {
997            name,
998            data_type: data_types::STRING,
999            role: roles::TAG,
1000            default_encoding: chronix_encoding::EncodingType::Dictionary.tag(),
1001        });
1002    }
1003
1004    // Discover field columns
1005    let mut field_defs: BTreeMap<String, u8> = BTreeMap::new();
1006    for p in points {
1007        for (key, val) in p.fields() {
1008            field_defs
1009                .entry(key.to_string())
1010                .or_insert_with(|| match val {
1011                    FieldValue::F64(_) => data_types::F64,
1012                    FieldValue::I64(_) => data_types::I64,
1013                    FieldValue::U64(_) => data_types::U64,
1014                    FieldValue::Bool(_) => data_types::BOOL,
1015                    FieldValue::String(_) => data_types::STRING,
1016                });
1017        }
1018    }
1019    for (name, dt) in field_defs {
1020        let enc = match dt {
1021            data_types::F64 => chronix_encoding::EncodingType::Chimp.tag(),
1022            data_types::I64 => chronix_encoding::EncodingType::IntegerI64.tag(),
1023            data_types::U64 => chronix_encoding::EncodingType::IntegerU64.tag(),
1024            data_types::BOOL => chronix_encoding::EncodingType::Bitmap.tag(),
1025            _ => chronix_encoding::EncodingType::Dictionary.tag(),
1026        };
1027        columns.push(ColumnDef {
1028            name,
1029            data_type: dt,
1030            role: roles::FIELD,
1031            default_encoding: enc,
1032        });
1033    }
1034
1035    columns
1036}
1037
1038/// Append a block's validity bitmap to the segment file.
1039///
1040/// Returns the `(offset, length)` pair recorded in
1041/// [`ColumnBlockMeta::validity_offset`] / `validity_length`. A `None` bitmap
1042/// (a block with no nulls) writes nothing and yields `(0, 0)`.
1043fn write_validity<W: Write>(
1044    writer: &mut W,
1045    validity: Option<&[u8]>,
1046    pos: &mut u64,
1047    crc: &mut u32,
1048) -> Result<(u64, u32)> {
1049    let Some(bytes) = validity else {
1050        return Ok((0, 0));
1051    };
1052    let offset = *pos;
1053    writer.write_all(bytes)?;
1054    *crc = crc32c::crc32c_append(*crc, bytes);
1055    *pos += bytes.len() as u64;
1056    let length = u32::try_from(bytes.len()).map_err(|_| SegmentError::CorruptFile {
1057        detail: format!("validity bitmap length {} exceeds u32::MAX", bytes.len()),
1058    })?;
1059    Ok((offset, length))
1060}
1061
1062/// An encoded column block together with its statistics and, when the block
1063/// contains nulls, its validity bitmap (`.csx` v2, D-NULL).
1064struct EncodedColumn {
1065    block: EncodedBlock,
1066    stats: ColumnStats,
1067    /// `None` when every row in the block holds a real value.
1068    validity: Option<Vec<u8>>,
1069}
1070
1071impl EncodedColumn {
1072    fn new(block: EncodedBlock, stats: ColumnStats, validity: ValidityBuilder) -> Self {
1073        Self {
1074            block,
1075            stats,
1076            validity: validity.finish(),
1077        }
1078    }
1079
1080    /// Wrap a column that cannot contain nulls (timestamps, tags).
1081    fn dense((block, stats): (EncodedBlock, ColumnStats)) -> Self {
1082        Self {
1083            block,
1084            stats,
1085            validity: None,
1086        }
1087    }
1088}
1089
1090/// Encode a single column for a chunk of rows.
1091fn encode_column(
1092    col: &ColumnDef,
1093    indices: &[usize],
1094    points: &[Point],
1095    float_encoding: FloatEncoding,
1096) -> Result<EncodedColumn> {
1097    let mut stats = ColumnStats::empty();
1098    // D-NULL: record which rows actually carry a value so the reader can
1099    // distinguish an absent field from a stored zero.
1100    let mut validity = ValidityBuilder::with_capacity(indices.len());
1101
1102    match (col.role, col.data_type) {
1103        // Timestamps are never null; tags are materialised as "" by design
1104        // (an absent tag is not part of the series key).
1105        (roles::TIMESTAMP, _) => encode_timestamp_column(indices, points).map(EncodedColumn::dense),
1106        (roles::TAG, _) => encode_tag_column(col, indices, points).map(EncodedColumn::dense),
1107        (_, data_types::F64) => {
1108            let mut values = Vec::with_capacity(indices.len());
1109            for &idx in indices {
1110                if let Some(FieldValue::F64(v)) = points[idx].field(&col.name) {
1111                    values.push(*v);
1112                    stats.update_f64(*v);
1113                    validity.push(true);
1114                } else {
1115                    values.push(0.0);
1116                    stats.record_null();
1117                    validity.push(false);
1118                }
1119            }
1120            let block = ColumnEncoder::encode_f64(&values, float_encoding)?;
1121            Ok(EncodedColumn::new(block, stats, validity))
1122        }
1123        (_, data_types::I64) => {
1124            let mut values = Vec::with_capacity(indices.len());
1125            for &idx in indices {
1126                if let Some(FieldValue::I64(v)) = points[idx].field(&col.name) {
1127                    values.push(*v);
1128                    stats.update_i64(*v);
1129                    validity.push(true);
1130                } else {
1131                    values.push(0);
1132                    stats.record_null();
1133                    validity.push(false);
1134                }
1135            }
1136            let block = ColumnEncoder::encode_i64(&values)?;
1137            Ok(EncodedColumn::new(block, stats, validity))
1138        }
1139        (_, data_types::U64) => {
1140            let mut values = Vec::with_capacity(indices.len());
1141            for &idx in indices {
1142                if let Some(FieldValue::U64(v)) = points[idx].field(&col.name) {
1143                    values.push(*v);
1144                    stats.update_u64(*v);
1145                    validity.push(true);
1146                } else {
1147                    values.push(0);
1148                    stats.record_null();
1149                    validity.push(false);
1150                }
1151            }
1152            let block = ColumnEncoder::encode_u64(&values)?;
1153            Ok(EncodedColumn::new(block, stats, validity))
1154        }
1155        (_, data_types::BOOL) => {
1156            let mut values = Vec::with_capacity(indices.len());
1157            for &idx in indices {
1158                if let Some(FieldValue::Bool(v)) = points[idx].field(&col.name) {
1159                    values.push(*v);
1160                    stats.update_bool(*v);
1161                    validity.push(true);
1162                } else {
1163                    values.push(false);
1164                    stats.record_null();
1165                    validity.push(false);
1166                }
1167            }
1168            let block = ColumnEncoder::encode_bool(&values)?;
1169            Ok(EncodedColumn::new(block, stats, validity))
1170        }
1171        (_, data_types::STRING) => {
1172            // Collect owned strings, then borrow as &str for encoding
1173            let mut owned: Vec<String> = Vec::with_capacity(indices.len());
1174            for &idx in indices {
1175                if let Some(FieldValue::String(v)) = points[idx].field(&col.name) {
1176                    owned.push(v.clone());
1177                    validity.push(true);
1178                } else {
1179                    owned.push(String::new());
1180                    stats.record_null();
1181                    validity.push(false);
1182                }
1183            }
1184            let values: Vec<&str> = owned.iter().map(String::as_str).collect();
1185            let distinct: std::collections::HashSet<&str> = values.iter().copied().collect();
1186            stats.distinct_count = distinct.len() as u32;
1187            // value_count should reflect non-null entries (null_count already tracked).
1188            stats.value_count = (values.len() as u64).saturating_sub(stats.null_count);
1189            let block = ColumnEncoder::encode_string(&values)?;
1190            Ok(EncodedColumn::new(block, stats, validity))
1191        }
1192        _ => Err(SegmentError::CorruptFile {
1193            detail: format!("unknown column type: {}", col.data_type),
1194        }),
1195    }
1196}
1197
1198/// Compute min/max timestamps and unique series count from sorted indices.
1199///
1200/// Since the indices are already sorted by `(canonical_form, timestamp)`,
1201/// we count distinct series by checking when the canonical form changes.
1202fn compute_global_stats(
1203    points: &[Point],
1204    sorted_indices: &[usize],
1205) -> (i64, i64, Vec<chronix_core::SeriesKey>) {
1206    let mut min_ts = i64::MAX;
1207    let mut max_ts = i64::MIN;
1208    let mut series_keys: Vec<chronix_core::SeriesKey> = Vec::new();
1209
1210    for &idx in sorted_indices {
1211        let p = &points[idx];
1212        let ts = p.timestamp();
1213        min_ts = min_ts.min(ts);
1214        max_ts = max_ts.max(ts);
1215        // Series-major order: a new series starts wherever the canonical form
1216        // changes. Comparing the form rather than the hash means a hash
1217        // collision between adjacent series is counted as two, not one.
1218        let is_new = series_keys
1219            .last()
1220            .is_none_or(|prev| prev.canonical_form() != p.series_key().canonical_form());
1221        if is_new {
1222            series_keys.push(p.series_key().clone());
1223        }
1224    }
1225    (min_ts, max_ts, series_keys)
1226}
1227
1228/// Encode a timestamp column from a set of points at the given indices.
1229fn encode_timestamp_column(
1230    indices: &[usize],
1231    points: &[Point],
1232) -> Result<(EncodedBlock, ColumnStats)> {
1233    let mut stats = ColumnStats::empty();
1234    let mut timestamps = Vec::with_capacity(indices.len());
1235    for &idx in indices {
1236        let ts = points[idx].timestamp();
1237        timestamps.push(ts);
1238        stats.update_i64(ts);
1239    }
1240    let block = ColumnEncoder::encode_timestamps(&timestamps)?;
1241    Ok((block, stats))
1242}
1243
1244/// Encode a tag column by looking up each tag value from the series key.
1245fn encode_tag_column(
1246    col: &ColumnDef,
1247    indices: &[usize],
1248    points: &[Point],
1249) -> Result<(EncodedBlock, ColumnStats)> {
1250    let mut stats = ColumnStats::empty();
1251    let mut values = Vec::with_capacity(indices.len());
1252    for &idx in indices {
1253        let val = points[idx].series_key().tag(&col.name).unwrap_or("");
1254        values.push(val);
1255    }
1256    let distinct: std::collections::HashSet<&str> = values.iter().copied().collect();
1257    stats.distinct_count = distinct.len() as u32;
1258    stats.value_count = values.len() as u64;
1259    let block = ColumnEncoder::encode_string(&values)?;
1260    Ok((block, stats))
1261}
1262
1263/// Merge source stats into destination.
1264///
1265/// **Note:** `distinct_count` uses `max()` when merging across row groups —
1266/// a lower bound on the true segment cardinality, and a tighter estimate than
1267/// summing. The segment-level per-column `distinct_count` computed over all
1268/// rows is the exact figure; this merge only applies to the row-group rollup.
1269fn merge_stats(dest: &mut ColumnStats, src: &ColumnStats) {
1270    dest.min_value = dest.min_value.min(src.min_value);
1271    dest.max_value = dest.max_value.max(src.max_value);
1272    // Also merge u64 min/max — previously these were silently
1273    // dropped, breaking predicate pushdown for u64 columns.
1274    dest.min_value_u64 = dest.min_value_u64.min(src.min_value_u64);
1275    dest.max_value_u64 = dest.max_value_u64.max(src.max_value_u64);
1276    dest.null_count += src.null_count;
1277    dest.value_count += src.value_count;
1278    dest.sum += src.sum;
1279    dest.sum_i128 += src.sum_i128;
1280    // Use max() instead of saturating_add for a tighter bound.
1281    // Row groups may share values, so summing grossly over-counts.
1282    dest.distinct_count = dest.distinct_count.max(src.distinct_count);
1283}
1284
1285// ── Batch-based encoding (zero-copy compaction path) ───────────────────
1286
1287/// Discover column schema from an Arrow `RecordBatch`.
1288///
1289/// Uses `tag_columns` to distinguish tags from fields (since Arrow
1290/// schema alone doesn't carry Chronix role information).
1291fn discover_schema_from_batch(
1292    batch: &RecordBatch,
1293    tag_columns: &std::collections::HashSet<&str>,
1294) -> Result<Vec<ColumnDef>> {
1295    let mut columns = Vec::new();
1296
1297    // Timestamp first
1298    columns.push(ColumnDef {
1299        name: chronix_core::TIME_COLUMN.to_string(),
1300        data_type: data_types::TIMESTAMP,
1301        role: roles::TIMESTAMP,
1302        default_encoding: chronix_encoding::EncodingType::DeltaOfDelta.tag(),
1303    });
1304
1305    // Tags (sorted for consistency)
1306    let mut tags: Vec<&str> = tag_columns.iter().copied().collect();
1307    tags.sort_unstable();
1308    for tag in tags {
1309        if batch.column_by_name(tag).is_some() {
1310            columns.push(ColumnDef {
1311                name: tag.to_string(),
1312                data_type: data_types::STRING,
1313                role: roles::TAG,
1314                default_encoding: chronix_encoding::EncodingType::Dictionary.tag(),
1315            });
1316        }
1317    }
1318
1319    // Fields (everything else except timestamp and tags)
1320    for field in batch.schema().fields() {
1321        let name = field.name().as_str();
1322        if name == chronix_core::TIME_COLUMN || tag_columns.contains(name) {
1323            continue;
1324        }
1325        let (dt, enc) = match field.data_type() {
1326            DataType::Float64 => (data_types::F64, chronix_encoding::EncodingType::Chimp.tag()),
1327            DataType::Int64 => (
1328                data_types::I64,
1329                chronix_encoding::EncodingType::IntegerI64.tag(),
1330            ),
1331            DataType::UInt64 => (
1332                data_types::U64,
1333                chronix_encoding::EncodingType::IntegerU64.tag(),
1334            ),
1335            DataType::Boolean => (
1336                data_types::BOOL,
1337                chronix_encoding::EncodingType::Bitmap.tag(),
1338            ),
1339            DataType::Utf8 | DataType::LargeUtf8 => (
1340                data_types::STRING,
1341                chronix_encoding::EncodingType::Dictionary.tag(),
1342            ),
1343            other => {
1344                return Err(SegmentError::CorruptFile {
1345                    detail: format!(
1346                        "unsupported Arrow data type {:?} for field '{}'",
1347                        other, name
1348                    ),
1349                });
1350            }
1351        };
1352        columns.push(ColumnDef {
1353            name: name.to_string(),
1354            data_type: dt,
1355            role: roles::FIELD,
1356            default_encoding: enc,
1357        });
1358    }
1359
1360    Ok(columns)
1361}
1362
1363/// Count distinct series in a RecordBatch by canonical series key form.
1364///
1365/// Uses the full canonical form (not just hash) to avoid undercounting
1366/// in the extremely rare case of FNV hash collisions.
1367fn series_keys_from_batch(
1368    batch: &RecordBatch,
1369    measurement: &str,
1370    tag_columns: &[String],
1371) -> Vec<chronix_core::SeriesKey> {
1372    let mut seen = std::collections::HashSet::new();
1373    let mut keys = Vec::new();
1374    let num_rows = batch.num_rows();
1375
1376    let tag_arrays: Vec<Option<&StringArray>> = tag_columns
1377        .iter()
1378        .map(|name| {
1379            batch
1380                .column_by_name(name)
1381                .and_then(|c| c.as_any().downcast_ref::<StringArray>())
1382        })
1383        .collect();
1384
1385    for row in 0..num_rows {
1386        let mut tags = BTreeMap::new();
1387        for (i, tag_name) in tag_columns.iter().enumerate() {
1388            if let Some(arr) = tag_arrays[i] {
1389                if !arr.is_null(row) {
1390                    tags.insert(tag_name.clone(), arr.value(row).to_string());
1391                }
1392            }
1393        }
1394        if let Ok(key) = chronix_core::SeriesKey::new(measurement, tags) {
1395            if seen.insert(key.canonical_form().to_string()) {
1396                keys.push(key);
1397            }
1398        }
1399    }
1400    keys
1401}
1402
1403/// Encode all row groups from a pre-sorted `RecordBatch`, streaming each
1404/// encoded column block directly to `writer` instead of buffering in memory.
1405///
1406/// This is the batch-based counterpart of [`encode_row_groups_streaming`].
1407fn encode_row_groups_from_batch_streaming<W: Write>(
1408    schema: &[ColumnDef],
1409    batch: &RecordBatch,
1410    config: &SegmentWriterConfig,
1411    // Only the encryption path reads it: it is half of the AEAD nonce's
1412    // associated data, so a block cannot be replayed into another segment.
1413    #[cfg_attr(not(feature = "field-encryption"), allow(unused_variables))] segment_created_at: i64,
1414    writer: &mut W,
1415    crc: &mut u32,
1416    pos: &mut u64,
1417) -> Result<RowGroupWriteOutput> {
1418    let total_rows = batch.num_rows();
1419
1420    let mut column_metas: Vec<ColumnMeta> = schema
1421        .iter()
1422        .map(|col| {
1423            #[cfg(feature = "field-encryption")]
1424            let (encrypted, key_id) = if let Some(k) = config.field_encryption.key_for(&col.name) {
1425                (true, Some(k.key_id.clone()))
1426            } else {
1427                (false, None)
1428            };
1429            #[cfg(not(feature = "field-encryption"))]
1430            let (encrypted, key_id) = (false, None);
1431
1432            ColumnMeta {
1433                name: col.name.clone(),
1434                data_type: col.data_type,
1435                role: col.role,
1436                default_encoding: col.default_encoding,
1437                stats: ColumnStats::empty(),
1438                bloom_filter: None,
1439                encrypted,
1440                key_id,
1441                row_group_blooms: None,
1442            }
1443        })
1444        .collect();
1445
1446    let mut row_group_blocks: Vec<Vec<ColumnBlockMeta>> = Vec::new();
1447    let mut total_uncompressed: u64 = 0;
1448
1449    // Zstd dictionary training state.
1450    let use_dict =
1451        config.zstd_dict_training && matches!(config.compression_codec, CompressionCodec::Zstd);
1452    let mut dict_samples: Vec<Vec<u8>> = Vec::new();
1453    let mut trained_dict: Option<Vec<u8>> = None;
1454    let mut rg_index: usize = 0;
1455
1456    #[allow(clippy::explicit_counter_loop)]
1457    // rg_index also names files/metadata, not just iterations
1458    for chunk_start in (0..total_rows).step_by(config.row_group_size) {
1459        let chunk_end = (chunk_start + config.row_group_size).min(total_rows);
1460        let chunk_size = chunk_end - chunk_start;
1461
1462        let mut rg_blocks = Vec::with_capacity(schema.len());
1463
1464        for (col_idx, col) in schema.iter().enumerate() {
1465            let EncodedColumn {
1466                block,
1467                stats,
1468                validity,
1469            } = encode_column_from_batch(
1470                col,
1471                batch,
1472                chunk_start,
1473                chunk_end,
1474                config.float_encoding,
1475            )?;
1476
1477            let raw_encoded = block.to_bytes();
1478            let raw_size = chunk_size * 8;
1479            total_uncompressed += raw_encoded.len() as u64;
1480
1481            // Collect training samples from the first row group.
1482            if use_dict && rg_index == 0 {
1483                dict_samples.push(raw_encoded.clone());
1484            }
1485
1486            let codec = config
1487                .column_codec_overrides
1488                .get(&col.name)
1489                .copied()
1490                .unwrap_or(config.compression_codec);
1491            let (final_data, compressed) =
1492                if config.compress && !should_skip_compression(raw_encoded.len(), raw_size) {
1493                    // Use dictionary compression when available.
1494                    if let Some(ref dict) = trained_dict {
1495                        if matches!(codec, CompressionCodec::Zstd) {
1496                            let compressed_data =
1497                                compress_block_zstd_dict(&raw_encoded, config.zstd_level, dict)?;
1498                            if compressed_data.len() < raw_encoded.len() {
1499                                (compressed_data, true)
1500                            } else {
1501                                (raw_encoded, false)
1502                            }
1503                        } else {
1504                            let compressed_data = compress_block_with_codec(&raw_encoded, codec)?;
1505                            if compressed_data.len() < raw_encoded.len() {
1506                                (compressed_data, true)
1507                            } else {
1508                                (raw_encoded, false)
1509                            }
1510                        }
1511                    } else {
1512                        let compressed_data = compress_block_with_codec(&raw_encoded, codec)?;
1513                        if compressed_data.len() < raw_encoded.len() {
1514                            (compressed_data, true)
1515                        } else {
1516                            (raw_encoded, false)
1517                        }
1518                    }
1519                } else {
1520                    (raw_encoded, false)
1521                };
1522
1523            // Field-level encryption: encrypt after encode+compress.
1524            #[cfg(feature = "field-encryption")]
1525            let (final_data, block_encrypted) =
1526                if let Some(enc_key) = config.field_encryption.key_for(&col.name) {
1527                    let encrypted = crate::segment::field_encryption::encrypt_block(
1528                        &final_data,
1529                        enc_key.key_bytes(),
1530                        crate::segment::field_encryption::BlockContext {
1531                            column: &col.name,
1532                            segment_created_at,
1533                        },
1534                    )?;
1535                    (encrypted, true)
1536                } else {
1537                    (final_data, false)
1538                };
1539            #[cfg(not(feature = "field-encryption"))]
1540            let block_encrypted = false;
1541
1542            let offset = *pos;
1543            writer.write_all(&final_data)?;
1544            *crc = crc32c::crc32c_append(*crc, &final_data);
1545            *pos += final_data.len() as u64;
1546
1547            let block_crc = crc32c::crc32c(&final_data);
1548
1549            // Suppress statistics for encrypted columns to prevent
1550            // information leakage through zone-map predicates.
1551            let final_stats = if block_encrypted {
1552                ColumnStats::empty()
1553            } else {
1554                stats
1555            };
1556
1557            merge_stats(&mut column_metas[col_idx].stats, &final_stats);
1558
1559            // D-NULL: validity bitmap follows the block payload.
1560            let (validity_offset, validity_length) =
1561                write_validity(writer, validity.as_deref(), pos, crc)?;
1562
1563            rg_blocks.push(ColumnBlockMeta {
1564                column_index: col_idx as u16,
1565                encoding: block.encoding,
1566                compressed,
1567                offset,
1568                length: u32::try_from(final_data.len()).map_err(|_| SegmentError::CorruptFile {
1569                    detail: format!("block length {} exceeds u32::MAX", final_data.len()),
1570                })?,
1571                value_count: u32::try_from(chunk_size).map_err(|_| SegmentError::CorruptFile {
1572                    detail: format!("value_count {chunk_size} exceeds u32::MAX"),
1573                })?,
1574                block_crc,
1575                encrypted: block_encrypted,
1576                validity_offset,
1577                validity_length,
1578                stats: final_stats,
1579            });
1580        }
1581
1582        row_group_blocks.push(rg_blocks);
1583
1584        // Build per-row-group bloom filters for tag columns (batch path).
1585        for (col_idx, col) in schema.iter().enumerate() {
1586            if col.role == roles::TAG && !column_metas[col_idx].encrypted {
1587                if let Some(arr) = batch
1588                    .column_by_name(&col.name)
1589                    .and_then(|c| c.as_any().downcast_ref::<StringArray>())
1590                {
1591                    let mut distinct: std::collections::HashSet<&str> =
1592                        std::collections::HashSet::new();
1593                    for i in chunk_start..chunk_end {
1594                        if !arr.is_null(i) {
1595                            let val = arr.value(i);
1596                            if !val.is_empty() {
1597                                distinct.insert(val);
1598                            }
1599                        }
1600                    }
1601                    let bloom = if distinct.is_empty() {
1602                        Vec::new()
1603                    } else {
1604                        let vals: Vec<&str> = distinct.into_iter().collect();
1605                        crate::segment::bloom::bloom_filter_build(&vals, config.bloom_fpr)
1606                    };
1607                    column_metas[col_idx]
1608                        .row_group_blooms
1609                        .get_or_insert_with(Vec::new)
1610                        .push(bloom);
1611                }
1612            }
1613        }
1614
1615        // Train dictionary after the first row group.
1616        if use_dict && rg_index == 0 && !dict_samples.is_empty() {
1617            let sample_refs: Vec<&[u8]> =
1618                dict_samples.iter().map(std::vec::Vec::as_slice).collect();
1619            if let Ok(Some(dict)) = train_zstd_dictionary(&sample_refs) {
1620                tracing::debug!(
1621                    dict_size = dict.len(),
1622                    samples = dict_samples.len(),
1623                    "trained Zstd dictionary from first row group (batch path)"
1624                );
1625                trained_dict = Some(dict);
1626            }
1627            dict_samples.clear();
1628        }
1629        rg_index += 1;
1630    }
1631
1632    // Build bloom filters for tag columns from Arrow arrays.
1633    // Skip bloom filter for encrypted columns to prevent information leakage.
1634    for (col_idx, col) in schema.iter().enumerate() {
1635        if col.role == roles::TAG && !column_metas[col_idx].encrypted {
1636            if let Some(arr) = batch
1637                .column_by_name(&col.name)
1638                .and_then(|c| c.as_any().downcast_ref::<StringArray>())
1639            {
1640                let mut distinct: std::collections::HashSet<&str> =
1641                    std::collections::HashSet::new();
1642                for i in 0..arr.len() {
1643                    if !arr.is_null(i) {
1644                        let val = arr.value(i);
1645                        if !val.is_empty() {
1646                            distinct.insert(val);
1647                        }
1648                    }
1649                }
1650                if !distinct.is_empty() {
1651                    let values: Vec<&str> = distinct.into_iter().collect();
1652                    column_metas[col_idx].bloom_filter = Some(
1653                        crate::segment::bloom::bloom_filter_build(&values, config.bloom_fpr),
1654                    );
1655                }
1656            }
1657        }
1658    }
1659
1660    Ok((
1661        column_metas,
1662        row_group_blocks,
1663        total_uncompressed,
1664        trained_dict,
1665    ))
1666}
1667
1668/// Encode a single column from a RecordBatch slice [start..end).
1669fn encode_column_from_batch(
1670    col: &ColumnDef,
1671    batch: &RecordBatch,
1672    start: usize,
1673    end: usize,
1674    float_encoding: FloatEncoding,
1675) -> Result<EncodedColumn> {
1676    let mut stats = ColumnStats::empty();
1677    // D-NULL: track per-row presence alongside the encoded values.
1678    let mut validity = ValidityBuilder::with_capacity(end.saturating_sub(start));
1679
1680    match (col.role, col.data_type) {
1681        (roles::TIMESTAMP, _) => {
1682            let arr = batch
1683                .column_by_name(chronix_core::TIME_COLUMN)
1684                .and_then(|c| c.as_any().downcast_ref::<Int64Array>())
1685                .ok_or_else(|| SegmentError::CorruptFile {
1686                    detail: "missing timestamp".into(),
1687                })?;
1688            let mut timestamps = Vec::with_capacity(end - start);
1689            for i in start..end {
1690                let t = arr.value(i);
1691                timestamps.push(t);
1692                stats.update_i64(t);
1693            }
1694            let block = ColumnEncoder::encode_timestamps(&timestamps)?;
1695            // Timestamps are never null.
1696            Ok(EncodedColumn::dense((block, stats)))
1697        }
1698        (roles::TAG, _) | (_, data_types::STRING) => {
1699            let arr = batch
1700                .column_by_name(&col.name)
1701                .and_then(|c| c.as_any().downcast_ref::<StringArray>())
1702                .ok_or_else(|| SegmentError::CorruptFile {
1703                    detail: format!("missing string column '{}'", col.name),
1704                })?;
1705            let mut values = Vec::with_capacity(end - start);
1706            for i in start..end {
1707                if arr.is_null(i) {
1708                    values.push("");
1709                    stats.record_null();
1710                    validity.push(false);
1711                } else {
1712                    values.push(arr.value(i));
1713                    validity.push(true);
1714                }
1715            }
1716            let distinct: std::collections::HashSet<&str> = values.iter().copied().collect();
1717            stats.distinct_count = distinct.len() as u32;
1718            stats.value_count = (values.len() as u64).saturating_sub(stats.null_count);
1719            let block = ColumnEncoder::encode_string(&values)?;
1720            Ok(EncodedColumn::new(block, stats, validity))
1721        }
1722        (_, data_types::F64) => {
1723            let arr = batch
1724                .column_by_name(&col.name)
1725                .and_then(|c| c.as_any().downcast_ref::<Float64Array>())
1726                .ok_or_else(|| SegmentError::CorruptFile {
1727                    detail: format!("missing f64 column '{}'", col.name),
1728                })?;
1729            let mut values = Vec::with_capacity(end - start);
1730            for i in start..end {
1731                if arr.is_null(i) {
1732                    values.push(0.0);
1733                    stats.record_null();
1734                    validity.push(false);
1735                } else {
1736                    let v = arr.value(i);
1737                    values.push(v);
1738                    stats.update_f64(v);
1739                    validity.push(true);
1740                }
1741            }
1742            let block = ColumnEncoder::encode_f64(&values, float_encoding)?;
1743            Ok(EncodedColumn::new(block, stats, validity))
1744        }
1745        (_, data_types::I64) => {
1746            let arr = batch
1747                .column_by_name(&col.name)
1748                .and_then(|c| c.as_any().downcast_ref::<Int64Array>())
1749                .ok_or_else(|| SegmentError::CorruptFile {
1750                    detail: format!("missing i64 column '{}'", col.name),
1751                })?;
1752            let mut values = Vec::with_capacity(end - start);
1753            for i in start..end {
1754                if arr.is_null(i) {
1755                    values.push(0);
1756                    stats.record_null();
1757                    validity.push(false);
1758                } else {
1759                    let v = arr.value(i);
1760                    values.push(v);
1761                    stats.update_i64(v);
1762                    validity.push(true);
1763                }
1764            }
1765            let block = ColumnEncoder::encode_i64(&values)?;
1766            Ok(EncodedColumn::new(block, stats, validity))
1767        }
1768        (_, data_types::U64) => {
1769            let arr = batch
1770                .column_by_name(&col.name)
1771                .and_then(|c| c.as_any().downcast_ref::<UInt64Array>())
1772                .ok_or_else(|| SegmentError::CorruptFile {
1773                    detail: format!("missing u64 column '{}'", col.name),
1774                })?;
1775            let mut values = Vec::with_capacity(end - start);
1776            for i in start..end {
1777                if arr.is_null(i) {
1778                    values.push(0);
1779                    stats.record_null();
1780                    validity.push(false);
1781                } else {
1782                    let v = arr.value(i);
1783                    values.push(v);
1784                    stats.update_u64(v);
1785                    validity.push(true);
1786                }
1787            }
1788            let block = ColumnEncoder::encode_u64(&values)?;
1789            Ok(EncodedColumn::new(block, stats, validity))
1790        }
1791        (_, data_types::BOOL) => {
1792            let arr = batch
1793                .column_by_name(&col.name)
1794                .and_then(|c| c.as_any().downcast_ref::<BooleanArray>())
1795                .ok_or_else(|| SegmentError::CorruptFile {
1796                    detail: format!("missing bool column '{}'", col.name),
1797                })?;
1798            let mut values = Vec::with_capacity(end - start);
1799            for i in start..end {
1800                if arr.is_null(i) {
1801                    values.push(false);
1802                    stats.record_null();
1803                    validity.push(false);
1804                } else {
1805                    let v = arr.value(i);
1806                    values.push(v);
1807                    stats.update_bool(v);
1808                    validity.push(true);
1809                }
1810            }
1811            let block = ColumnEncoder::encode_bool(&values)?;
1812            Ok(EncodedColumn::new(block, stats, validity))
1813        }
1814        _ => Err(SegmentError::CorruptFile {
1815            detail: format!("unknown column type: {}", col.data_type),
1816        }),
1817    }
1818}
1819
1820#[cfg(test)]
1821mod tests {
1822    use super::*;
1823    use crate::segment::reader::SegmentReader;
1824    use chronix_core::config::{CompressionCodec, FloatEncoding};
1825    use chronix_core::types::{FieldValue, Point, SeriesKey};
1826    use std::collections::{BTreeMap, HashMap};
1827
1828    fn make_point(host: &str, cpu: f64, ts: i64) -> Point {
1829        let tags = BTreeMap::from([("host".to_string(), host.to_string())]);
1830        let key = SeriesKey::new("cpu_usage", tags).unwrap();
1831        let fields = BTreeMap::from([("cpu".to_string(), FieldValue::F64(cpu))]);
1832        Point::new(key, fields, ts).unwrap()
1833    }
1834
1835    #[test]
1836    fn per_column_codec_mixed_lz4_zstd() {
1837        let dir = tempfile::tempdir().unwrap();
1838        let path = dir.path().join("mixed_codec.csx");
1839
1840        let points: Vec<Point> = (0..200)
1841            .map(|i| make_point("server-1", 50.0 + i as f64, 1_000_000 + i * 10_000))
1842            .collect();
1843
1844        // Override the "cpu" column to use Zstd while default is Lz4
1845        let mut overrides = HashMap::new();
1846        overrides.insert("cpu".to_string(), CompressionCodec::Zstd);
1847
1848        let config = SegmentWriterConfig {
1849            row_group_size: 100,
1850            compress: true,
1851            float_encoding: FloatEncoding::Gorilla,
1852            compression_codec: CompressionCodec::Lz4,
1853            zstd_level: 3,
1854            column_codec_overrides: overrides,
1855            ..Default::default()
1856        };
1857
1858        let mut writer = SegmentWriter::new(&path, config).unwrap();
1859        writer.write_rows(&points).unwrap();
1860        let meta = writer.finalize().unwrap();
1861
1862        assert_eq!(meta.row_count, 200);
1863
1864        // Read back and verify data integrity
1865        let reader = SegmentReader::open(&path).unwrap();
1866        let batch = reader.read_all().unwrap();
1867        assert_eq!(batch.num_rows(), 200);
1868
1869        // Verify cpu values
1870        let cpu_col = batch
1871            .column_by_name("cpu")
1872            .unwrap()
1873            .as_any()
1874            .downcast_ref::<arrow::array::Float64Array>()
1875            .unwrap();
1876        for i in 0..200 {
1877            assert!(
1878                (cpu_col.value(i) - (50.0 + i as f64)).abs() < 1e-9,
1879                "cpu mismatch at row {i}"
1880            );
1881        }
1882    }
1883
1884    #[test]
1885    fn p09_zstd_dict_training_roundtrip() {
1886        let dir = tempfile::tempdir().unwrap();
1887        let path = dir.path().join("dict_trained.csx");
1888
1889        // Generate enough data for 3+ row groups so dictionary training
1890        // kicks in on the first and is used on subsequent groups.
1891        let points: Vec<Point> = (0..600)
1892            .map(|i| {
1893                let host = format!("host-{}", i % 5);
1894                make_point(&host, 10.0 + (i as f64) * 0.1, 1_000_000 + i * 10_000)
1895            })
1896            .collect();
1897
1898        let config = SegmentWriterConfig {
1899            row_group_size: 100,
1900            compress: true,
1901            compression_codec: CompressionCodec::Zstd,
1902            zstd_level: 3,
1903            zstd_dict_training: true,
1904            ..Default::default()
1905        };
1906
1907        let mut writer = SegmentWriter::new(&path, config).unwrap();
1908        writer.write_rows(&points).unwrap();
1909        let meta = writer.finalize().unwrap();
1910
1911        assert_eq!(meta.row_count, 600);
1912        assert!(meta.row_group_count >= 3);
1913
1914        // Verify the dictionary was trained and embedded
1915        let reader = SegmentReader::open(&path).unwrap();
1916        let batch = reader.read_all().unwrap();
1917        assert_eq!(batch.num_rows(), 600);
1918
1919        // Verify data integrity
1920        let cpu_col = batch
1921            .column_by_name("cpu")
1922            .unwrap()
1923            .as_any()
1924            .downcast_ref::<arrow::array::Float64Array>()
1925            .unwrap();
1926        assert_eq!(cpu_col.len(), 600);
1927    }
1928
1929    /// Segment metadata must stay proportional to the data, not fixed.
1930    ///
1931    /// Every column used to carry a full-precision HyperLogLog sketch
1932    /// (16 KiB) plus an equi-depth histogram, unconditionally — roughly 49 KiB
1933    /// of fixed overhead per segment that nothing ever read: the DataFusion
1934    /// statistics provider works from the catalog's exact `distinct_count`.
1935    /// On an hourly-sharded gateway that dwarfed the data and burned flash for
1936    /// nothing, so the sketches were removed. This pins the result.
1937    #[test]
1938    fn segment_metadata_overhead_stays_proportional_to_data() {
1939        let dir = tempfile::tempdir().unwrap();
1940        let path = dir.path().join("overhead.csx");
1941
1942        let points: Vec<Point> = (0..500)
1943            .map(|i| {
1944                let key = SeriesKey::new(
1945                    "m".to_string(),
1946                    std::collections::BTreeMap::from([(
1947                        "host".to_string(),
1948                        format!("h{}", i % 10),
1949                    )]),
1950                )
1951                .unwrap();
1952                Point::new(
1953                    key,
1954                    std::collections::BTreeMap::from([(
1955                        "cpu".to_string(),
1956                        FieldValue::F64(f64::from(i % 100) / 10.0),
1957                    )]),
1958                    i64::from(i) * 1_000_000,
1959                )
1960                .unwrap()
1961            })
1962            .collect();
1963
1964        let mut writer = SegmentWriter::new(&path, SegmentWriterConfig::default()).unwrap();
1965        writer.write_rows(&points).unwrap();
1966        writer.finalize().unwrap();
1967
1968        let file_size = std::fs::metadata(&path).unwrap().len();
1969        let block_bytes: u64 = {
1970            let reader = SegmentReader::open(&path).unwrap();
1971            (0..reader.row_group_count())
1972                .filter_map(|rg| reader.row_group_blocks(rg))
1973                .flatten()
1974                .map(|b| u64::from(b.length))
1975                .sum()
1976        };
1977        let overhead = file_size - block_bytes;
1978
1979        assert!(
1980            overhead < 8 * 1024,
1981            "segment carries {overhead} bytes of non-block overhead for {block_bytes} bytes of \
1982             data — fixed-size metadata has crept back in"
1983        );
1984    }
1985}