1use nodedb_codec::{ColumnCodec, ColumnTypeHint, ResolvedColumnCodec};
6use nodedb_mem::ScopedMemory;
7use nodedb_types::columnar::{ColumnType, ColumnarSchema};
8
9use crate::error::ColumnarError;
10use crate::format::{ColumnMeta, HEADER_SIZE, SegmentFooter, SegmentHeader};
11use crate::memtable::ColumnData;
12
13use super::block::{block_layout, encode_column_blocks};
14use super::encode::compute_schema_hash;
15
16pub const PROFILE_PLAIN: u8 = 0;
18pub const PROFILE_TIMESERIES: u8 = 1;
19pub const PROFILE_SPATIAL: u8 = 2;
20
21pub struct SegmentWriter {
27 profile_tag: u8,
28 memory: ScopedMemory,
30}
31
32impl SegmentWriter {
33 pub fn new(profile_tag: u8, memory: ScopedMemory) -> Self {
35 Self {
36 profile_tag,
37 memory,
38 }
39 }
40
41 pub fn write_segment(
50 &self,
51 schema: &ColumnarSchema,
52 columns: &[ColumnData],
53 row_count: usize,
54 kek: Option<&nodedb_wal::crypto::WalEncryptionKey>,
55 ) -> Result<Vec<u8>, ColumnarError> {
56 if row_count == 0 {
57 return Err(ColumnarError::EmptyMemtable);
58 }
59 if columns.len() != schema.columns.len() {
60 return Err(ColumnarError::SchemaMismatch {
61 expected: schema.columns.len(),
62 got: columns.len(),
63 });
64 }
65
66 let mut buf = Vec::new();
67
68 buf.extend_from_slice(&SegmentHeader::current().to_bytes());
70
71 let _metas_guard = self
73 .memory
74 .reserve(columns.len() * std::mem::size_of::<ColumnMeta>())?;
75 let mut column_metas = Vec::with_capacity(columns.len());
76
77 for (i, (col_def, col_data)) in schema.columns.iter().zip(columns.iter()).enumerate() {
78 let col_start = buf.len() as u64;
79
80 let codec = select_codec_for_profile(&col_def.column_type, self.profile_tag);
82
83 let block_stats = encode_column_blocks(
85 &mut buf,
86 &col_def.name,
87 col_data,
88 codec,
89 row_count,
90 &self.memory,
91 )?;
92
93 let col_end = buf.len() as u64;
94
95 let (effective_codec, dictionary) = match col_data {
98 ColumnData::DictEncoded { dictionary, .. } => (
99 ResolvedColumnCodec::DeltaFastLanesLz4,
100 Some(dictionary.clone()),
101 ),
102 _ => (codec, None),
103 };
104
105 column_metas.push(ColumnMeta {
106 name: col_def.name.clone(),
107 offset: col_start - HEADER_SIZE as u64,
108 length: col_end - col_start,
109 codec: effective_codec,
110 layout: block_layout(col_data),
111 block_count: block_stats.len() as u32,
112 block_stats,
113 dictionary,
114 });
115
116 let _ = i; }
118
119 let schema_hash = compute_schema_hash(schema);
121
122 let footer = SegmentFooter {
124 schema_hash,
125 column_count: schema.columns.len() as u32,
126 row_count: row_count as u64,
127 profile_tag: self.profile_tag,
128 columns: column_metas,
129 };
130 let footer_bytes = footer.to_bytes()?;
131 buf.extend_from_slice(&footer_bytes);
132
133 if let Some(key) = kek {
135 return crate::encrypt::encrypt_segment(key, &buf);
136 }
137
138 Ok(buf)
139 }
140}
141
142pub fn select_codec_for_profile(col_type: &ColumnType, profile_tag: u8) -> ResolvedColumnCodec {
150 if profile_tag == PROFILE_TIMESERIES && matches!(col_type, ColumnType::Float64) {
152 return ResolvedColumnCodec::Gorilla;
153 }
154 if profile_tag == PROFILE_TIMESERIES
156 && matches!(col_type, ColumnType::Timestamp | ColumnType::Timestamptz)
157 {
158 return ResolvedColumnCodec::DeltaFastLanesLz4;
159 }
160 select_codec(col_type)
161}
162
163fn select_codec(col_type: &ColumnType) -> ResolvedColumnCodec {
168 let hint = match col_type {
169 ColumnType::Int64 => ColumnTypeHint::Int64,
170 ColumnType::Float64 => ColumnTypeHint::Float64,
171 ColumnType::Timestamp | ColumnType::Timestamptz | ColumnType::SystemTimestamp => {
172 ColumnTypeHint::Timestamp
173 }
174 ColumnType::String
175 | ColumnType::Geometry
176 | ColumnType::Regex
177 | ColumnType::SparseVector => ColumnTypeHint::String,
178 ColumnType::Bool
179 | ColumnType::Bytes
180 | ColumnType::Decimal { .. }
181 | ColumnType::Uuid
182 | ColumnType::Ulid => {
183 return ResolvedColumnCodec::Lz4;
184 }
185 ColumnType::Json
186 | ColumnType::Array
187 | ColumnType::Set
188 | ColumnType::Range
189 | ColumnType::Record => {
190 return ResolvedColumnCodec::Lz4;
191 }
192 ColumnType::Duration => ColumnTypeHint::Int64, ColumnType::Vector(_) => {
194 return ResolvedColumnCodec::Lz4;
195 }
196 _ => {
199 return ResolvedColumnCodec::Lz4;
200 }
201 };
202 nodedb_codec::detect_codec(ColumnCodec::Auto, hint)
206 .try_resolve()
207 .unwrap_or(ResolvedColumnCodec::Lz4)
208}
209
210#[cfg(test)]
211mod tests {
212 use nodedb_types::columnar::{ColumnDef, ColumnType, ColumnarSchema};
213 use nodedb_types::value::Value;
214
215 use super::*;
216 use crate::format::{SegmentFooter, SegmentHeader};
217 use crate::memtable::ColumnarMemtable;
218 use crate::test_support::test_memory;
219
220 fn analytics_schema() -> ColumnarSchema {
221 ColumnarSchema::new(vec![
222 ColumnDef::required("id", ColumnType::Int64).with_primary_key(),
223 ColumnDef::required("name", ColumnType::String),
224 ColumnDef::nullable("score", ColumnType::Float64),
225 ])
226 .expect("valid")
227 }
228
229 #[test]
234 fn auto_codec_resolves_to_concrete_before_write() {
235 let schema = ColumnarSchema::new(vec![
236 ColumnDef::required("id", ColumnType::Int64).with_primary_key(),
237 ColumnDef::required("name", ColumnType::String),
238 ColumnDef::nullable("score", ColumnType::Float64),
239 ])
240 .expect("valid");
241
242 let mut mt = ColumnarMemtable::new(&schema);
243 for i in 0..10 {
244 mt.append_row(&[
245 Value::Integer(i),
246 Value::String(format!("item_{i}")),
247 Value::Float(i as f64 * 1.5),
248 ])
249 .expect("append");
250 }
251 let (schema, columns, row_count) = mt.drain();
252 let writer = SegmentWriter::new(PROFILE_PLAIN, test_memory());
253 let segment = writer
254 .write_segment(&schema, &columns, row_count, None)
255 .expect("write must succeed");
256
257 let footer = SegmentFooter::from_segment_tail(&segment).expect("valid footer");
258
259 for col in &footer.columns {
262 let encoded = zerompk::to_msgpack_vec(&col.codec).expect("serialize");
266 let discriminant_byte = *encoded.last().expect("non-empty");
269 assert_ne!(
270 discriminant_byte, 0,
271 "column '{}' has Auto discriminant (0) on disk — resolve was skipped",
272 col.name
273 );
274 }
275 }
276
277 #[test]
279 fn auto_codec_int64_resolves_to_non_raw() {
280 use nodedb_codec::ResolvedColumnCodec;
281
282 let schema = ColumnarSchema::new(vec![
283 ColumnDef::required("val", ColumnType::Int64).with_primary_key(),
284 ])
285 .expect("valid");
286
287 let mut mt = ColumnarMemtable::new(&schema);
288 for i in 0..10 {
289 mt.append_row(&[Value::Integer(i)]).expect("append");
290 }
291 let (schema, columns, row_count) = mt.drain();
292 let writer = SegmentWriter::new(PROFILE_PLAIN, test_memory());
293 let segment = writer
294 .write_segment(&schema, &columns, row_count, None)
295 .expect("write");
296 let footer = SegmentFooter::from_segment_tail(&segment).expect("footer");
297
298 let codec = footer.columns[0].codec;
300 assert_ne!(
301 codec,
302 ResolvedColumnCodec::Raw,
303 "Int64 should not resolve to Raw"
304 );
305 }
306
307 #[test]
308 fn write_segment_roundtrip() {
309 let schema = analytics_schema();
310 let mut mt = ColumnarMemtable::new(&schema);
311
312 for i in 0..100 {
313 mt.append_row(&[
314 Value::Integer(i),
315 Value::String(format!("user_{i}")),
316 if i % 3 == 0 {
317 Value::Null
318 } else {
319 Value::Float(i as f64 * 0.25)
320 },
321 ])
322 .expect("append");
323 }
324
325 let (schema, columns, row_count) = mt.drain();
326 let writer = SegmentWriter::new(PROFILE_PLAIN, test_memory());
327 let segment = writer
328 .write_segment(&schema, &columns, row_count, None)
329 .expect("write");
330
331 let header = SegmentHeader::from_bytes(&segment).expect("valid header");
333 assert_eq!(header.magic, *b"NDBS");
334 assert_eq!(header.version_major, 1);
335
336 let footer = SegmentFooter::from_segment_tail(&segment).expect("valid footer");
338 assert_eq!(footer.column_count, 3);
339 assert_eq!(footer.row_count, 100);
340 assert_eq!(footer.profile_tag, PROFILE_PLAIN);
341 assert_eq!(footer.columns.len(), 3);
342
343 assert_eq!(footer.columns[0].name, "id");
345 assert_eq!(footer.columns[1].name, "name");
346 assert_eq!(footer.columns[2].name, "score");
347
348 assert_eq!(footer.columns[0].block_count, 1);
350 assert_eq!(footer.columns[0].block_stats[0].row_count, 100);
351
352 assert_eq!(footer.columns[0].block_stats[0].min, 0.0);
354 assert_eq!(footer.columns[0].block_stats[0].max, 99.0);
355 assert_eq!(footer.columns[0].block_stats[0].null_count, 0);
356
357 assert_eq!(footer.columns[2].block_stats[0].null_count, 34);
359 }
360
361 #[test]
362 fn write_segment_multi_block() {
363 let schema =
364 ColumnarSchema::new(vec![ColumnDef::required("x", ColumnType::Int64)]).expect("valid");
365
366 let mut mt = ColumnarMemtable::new(&schema);
367 for i in 0..2500 {
368 mt.append_row(&[Value::Integer(i)]).expect("append");
369 }
370
371 let (schema, columns, row_count) = mt.drain();
372 let writer = SegmentWriter::new(PROFILE_PLAIN, test_memory());
373 let segment = writer
374 .write_segment(&schema, &columns, row_count, None)
375 .expect("write");
376
377 let footer = SegmentFooter::from_segment_tail(&segment).expect("valid footer");
378 assert_eq!(footer.row_count, 2500);
379
380 assert_eq!(footer.columns[0].block_count, 3);
382 assert_eq!(footer.columns[0].block_stats[0].row_count, 1024);
383 assert_eq!(footer.columns[0].block_stats[1].row_count, 1024);
384 assert_eq!(footer.columns[0].block_stats[2].row_count, 452);
385
386 assert_eq!(footer.columns[0].block_stats[0].min, 0.0);
388 assert_eq!(footer.columns[0].block_stats[0].max, 1023.0);
389 assert_eq!(footer.columns[0].block_stats[2].min, 2048.0);
391 assert_eq!(footer.columns[0].block_stats[2].max, 2499.0);
392 }
393
394 #[test]
395 fn write_segment_empty_rejected() {
396 let schema = analytics_schema();
397 let mt = ColumnarMemtable::new(&schema);
398 let (schema, columns, row_count) = {
399 let mut m = mt;
400 m.drain()
401 };
402 let writer = SegmentWriter::new(PROFILE_PLAIN, test_memory());
403 assert!(matches!(
404 writer.write_segment(&schema, &columns, row_count, None),
405 Err(ColumnarError::EmptyMemtable)
406 ));
407 }
408
409 #[test]
410 fn block_stats_predicate_pushdown() {
411 let schema = analytics_schema();
412 let mut mt = ColumnarMemtable::new(&schema);
413
414 for i in 0..50 {
415 mt.append_row(&[
416 Value::Integer(i + 100),
417 Value::String(format!("item_{i}")),
418 Value::Float(i as f64 + 10.0),
419 ])
420 .expect("append");
421 }
422
423 let (schema, columns, row_count) = mt.drain();
424 let writer = SegmentWriter::new(PROFILE_PLAIN, test_memory());
425 let segment = writer
426 .write_segment(&schema, &columns, row_count, None)
427 .expect("write");
428 let footer = SegmentFooter::from_segment_tail(&segment).expect("valid");
429
430 use crate::predicate::ScanPredicate;
431
432 let id_stats = &footer.columns[0].block_stats[0];
433 assert!(ScanPredicate::gt(0, 200.0).can_skip_block(id_stats)); assert!(!ScanPredicate::gt(0, 120.0).can_skip_block(id_stats)); assert!(ScanPredicate::lt(0, 50.0).can_skip_block(id_stats)); assert!(ScanPredicate::eq(0, 200.0).can_skip_block(id_stats)); assert!(!ScanPredicate::eq(0, 125.0).can_skip_block(id_stats)); }
440
441 #[test]
442 fn string_block_stats_zone_map() {
443 let schema = ColumnarSchema::new(vec![ColumnDef::required("tag", ColumnType::String)])
445 .expect("valid");
446
447 let mut mt = ColumnarMemtable::new(&schema);
448 let values: Vec<String> = (0..20).map(|i| format!("item_{i:02}")).collect();
451 for name in &values {
452 mt.append_row(&[Value::String(name.clone())])
453 .expect("append");
454 }
455 mt.append_row(&[Value::String("apple".into())])
457 .expect("append");
458 mt.append_row(&[Value::String("date".into())])
459 .expect("append");
460
461 let (schema, columns, row_count) = mt.drain();
462 let writer = SegmentWriter::new(PROFILE_PLAIN, test_memory());
463 let segment = writer
464 .write_segment(&schema, &columns, row_count, None)
465 .expect("write");
466 let footer = SegmentFooter::from_segment_tail(&segment).expect("footer");
467
468 let stats = &footer.columns[0].block_stats[0];
469 assert!(stats.str_min.is_some(), "str_min should be populated");
470 assert!(stats.str_max.is_some(), "str_max should be populated");
471 assert_eq!(stats.str_min.as_deref(), Some("apple"));
473 assert_eq!(stats.str_max.as_deref(), Some("item_19"));
474
475 assert!(
477 stats.bloom.is_some(),
478 "bloom should be populated for >16 distinct values"
479 );
480
481 use crate::predicate::ScanPredicate;
482
483 assert!(ScanPredicate::str_eq(0, "aaa").can_skip_block(stats));
485 assert!(ScanPredicate::str_eq(0, "zzz").can_skip_block(stats));
487 assert!(!ScanPredicate::str_eq(0, "date").can_skip_block(stats));
489 assert!(ScanPredicate::str_gt(0, "item_19").can_skip_block(stats));
491 assert!(ScanPredicate::str_lt(0, "apple").can_skip_block(stats));
493 }
494
495 #[test]
499 fn timestamp_large_value_roundtrip() {
500 use crate::predicate::ScanPredicate;
501
502 let schema = ColumnarSchema::new(vec![
503 ColumnDef::required("ts", ColumnType::Timestamp).with_primary_key(),
504 ])
505 .expect("valid schema");
506
507 let base: i64 = 10_413_792_000_000_000;
511 let target = base + 500_000; let mut mt = ColumnarMemtable::new(&schema);
514 for delta in 0..10i64 {
515 mt.append_row(&[Value::Integer(base + delta * 100_000)])
516 .expect("append");
517 }
518
519 let (schema, columns, row_count) = mt.drain();
520 let segment = SegmentWriter::new(PROFILE_PLAIN, test_memory())
521 .write_segment(&schema, &columns, row_count, None)
522 .expect("write");
523 let footer = SegmentFooter::from_segment_tail(&segment).expect("footer");
524
525 let stats = &footer.columns[0].block_stats[0];
526
527 assert!(
529 stats.min_i64.is_some(),
530 "min_i64 must be set for timestamp columns"
531 );
532 assert!(
533 stats.max_i64.is_some(),
534 "max_i64 must be set for timestamp columns"
535 );
536 assert_eq!(stats.min_i64.unwrap(), base);
537 assert_eq!(stats.max_i64.unwrap(), base + 9 * 100_000);
538
539 assert!(
541 !ScanPredicate::eq_i64(0, target).can_skip_block(stats),
542 "must not skip: target={target} is within the block range"
543 );
544
545 assert!(
547 ScanPredicate::eq_i64(0, base - 1).can_skip_block(stats),
548 "must skip: base-1 is below block min"
549 );
550
551 let min_f64 = base as f64;
554 let target_f64 = target as f64;
555 let max_f64 = (base + 9 * 100_000) as f64;
556 let _ = (min_f64, target_f64, max_f64); }
560
561 #[test]
562 fn integer_block_stats_have_exact_i64_fields() {
563 let schema = ColumnarSchema::new(vec![
565 ColumnDef::required("id", ColumnType::Int64).with_primary_key(),
566 ])
567 .expect("valid");
568
569 let mut mt = ColumnarMemtable::new(&schema);
570 for i in 0..5i64 {
571 mt.append_row(&[Value::Integer(i64::MAX - 4 + i)])
572 .expect("append");
573 }
574
575 let (schema, columns, row_count) = mt.drain();
576 let segment = SegmentWriter::new(PROFILE_PLAIN, test_memory())
577 .write_segment(&schema, &columns, row_count, None)
578 .expect("write");
579 let footer = SegmentFooter::from_segment_tail(&segment).expect("footer");
580
581 let stats = &footer.columns[0].block_stats[0];
582 assert_eq!(stats.min_i64, Some(i64::MAX - 4));
583 assert_eq!(stats.max_i64, Some(i64::MAX));
584
585 use crate::predicate::ScanPredicate;
587 assert!(!ScanPredicate::eq_i64(0, i64::MAX - 2).can_skip_block(stats));
588 assert!(ScanPredicate::eq_i64(0, i64::MAX - 10).can_skip_block(stats));
590 }
591
592 #[test]
593 fn string_block_stats_bloom_rejects_absent_value() {
594 let schema = ColumnarSchema::new(vec![ColumnDef::required("label", ColumnType::String)])
595 .expect("valid");
596
597 let mut mt = ColumnarMemtable::new(&schema);
598 let values: Vec<String> = (0..20).map(|i| format!("val_{i:02}")).collect();
600 for name in &values {
601 mt.append_row(&[Value::String(name.clone())])
602 .expect("append");
603 }
604 mt.append_row(&[Value::String("alpha".into())])
606 .expect("append");
607 mt.append_row(&[Value::String("beta".into())])
608 .expect("append");
609 mt.append_row(&[Value::String("gamma".into())])
610 .expect("append");
611
612 let (schema, columns, row_count) = mt.drain();
613 let segment = SegmentWriter::new(PROFILE_PLAIN, test_memory())
614 .write_segment(&schema, &columns, row_count, None)
615 .expect("write");
616 let footer = SegmentFooter::from_segment_tail(&segment).expect("footer");
617 let stats = &footer.columns[0].block_stats[0];
618
619 use crate::predicate::{ScanPredicate, bloom_may_contain};
620
621 let bloom = stats
622 .bloom
623 .as_ref()
624 .expect("bloom present for >16 distinct");
625 assert!(bloom_may_contain(bloom, "alpha"));
626 assert!(bloom_may_contain(bloom, "beta"));
627 assert!(bloom_may_contain(bloom, "gamma"));
628
629 let delta_absent = !bloom_may_contain(bloom, "delta");
631 if delta_absent {
632 assert!(ScanPredicate::str_eq(0, "delta").can_skip_block(stats));
634 }
635 }
636}