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    /// Per-row-group bloom filters for string columns .
70    ///
71    /// `row_group_blooms[rg_idx]` is a serialized bloom filter containing
72    /// all distinct string values for this column in that row group.
73    /// Enables row-group-level predicate pushdown: if a queried value is
74    /// definitely not in the bloom filter, the row group can be skipped.
75    /// `None` for non-string columns, encrypted columns, or segments
76    /// written before .
77    #[serde(default)]
78    pub row_group_blooms: Option<Vec<Vec<u8>>>,
79}
80
81/// Column data type tags.
82pub mod data_types {
83    /// Timestamp column (i64 nanoseconds).
84    pub const TIMESTAMP: u8 = 0;
85    /// String column (tags or string fields).
86    pub const STRING: u8 = 1;
87    /// 64-bit float column.
88    pub const F64: u8 = 2;
89    /// Signed 64-bit integer column.
90    pub const I64: u8 = 3;
91    /// Unsigned 64-bit integer column.
92    pub const U64: u8 = 4;
93    /// Boolean column.
94    pub const BOOL: u8 = 5;
95}
96
97/// Column role tags.
98pub mod roles {
99    /// Timestamp column.
100    pub const TIMESTAMP: u8 = 0;
101    /// Tag column (string, indexed, low cardinality).
102    pub const TAG: u8 = 1;
103    /// Field column (any data type).
104    pub const FIELD: u8 = 2;
105
106    /// The Arrow field-metadata key a decoded batch carries its role under.
107    ///
108    /// A tag and a string field are the same Arrow type, so a schema built
109    /// from types alone loses the distinction — and every consumer then
110    /// guesses, differently.
111    pub const ARROW_ROLE_KEY: &str = "role";
112
113    /// Arrow field metadata for one stored role.
114    #[must_use]
115    pub fn arrow_metadata(role: u8) -> std::collections::HashMap<String, String> {
116        // The *role*, not the column name: a consumer reads `role=timestamp`,
117        // `role=tag` or `role=field` off the field's metadata.
118        let name = match role {
119            TIMESTAMP => "timestamp",
120            TAG => "tag",
121            _ => "field",
122        };
123        [(ARROW_ROLE_KEY.to_string(), name.to_string())].into()
124    }
125}
126
127/// Per-row-group column block location and metadata.
128#[derive(Debug, Clone, PartialEq)]
129pub struct ColumnBlockMeta {
130    /// Index of the column in the column metadata table.
131    pub column_index: u16,
132    /// Encoding type for this specific block.
133    pub encoding: EncodingType,
134    /// Whether the block is LZ4-compressed.
135    pub compressed: bool,
136    /// Byte offset within the segment file.
137    pub offset: u64,
138    /// Length of the (possibly compressed) block in bytes.
139    pub length: u32,
140    /// Number of values in this block.
141    pub value_count: u32,
142    /// CRC32c checksum of the block data for integrity verification.
143    /// A value of 0 indicates no checksum (backward compatibility with old segments).
144    pub block_crc: u32,
145    /// Whether this block is encrypted (AES-256-GCM).
146    pub encrypted: bool,
147    /// Byte offset of this block's validity bitmap within the segment file.
148    ///
149    /// See [`validity_length`](Self::validity_length). Meaningless when
150    /// `validity_length == 0`.
151    pub validity_offset: u64,
152    /// Length in bytes of this block's validity bitmap, or `0` when the block
153    /// contains no nulls (`.csx` v2, D-NULL).
154    ///
155    /// The bitmap is an Arrow-compatible LSB-first packed bitmap of
156    /// `value_count` bits: bit *i* set means row *i* holds a real value, bit
157    /// clear means SQL `NULL`. It is stored uncompressed and unencrypted
158    /// immediately after the block's value bytes.
159    ///
160    /// Before v2, absent values were encoded as type-specific sentinels
161    /// (`0`/`""`/`false`) and only an aggregate `null_count` survived, so a
162    /// stored zero and an absent field were indistinguishable at read time.
163    pub validity_length: u32,
164    /// Block-level statistics.
165    pub stats: ColumnStats,
166}
167
168impl ColumnBlockMeta {
169    /// Serialized size of a column block metadata entry.
170    pub const SIZE: usize = 2 + 1 + 1 + 8 + 4 + 4 + 4 + 1 + 8 + 4 + COLUMN_STATS_SIZE;
171    // column_index(2) + encoding(1) + compressed(1) + offset(8) + length(4)
172    // + value_count(4) + block_crc(4) + encrypted(1) + validity_offset(8)
173    // + validity_length(4) + stats(COLUMN_STATS_SIZE)
174
175    /// Serialize to bytes.
176    #[must_use]
177    pub fn to_bytes(&self) -> Vec<u8> {
178        let mut buf = Vec::with_capacity(Self::SIZE);
179        buf.extend_from_slice(&self.column_index.to_le_bytes());
180        buf.push(self.encoding.tag());
181        buf.push(u8::from(self.compressed));
182        buf.extend_from_slice(&self.offset.to_le_bytes());
183        buf.extend_from_slice(&self.length.to_le_bytes());
184        buf.extend_from_slice(&self.value_count.to_le_bytes());
185        buf.extend_from_slice(&self.block_crc.to_le_bytes());
186        buf.push(u8::from(self.encrypted));
187        buf.extend_from_slice(&self.validity_offset.to_le_bytes());
188        buf.extend_from_slice(&self.validity_length.to_le_bytes());
189        buf.extend_from_slice(&self.stats.to_bytes());
190        buf
191    }
192
193    /// Deserialize from bytes.
194    ///
195    /// # Errors
196    ///
197    /// Returns an error if the data is too short or contains invalid values.
198    ///
199    pub fn from_bytes(data: &[u8]) -> Result<Self> {
200        if data.len() < Self::SIZE {
201            return Err(SegmentError::CorruptFile {
202                detail: format!(
203                    "column block meta too short: expected {}, got {}",
204                    Self::SIZE,
205                    data.len()
206                ),
207            });
208        }
209
210        let column_index = u16::from_le_bytes([data[0], data[1]]);
211        let encoding = EncodingType::from_tag(data[2]).map_err(|e| SegmentError::CorruptFile {
212            detail: format!("invalid encoding tag: {e}"),
213        })?;
214        let compressed = data[3] != 0;
215        let offset = u64::from_le_bytes(to_array!(data[4..12], "block meta offset")?);
216        let length = u32::from_le_bytes(to_array!(data[12..16], "block meta length")?);
217        let value_count = u32::from_le_bytes(to_array!(data[16..20], "block meta value_count")?);
218        let block_crc = u32::from_le_bytes(to_array!(data[20..24], "block meta block_crc")?);
219        let encrypted = data[24] != 0;
220        let validity_offset =
221            u64::from_le_bytes(to_array!(data[25..33], "block meta validity_offset")?);
222        let validity_length =
223            u32::from_le_bytes(to_array!(data[33..37], "block meta validity_length")?);
224        let stats = ColumnStats::from_bytes(&data[37..])?;
225
226        Ok(Self {
227            column_index,
228            encoding,
229            compressed,
230            offset,
231            length,
232            value_count,
233            block_crc,
234            encrypted,
235            validity_offset,
236            validity_length,
237            stats,
238        })
239    }
240}
241
242/// Metadata section stored after all row groups, before the footer.
243#[derive(Debug, Clone, PartialEq)]
244pub struct SegmentMetadata {
245    /// Column metadata table.
246    pub columns: Vec<ColumnMeta>,
247    /// Per-row-group, per-column block metadata.
248    pub row_group_blocks: Vec<Vec<ColumnBlockMeta>>,
249    /// Optional Zstd compression dictionary trained from this segment's
250    /// data. When present, blocks tagged with `CODEC_TAG_ZSTD_DICT`
251    /// must be decompressed using this dictionary.
252    pub zstd_dictionary: Option<Vec<u8>>,
253}
254
255impl SegmentMetadata {
256    /// Serialize the metadata section to bytes.
257    ///
258    /// # Errors
259    ///
260    /// Returns an error if column metadata serialization fails.
261    pub fn to_bytes(&self) -> Result<Vec<u8>> {
262        let encoded =
263            postcard::to_stdvec(&self.columns).map_err(|e| SegmentError::CorruptFile {
264                detail: format!("column metadata serialization failed: {e}"),
265            })?;
266
267        // Format: [column_meta_len: u32][column_meta_postcard]
268        //         [rg_count: u32]
269        //         for each rg: [block_count: u16][block_meta...][block_meta...]
270        //         [zstd_dict_len: u32][dict_bytes]  (0 = no dict)
271        let mut buf = Vec::new();
272
273        // Column metadata (postcard)
274        buf.extend_from_slice(&(encoded.len() as u32).to_le_bytes());
275        buf.extend_from_slice(&encoded);
276
277        // Row group block metadata
278        buf.extend_from_slice(&(self.row_group_blocks.len() as u32).to_le_bytes());
279        for rg_blocks in &self.row_group_blocks {
280            buf.extend_from_slice(&(rg_blocks.len() as u16).to_le_bytes());
281            for block in rg_blocks {
282                buf.extend_from_slice(&block.to_bytes());
283            }
284        }
285
286        // Zstd dictionary (trailing, backward-compatible)
287        if let Some(dict) = &self.zstd_dictionary {
288            buf.extend_from_slice(&(dict.len() as u32).to_le_bytes());
289            buf.extend_from_slice(dict);
290        } else {
291            buf.extend_from_slice(&0u32.to_le_bytes());
292        }
293
294        Ok(buf)
295    }
296
297    /// Deserialize the metadata section from bytes.
298    ///
299    /// # Errors
300    ///
301    /// Returns an error if the data is malformed.
302    pub fn from_bytes(data: &[u8]) -> Result<Self> {
303        if data.len() < 4 {
304            return Err(SegmentError::CorruptFile {
305                detail: "metadata section too short".to_string(),
306            });
307        }
308
309        let json_len = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
310        let mut offset = 4;
311
312        if data.len() < offset + json_len {
313            return Err(SegmentError::CorruptFile {
314                detail: "metadata column section truncated".to_string(),
315            });
316        }
317
318        let columns: Vec<ColumnMeta> = postcard::from_bytes(&data[offset..offset + json_len])
319            .map_err(|e| SegmentError::CorruptFile {
320                detail: format!("invalid column metadata: {e}"),
321            })?;
322        offset += json_len;
323
324        if data.len() < offset + 4 {
325            return Err(SegmentError::CorruptFile {
326                detail: "metadata missing row group count".to_string(),
327            });
328        }
329
330        let rg_count = u32::from_le_bytes(to_array!(
331            data[offset..offset + 4],
332            "metadata row group count"
333        )?) as usize;
334        offset += 4;
335
336        // Validate that the claimed rg_count is plausible given remaining data.
337        // Each row group needs at least 2 bytes (block_count), so the total
338        // remaining data must be at least rg_count * 2.
339        let remaining = data.len().saturating_sub(offset);
340        if rg_count > remaining / 2 {
341            return Err(SegmentError::CorruptFile {
342                detail: format!(
343                    "row group count ({rg_count}) exceeds available metadata ({remaining} bytes)"
344                ),
345            });
346        }
347
348        let mut row_group_blocks = Vec::with_capacity(rg_count);
349        for _ in 0..rg_count {
350            if data.len() < offset + 2 {
351                return Err(SegmentError::CorruptFile {
352                    detail: "metadata missing block count".to_string(),
353                });
354            }
355            let block_count = u16::from_le_bytes([data[offset], data[offset + 1]]) as usize;
356            offset += 2;
357
358            let mut blocks = Vec::with_capacity(block_count);
359            for _ in 0..block_count {
360                let block = ColumnBlockMeta::from_bytes(&data[offset..])?;
361                offset += ColumnBlockMeta::SIZE;
362                blocks.push(block);
363            }
364            row_group_blocks.push(blocks);
365        }
366
367        // Zstd dictionary (trailing, optional for backward compat).
368        let zstd_dictionary = if data.len() >= offset + 4 {
369            let dict_len = u32::from_le_bytes(to_array!(
370                data[offset..offset + 4],
371                "metadata zstd_dict_len"
372            )?) as usize;
373            offset += 4;
374            if dict_len > 0 {
375                if data.len() < offset + dict_len {
376                    return Err(SegmentError::CorruptFile {
377                        detail: "metadata zstd dictionary truncated".to_string(),
378                    });
379                }
380                let dict = data[offset..offset + dict_len].to_vec();
381                Some(dict)
382            } else {
383                None
384            }
385        } else {
386            // Old segment without dictionary section — backward compatible.
387            None
388        };
389
390        Ok(Self {
391            columns,
392            row_group_blocks,
393            zstd_dictionary,
394        })
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn column_block_meta_roundtrip() {
404        let meta = ColumnBlockMeta {
405            column_index: 3,
406            encoding: EncodingType::Chimp,
407            compressed: true,
408            offset: 1024,
409            length: 512,
410            value_count: 1000,
411            block_crc: 0xDEAD_BEEF,
412            encrypted: false,
413            validity_offset: 0,
414            validity_length: 0,
415            stats: ColumnStats::empty(),
416        };
417        let bytes = meta.to_bytes();
418        assert_eq!(bytes.len(), ColumnBlockMeta::SIZE);
419        let recovered = ColumnBlockMeta::from_bytes(&bytes).unwrap();
420        assert_eq!(meta, recovered);
421    }
422
423    #[test]
424    fn segment_metadata_roundtrip() {
425        let meta = SegmentMetadata {
426            columns: vec![
427                ColumnMeta {
428                    name: chronix_core::TIME_COLUMN.to_string(),
429                    data_type: data_types::TIMESTAMP,
430                    role: roles::TIMESTAMP,
431                    default_encoding: EncodingType::DeltaOfDelta.tag(),
432                    stats: ColumnStats::empty(),
433                    bloom_filter: None,
434                    encrypted: false,
435                    key_id: None,
436                    row_group_blooms: None,
437                },
438                ColumnMeta {
439                    name: "host".to_string(),
440                    data_type: data_types::STRING,
441                    role: roles::TAG,
442                    default_encoding: EncodingType::Dictionary.tag(),
443                    stats: ColumnStats::empty(),
444                    bloom_filter: None,
445                    encrypted: false,
446                    key_id: None,
447                    row_group_blooms: None,
448                },
449                ColumnMeta {
450                    name: "cpu".to_string(),
451                    data_type: data_types::F64,
452                    role: roles::FIELD,
453                    default_encoding: EncodingType::Chimp.tag(),
454                    stats: ColumnStats::empty(),
455                    bloom_filter: None,
456                    encrypted: false,
457                    key_id: None,
458                    row_group_blooms: None,
459                },
460            ],
461            row_group_blocks: vec![vec![
462                ColumnBlockMeta {
463                    column_index: 0,
464                    encoding: EncodingType::DeltaOfDelta,
465                    compressed: false,
466                    offset: 48,
467                    length: 200,
468                    value_count: 1000,
469                    block_crc: 0,
470                    encrypted: false,
471                    validity_offset: 0,
472                    validity_length: 0,
473                    stats: ColumnStats::empty(),
474                },
475                ColumnBlockMeta {
476                    column_index: 1,
477                    encoding: EncodingType::Dictionary,
478                    compressed: true,
479                    offset: 248,
480                    length: 50,
481                    value_count: 1000,
482                    block_crc: 0,
483                    encrypted: false,
484                    validity_offset: 0,
485                    validity_length: 0,
486                    stats: ColumnStats::empty(),
487                },
488            ]],
489            zstd_dictionary: None,
490        };
491
492        let bytes = meta.to_bytes().unwrap();
493        let recovered = SegmentMetadata::from_bytes(&bytes).unwrap();
494        assert_eq!(meta, recovered);
495    }
496
497    #[test]
498    fn corrupt_metadata_detected() {
499        assert!(SegmentMetadata::from_bytes(&[0; 2]).is_err());
500    }
501}