1use serde::{Deserialize, Serialize};
10use zerompk::{FromMessagePack, ToMessagePack};
11
12pub const MAGIC: [u8; 4] = *b"NDBS";
14
15pub const VERSION_MAJOR: u8 = 1;
17
18pub const VERSION_MINOR: u8 = 1;
21
22pub const ENDIANNESS_LE: u8 = 0x01;
24
25pub const BLOCK_SIZE: usize = 1024;
29
30pub const HEADER_SIZE: usize = 7;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct SegmentHeader {
36 pub magic: [u8; 4],
37 pub version_major: u8,
38 pub version_minor: u8,
39 pub endianness: u8,
40}
41
42impl SegmentHeader {
43 pub fn current() -> Self {
45 Self {
46 magic: MAGIC,
47 version_major: VERSION_MAJOR,
48 version_minor: VERSION_MINOR,
49 endianness: ENDIANNESS_LE,
50 }
51 }
52
53 pub fn to_bytes(&self) -> [u8; HEADER_SIZE] {
55 let mut buf = [0u8; HEADER_SIZE];
56 buf[0..4].copy_from_slice(&self.magic);
57 buf[4] = self.version_major;
58 buf[5] = self.version_minor;
59 buf[6] = self.endianness;
60 buf
61 }
62
63 pub fn from_bytes(data: &[u8]) -> Result<Self, crate::error::ColumnarError> {
65 if data.len() < HEADER_SIZE {
66 return Err(crate::error::ColumnarError::TruncatedSegment {
67 expected: HEADER_SIZE,
68 got: data.len(),
69 });
70 }
71
72 let mut magic = [0u8; 4];
73 magic.copy_from_slice(&data[0..4]);
74 if magic != MAGIC {
75 return Err(crate::error::ColumnarError::InvalidMagic(magic));
76 }
77
78 let version_major = data[4];
79 let version_minor = data[5];
80
81 if version_major > VERSION_MAJOR {
83 return Err(crate::error::ColumnarError::IncompatibleVersion {
84 reader_major: VERSION_MAJOR,
85 segment_major: version_major,
86 segment_minor: version_minor,
87 });
88 }
89
90 Ok(Self {
91 magic,
92 version_major,
93 version_minor,
94 endianness: data[6],
95 })
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToMessagePack, FromMessagePack)]
105pub struct BloomFilter {
106 pub k: u8,
109 pub m: u32,
112 pub bytes: Vec<u8>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, ToMessagePack, FromMessagePack)]
119pub struct BlockStats {
120 pub min: f64,
126 pub max: f64,
128 pub null_count: u32,
130 pub row_count: u32,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub str_min: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub str_max: Option<String>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub bloom: Option<BloomFilter>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub min_i64: Option<i64>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub max_i64: Option<i64>,
158}
159
160impl BlockStats {
161 pub fn numeric(min: f64, max: f64, null_count: u32, row_count: u32) -> Self {
163 Self {
164 min,
165 max,
166 null_count,
167 row_count,
168 str_min: None,
169 str_max: None,
170 bloom: None,
171 min_i64: None,
172 max_i64: None,
173 }
174 }
175
176 pub fn integer(min: i64, max: i64, null_count: u32, row_count: u32) -> Self {
182 Self {
183 min: min as f64,
184 max: max as f64,
185 null_count,
186 row_count,
187 str_min: None,
188 str_max: None,
189 bloom: None,
190 min_i64: Some(min),
191 max_i64: Some(max),
192 }
193 }
194
195 pub fn non_numeric(null_count: u32, row_count: u32) -> Self {
197 Self {
198 min: f64::NAN,
199 max: f64::NAN,
200 null_count,
201 row_count,
202 str_min: None,
203 str_max: None,
204 bloom: None,
205 min_i64: None,
206 max_i64: None,
207 }
208 }
209
210 pub fn string_block(
212 null_count: u32,
213 row_count: u32,
214 str_min: Option<String>,
215 str_max: Option<String>,
216 bloom: Option<BloomFilter>,
217 ) -> Self {
218 Self {
219 min: f64::NAN,
220 max: f64::NAN,
221 null_count,
222 row_count,
223 str_min,
224 str_max,
225 bloom,
226 min_i64: None,
227 max_i64: None,
228 }
229 }
230}
231
232#[derive(
237 Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToMessagePack, FromMessagePack,
238)]
239#[serde(rename_all = "snake_case")]
240#[repr(u8)]
241#[msgpack(c_enum)]
242pub enum BlockLayout {
243 Int64 = 1,
245 Float64 = 2,
247 PackedBool = 3,
249 VarLen = 4,
251 FixedWidth = 5,
254 DictIds = 6,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize, ToMessagePack, FromMessagePack)]
260pub struct ColumnMeta {
261 pub name: String,
263 pub offset: u64,
266 pub length: u64,
268 pub codec: nodedb_codec::ResolvedColumnCodec,
272 pub layout: BlockLayout,
274 pub block_count: u32,
276 pub block_stats: Vec<BlockStats>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub dictionary: Option<Vec<String>>,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize, ToMessagePack, FromMessagePack)]
299pub struct SegmentFooter {
300 pub schema_hash: u64,
302 pub column_count: u32,
304 pub row_count: u64,
306 pub profile_tag: u8,
308 pub columns: Vec<ColumnMeta>,
310}
311
312impl SegmentFooter {
313 pub fn to_bytes(&self) -> Result<Vec<u8>, crate::error::ColumnarError> {
315 let footer_msgpack = zerompk::to_msgpack_vec(self)
316 .map_err(|e| crate::error::ColumnarError::Serialization(e.to_string()))?;
317
318 let footer_len = footer_msgpack.len() as u32;
319 let footer_crc = crc32c::crc32c(&footer_msgpack);
320
321 let mut buf = Vec::with_capacity(footer_msgpack.len() + 8);
322 buf.extend_from_slice(&footer_msgpack);
323 buf.extend_from_slice(&footer_len.to_le_bytes());
324 buf.extend_from_slice(&footer_crc.to_le_bytes());
325 Ok(buf)
326 }
327
328 pub fn from_segment_tail(data: &[u8]) -> Result<Self, crate::error::ColumnarError> {
333 if data.len() < 8 {
334 return Err(crate::error::ColumnarError::TruncatedSegment {
335 expected: 8,
336 got: data.len(),
337 });
338 }
339
340 let tail = &data[data.len() - 8..];
341 let footer_len = u32::from_le_bytes(tail[0..4].try_into().map_err(|_| {
342 crate::error::ColumnarError::Corruption {
343 segment_id: None,
344 reason: "footer length field: expected 4 bytes at segment tail - 8".into(),
345 offset: Some((data.len() - 8) as u64),
346 }
347 })?) as usize;
348 let stored_crc = u32::from_le_bytes(tail[4..8].try_into().map_err(|_| {
349 crate::error::ColumnarError::Corruption {
350 segment_id: None,
351 reason: "footer CRC field: expected 4 bytes at segment tail - 4".into(),
352 offset: Some((data.len() - 4) as u64),
353 }
354 })?);
355
356 let footer_start = data.len().checked_sub(8 + footer_len).ok_or(
357 crate::error::ColumnarError::TruncatedSegment {
358 expected: 8 + footer_len,
359 got: data.len(),
360 },
361 )?;
362
363 let footer_bytes = &data[footer_start..footer_start + footer_len];
364 let computed_crc = crc32c::crc32c(footer_bytes);
365
366 if computed_crc != stored_crc {
367 return Err(crate::error::ColumnarError::FooterCrcMismatch {
368 stored: stored_crc,
369 computed: computed_crc,
370 });
371 }
372
373 zerompk::from_msgpack(footer_bytes)
374 .map_err(|e| crate::error::ColumnarError::Serialization(e.to_string()))
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn header_roundtrip() {
384 let header = SegmentHeader::current();
385 let bytes = header.to_bytes();
386 let parsed = SegmentHeader::from_bytes(&bytes).expect("valid header");
387 assert_eq!(parsed, header);
388 }
389
390 #[test]
391 fn header_invalid_magic() {
392 let mut bytes = SegmentHeader::current().to_bytes();
393 bytes[0] = b'X';
394 assert!(matches!(
395 SegmentHeader::from_bytes(&bytes),
396 Err(crate::error::ColumnarError::InvalidMagic(_))
397 ));
398 }
399
400 #[test]
401 fn header_incompatible_major() {
402 let mut bytes = SegmentHeader::current().to_bytes();
403 bytes[4] = VERSION_MAJOR + 1; assert!(matches!(
405 SegmentHeader::from_bytes(&bytes),
406 Err(crate::error::ColumnarError::IncompatibleVersion { .. })
407 ));
408 }
409
410 #[test]
411 fn header_compatible_minor() {
412 let mut bytes = SegmentHeader::current().to_bytes();
413 bytes[5] = VERSION_MINOR + 5; let parsed = SegmentHeader::from_bytes(&bytes).expect("compatible minor");
415 assert_eq!(parsed.version_major, VERSION_MAJOR);
416 assert_eq!(parsed.version_minor, VERSION_MINOR + 5);
417 }
418
419 #[test]
420 fn footer_roundtrip() {
421 let footer = SegmentFooter {
422 schema_hash: 0xDEAD_BEEF_CAFE_1234,
423 column_count: 3,
424 row_count: 2048,
425 profile_tag: 0,
426 columns: vec![
427 ColumnMeta {
428 name: "id".into(),
429 offset: 7,
430 length: 512,
431 codec: nodedb_codec::ResolvedColumnCodec::DeltaFastLanesLz4,
432 layout: BlockLayout::Int64,
433 block_count: 2,
434 block_stats: vec![
435 BlockStats::numeric(1.0, 1024.0, 0, 1024),
436 BlockStats::numeric(1025.0, 2048.0, 0, 1024),
437 ],
438 dictionary: None,
439 },
440 ColumnMeta {
441 name: "name".into(),
442 offset: 519,
443 length: 256,
444 codec: nodedb_codec::ResolvedColumnCodec::FsstLz4,
445 layout: BlockLayout::VarLen,
446 block_count: 2,
447 block_stats: vec![
448 BlockStats::non_numeric(0, 1024),
449 BlockStats::non_numeric(5, 1024),
450 ],
451 dictionary: None,
452 },
453 ColumnMeta {
454 name: "score".into(),
455 offset: 775,
456 length: 128,
457 codec: nodedb_codec::ResolvedColumnCodec::AlpFastLanesLz4,
458 layout: BlockLayout::Float64,
459 block_count: 2,
460 block_stats: vec![
461 BlockStats::numeric(0.0, 100.0, 10, 1024),
462 BlockStats::numeric(0.5, 99.5, 3, 1024),
463 ],
464 dictionary: None,
465 },
466 ],
467 };
468
469 let footer_bytes = footer.to_bytes().expect("serialize");
471
472 let mut segment = Vec::new();
474 segment.extend_from_slice(&SegmentHeader::current().to_bytes());
475 segment.extend_from_slice(&vec![0u8; 896]); segment.extend_from_slice(&footer_bytes);
477
478 let parsed = SegmentFooter::from_segment_tail(&segment).expect("parse footer");
479 assert_eq!(parsed.schema_hash, footer.schema_hash);
480 assert_eq!(parsed.column_count, 3);
481 assert_eq!(parsed.row_count, 2048);
482 assert_eq!(parsed.columns.len(), 3);
483 assert_eq!(parsed.columns[0].name, "id");
484 assert_eq!(parsed.columns[1].name, "name");
485 assert_eq!(parsed.columns[2].name, "score");
486 assert_eq!(parsed.columns[1].layout, BlockLayout::VarLen);
487 }
488
489 #[test]
490 fn footer_crc_mismatch() {
491 let footer = SegmentFooter {
492 schema_hash: 0,
493 column_count: 0,
494 row_count: 0,
495 profile_tag: 0,
496 columns: vec![],
497 };
498 let mut bytes = footer.to_bytes().expect("serialize");
499 let len = bytes.len();
501 bytes[len - 1] ^= 0xFF;
502
503 assert!(matches!(
504 SegmentFooter::from_segment_tail(&bytes),
505 Err(crate::error::ColumnarError::FooterCrcMismatch { .. })
506 ));
507 }
508
509 #[test]
510 fn block_stats_predicate_skip() {
511 let stats = BlockStats::numeric(10.0, 50.0, 0, 1024);
512
513 use crate::predicate::ScanPredicate;
514
515 assert!(ScanPredicate::gt(0, 60.0).can_skip_block(&stats));
517 assert!(!ScanPredicate::gt(0, 40.0).can_skip_block(&stats));
519 assert!(ScanPredicate::lt(0, 5.0).can_skip_block(&stats));
521 assert!(ScanPredicate::eq(0, 100.0).can_skip_block(&stats));
523 assert!(!ScanPredicate::eq(0, 30.0).can_skip_block(&stats));
525 }
526
527 #[cfg(test)]
528 mod golden {
529 use super::*;
530
531 #[test]
534 fn golden_columnar_segment_format() {
535 let header = SegmentHeader::current();
537 let bytes = header.to_bytes();
538 assert_eq!(&bytes[0..4], b"NDBS", "magic mismatch");
539 assert_eq!(bytes[4], VERSION_MAJOR, "major version mismatch");
540 assert_eq!(bytes[5], VERSION_MINOR, "minor version mismatch");
541 assert_eq!(bytes[4], 1u8, "expected VERSION_MAJOR == 1");
542 assert_eq!(bytes[5], 1u8, "expected VERSION_MINOR == 1");
543
544 let footer = SegmentFooter {
546 schema_hash: 0xAB_CD_EF_01,
547 column_count: 1,
548 row_count: 128,
549 profile_tag: 0,
550 columns: vec![ColumnMeta {
551 name: "v".into(),
552 offset: 0,
553 length: 64,
554 codec: nodedb_codec::ResolvedColumnCodec::Lz4,
555 layout: BlockLayout::FixedWidth,
556 block_count: 1,
557 block_stats: vec![BlockStats::non_numeric(0, 128)],
558 dictionary: None,
559 }],
560 };
561 let footer_bytes = footer.to_bytes().expect("serialize");
562 let n = footer_bytes.len();
564 let stored_crc = u32::from_le_bytes([
565 footer_bytes[n - 4],
566 footer_bytes[n - 3],
567 footer_bytes[n - 2],
568 footer_bytes[n - 1],
569 ]);
570 let body_len = u32::from_le_bytes([
571 footer_bytes[n - 8],
572 footer_bytes[n - 7],
573 footer_bytes[n - 6],
574 footer_bytes[n - 5],
575 ]) as usize;
576 let recomputed = crc32c::crc32c(&footer_bytes[..body_len]);
578 assert_eq!(stored_crc, recomputed, "footer CRC mismatch");
579
580 let mut segment = Vec::new();
582 segment.extend_from_slice(&bytes);
583 segment.extend_from_slice(&[0u8; 64]);
584 segment.extend_from_slice(&footer_bytes);
585 let parsed = SegmentFooter::from_segment_tail(&segment).expect("parse");
586 assert_eq!(parsed.schema_hash, footer.schema_hash);
587 assert_eq!(parsed.row_count, 128);
588 }
589 }
590}