1use crate::storage::unified::{
38 read_column_block, write_column_block, ColumnBlockError, ColumnInput, ColumnSemantics,
39};
40use reddb_types::types::{DataType, Value};
41
42const GRANULE_SIZE: u32 = 128;
46
47pub type Row = Vec<Value>;
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ProjectionColumn {
55 pub column_id: u32,
57 pub data_type: DataType,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ProjectionSchema {
64 pub columns: Vec<ProjectionColumn>,
65}
66
67impl ProjectionSchema {
68 pub fn new(columns: Vec<ProjectionColumn>) -> Self {
69 Self { columns }
70 }
71
72 fn width(&self) -> usize {
73 self.columns.len()
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum ProjectionError {
81 TypeMismatch {
83 column: usize,
84 expected: DataType,
85 found: &'static str,
86 },
87 RowWidth { expected: usize, found: usize },
89 NonMonotonicLsn { last: u64, next: u64 },
91 Block(ColumnBlockError),
93 Envelope(String),
95 CorruptSegment(&'static str),
98}
99
100impl std::fmt::Display for ProjectionError {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 match self {
103 Self::TypeMismatch {
104 column,
105 expected,
106 found,
107 } => write!(
108 f,
109 "projection column {column}: expected {expected}, found {found}"
110 ),
111 Self::RowWidth { expected, found } => {
112 write!(
113 f,
114 "projection row width mismatch: expected {expected}, got {found}"
115 )
116 }
117 Self::NonMonotonicLsn { last, next } => {
118 write!(f, "projection: non-monotonic lsn {next} after {last}")
119 }
120 Self::Block(e) => write!(f, "projection segment decode failed: {e}"),
121 Self::Envelope(detail) => write!(f, "projection segment envelope failed: {detail}"),
122 Self::CorruptSegment(detail) => write!(f, "projection segment corrupt: {detail}"),
123 }
124 }
125}
126
127impl std::error::Error for ProjectionError {}
128
129impl ProjectionError {
130 fn is_projection_artifact_failure(&self) -> bool {
131 matches!(
132 self,
133 Self::Block(_) | Self::Envelope(_) | Self::CorruptSegment(_)
134 )
135 }
136}
137
138impl From<ColumnBlockError> for ProjectionError {
139 fn from(e: ColumnBlockError) -> Self {
140 Self::Block(e)
141 }
142}
143
144#[derive(Debug, Clone, PartialEq)]
146struct LsnRow {
147 lsn: u64,
148 row: Row,
149}
150
151#[derive(Debug, Clone)]
155pub struct AppendOnlyCollection {
156 schema: ProjectionSchema,
157 rows: Vec<LsnRow>,
158 last_lsn: u64,
159}
160
161impl AppendOnlyCollection {
162 pub fn new(schema: ProjectionSchema) -> Self {
163 Self {
164 schema,
165 rows: Vec::new(),
166 last_lsn: 0,
167 }
168 }
169
170 pub fn schema(&self) -> &ProjectionSchema {
171 &self.schema
172 }
173
174 pub fn latest_lsn(&self) -> u64 {
177 self.last_lsn
178 }
179
180 pub fn append(&mut self, lsn: u64, row: Row) -> Result<(), ProjectionError> {
183 if row.len() != self.schema.width() {
184 return Err(ProjectionError::RowWidth {
185 expected: self.schema.width(),
186 found: row.len(),
187 });
188 }
189 if lsn <= self.last_lsn {
190 return Err(ProjectionError::NonMonotonicLsn {
191 last: self.last_lsn,
192 next: lsn,
193 });
194 }
195 for (idx, value) in row.iter().enumerate() {
196 check_value_type(idx, &self.schema.columns[idx].data_type, value)?;
197 }
198 self.last_lsn = lsn;
199 self.rows.push(LsnRow { lsn, row });
200 Ok(())
201 }
202
203 pub fn row_scan(&self, pinned_lsn: u64) -> Vec<Row> {
207 self.rows
208 .iter()
209 .filter(|r| r.lsn <= pinned_lsn)
210 .map(|r| r.row.clone())
211 .collect()
212 }
213
214 fn rows_between(&self, after: u64, up_to: u64) -> impl Iterator<Item = &LsnRow> {
216 self.rows
217 .iter()
218 .filter(move |r| r.lsn > after && r.lsn <= up_to)
219 }
220
221 fn next_visible_lsn_after(&self, after: u64, up_to: u64) -> Option<u64> {
222 self.rows_between(after, up_to).next().map(|r| r.lsn)
223 }
224}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct ProjectionSegmentEntry {
231 pub segment_id: u64,
232 pub first_lsn: u64,
234 pub last_lsn: u64,
235 pub row_count: u64,
236 pub sealed_crc32: u32,
239 pub derived: bool,
241}
242
243#[derive(Debug, Clone, Default, PartialEq, Eq)]
246pub struct ProjectionManifest {
247 segments: Vec<ProjectionSegmentEntry>,
248 last_materialized_lsn: u64,
249}
250
251impl ProjectionManifest {
252 pub fn last_materialized_lsn(&self) -> u64 {
256 self.last_materialized_lsn
257 }
258
259 pub fn segments(&self) -> &[ProjectionSegmentEntry] {
260 &self.segments
261 }
262
263 pub fn all_derived(&self) -> bool {
266 self.segments.iter().all(|s| s.derived)
267 }
268
269 pub fn durable_entries(&self) -> impl Iterator<Item = &ProjectionSegmentEntry> {
272 self.segments.iter().filter(|s| !s.derived)
273 }
274}
275
276#[derive(Debug, Clone, Copy)]
278pub struct TranscodeBudget {
279 pub max_rows: u64,
282 pub segment_rows: u64,
284 pub size_floor_rows: u64,
289}
290
291impl Default for TranscodeBudget {
292 fn default() -> Self {
293 Self {
294 max_rows: u64::MAX,
295 segment_rows: 1024,
296 size_floor_rows: 1,
297 }
298 }
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub struct EmitOutcome {
304 pub segments_emitted: usize,
305 pub rows_materialized: u64,
306 pub rows_deferred: u64,
309 pub budget_exhausted: bool,
311 pub floor_skipped: bool,
313}
314
315#[derive(Debug, Clone)]
318struct StoredSegment {
319 entry: ProjectionSegmentEntry,
320 sealed: Vec<u8>,
321}
322
323#[derive(Debug, Clone)]
325pub struct ColumnarProjection {
326 schema: ProjectionSchema,
327 key: [u8; 32],
328 segments: Vec<StoredSegment>,
329 manifest: ProjectionManifest,
330 next_segment_id: u64,
331}
332
333impl ColumnarProjection {
334 pub fn new(schema: ProjectionSchema, key: [u8; 32]) -> Self {
336 Self {
337 schema,
338 key,
339 segments: Vec::new(),
340 manifest: ProjectionManifest::default(),
341 next_segment_id: 1,
342 }
343 }
344
345 pub fn manifest(&self) -> &ProjectionManifest {
346 &self.manifest
347 }
348
349 pub fn drop_projection(&mut self) {
353 self.segments.clear();
354 self.manifest = ProjectionManifest::default();
355 }
356
357 pub fn rebuild_from_truth(
361 &mut self,
362 collection: &AppendOnlyCollection,
363 checkpoint_lsn: u64,
364 budget: TranscodeBudget,
365 ) -> Result<EmitOutcome, ProjectionError> {
366 self.drop_projection();
367 self.emit_at_checkpoint(collection, checkpoint_lsn, budget)
368 }
369
370 pub fn emit_at_checkpoint(
375 &mut self,
376 collection: &AppendOnlyCollection,
377 checkpoint_lsn: u64,
378 budget: TranscodeBudget,
379 ) -> Result<EmitOutcome, ProjectionError> {
380 let watermark = self.manifest.last_materialized_lsn;
381 let pending: Vec<&LsnRow> = collection.rows_between(watermark, checkpoint_lsn).collect();
382 let available = pending.len() as u64;
383
384 if available < budget.size_floor_rows.max(1) {
385 return Ok(EmitOutcome {
386 segments_emitted: 0,
387 rows_materialized: 0,
388 rows_deferred: available,
389 budget_exhausted: false,
390 floor_skipped: available > 0,
391 });
392 }
393
394 let to_materialize = available.min(budget.max_rows) as usize;
395 let segment_rows = budget.segment_rows.max(1) as usize;
396 let mut segments_emitted = 0usize;
397
398 for chunk in pending[..to_materialize].chunks(segment_rows) {
399 let entry = self.seal_segment(chunk)?;
400 self.manifest.last_materialized_lsn = entry.last_lsn;
401 self.manifest.segments.push(entry);
402 segments_emitted += 1;
403 }
404
405 let rows_deferred = available - to_materialize as u64;
406 Ok(EmitOutcome {
407 segments_emitted,
408 rows_materialized: to_materialize as u64,
409 rows_deferred,
410 budget_exhausted: rows_deferred > 0,
411 floor_skipped: false,
412 })
413 }
414
415 fn seal_segment(
418 &mut self,
419 rows: &[&LsnRow],
420 ) -> Result<ProjectionSegmentEntry, ProjectionError> {
421 let segment_id = self.next_segment_id;
422 self.next_segment_id += 1;
423 let first_lsn = rows.first().map(|r| r.lsn).unwrap_or(0);
424 let last_lsn = rows.last().map(|r| r.lsn).unwrap_or(0);
425
426 let mut column_bytes: Vec<Vec<u8>> = vec![Vec::new(); self.schema.columns.len()];
428 for lsn_row in rows {
429 for (idx, value) in lsn_row.row.iter().enumerate() {
430 let dt = self.schema.columns[idx].data_type;
431 check_value_type(idx, &dt, value)?;
432 encode_cell(dt, value, &mut column_bytes[idx])?;
433 }
434 }
435
436 let inputs: Vec<ColumnInput<'_>> = self
437 .schema
438 .columns
439 .iter()
440 .zip(column_bytes.iter())
441 .map(|(col, data)| ColumnInput {
442 column_id: col.column_id,
443 logical_type: col.data_type.to_byte(),
444 semantics: ColumnSemantics::Generic,
445 data,
446 })
447 .collect();
448
449 let frame = write_column_block(
451 segment_id,
452 self.schema.columns.len() as u64,
453 rows.len() as u64,
454 first_lsn,
455 last_lsn,
456 GRANULE_SIZE,
457 &inputs,
458 )?;
459
460 let sealed = reddb_crypto::encrypt_page(&self.key, segment_id as u32, &frame)
463 .map_err(|e| ProjectionError::Envelope(e.to_string()))?;
464 let sealed_crc32 = crc32fast::hash(&sealed);
465
466 let entry = ProjectionSegmentEntry {
467 segment_id,
468 first_lsn,
469 last_lsn,
470 row_count: rows.len() as u64,
471 sealed_crc32,
472 derived: true,
473 };
474 self.segments.push(StoredSegment {
475 entry: entry.clone(),
476 sealed,
477 });
478 Ok(entry)
479 }
480
481 pub fn analytical_scan(
488 &self,
489 collection: &AppendOnlyCollection,
490 pinned_lsn: u64,
491 ) -> Result<Vec<Row>, ProjectionError> {
492 let columnar_ceiling = self.manifest.last_materialized_lsn.min(pinned_lsn);
493
494 let mut out = Vec::new();
495 let mut tail_start = 0u64;
496 for stored in &self.segments {
497 if stored.entry.last_lsn > columnar_ceiling {
498 continue;
499 }
500 match collection.next_visible_lsn_after(tail_start, pinned_lsn) {
501 Some(next_lsn) if next_lsn == stored.entry.first_lsn => {}
502 Some(_) | None => return Ok(collection.row_scan(pinned_lsn)),
503 }
504 self.verify_and_decode_segment(stored, &mut out)?;
505 tail_start = tail_start.max(stored.entry.last_lsn);
506 }
507
508 for lsn_row in collection.rows_between(tail_start, pinned_lsn) {
511 out.push(lsn_row.row.clone());
512 }
513 Ok(out)
514 }
515
516 pub fn repairing_analytical_scan(
521 &mut self,
522 collection: &AppendOnlyCollection,
523 pinned_lsn: u64,
524 budget: TranscodeBudget,
525 ) -> Result<Vec<Row>, ProjectionError> {
526 if self.projection_artifacts_missing() {
527 self.rebuild_from_truth(collection, pinned_lsn, budget)?;
528 return self.analytical_scan(collection, pinned_lsn);
529 }
530
531 match self.analytical_scan(collection, pinned_lsn) {
532 Ok(rows) => Ok(rows),
533 Err(err) if err.is_projection_artifact_failure() => {
534 self.rebuild_from_truth(collection, pinned_lsn, budget)?;
535 self.analytical_scan(collection, pinned_lsn)
536 }
537 Err(err) => Err(err),
538 }
539 }
540
541 fn projection_artifacts_missing(&self) -> bool {
542 if self.manifest.segments.is_empty() {
543 return false;
544 }
545 if self.manifest.segments.len() != self.segments.len() {
546 return true;
547 }
548 self.manifest
549 .segments
550 .iter()
551 .zip(self.segments.iter())
552 .any(|(manifest, stored)| manifest != &stored.entry)
553 }
554
555 fn verify_and_decode_segment(
558 &self,
559 stored: &StoredSegment,
560 out: &mut Vec<Row>,
561 ) -> Result<(), ProjectionError> {
562 if crc32fast::hash(&stored.sealed) != stored.entry.sealed_crc32 {
563 return Err(ProjectionError::CorruptSegment("sealed checksum mismatch"));
564 }
565 let frame =
566 reddb_crypto::decrypt_page(&self.key, stored.entry.segment_id as u32, &stored.sealed)
567 .map_err(|e| ProjectionError::Envelope(e.to_string()))?;
568 let block = read_column_block(&frame)?;
570 let row_count = block.row_count as usize;
571
572 let mut decoded_columns: Vec<Vec<Value>> = Vec::with_capacity(self.schema.columns.len());
574 for col in &self.schema.columns {
575 let decoded = block
576 .columns
577 .iter()
578 .find(|c| c.column_id == col.column_id)
579 .ok_or(ProjectionError::CorruptSegment("missing column"))?;
580 decoded_columns.push(decode_column(col.data_type, &decoded.data, row_count)?);
581 }
582
583 let base = out.len();
588 for _ in 0..row_count {
589 out.push(Vec::with_capacity(self.schema.columns.len()));
590 }
591 for column in decoded_columns {
592 debug_assert_eq!(
596 column.len(),
597 row_count,
598 "decoded column is not row_count long"
599 );
600 for (r, value) in column.into_iter().take(row_count).enumerate() {
601 out[base + r].push(value);
602 }
603 }
604 Ok(())
605 }
606}
607
608fn check_value_type(column: usize, dt: &DataType, value: &Value) -> Result<(), ProjectionError> {
611 if value_matches_declared_type(dt, value) {
612 return Ok(());
613 }
614 Err(ProjectionError::TypeMismatch {
615 column,
616 expected: *dt,
617 found: value_kind(value),
618 })
619}
620
621fn value_matches_declared_type(dt: &DataType, value: &Value) -> bool {
622 match (dt, value) {
623 (DataType::Nullable, Value::Null) => true,
624 (DataType::TextZstd, Value::Text(_)) => true,
625 (DataType::BlobZstd, Value::Blob(_)) => true,
626 _ => value.data_type() == *dt,
627 }
628}
629
630fn value_kind(value: &Value) -> &'static str {
631 match value {
632 Value::Null => "Null",
633 Value::Integer(_) => "Integer",
634 Value::UnsignedInteger(_) => "UnsignedInteger",
635 Value::Timestamp(_) => "Timestamp",
636 Value::Float(_) => "Float",
637 Value::Boolean(_) => "Boolean",
638 Value::Text(_) => "Text",
639 Value::Blob(_) => "Blob",
640 Value::Duration(_) => "Duration",
641 Value::IpAddr(_) => "IpAddr",
642 Value::MacAddr(_) => "MacAddr",
643 Value::Vector(_) => "Vector",
644 Value::Json(_) => "Json",
645 Value::Uuid(_) => "Uuid",
646 Value::NodeRef(_) => "NodeRef",
647 Value::EdgeRef(_) => "EdgeRef",
648 Value::VectorRef(_, _) => "VectorRef",
649 Value::RowRef(_, _) => "RowRef",
650 Value::Color(_) => "Color",
651 Value::Email(_) => "Email",
652 Value::Url(_) => "Url",
653 Value::Phone(_) => "Phone",
654 Value::Semver(_) => "Semver",
655 Value::Cidr(_, _) => "Cidr",
656 Value::Date(_) => "Date",
657 Value::Time(_) => "Time",
658 Value::Decimal(_) => "Decimal",
659 Value::DecimalText(_) => "DecimalText",
660 Value::EnumValue(_) => "Enum",
661 Value::Array(_) => "Array",
662 Value::TimestampMs(_) => "TimestampMs",
663 Value::Ipv4(_) => "Ipv4",
664 Value::Ipv6(_) => "Ipv6",
665 Value::Subnet(_, _) => "Subnet",
666 Value::Port(_) => "Port",
667 Value::Latitude(_) => "Latitude",
668 Value::Longitude(_) => "Longitude",
669 Value::GeoPoint(_, _) => "GeoPoint",
670 Value::Country2(_) => "Country2",
671 Value::Country3(_) => "Country3",
672 Value::Lang2(_) => "Lang2",
673 Value::Lang5(_) => "Lang5",
674 Value::Currency(_) => "Currency",
675 Value::AssetCode(_) => "AssetCode",
676 Value::Money { .. } => "Money",
677 Value::Temperature(_) => "Temperature",
678 Value::ColorAlpha(_) => "ColorAlpha",
679 Value::BigInt(_) => "BigInt",
680 Value::KeyRef(_, _) => "KeyRef",
681 Value::DocRef(_, _) => "DocRef",
682 Value::TableRef(_) => "TableRef",
683 Value::PageRef(_) => "PageRef",
684 Value::Secret(_) => "Secret",
685 Value::Password(_) => "Password",
686 }
687}
688
689fn encode_cell(dt: DataType, value: &Value, out: &mut Vec<u8>) -> Result<(), ProjectionError> {
691 check_value_type(0, &dt, value)?;
692 let encoded = value.to_bytes();
693 out.extend_from_slice(&(encoded.len() as u32).to_le_bytes());
694 out.extend_from_slice(&encoded);
695 Ok(())
696}
697
698fn decode_column(
701 dt: DataType,
702 data: &[u8],
703 row_count: usize,
704) -> Result<Vec<Value>, ProjectionError> {
705 let mut out = Vec::with_capacity(row_count);
706 let mut cur = 0usize;
707 for _ in 0..row_count {
708 if cur + 4 > data.len() {
709 return Err(ProjectionError::CorruptSegment("truncated value length"));
710 }
711 let len = u32::from_le_bytes(to_4(&data[cur..cur + 4])) as usize;
712 cur += 4;
713 let end = cur
714 .checked_add(len)
715 .filter(|e| *e <= data.len())
716 .ok_or(ProjectionError::CorruptSegment("truncated value body"))?;
717 let (value, consumed) = Value::from_bytes(&data[cur..end])
718 .map_err(|_| ProjectionError::CorruptSegment("invalid value bytes"))?;
719 if consumed != len {
720 return Err(ProjectionError::CorruptSegment("trailing value bytes"));
721 }
722 if !value_matches_declared_type(&dt, &value) {
723 return Err(ProjectionError::CorruptSegment(
724 "decoded value type mismatch",
725 ));
726 }
727 out.push(value);
728 cur = end;
729 }
730 if cur != data.len() {
731 return Err(ProjectionError::CorruptSegment("trailing column bytes"));
732 }
733 Ok(out)
734}
735
736fn to_4(b: &[u8]) -> [u8; 4] {
737 let mut a = [0u8; 4];
738 a.copy_from_slice(b);
739 a
740}
741
742#[cfg(test)]
743mod tests {
744 use super::*;
745 use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
746
747 const KEY: [u8; 32] = [7u8; 32];
748
749 fn schema() -> ProjectionSchema {
750 ProjectionSchema::new(vec![
751 ProjectionColumn {
752 column_id: 0,
753 data_type: DataType::Timestamp,
754 },
755 ProjectionColumn {
756 column_id: 1,
757 data_type: DataType::Integer,
758 },
759 ProjectionColumn {
760 column_id: 2,
761 data_type: DataType::Float,
762 },
763 ProjectionColumn {
764 column_id: 3,
765 data_type: DataType::Boolean,
766 },
767 ProjectionColumn {
768 column_id: 4,
769 data_type: DataType::Text,
770 },
771 ])
772 }
773
774 fn row(i: i64) -> Row {
775 vec![
776 Value::Timestamp(1_700_000_000 + i),
777 Value::Integer(i * 3),
778 Value::Float(i as f64 * 0.5),
779 Value::Boolean(i % 2 == 0),
780 Value::Text(format!("event-{i}").into()),
781 ]
782 }
783
784 fn full_type_schema_and_row(seed: u8) -> (ProjectionSchema, Row) {
785 let types_and_values = vec![
786 (DataType::Nullable, Value::Null),
787 (DataType::Integer, Value::Integer(i64::MIN + seed as i64)),
788 (
789 DataType::UnsignedInteger,
790 Value::UnsignedInteger(u64::MAX - seed as u64),
791 ),
792 (
793 DataType::Float,
794 Value::Float(f64::from_bits(0x4009_21fb_5444_2d18)),
795 ),
796 (DataType::Text, Value::Text(format!("text-{seed}").into())),
797 (DataType::Blob, Value::Blob(vec![0, seed, 255])),
798 (DataType::Boolean, Value::Boolean(seed % 2 == 0)),
799 (
800 DataType::Timestamp,
801 Value::Timestamp(-1_700_000_000 + seed as i64),
802 ),
803 (DataType::Duration, Value::Duration(-42 + seed as i64)),
804 (
805 DataType::IpAddr,
806 Value::IpAddr(IpAddr::V6(Ipv6Addr::new(
807 0x2606,
808 0x4700,
809 0,
810 0,
811 0,
812 0,
813 0,
814 seed as u16,
815 ))),
816 ),
817 (DataType::MacAddr, Value::MacAddr([0, 1, 2, 3, 4, seed])),
818 (
819 DataType::Vector,
820 Value::Vector(vec![1.25, -2.5, seed as f32]),
821 ),
822 (
823 DataType::Json,
824 Value::Json(format!(r#"{{"seed":{seed}}}"#).into_bytes()),
825 ),
826 (DataType::Uuid, Value::Uuid([seed; 16])),
827 (DataType::NodeRef, Value::NodeRef(format!("node-{seed}"))),
828 (DataType::EdgeRef, Value::EdgeRef(format!("edge-{seed}"))),
829 (
830 DataType::VectorRef,
831 Value::VectorRef(format!("vectors-{seed}"), 99),
832 ),
833 (
834 DataType::RowRef,
835 Value::RowRef(format!("table-{seed}"), 100),
836 ),
837 (DataType::Color, Value::Color([seed, 2, 3])),
838 (
839 DataType::Email,
840 Value::Email(format!("u{seed}@example.com")),
841 ),
842 (
843 DataType::Url,
844 Value::Url(format!("https://example.com/{seed}")),
845 ),
846 (DataType::Phone, Value::Phone(55_119_000_0000 + seed as u64)),
847 (DataType::Semver, Value::Semver(1_002_003 + seed as u32)),
848 (DataType::Cidr, Value::Cidr(0x0a00_0000 + seed as u32, 24)),
849 (DataType::Date, Value::Date(-20_000 + seed as i32)),
850 (DataType::Time, Value::Time(86_399_000 - seed as u32)),
851 (DataType::Decimal, Value::Decimal(-123_456 + seed as i64)),
852 (DataType::Enum, Value::EnumValue(seed)),
853 (
854 DataType::Array,
855 Value::Array(vec![
856 Value::Integer(seed as i64),
857 Value::Text("nested".into()),
858 ]),
859 ),
860 (
861 DataType::TimestampMs,
862 Value::TimestampMs(1_700_000_000_000 + seed as i64),
863 ),
864 (
865 DataType::Ipv4,
866 Value::Ipv4(u32::from(Ipv4Addr::new(192, 0, 2, seed))),
867 ),
868 (DataType::Ipv6, Value::Ipv6(Ipv6Addr::LOCALHOST.octets())),
869 (
870 DataType::Subnet,
871 Value::Subnet(0xc000_0200 + seed as u32, 0xffff_ff00),
872 ),
873 (DataType::Port, Value::Port(8000 + seed as u16)),
874 (
875 DataType::Latitude,
876 Value::Latitude(-23_550_000 + seed as i32),
877 ),
878 (
879 DataType::Longitude,
880 Value::Longitude(-46_633_000 + seed as i32),
881 ),
882 (
883 DataType::GeoPoint,
884 Value::GeoPoint(-23_550_000, -46_633_000 + seed as i32),
885 ),
886 (DataType::Country2, Value::Country2(*b"BR")),
887 (DataType::Country3, Value::Country3(*b"BRA")),
888 (DataType::Lang2, Value::Lang2(*b"pt")),
889 (DataType::Lang5, Value::Lang5(*b"pt-BR")),
890 (DataType::Currency, Value::Currency(*b"BRL")),
891 (
892 DataType::AssetCode,
893 Value::AssetCode(format!("ASSET{seed}")),
894 ),
895 (
896 DataType::Money,
897 Value::Money {
898 asset_code: "BRL".to_string(),
899 minor_units: -1_234_567 + seed as i64,
900 scale: 2,
901 },
902 ),
903 (DataType::ColorAlpha, Value::ColorAlpha([seed, 2, 3, 4])),
904 (DataType::BigInt, Value::BigInt(i64::MAX - seed as i64)),
905 (
906 DataType::KeyRef,
907 Value::KeyRef(format!("kv-{seed}"), format!("key-{seed}")),
908 ),
909 (DataType::DocRef, Value::DocRef(format!("docs-{seed}"), 321)),
910 (DataType::TableRef, Value::TableRef(format!("table-{seed}"))),
911 (DataType::PageRef, Value::PageRef(42 + seed as u32)),
912 (DataType::Secret, Value::Secret(vec![seed, 7, 8, 9])),
913 (
914 DataType::Password,
915 Value::Password(format!("argon2id-hash-{seed}")),
916 ),
917 (
918 DataType::TextZstd,
919 Value::Text(format!("toast-text-{seed}").into()),
920 ),
921 (DataType::BlobZstd, Value::Blob(vec![9, 8, 7, seed])),
922 ];
923
924 let columns = types_and_values
925 .iter()
926 .enumerate()
927 .map(|(idx, (data_type, _))| ProjectionColumn {
928 column_id: idx as u32,
929 data_type: *data_type,
930 })
931 .collect();
932 let row = types_and_values
933 .into_iter()
934 .map(|(_, value)| value)
935 .collect();
936 (ProjectionSchema::new(columns), row)
937 }
938
939 fn filled(n: i64) -> AppendOnlyCollection {
941 let mut c = AppendOnlyCollection::new(schema());
942 for i in 1..=n {
943 c.append(i as u64, row(i)).expect("append");
944 }
945 c
946 }
947
948 #[test]
949 fn write_path_never_dual_writes_columnar() {
950 let collection = filled(10);
953 let projection = ColumnarProjection::new(schema(), KEY);
954 assert_eq!(projection.manifest().segments().len(), 0);
955 assert_eq!(projection.manifest().last_materialized_lsn(), 0);
956 assert_eq!(collection.row_scan(collection.latest_lsn()).len(), 10);
958 }
959
960 #[test]
961 fn equivalence_oracle_scan_matches_row_scan() {
962 let collection = filled(500);
964 let mut projection = ColumnarProjection::new(schema(), KEY);
965 projection
966 .emit_at_checkpoint(&collection, 300, TranscodeBudget::default())
967 .expect("emit");
968
969 let pinned = collection.latest_lsn();
970 let via_projection = projection
971 .analytical_scan(&collection, pinned)
972 .expect("scan");
973 let via_row = collection.row_scan(pinned);
974
975 assert_eq!(via_projection.len(), 500);
976 assert_eq!(
977 via_projection, via_row,
978 "projection scan must equal row scan"
979 );
980 }
981
982 #[test]
983 fn columnar_segments_are_actually_exercised() {
984 let collection = filled(300);
987 let mut projection = ColumnarProjection::new(schema(), KEY);
988 let outcome = projection
989 .emit_at_checkpoint(&collection, 300, TranscodeBudget::default())
990 .expect("emit");
991 assert!(outcome.segments_emitted >= 1);
992 assert_eq!(projection.manifest().last_materialized_lsn(), 300);
993 let scan = projection.analytical_scan(&collection, 300).expect("scan");
995 assert_eq!(scan, collection.row_scan(300));
996 }
997
998 #[test]
999 fn full_value_type_set_round_trips_through_columnar_projection() {
1000 let (schema, first) = full_type_schema_and_row(1);
1001 let (_, second) = full_type_schema_and_row(2);
1002 let mut collection = AppendOnlyCollection::new(schema.clone());
1003 collection.append(1, first).expect("append first");
1004 collection.append(2, second).expect("append second");
1005
1006 let mut projection = ColumnarProjection::new(schema, KEY);
1007 let outcome = projection
1008 .emit_at_checkpoint(&collection, 2, TranscodeBudget::default())
1009 .expect("emit");
1010 assert_eq!(outcome.rows_materialized, 2);
1011 assert_eq!(outcome.rows_deferred, 0);
1012
1013 let via_projection = projection.analytical_scan(&collection, 2).expect("scan");
1014 let via_row = collection.row_scan(2);
1015 assert_eq!(
1016 via_projection, via_row,
1017 "every storable value type must round-trip without coercion or loss"
1018 );
1019 }
1020
1021 #[test]
1022 fn freshness_row_after_checkpoint_is_immediately_visible() {
1023 let mut collection = filled(100);
1026 let mut projection = ColumnarProjection::new(schema(), KEY);
1027 projection
1028 .emit_at_checkpoint(&collection, 100, TranscodeBudget::default())
1029 .expect("emit");
1030 assert_eq!(projection.manifest().last_materialized_lsn(), 100);
1031
1032 collection.append(101, row(101)).expect("append fresh");
1034
1035 let scan = projection.analytical_scan(&collection, 101).expect("scan");
1036 assert_eq!(scan.len(), 101, "fresh row must be visible immediately");
1037 assert_eq!(scan.last().cloned().unwrap(), row(101));
1038 assert_eq!(projection.manifest().last_materialized_lsn(), 100);
1040 }
1041
1042 #[test]
1043 fn as_of_composes_by_pinning_a_historical_lsn() {
1044 let collection = filled(200);
1046 let mut projection = ColumnarProjection::new(schema(), KEY);
1047 projection
1048 .emit_at_checkpoint(&collection, 200, TranscodeBudget::default())
1049 .expect("emit");
1050
1051 for pin in [1u64, 57, 150, 200] {
1052 let via_projection = projection.analytical_scan(&collection, pin).expect("scan");
1053 assert_eq!(
1054 via_projection,
1055 collection.row_scan(pin),
1056 "AS OF {pin} must match row scan"
1057 );
1058 assert_eq!(via_projection.len(), pin as usize);
1059 }
1060 }
1061
1062 #[test]
1063 fn historical_pin_before_projection_coverage_falls_back_to_row_path() {
1064 let collection = filled(300);
1069 let mut projection = ColumnarProjection::new(schema(), KEY);
1070 projection
1071 .emit_at_checkpoint(
1072 &collection,
1073 300,
1074 TranscodeBudget {
1075 max_rows: u64::MAX,
1076 segment_rows: 100,
1077 size_floor_rows: 1,
1078 },
1079 )
1080 .expect("emit segmented projection");
1081
1082 projection.segments.remove(0);
1083 projection.manifest.segments.remove(0);
1084
1085 let pinned = 250;
1086 let via_projection = projection
1087 .analytical_scan(&collection, pinned)
1088 .expect("historical scan");
1089 let via_row = collection.row_scan(pinned);
1090
1091 assert_eq!(
1092 via_projection, via_row,
1093 "historical AS OF must not drop rows older than projection coverage"
1094 );
1095 }
1096
1097 #[test]
1098 fn manifest_entries_are_derived_and_checksummed_and_enveloped() {
1099 let collection = filled(50);
1101 let mut projection = ColumnarProjection::new(schema(), KEY);
1102 projection
1103 .emit_at_checkpoint(&collection, 50, TranscodeBudget::default())
1104 .expect("emit");
1105
1106 let manifest = projection.manifest();
1107 assert!(manifest.all_derived());
1108 assert_eq!(
1109 manifest.durable_entries().count(),
1110 0,
1111 "derived → backup skips"
1112 );
1113 for entry in manifest.segments() {
1114 assert!(entry.derived);
1115 assert_ne!(entry.sealed_crc32, 0);
1116 }
1117
1118 let seg = &projection.segments[0];
1121 let wrong = ColumnarProjection::new(schema(), [9u8; 32]);
1122 let err = wrong
1123 .verify_and_decode_segment(seg, &mut Vec::new())
1124 .expect_err("wrong key must fail");
1125 assert!(matches!(err, ProjectionError::Envelope(_)));
1126 }
1127
1128 #[test]
1129 fn sealed_segment_tamper_is_rejected_by_checksum() {
1130 let collection = filled(20);
1131 let mut projection = ColumnarProjection::new(schema(), KEY);
1132 projection
1133 .emit_at_checkpoint(&collection, 20, TranscodeBudget::default())
1134 .expect("emit");
1135 let mut tampered = projection.segments[0].clone();
1137 tampered.sealed[0] ^= 0xFF;
1138 let err = projection
1139 .verify_and_decode_segment(&tampered, &mut Vec::new())
1140 .expect_err("tamper must be rejected");
1141 assert_eq!(
1142 err,
1143 ProjectionError::CorruptSegment("sealed checksum mismatch")
1144 );
1145 }
1146
1147 #[test]
1148 fn transcoding_budget_completes_and_defers() {
1149 let collection = filled(1000);
1152 let mut projection = ColumnarProjection::new(schema(), KEY);
1153
1154 let budget = TranscodeBudget {
1155 max_rows: 400,
1156 segment_rows: 128,
1157 size_floor_rows: 1,
1158 };
1159 let first = projection
1160 .emit_at_checkpoint(&collection, 1000, budget)
1161 .expect("first checkpoint");
1162 assert!(first.budget_exhausted);
1163 assert_eq!(first.rows_materialized, 400);
1164 assert_eq!(first.rows_deferred, 600);
1165 assert_eq!(projection.manifest().last_materialized_lsn(), 400);
1166
1167 let mid = projection.analytical_scan(&collection, 1000).expect("scan");
1170 assert_eq!(mid, collection.row_scan(1000));
1171 assert_eq!(mid.len(), 1000);
1172
1173 let mut rounds = 1;
1177 loop {
1178 let out = projection
1179 .emit_at_checkpoint(&collection, 1000, budget)
1180 .expect("checkpoint");
1181 rounds += 1;
1182 assert_eq!(
1183 projection.analytical_scan(&collection, 1000).expect("scan"),
1184 collection.row_scan(1000)
1185 );
1186 if !out.budget_exhausted {
1187 assert_eq!(out.rows_deferred, 0);
1188 break;
1189 }
1190 }
1191 assert_eq!(rounds, 3);
1193 assert_eq!(projection.manifest().last_materialized_lsn(), 1000);
1194
1195 let full = projection.analytical_scan(&collection, 1000).expect("scan");
1196 assert_eq!(full, collection.row_scan(1000));
1197 }
1198
1199 #[test]
1200 fn size_floor_skips_tiny_materialization() {
1201 let collection = filled(3);
1203 let mut projection = ColumnarProjection::new(schema(), KEY);
1204 let budget = TranscodeBudget {
1205 max_rows: u64::MAX,
1206 segment_rows: 128,
1207 size_floor_rows: 10,
1208 };
1209 let outcome = projection
1210 .emit_at_checkpoint(&collection, 3, budget)
1211 .expect("emit");
1212 assert!(outcome.floor_skipped);
1213 assert_eq!(outcome.segments_emitted, 0);
1214 assert_eq!(projection.manifest().last_materialized_lsn(), 0);
1215 assert_eq!(
1217 projection.analytical_scan(&collection, 3).expect("scan"),
1218 collection.row_scan(3)
1219 );
1220 }
1221
1222 #[test]
1223 fn drop_and_rebuild_is_repair() {
1224 let collection = filled(300);
1226 let mut projection = ColumnarProjection::new(schema(), KEY);
1227 projection
1228 .emit_at_checkpoint(&collection, 300, TranscodeBudget::default())
1229 .expect("emit");
1230 let before = projection.analytical_scan(&collection, 300).expect("scan");
1231
1232 projection.drop_projection();
1233 assert_eq!(projection.manifest().segments().len(), 0);
1234 assert_eq!(
1236 projection.analytical_scan(&collection, 300).expect("scan"),
1237 collection.row_scan(300)
1238 );
1239
1240 projection
1242 .emit_at_checkpoint(&collection, 300, TranscodeBudget::default())
1243 .expect("rebuild");
1244 let after = projection.analytical_scan(&collection, 300).expect("scan");
1245 assert_eq!(before, after);
1246 }
1247
1248 #[test]
1249 fn repairing_scan_rebuilds_after_projection_artifacts_are_deleted() {
1250 let collection = filled(300);
1251 let mut projection = ColumnarProjection::new(schema(), KEY);
1252 projection
1253 .emit_at_checkpoint(&collection, 300, TranscodeBudget::default())
1254 .expect("emit");
1255 assert!(!projection.segments.is_empty());
1256
1257 projection.segments.clear();
1260
1261 let scan = projection
1262 .repairing_analytical_scan(&collection, 300, TranscodeBudget::default())
1263 .expect("repairing scan");
1264
1265 assert_eq!(scan, collection.row_scan(300));
1266 assert!(!projection.manifest().segments().is_empty());
1267 assert!(!projection.segments.is_empty());
1268 }
1269
1270 #[test]
1271 fn repairing_scan_rebuilds_after_projection_checksum_mismatch() {
1272 let collection = filled(20);
1273 let mut projection = ColumnarProjection::new(schema(), KEY);
1274 projection
1275 .emit_at_checkpoint(&collection, 20, TranscodeBudget::default())
1276 .expect("emit");
1277 let original_segment_id = projection.manifest().segments()[0].segment_id;
1278
1279 projection.segments[0].sealed[0] ^= 0xFF;
1280
1281 let scan = projection
1282 .repairing_analytical_scan(&collection, 20, TranscodeBudget::default())
1283 .expect("repairing scan");
1284
1285 assert_eq!(scan, collection.row_scan(20));
1286 assert_ne!(
1287 projection.manifest().segments()[0].segment_id,
1288 original_segment_id,
1289 "corrupt projection bytes must be replaced by a rebuilt segment"
1290 );
1291 }
1292
1293 #[test]
1294 fn append_rejects_non_monotonic_lsn_and_bad_arity() {
1295 let mut c = AppendOnlyCollection::new(schema());
1296 c.append(5, row(1)).expect("first");
1297 assert!(matches!(
1298 c.append(5, row(2)),
1299 Err(ProjectionError::NonMonotonicLsn { .. })
1300 ));
1301 assert!(matches!(
1302 c.append(6, vec![Value::Integer(1)]),
1303 Err(ProjectionError::RowWidth { .. })
1304 ));
1305 }
1306
1307 #[test]
1308 fn mismatched_types_are_rejected() {
1309 let s = ProjectionSchema::new(vec![ProjectionColumn {
1310 column_id: 0,
1311 data_type: DataType::Integer,
1312 }]);
1313 let mut c = AppendOnlyCollection::new(s);
1314 assert!(matches!(
1315 c.append(1, vec![Value::Text("x".into())]),
1316 Err(ProjectionError::TypeMismatch { .. })
1317 ));
1318 }
1319}