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}