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