Skip to main content

chronix_engine/segment/
metadata.rs

1//! Per-column metadata within a segment file.
2//!
3//! Column metadata enables selective reading: the reader can determine which
4//! columns exist, their data types, and where their data is stored without
5//! reading any row group data.
6
7use chronix_encoding::EncodingType;
8use serde::{Deserialize, Serialize};
9
10use crate::segment::error::{Result, SegmentError};
11use crate::segment::stats::{ColumnStats, COLUMN_STATS_SIZE};
12use crate::segment::to_array;
13
14/// Metadata for a single column within a segment.
15///
16/// # Segment-level statistics
17///
18/// The [`stats`](Self::stats) field stores aggregate statistics across all
19/// row groups: `min_value`, `max_value`, `null_count`, `value_count`,
20/// `sum`, and `distinct_count`.  These are computed by the segment writer
21/// during encoding and merged via `merge_stats()`.  Query-time code can
22/// use these to prune entire segments without reading row-group data.
23///
24/// Metadata is kept proportional to the data: no fixed-size sketches or
25/// histograms, which cost ~49 KiB per segment however little it holds, and
26/// which the planner does not need — `distinct_count` above is exact.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct ColumnMeta {
29    /// Column name.
30    pub name: String,
31    /// Column data type tag (maps to `ColumnType`).
32    pub data_type: u8,
33    /// Column role tag: 0 = timestamp, 1 = tag, 2 = field.
34    pub role: u8,
35    /// The encoding the writer *intended* for this column, chosen from the
36    /// column's type before any data was seen.
37    ///
38    /// This is a hint only. The adaptive selector may fall back to a different
39    /// codec per block (e.g. Chimp → Gorilla → plain), so the authoritative
40    /// encoding for reading is always
41    /// [`ColumnBlockMeta::encoding`](crate::segment::metadata::ColumnBlockMeta::encoding).
42    /// The field was previously named `encoding`, which read as a statement of
43    /// fact; nothing decodes from it, but the name invited that mistake.
44    pub default_encoding: u8,
45    /// Global statistics across all row groups (min, max, null_count,
46    /// value_count, sum, distinct_count).  Enables segment-level pruning
47    /// at query time.
48    pub stats: ColumnStats,
49    /// Bloom filter for tag columns.
50    ///
51    /// Contains the serialized bloom filter bit array for tag columns,
52    /// enabling segment-level predicate pushdown: if a queried tag value
53    /// is definitely not in the bloom filter, the segment can be skipped.
54    /// `None` for non-tag columns, and for segments written before per-tag
55    /// row-group blooms existed.
56    #[serde(default)]
57    pub bloom_filter: Option<Vec<u8>>,
58    /// Whether this column is encrypted at rest (field-level encryption).
59    ///
60    /// When `true`, all column blocks for this column are AES-256-GCM
61    /// encrypted and `key_id` identifies the encryption key.
62    /// Statistics (min/max/sum/distinct) are zeroed to prevent leakage.
63    #[serde(default)]
64    pub encrypted: bool,
65    /// Encryption key ID for this column. Used by the reader to look up
66    /// the decryption key from a `FieldKeyProvider`.
67    #[serde(default)]
68    pub key_id: Option<String>,
69    /// Digits after the decimal point, for a decimal column.
70    ///
71    /// `None` for every other type. The scale lives here, once per column,
72    /// rather than beside each value: it is a property of the column, fixed
73    /// when the column was created, and the write path rescales every value
74    /// to it before the memtable ever sees it. Storing it per value would
75    /// cost a byte a row to say the same thing 65 536 times, and would make
76    /// a segment whose rows disagreed representable.
77    #[serde(default)]
78    pub decimal_scale: Option<u8>,
79    /// Per-row-group bloom filters for string columns .
80    ///
81    /// `row_group_blooms[rg_idx]` is a serialized bloom filter containing
82    /// all distinct string values for this column in that row group.
83    /// Enables row-group-level predicate pushdown: if a queried value is
84    /// definitely not in the bloom filter, the row group can be skipped.
85    /// `None` for non-string columns, encrypted columns, or segments
86    /// written before .
87    #[serde(default)]
88    pub row_group_blooms: Option<Vec<Vec<u8>>>,
89}
90
91impl ColumnMeta {
92    /// The Arrow type this column decodes to.
93    ///
94    /// The single answer to a question four places in the reader used to
95    /// answer separately, each ending in `_ => DataType::Utf8`. That
96    /// fallback was fine while every non-numeric column was a string; it
97    /// silently turned a decimal column into `Utf8` the moment one existed.
98    #[must_use]
99    pub fn arrow_data_type(&self) -> arrow::datatypes::DataType {
100        use arrow::datatypes::DataType;
101        match self.data_type {
102            data_types::TIMESTAMP | data_types::I64 => DataType::Int64,
103            data_types::U64 => DataType::UInt64,
104            data_types::F64 => DataType::Float64,
105            data_types::BOOL => DataType::Boolean,
106            data_types::DECIMAL => DataType::Decimal128(
107                chronix_core::DECIMAL_PRECISION,
108                // A decimal column always carries its scale; a segment whose
109                // metadata lost it reads as scale 0, which is wrong by a
110                // power of ten but not a panic.
111                self.decimal_scale.unwrap_or(0) as i8,
112            ),
113            _ => DataType::Utf8,
114        }
115    }
116
117    /// An empty array of this column's Arrow type, for a pruned row group.
118    #[must_use]
119    pub fn empty_array(&self) -> arrow::array::ArrayRef {
120        use arrow::array::{
121            ArrayRef, BooleanArray, Decimal128Array, Float64Array, Int64Array, StringArray,
122            UInt64Array,
123        };
124        match self.data_type {
125            data_types::TIMESTAMP | data_types::I64 => {
126                std::sync::Arc::new(Int64Array::from(Vec::<i64>::new())) as ArrayRef
127            }
128            data_types::U64 => std::sync::Arc::new(UInt64Array::from(Vec::<u64>::new())),
129            data_types::F64 => std::sync::Arc::new(Float64Array::from(Vec::<f64>::new())),
130            data_types::BOOL => std::sync::Arc::new(BooleanArray::from(Vec::<bool>::new())),
131            data_types::DECIMAL => {
132                let array = Decimal128Array::from(Vec::<i128>::new());
133                // Empty, so there is no value to fail validation; the scale
134                // still has to match the field the schema declares.
135                match array.with_precision_and_scale(
136                    chronix_core::DECIMAL_PRECISION,
137                    self.decimal_scale.unwrap_or(0) as i8,
138                ) {
139                    Ok(a) => std::sync::Arc::new(a),
140                    Err(_) => std::sync::Arc::new(Decimal128Array::from(Vec::<i128>::new())),
141                }
142            }
143            _ => std::sync::Arc::new(StringArray::from(Vec::<&str>::new())),
144        }
145    }
146
147    /// The Arrow field for this column, carrying its role as metadata.
148    #[must_use]
149    pub fn arrow_field(&self) -> arrow::datatypes::Field {
150        arrow::datatypes::Field::new(&self.name, self.arrow_data_type(), true)
151            .with_metadata(roles::arrow_metadata(self.role))
152    }
153}
154
155/// Column data type tags.
156pub mod data_types {
157    /// Timestamp column (i64 nanoseconds).
158    pub const TIMESTAMP: u8 = 0;
159    /// String column (tags or string fields).
160    pub const STRING: u8 = 1;
161    /// 64-bit float column.
162    pub const F64: u8 = 2;
163    /// Signed 64-bit integer column.
164    pub const I64: u8 = 3;
165    /// Unsigned 64-bit integer column.
166    pub const U64: u8 = 4;
167    /// Boolean column.
168    pub const BOOL: u8 = 5;
169    /// Exact decimal column: an `i128` mantissa with the scale held in
170    /// [`ColumnMeta::decimal_scale`](super::ColumnMeta::decimal_scale).
171    pub const DECIMAL: u8 = 6;
172}
173
174/// Column role tags.
175pub mod roles {
176    /// Timestamp column.
177    pub const TIMESTAMP: u8 = 0;
178    /// Tag column (string, indexed, low cardinality).
179    pub const TAG: u8 = 1;
180    /// Field column (any data type).
181    pub const FIELD: u8 = 2;
182
183    /// The Arrow field-metadata key a decoded batch carries its role under.
184    ///
185    /// A tag and a string field are the same Arrow type, so a schema built
186    /// from types alone loses the distinction — and every consumer then
187    /// guesses, differently.
188    pub const ARROW_ROLE_KEY: &str = "role";
189
190    /// Arrow field metadata for one stored role.
191    #[must_use]
192    pub fn arrow_metadata(role: u8) -> std::collections::HashMap<String, String> {
193        // The *role*, not the column name: a consumer reads `role=timestamp`,
194        // `role=tag` or `role=field` off the field's metadata.
195        let name = match role {
196            TIMESTAMP => "timestamp",
197            TAG => "tag",
198            _ => "field",
199        };
200        [(ARROW_ROLE_KEY.to_string(), name.to_string())].into()
201    }
202}
203
204/// Per-row-group column block location and metadata.
205#[derive(Debug, Clone, PartialEq)]
206pub struct ColumnBlockMeta {
207    /// Index of the column in the column metadata table.
208    pub column_index: u16,
209    /// Encoding type for this specific block.
210    pub encoding: EncodingType,
211    /// Whether the block is LZ4-compressed.
212    pub compressed: bool,
213    /// Byte offset within the segment file.
214    pub offset: u64,
215    /// Length of the (possibly compressed) block in bytes.
216    pub length: u32,
217    /// Number of values in this block.
218    pub value_count: u32,
219    /// CRC32c checksum of the block data for integrity verification.
220    /// A value of 0 indicates no checksum (backward compatibility with old segments).
221    pub block_crc: u32,
222    /// Whether this block is encrypted (AES-256-GCM).
223    pub encrypted: bool,
224    /// Byte offset of this block's validity bitmap within the segment file.
225    ///
226    /// See [`validity_length`](Self::validity_length). Meaningless when
227    /// `validity_length == 0`.
228    pub validity_offset: u64,
229    /// Length in bytes of this block's validity bitmap, or `0` when the block
230    /// contains no nulls (`.csx` v2, D-NULL).
231    ///
232    /// The bitmap is an Arrow-compatible LSB-first packed bitmap of
233    /// `value_count` bits: bit *i* set means row *i* holds a real value, bit
234    /// clear means SQL `NULL`. It is stored uncompressed and unencrypted
235    /// immediately after the block's value bytes.
236    ///
237    /// Before v2, absent values were encoded as type-specific sentinels
238    /// (`0`/`""`/`false`) and only an aggregate `null_count` survived, so a
239    /// stored zero and an absent field were indistinguishable at read time.
240    pub validity_length: u32,
241    /// Block-level statistics.
242    pub stats: ColumnStats,
243}
244
245impl ColumnBlockMeta {
246    /// Serialized size of a column block metadata entry.
247    pub const SIZE: usize = 2 + 1 + 1 + 8 + 4 + 4 + 4 + 1 + 8 + 4 + COLUMN_STATS_SIZE;
248    // column_index(2) + encoding(1) + compressed(1) + offset(8) + length(4)
249    // + value_count(4) + block_crc(4) + encrypted(1) + validity_offset(8)
250    // + validity_length(4) + stats(COLUMN_STATS_SIZE)
251
252    /// Serialize to bytes.
253    #[must_use]
254    pub fn to_bytes(&self) -> Vec<u8> {
255        let mut buf = Vec::with_capacity(Self::SIZE);
256        buf.extend_from_slice(&self.column_index.to_le_bytes());
257        buf.push(self.encoding.tag());
258        buf.push(u8::from(self.compressed));
259        buf.extend_from_slice(&self.offset.to_le_bytes());
260        buf.extend_from_slice(&self.length.to_le_bytes());
261        buf.extend_from_slice(&self.value_count.to_le_bytes());
262        buf.extend_from_slice(&self.block_crc.to_le_bytes());
263        buf.push(u8::from(self.encrypted));
264        buf.extend_from_slice(&self.validity_offset.to_le_bytes());
265        buf.extend_from_slice(&self.validity_length.to_le_bytes());
266        buf.extend_from_slice(&self.stats.to_bytes());
267        buf
268    }
269
270    /// Deserialize from bytes.
271    ///
272    /// # Errors
273    ///
274    /// Returns an error if the data is too short or contains invalid values.
275    ///
276    pub fn from_bytes(data: &[u8]) -> Result<Self> {
277        if data.len() < Self::SIZE {
278            return Err(SegmentError::CorruptFile {
279                detail: format!(
280                    "column block meta too short: expected {}, got {}",
281                    Self::SIZE,
282                    data.len()
283                ),
284            });
285        }
286
287        let column_index = u16::from_le_bytes([data[0], data[1]]);
288        let encoding = EncodingType::from_tag(data[2]).map_err(|e| SegmentError::CorruptFile {
289            detail: format!("invalid encoding tag: {e}"),
290        })?;
291        let compressed = data[3] != 0;
292        let offset = u64::from_le_bytes(to_array!(data[4..12], "block meta offset")?);
293        let length = u32::from_le_bytes(to_array!(data[12..16], "block meta length")?);
294        let value_count = u32::from_le_bytes(to_array!(data[16..20], "block meta value_count")?);
295        let block_crc = u32::from_le_bytes(to_array!(data[20..24], "block meta block_crc")?);
296        let encrypted = data[24] != 0;
297        let validity_offset =
298            u64::from_le_bytes(to_array!(data[25..33], "block meta validity_offset")?);
299        let validity_length =
300            u32::from_le_bytes(to_array!(data[33..37], "block meta validity_length")?);
301        let stats = ColumnStats::from_bytes(&data[37..])?;
302
303        Ok(Self {
304            column_index,
305            encoding,
306            compressed,
307            offset,
308            length,
309            value_count,
310            block_crc,
311            encrypted,
312            validity_offset,
313            validity_length,
314            stats,
315        })
316    }
317}
318
319/// Metadata section stored after all row groups, before the footer.
320#[derive(Debug, Clone, PartialEq)]
321pub struct SegmentMetadata {
322    /// Column metadata table.
323    pub columns: Vec<ColumnMeta>,
324    /// Per-row-group, per-column block metadata.
325    pub row_group_blocks: Vec<Vec<ColumnBlockMeta>>,
326    /// Optional Zstd compression dictionary trained from this segment's
327    /// data. When present, blocks tagged with `CODEC_TAG_ZSTD_DICT`
328    /// must be decompressed using this dictionary.
329    pub zstd_dictionary: Option<Vec<u8>>,
330}
331
332impl SegmentMetadata {
333    /// Serialize the metadata section to bytes.
334    ///
335    /// # Errors
336    ///
337    /// Returns an error if column metadata serialization fails.
338    pub fn to_bytes(&self) -> Result<Vec<u8>> {
339        let encoded =
340            postcard::to_stdvec(&self.columns).map_err(|e| SegmentError::CorruptFile {
341                detail: format!("column metadata serialization failed: {e}"),
342            })?;
343
344        // Format: [column_meta_len: u32][column_meta_postcard]
345        //         [rg_count: u32]
346        //         for each rg: [block_count: u16][block_meta...][block_meta...]
347        //         [zstd_dict_len: u32][dict_bytes]  (0 = no dict)
348        let mut buf = Vec::new();
349
350        // Column metadata (postcard)
351        buf.extend_from_slice(&(encoded.len() as u32).to_le_bytes());
352        buf.extend_from_slice(&encoded);
353
354        // Row group block metadata
355        buf.extend_from_slice(&(self.row_group_blocks.len() as u32).to_le_bytes());
356        for rg_blocks in &self.row_group_blocks {
357            buf.extend_from_slice(&(rg_blocks.len() as u16).to_le_bytes());
358            for block in rg_blocks {
359                buf.extend_from_slice(&block.to_bytes());
360            }
361        }
362
363        // Zstd dictionary (trailing, backward-compatible)
364        if let Some(dict) = &self.zstd_dictionary {
365            buf.extend_from_slice(&(dict.len() as u32).to_le_bytes());
366            buf.extend_from_slice(dict);
367        } else {
368            buf.extend_from_slice(&0u32.to_le_bytes());
369        }
370
371        Ok(buf)
372    }
373
374    /// Deserialize the metadata section from bytes.
375    ///
376    /// # Errors
377    ///
378    /// Returns an error if the data is malformed.
379    pub fn from_bytes(data: &[u8]) -> Result<Self> {
380        if data.len() < 4 {
381            return Err(SegmentError::CorruptFile {
382                detail: "metadata section too short".to_string(),
383            });
384        }
385
386        let json_len = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
387        let mut offset = 4;
388
389        if data.len() < offset + json_len {
390            return Err(SegmentError::CorruptFile {
391                detail: "metadata column section truncated".to_string(),
392            });
393        }
394
395        let columns: Vec<ColumnMeta> = postcard::from_bytes(&data[offset..offset + json_len])
396            .map_err(|e| SegmentError::CorruptFile {
397                detail: format!("invalid column metadata: {e}"),
398            })?;
399        offset += json_len;
400
401        if data.len() < offset + 4 {
402            return Err(SegmentError::CorruptFile {
403                detail: "metadata missing row group count".to_string(),
404            });
405        }
406
407        let rg_count = u32::from_le_bytes(to_array!(
408            data[offset..offset + 4],
409            "metadata row group count"
410        )?) as usize;
411        offset += 4;
412
413        // Validate that the claimed rg_count is plausible given remaining data.
414        // Each row group needs at least 2 bytes (block_count), so the total
415        // remaining data must be at least rg_count * 2.
416        let remaining = data.len().saturating_sub(offset);
417        if rg_count > remaining / 2 {
418            return Err(SegmentError::CorruptFile {
419                detail: format!(
420                    "row group count ({rg_count}) exceeds available metadata ({remaining} bytes)"
421                ),
422            });
423        }
424
425        let mut row_group_blocks = Vec::with_capacity(rg_count);
426        for _ in 0..rg_count {
427            if data.len() < offset + 2 {
428                return Err(SegmentError::CorruptFile {
429                    detail: "metadata missing block count".to_string(),
430                });
431            }
432            let block_count = u16::from_le_bytes([data[offset], data[offset + 1]]) as usize;
433            offset += 2;
434
435            let mut blocks = Vec::with_capacity(block_count);
436            for _ in 0..block_count {
437                let block = ColumnBlockMeta::from_bytes(&data[offset..])?;
438                offset += ColumnBlockMeta::SIZE;
439                blocks.push(block);
440            }
441            row_group_blocks.push(blocks);
442        }
443
444        // Zstd dictionary (trailing, optional for backward compat).
445        let zstd_dictionary = if data.len() >= offset + 4 {
446            let dict_len = u32::from_le_bytes(to_array!(
447                data[offset..offset + 4],
448                "metadata zstd_dict_len"
449            )?) as usize;
450            offset += 4;
451            if dict_len > 0 {
452                if data.len() < offset + dict_len {
453                    return Err(SegmentError::CorruptFile {
454                        detail: "metadata zstd dictionary truncated".to_string(),
455                    });
456                }
457                let dict = data[offset..offset + dict_len].to_vec();
458                Some(dict)
459            } else {
460                None
461            }
462        } else {
463            // Old segment without dictionary section — backward compatible.
464            None
465        };
466
467        Ok(Self {
468            columns,
469            row_group_blocks,
470            zstd_dictionary,
471        })
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn column_block_meta_roundtrip() {
481        let meta = ColumnBlockMeta {
482            column_index: 3,
483            encoding: EncodingType::Chimp,
484            compressed: true,
485            offset: 1024,
486            length: 512,
487            value_count: 1000,
488            block_crc: 0xDEAD_BEEF,
489            encrypted: false,
490            validity_offset: 0,
491            validity_length: 0,
492            stats: ColumnStats::empty(),
493        };
494        let bytes = meta.to_bytes();
495        assert_eq!(bytes.len(), ColumnBlockMeta::SIZE);
496        let recovered = ColumnBlockMeta::from_bytes(&bytes).unwrap();
497        assert_eq!(meta, recovered);
498    }
499
500    #[test]
501    fn segment_metadata_roundtrip() {
502        let meta = SegmentMetadata {
503            columns: vec![
504                ColumnMeta {
505                    name: chronix_core::TIME_COLUMN.to_string(),
506                    data_type: data_types::TIMESTAMP,
507                    role: roles::TIMESTAMP,
508                    default_encoding: EncodingType::DeltaOfDelta.tag(),
509                    stats: ColumnStats::empty(),
510                    bloom_filter: None,
511                    encrypted: false,
512                    key_id: None,
513                    decimal_scale: None,
514                    row_group_blooms: None,
515                },
516                ColumnMeta {
517                    name: "host".to_string(),
518                    data_type: data_types::STRING,
519                    role: roles::TAG,
520                    default_encoding: EncodingType::Dictionary.tag(),
521                    stats: ColumnStats::empty(),
522                    bloom_filter: None,
523                    encrypted: false,
524                    key_id: None,
525                    decimal_scale: None,
526                    row_group_blooms: None,
527                },
528                ColumnMeta {
529                    name: "cpu".to_string(),
530                    data_type: data_types::F64,
531                    role: roles::FIELD,
532                    default_encoding: EncodingType::Chimp.tag(),
533                    stats: ColumnStats::empty(),
534                    bloom_filter: None,
535                    encrypted: false,
536                    key_id: None,
537                    decimal_scale: None,
538                    row_group_blooms: None,
539                },
540            ],
541            row_group_blocks: vec![vec![
542                ColumnBlockMeta {
543                    column_index: 0,
544                    encoding: EncodingType::DeltaOfDelta,
545                    compressed: false,
546                    offset: 48,
547                    length: 200,
548                    value_count: 1000,
549                    block_crc: 0,
550                    encrypted: false,
551                    validity_offset: 0,
552                    validity_length: 0,
553                    stats: ColumnStats::empty(),
554                },
555                ColumnBlockMeta {
556                    column_index: 1,
557                    encoding: EncodingType::Dictionary,
558                    compressed: true,
559                    offset: 248,
560                    length: 50,
561                    value_count: 1000,
562                    block_crc: 0,
563                    encrypted: false,
564                    validity_offset: 0,
565                    validity_length: 0,
566                    stats: ColumnStats::empty(),
567                },
568            ]],
569            zstd_dictionary: None,
570        };
571
572        let bytes = meta.to_bytes().unwrap();
573        let recovered = SegmentMetadata::from_bytes(&bytes).unwrap();
574        assert_eq!(meta, recovered);
575    }
576
577    #[test]
578    fn corrupt_metadata_detected() {
579        assert!(SegmentMetadata::from_bytes(&[0; 2]).is_err());
580    }
581}