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}