1use crate::storage::schema::types::{DataType, Value};
38use crate::storage::unified::{
39 read_column_block, write_column_block, ColumnBlockError, ColumnInput, ColumnSemantics,
40};
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::ColorAlpha(_) => "ColorAlpha",
678 Value::BigInt(_) => "BigInt",
679 Value::KeyRef(_, _) => "KeyRef",
680 Value::DocRef(_, _) => "DocRef",
681 Value::TableRef(_) => "TableRef",
682 Value::PageRef(_) => "PageRef",
683 Value::Secret(_) => "Secret",
684 Value::Password(_) => "Password",
685 }
686}
687
688fn encode_cell(dt: DataType, value: &Value, out: &mut Vec<u8>) -> Result<(), ProjectionError> {
690 check_value_type(0, &dt, value)?;
691 let encoded = value.to_bytes();
692 out.extend_from_slice(&(encoded.len() as u32).to_le_bytes());
693 out.extend_from_slice(&encoded);
694 Ok(())
695}
696
697fn decode_column(
700 dt: DataType,
701 data: &[u8],
702 row_count: usize,
703) -> Result<Vec<Value>, ProjectionError> {
704 let mut out = Vec::with_capacity(row_count);
705 let mut cur = 0usize;
706 for _ in 0..row_count {
707 if cur + 4 > data.len() {
708 return Err(ProjectionError::CorruptSegment("truncated value length"));
709 }
710 let len = u32::from_le_bytes(to_4(&data[cur..cur + 4])) as usize;
711 cur += 4;
712 let end = cur
713 .checked_add(len)
714 .filter(|e| *e <= data.len())
715 .ok_or(ProjectionError::CorruptSegment("truncated value body"))?;
716 let (value, consumed) = Value::from_bytes(&data[cur..end])
717 .map_err(|_| ProjectionError::CorruptSegment("invalid value bytes"))?;
718 if consumed != len {
719 return Err(ProjectionError::CorruptSegment("trailing value bytes"));
720 }
721 if !value_matches_declared_type(&dt, &value) {
722 return Err(ProjectionError::CorruptSegment(
723 "decoded value type mismatch",
724 ));
725 }
726 out.push(value);
727 cur = end;
728 }
729 if cur != data.len() {
730 return Err(ProjectionError::CorruptSegment("trailing column bytes"));
731 }
732 Ok(out)
733}
734
735fn to_4(b: &[u8]) -> [u8; 4] {
736 let mut a = [0u8; 4];
737 a.copy_from_slice(b);
738 a
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744 use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
745
746 const KEY: [u8; 32] = [7u8; 32];
747
748 fn schema() -> ProjectionSchema {
749 ProjectionSchema::new(vec![
750 ProjectionColumn {
751 column_id: 0,
752 data_type: DataType::Timestamp,
753 },
754 ProjectionColumn {
755 column_id: 1,
756 data_type: DataType::Integer,
757 },
758 ProjectionColumn {
759 column_id: 2,
760 data_type: DataType::Float,
761 },
762 ProjectionColumn {
763 column_id: 3,
764 data_type: DataType::Boolean,
765 },
766 ProjectionColumn {
767 column_id: 4,
768 data_type: DataType::Text,
769 },
770 ])
771 }
772
773 fn row(i: i64) -> Row {
774 vec![
775 Value::Timestamp(1_700_000_000 + i),
776 Value::Integer(i * 3),
777 Value::Float(i as f64 * 0.5),
778 Value::Boolean(i % 2 == 0),
779 Value::Text(format!("event-{i}").into()),
780 ]
781 }
782
783 fn full_type_schema_and_row(seed: u8) -> (ProjectionSchema, Row) {
784 let types_and_values = vec![
785 (DataType::Nullable, Value::Null),
786 (DataType::Integer, Value::Integer(i64::MIN + seed as i64)),
787 (
788 DataType::UnsignedInteger,
789 Value::UnsignedInteger(u64::MAX - seed as u64),
790 ),
791 (
792 DataType::Float,
793 Value::Float(f64::from_bits(0x4009_21fb_5444_2d18)),
794 ),
795 (DataType::Text, Value::Text(format!("text-{seed}").into())),
796 (DataType::Blob, Value::Blob(vec![0, seed, 255])),
797 (DataType::Boolean, Value::Boolean(seed % 2 == 0)),
798 (
799 DataType::Timestamp,
800 Value::Timestamp(-1_700_000_000 + seed as i64),
801 ),
802 (DataType::Duration, Value::Duration(-42 + seed as i64)),
803 (
804 DataType::IpAddr,
805 Value::IpAddr(IpAddr::V6(Ipv6Addr::new(
806 0x2606,
807 0x4700,
808 0,
809 0,
810 0,
811 0,
812 0,
813 seed as u16,
814 ))),
815 ),
816 (DataType::MacAddr, Value::MacAddr([0, 1, 2, 3, 4, seed])),
817 (
818 DataType::Vector,
819 Value::Vector(vec![1.25, -2.5, seed as f32]),
820 ),
821 (
822 DataType::Json,
823 Value::Json(format!(r#"{{"seed":{seed}}}"#).into_bytes()),
824 ),
825 (DataType::Uuid, Value::Uuid([seed; 16])),
826 (DataType::NodeRef, Value::NodeRef(format!("node-{seed}"))),
827 (DataType::EdgeRef, Value::EdgeRef(format!("edge-{seed}"))),
828 (
829 DataType::VectorRef,
830 Value::VectorRef(format!("vectors-{seed}"), 99),
831 ),
832 (
833 DataType::RowRef,
834 Value::RowRef(format!("table-{seed}"), 100),
835 ),
836 (DataType::Color, Value::Color([seed, 2, 3])),
837 (
838 DataType::Email,
839 Value::Email(format!("u{seed}@example.com")),
840 ),
841 (
842 DataType::Url,
843 Value::Url(format!("https://example.com/{seed}")),
844 ),
845 (DataType::Phone, Value::Phone(55_119_000_0000 + seed as u64)),
846 (DataType::Semver, Value::Semver(1_002_003 + seed as u32)),
847 (DataType::Cidr, Value::Cidr(0x0a00_0000 + seed as u32, 24)),
848 (DataType::Date, Value::Date(-20_000 + seed as i32)),
849 (DataType::Time, Value::Time(86_399_000 - seed as u32)),
850 (DataType::Decimal, Value::Decimal(-123_456 + seed as i64)),
851 (DataType::Enum, Value::EnumValue(seed)),
852 (
853 DataType::Array,
854 Value::Array(vec![
855 Value::Integer(seed as i64),
856 Value::Text("nested".into()),
857 ]),
858 ),
859 (
860 DataType::TimestampMs,
861 Value::TimestampMs(1_700_000_000_000 + seed as i64),
862 ),
863 (
864 DataType::Ipv4,
865 Value::Ipv4(u32::from(Ipv4Addr::new(192, 0, 2, seed))),
866 ),
867 (DataType::Ipv6, Value::Ipv6(Ipv6Addr::LOCALHOST.octets())),
868 (
869 DataType::Subnet,
870 Value::Subnet(0xc000_0200 + seed as u32, 0xffff_ff00),
871 ),
872 (DataType::Port, Value::Port(8000 + seed as u16)),
873 (
874 DataType::Latitude,
875 Value::Latitude(-23_550_000 + seed as i32),
876 ),
877 (
878 DataType::Longitude,
879 Value::Longitude(-46_633_000 + seed as i32),
880 ),
881 (
882 DataType::GeoPoint,
883 Value::GeoPoint(-23_550_000, -46_633_000 + seed as i32),
884 ),
885 (DataType::Country2, Value::Country2(*b"BR")),
886 (DataType::Country3, Value::Country3(*b"BRA")),
887 (DataType::Lang2, Value::Lang2(*b"pt")),
888 (DataType::Lang5, Value::Lang5(*b"pt-BR")),
889 (DataType::Currency, Value::Currency(*b"BRL")),
890 (
891 DataType::AssetCode,
892 Value::AssetCode(format!("ASSET{seed}")),
893 ),
894 (
895 DataType::Money,
896 Value::Money {
897 asset_code: "BRL".to_string(),
898 minor_units: -1_234_567 + seed as i64,
899 scale: 2,
900 },
901 ),
902 (DataType::ColorAlpha, Value::ColorAlpha([seed, 2, 3, 4])),
903 (DataType::BigInt, Value::BigInt(i64::MAX - seed as i64)),
904 (
905 DataType::KeyRef,
906 Value::KeyRef(format!("kv-{seed}"), format!("key-{seed}")),
907 ),
908 (DataType::DocRef, Value::DocRef(format!("docs-{seed}"), 321)),
909 (DataType::TableRef, Value::TableRef(format!("table-{seed}"))),
910 (DataType::PageRef, Value::PageRef(42 + seed as u32)),
911 (DataType::Secret, Value::Secret(vec![seed, 7, 8, 9])),
912 (
913 DataType::Password,
914 Value::Password(format!("argon2id-hash-{seed}")),
915 ),
916 (
917 DataType::TextZstd,
918 Value::Text(format!("toast-text-{seed}").into()),
919 ),
920 (DataType::BlobZstd, Value::Blob(vec![9, 8, 7, seed])),
921 ];
922
923 let columns = types_and_values
924 .iter()
925 .enumerate()
926 .map(|(idx, (data_type, _))| ProjectionColumn {
927 column_id: idx as u32,
928 data_type: *data_type,
929 })
930 .collect();
931 let row = types_and_values
932 .into_iter()
933 .map(|(_, value)| value)
934 .collect();
935 (ProjectionSchema::new(columns), row)
936 }
937
938 fn filled(n: i64) -> AppendOnlyCollection {
940 let mut c = AppendOnlyCollection::new(schema());
941 for i in 1..=n {
942 c.append(i as u64, row(i)).expect("append");
943 }
944 c
945 }
946
947 #[test]
948 fn write_path_never_dual_writes_columnar() {
949 let collection = filled(10);
952 let projection = ColumnarProjection::new(schema(), KEY);
953 assert_eq!(projection.manifest().segments().len(), 0);
954 assert_eq!(projection.manifest().last_materialized_lsn(), 0);
955 assert_eq!(collection.row_scan(collection.latest_lsn()).len(), 10);
957 }
958
959 #[test]
960 fn equivalence_oracle_scan_matches_row_scan() {
961 let collection = filled(500);
963 let mut projection = ColumnarProjection::new(schema(), KEY);
964 projection
965 .emit_at_checkpoint(&collection, 300, TranscodeBudget::default())
966 .expect("emit");
967
968 let pinned = collection.latest_lsn();
969 let via_projection = projection
970 .analytical_scan(&collection, pinned)
971 .expect("scan");
972 let via_row = collection.row_scan(pinned);
973
974 assert_eq!(via_projection.len(), 500);
975 assert_eq!(
976 via_projection, via_row,
977 "projection scan must equal row scan"
978 );
979 }
980
981 #[test]
982 fn columnar_segments_are_actually_exercised() {
983 let collection = filled(300);
986 let mut projection = ColumnarProjection::new(schema(), KEY);
987 let outcome = projection
988 .emit_at_checkpoint(&collection, 300, TranscodeBudget::default())
989 .expect("emit");
990 assert!(outcome.segments_emitted >= 1);
991 assert_eq!(projection.manifest().last_materialized_lsn(), 300);
992 let scan = projection.analytical_scan(&collection, 300).expect("scan");
994 assert_eq!(scan, collection.row_scan(300));
995 }
996
997 #[test]
998 fn full_value_type_set_round_trips_through_columnar_projection() {
999 let (schema, first) = full_type_schema_and_row(1);
1000 let (_, second) = full_type_schema_and_row(2);
1001 let mut collection = AppendOnlyCollection::new(schema.clone());
1002 collection.append(1, first).expect("append first");
1003 collection.append(2, second).expect("append second");
1004
1005 let mut projection = ColumnarProjection::new(schema, KEY);
1006 let outcome = projection
1007 .emit_at_checkpoint(&collection, 2, TranscodeBudget::default())
1008 .expect("emit");
1009 assert_eq!(outcome.rows_materialized, 2);
1010 assert_eq!(outcome.rows_deferred, 0);
1011
1012 let via_projection = projection.analytical_scan(&collection, 2).expect("scan");
1013 let via_row = collection.row_scan(2);
1014 assert_eq!(
1015 via_projection, via_row,
1016 "every storable value type must round-trip without coercion or loss"
1017 );
1018 }
1019
1020 #[test]
1021 fn freshness_row_after_checkpoint_is_immediately_visible() {
1022 let mut collection = filled(100);
1025 let mut projection = ColumnarProjection::new(schema(), KEY);
1026 projection
1027 .emit_at_checkpoint(&collection, 100, TranscodeBudget::default())
1028 .expect("emit");
1029 assert_eq!(projection.manifest().last_materialized_lsn(), 100);
1030
1031 collection.append(101, row(101)).expect("append fresh");
1033
1034 let scan = projection.analytical_scan(&collection, 101).expect("scan");
1035 assert_eq!(scan.len(), 101, "fresh row must be visible immediately");
1036 assert_eq!(scan.last().cloned().unwrap(), row(101));
1037 assert_eq!(projection.manifest().last_materialized_lsn(), 100);
1039 }
1040
1041 #[test]
1042 fn as_of_composes_by_pinning_a_historical_lsn() {
1043 let collection = filled(200);
1045 let mut projection = ColumnarProjection::new(schema(), KEY);
1046 projection
1047 .emit_at_checkpoint(&collection, 200, TranscodeBudget::default())
1048 .expect("emit");
1049
1050 for pin in [1u64, 57, 150, 200] {
1051 let via_projection = projection.analytical_scan(&collection, pin).expect("scan");
1052 assert_eq!(
1053 via_projection,
1054 collection.row_scan(pin),
1055 "AS OF {pin} must match row scan"
1056 );
1057 assert_eq!(via_projection.len(), pin as usize);
1058 }
1059 }
1060
1061 #[test]
1062 fn historical_pin_before_projection_coverage_falls_back_to_row_path() {
1063 let collection = filled(300);
1068 let mut projection = ColumnarProjection::new(schema(), KEY);
1069 projection
1070 .emit_at_checkpoint(
1071 &collection,
1072 300,
1073 TranscodeBudget {
1074 max_rows: u64::MAX,
1075 segment_rows: 100,
1076 size_floor_rows: 1,
1077 },
1078 )
1079 .expect("emit segmented projection");
1080
1081 projection.segments.remove(0);
1082 projection.manifest.segments.remove(0);
1083
1084 let pinned = 250;
1085 let via_projection = projection
1086 .analytical_scan(&collection, pinned)
1087 .expect("historical scan");
1088 let via_row = collection.row_scan(pinned);
1089
1090 assert_eq!(
1091 via_projection, via_row,
1092 "historical AS OF must not drop rows older than projection coverage"
1093 );
1094 }
1095
1096 #[test]
1097 fn manifest_entries_are_derived_and_checksummed_and_enveloped() {
1098 let collection = filled(50);
1100 let mut projection = ColumnarProjection::new(schema(), KEY);
1101 projection
1102 .emit_at_checkpoint(&collection, 50, TranscodeBudget::default())
1103 .expect("emit");
1104
1105 let manifest = projection.manifest();
1106 assert!(manifest.all_derived());
1107 assert_eq!(
1108 manifest.durable_entries().count(),
1109 0,
1110 "derived → backup skips"
1111 );
1112 for entry in manifest.segments() {
1113 assert!(entry.derived);
1114 assert_ne!(entry.sealed_crc32, 0);
1115 }
1116
1117 let seg = &projection.segments[0];
1120 let wrong = ColumnarProjection::new(schema(), [9u8; 32]);
1121 let err = wrong
1122 .verify_and_decode_segment(seg, &mut Vec::new())
1123 .expect_err("wrong key must fail");
1124 assert!(matches!(err, ProjectionError::Envelope(_)));
1125 }
1126
1127 #[test]
1128 fn sealed_segment_tamper_is_rejected_by_checksum() {
1129 let collection = filled(20);
1130 let mut projection = ColumnarProjection::new(schema(), KEY);
1131 projection
1132 .emit_at_checkpoint(&collection, 20, TranscodeBudget::default())
1133 .expect("emit");
1134 let mut tampered = projection.segments[0].clone();
1136 tampered.sealed[0] ^= 0xFF;
1137 let err = projection
1138 .verify_and_decode_segment(&tampered, &mut Vec::new())
1139 .expect_err("tamper must be rejected");
1140 assert_eq!(
1141 err,
1142 ProjectionError::CorruptSegment("sealed checksum mismatch")
1143 );
1144 }
1145
1146 #[test]
1147 fn transcoding_budget_completes_and_defers() {
1148 let collection = filled(1000);
1151 let mut projection = ColumnarProjection::new(schema(), KEY);
1152
1153 let budget = TranscodeBudget {
1154 max_rows: 400,
1155 segment_rows: 128,
1156 size_floor_rows: 1,
1157 };
1158 let first = projection
1159 .emit_at_checkpoint(&collection, 1000, budget)
1160 .expect("first checkpoint");
1161 assert!(first.budget_exhausted);
1162 assert_eq!(first.rows_materialized, 400);
1163 assert_eq!(first.rows_deferred, 600);
1164 assert_eq!(projection.manifest().last_materialized_lsn(), 400);
1165
1166 let mid = projection.analytical_scan(&collection, 1000).expect("scan");
1169 assert_eq!(mid, collection.row_scan(1000));
1170 assert_eq!(mid.len(), 1000);
1171
1172 let mut rounds = 1;
1176 loop {
1177 let out = projection
1178 .emit_at_checkpoint(&collection, 1000, budget)
1179 .expect("checkpoint");
1180 rounds += 1;
1181 assert_eq!(
1182 projection.analytical_scan(&collection, 1000).expect("scan"),
1183 collection.row_scan(1000)
1184 );
1185 if !out.budget_exhausted {
1186 assert_eq!(out.rows_deferred, 0);
1187 break;
1188 }
1189 }
1190 assert_eq!(rounds, 3);
1192 assert_eq!(projection.manifest().last_materialized_lsn(), 1000);
1193
1194 let full = projection.analytical_scan(&collection, 1000).expect("scan");
1195 assert_eq!(full, collection.row_scan(1000));
1196 }
1197
1198 #[test]
1199 fn size_floor_skips_tiny_materialization() {
1200 let collection = filled(3);
1202 let mut projection = ColumnarProjection::new(schema(), KEY);
1203 let budget = TranscodeBudget {
1204 max_rows: u64::MAX,
1205 segment_rows: 128,
1206 size_floor_rows: 10,
1207 };
1208 let outcome = projection
1209 .emit_at_checkpoint(&collection, 3, budget)
1210 .expect("emit");
1211 assert!(outcome.floor_skipped);
1212 assert_eq!(outcome.segments_emitted, 0);
1213 assert_eq!(projection.manifest().last_materialized_lsn(), 0);
1214 assert_eq!(
1216 projection.analytical_scan(&collection, 3).expect("scan"),
1217 collection.row_scan(3)
1218 );
1219 }
1220
1221 #[test]
1222 fn drop_and_rebuild_is_repair() {
1223 let collection = filled(300);
1225 let mut projection = ColumnarProjection::new(schema(), KEY);
1226 projection
1227 .emit_at_checkpoint(&collection, 300, TranscodeBudget::default())
1228 .expect("emit");
1229 let before = projection.analytical_scan(&collection, 300).expect("scan");
1230
1231 projection.drop_projection();
1232 assert_eq!(projection.manifest().segments().len(), 0);
1233 assert_eq!(
1235 projection.analytical_scan(&collection, 300).expect("scan"),
1236 collection.row_scan(300)
1237 );
1238
1239 projection
1241 .emit_at_checkpoint(&collection, 300, TranscodeBudget::default())
1242 .expect("rebuild");
1243 let after = projection.analytical_scan(&collection, 300).expect("scan");
1244 assert_eq!(before, after);
1245 }
1246
1247 #[test]
1248 fn repairing_scan_rebuilds_after_projection_artifacts_are_deleted() {
1249 let collection = filled(300);
1250 let mut projection = ColumnarProjection::new(schema(), KEY);
1251 projection
1252 .emit_at_checkpoint(&collection, 300, TranscodeBudget::default())
1253 .expect("emit");
1254 assert!(!projection.segments.is_empty());
1255
1256 projection.segments.clear();
1259
1260 let scan = projection
1261 .repairing_analytical_scan(&collection, 300, TranscodeBudget::default())
1262 .expect("repairing scan");
1263
1264 assert_eq!(scan, collection.row_scan(300));
1265 assert!(!projection.manifest().segments().is_empty());
1266 assert!(!projection.segments.is_empty());
1267 }
1268
1269 #[test]
1270 fn repairing_scan_rebuilds_after_projection_checksum_mismatch() {
1271 let collection = filled(20);
1272 let mut projection = ColumnarProjection::new(schema(), KEY);
1273 projection
1274 .emit_at_checkpoint(&collection, 20, TranscodeBudget::default())
1275 .expect("emit");
1276 let original_segment_id = projection.manifest().segments()[0].segment_id;
1277
1278 projection.segments[0].sealed[0] ^= 0xFF;
1279
1280 let scan = projection
1281 .repairing_analytical_scan(&collection, 20, TranscodeBudget::default())
1282 .expect("repairing scan");
1283
1284 assert_eq!(scan, collection.row_scan(20));
1285 assert_ne!(
1286 projection.manifest().segments()[0].segment_id,
1287 original_segment_id,
1288 "corrupt projection bytes must be replaced by a rebuilt segment"
1289 );
1290 }
1291
1292 #[test]
1293 fn append_rejects_non_monotonic_lsn_and_bad_arity() {
1294 let mut c = AppendOnlyCollection::new(schema());
1295 c.append(5, row(1)).expect("first");
1296 assert!(matches!(
1297 c.append(5, row(2)),
1298 Err(ProjectionError::NonMonotonicLsn { .. })
1299 ));
1300 assert!(matches!(
1301 c.append(6, vec![Value::Integer(1)]),
1302 Err(ProjectionError::RowWidth { .. })
1303 ));
1304 }
1305
1306 #[test]
1307 fn mismatched_types_are_rejected() {
1308 let s = ProjectionSchema::new(vec![ProjectionColumn {
1309 column_id: 0,
1310 data_type: DataType::Integer,
1311 }]);
1312 let mut c = AppendOnlyCollection::new(s);
1313 assert!(matches!(
1314 c.append(1, vec![Value::Text("x".into())]),
1315 Err(ProjectionError::TypeMismatch { .. })
1316 ));
1317 }
1318}