|
| 1 | +//! Bitemporal retention: backfill preservation + version-lossless compaction. |
| 2 | +//! |
| 3 | +//! Retention on bitemporal timeseries keys off `max_system_ts` (when the |
| 4 | +//! write happened) rather than `max_ts` (event time). A late-arriving |
| 5 | +//! backfill with old event-time but current system-time must survive a |
| 6 | +//! short event-time-based retention window. |
| 7 | +//! |
| 8 | +//! Plain columnar bitemporal preserves every version of a PK: consecutive |
| 9 | +//! inserts for the same `id` append rather than tombstone the prior row. |
| 10 | +
|
| 11 | +use nodedb_types::timeseries::{ |
| 12 | + PartitionInterval, PartitionMeta, PartitionState, TieredPartitionConfig, |
| 13 | +}; |
| 14 | + |
| 15 | +use nodedb::engine::timeseries::partition_registry::PartitionRegistry; |
| 16 | + |
| 17 | +fn test_config(retention_ms: u64) -> TieredPartitionConfig { |
| 18 | + let mut cfg = TieredPartitionConfig::origin_defaults(); |
| 19 | + cfg.partition_by = PartitionInterval::Duration(86_400_000); // 1d |
| 20 | + cfg.merge_after_ms = 7 * 86_400_000; |
| 21 | + cfg.merge_count = 3; |
| 22 | + cfg.retention_period_ms = retention_ms; |
| 23 | + cfg |
| 24 | +} |
| 25 | + |
| 26 | +fn sealed_partition_meta(min_ts: i64, max_ts: i64, max_system_ts: i64) -> PartitionMeta { |
| 27 | + PartitionMeta { |
| 28 | + min_ts, |
| 29 | + max_ts, |
| 30 | + row_count: 10, |
| 31 | + size_bytes: 1024, |
| 32 | + schema_version: 1, |
| 33 | + state: PartitionState::Sealed, |
| 34 | + interval_ms: 86_400_000, |
| 35 | + last_flushed_wal_lsn: 0, |
| 36 | + column_stats: Default::default(), |
| 37 | + max_system_ts, |
| 38 | + } |
| 39 | +} |
| 40 | + |
| 41 | +/// A backdated partition (old `max_ts`, recent `max_system_ts`) is |
| 42 | +/// expired under event-time retention but survives under bitemporal |
| 43 | +/// (system-time) retention. |
| 44 | +#[test] |
| 45 | +fn bitemporal_retention_preserves_backfill() { |
| 46 | + let now_ms = 40 * 86_400_000i64; |
| 47 | + let backfill_event_ts = 5 * 86_400_000i64; // 35 days old (event time) |
| 48 | + let backfill_system_ts = now_ms - 60_000; // 1 minute ago (system time) |
| 49 | + let retention_ms: i64 = 30 * 86_400_000; // 30-day retention window |
| 50 | + |
| 51 | + let mut reg = PartitionRegistry::new(test_config(retention_ms as u64)); |
| 52 | + |
| 53 | + // Seed a partition directly — simulates a flushed backfill batch. |
| 54 | + let start = backfill_event_ts; |
| 55 | + reg.get_or_create_partition(start); |
| 56 | + let meta = sealed_partition_meta(backfill_event_ts, backfill_event_ts, backfill_system_ts); |
| 57 | + reg.update_meta(start, meta); |
| 58 | + |
| 59 | + // Non-bitemporal axis: partition is older than retention → expired. |
| 60 | + let expired_event = reg.find_expired(now_ms, false); |
| 61 | + assert_eq!( |
| 62 | + expired_event, |
| 63 | + vec![start], |
| 64 | + "event-time retention should drop the backdated partition" |
| 65 | + ); |
| 66 | + |
| 67 | + // Bitemporal axis: partition was *written* recently → not expired. |
| 68 | + let expired_system = reg.find_expired(now_ms, true); |
| 69 | + assert!( |
| 70 | + expired_system.is_empty(), |
| 71 | + "bitemporal retention must preserve backfilled partition; got {expired_system:?}" |
| 72 | + ); |
| 73 | +} |
| 74 | + |
| 75 | +/// When `max_system_ts` is 0 (non-bitemporal partition or pre-upgrade |
| 76 | +/// manifest), the bitemporal axis falls back to `max_ts` so existing |
| 77 | +/// non-bitemporal retention continues to work. |
| 78 | +#[test] |
| 79 | +fn bitemporal_fallback_when_system_ts_zero() { |
| 80 | + let now_ms = 40 * 86_400_000i64; |
| 81 | + let retention_ms = 30 * 86_400_000i64; |
| 82 | + |
| 83 | + let mut reg = PartitionRegistry::new(test_config(retention_ms as u64)); |
| 84 | + let start = 5 * 86_400_000i64; |
| 85 | + reg.get_or_create_partition(start); |
| 86 | + let meta = sealed_partition_meta(start, start, 0); // max_system_ts = 0 |
| 87 | + reg.update_meta(start, meta); |
| 88 | + |
| 89 | + // Both axes should agree because max_system_ts = 0 triggers fallback. |
| 90 | + assert_eq!(reg.find_expired(now_ms, false), vec![start]); |
| 91 | + assert_eq!(reg.find_expired(now_ms, true), vec![start]); |
| 92 | +} |
| 93 | + |
| 94 | +/// Plain columnar bitemporal: two inserts with the same user PK but |
| 95 | +/// different `_ts_system` stamps produce two memtable rows with no |
| 96 | +/// delete bitmap entry — compaction sees both versions. |
| 97 | +#[test] |
| 98 | +fn bitemporal_columnar_insert_preserves_versions() { |
| 99 | + use nodedb_columnar::MutationEngine; |
| 100 | + use nodedb_types::columnar::{ColumnDef, ColumnType, ColumnarSchema}; |
| 101 | + use nodedb_types::value::Value; |
| 102 | + |
| 103 | + // Schema matches the Data Plane's `prepend_bitemporal_columns` shape: |
| 104 | + // three reserved bitemporal columns ahead of the user-provided ones. |
| 105 | + let schema = ColumnarSchema::new(vec![ |
| 106 | + ColumnDef::required("_ts_system", ColumnType::Int64), |
| 107 | + ColumnDef::required("_ts_valid_from", ColumnType::Int64), |
| 108 | + ColumnDef::required("_ts_valid_until", ColumnType::Int64), |
| 109 | + ColumnDef::required("id", ColumnType::Int64).with_primary_key(), |
| 110 | + ColumnDef::nullable("value", ColumnType::Float64), |
| 111 | + ]) |
| 112 | + .unwrap(); |
| 113 | + assert!( |
| 114 | + schema.is_bitemporal(), |
| 115 | + "schema should self-report bitemporal" |
| 116 | + ); |
| 117 | + |
| 118 | + let mut engine = MutationEngine::new("bi_collection".into(), schema); |
| 119 | + |
| 120 | + // First version of user pk=42. |
| 121 | + engine |
| 122 | + .insert(&[ |
| 123 | + Value::Integer(1_000), |
| 124 | + Value::Integer(i64::MIN), |
| 125 | + Value::Integer(i64::MAX), |
| 126 | + Value::Integer(42), |
| 127 | + Value::Float(1.0), |
| 128 | + ]) |
| 129 | + .expect("insert v1"); |
| 130 | + |
| 131 | + // Second version of the same pk — later system time, different value. |
| 132 | + let r2 = engine |
| 133 | + .insert(&[ |
| 134 | + Value::Integer(2_000), |
| 135 | + Value::Integer(i64::MIN), |
| 136 | + Value::Integer(i64::MAX), |
| 137 | + Value::Integer(42), |
| 138 | + Value::Float(2.0), |
| 139 | + ]) |
| 140 | + .expect("insert v2"); |
| 141 | + |
| 142 | + // Non-bitemporal would emit a DeleteRows record for the prior version; |
| 143 | + // bitemporal should emit ONLY the InsertRow. |
| 144 | + assert_eq!( |
| 145 | + r2.wal_records.len(), |
| 146 | + 1, |
| 147 | + "bitemporal insert must not tombstone prior version" |
| 148 | + ); |
| 149 | + assert!(matches!( |
| 150 | + &r2.wal_records[0], |
| 151 | + nodedb_columnar::wal_record::ColumnarWalRecord::InsertRow { .. } |
| 152 | + )); |
| 153 | + |
| 154 | + // Both rows are live in the memtable — no delete bitmap entries. |
| 155 | + assert_eq!(engine.memtable().row_count(), 2); |
| 156 | + assert!( |
| 157 | + engine |
| 158 | + .delete_bitmap(0) |
| 159 | + .is_none_or(|bm| bm.deleted_count() == 0), |
| 160 | + "bitemporal insert must not create tombstones" |
| 161 | + ); |
| 162 | + |
| 163 | + // PK index rebinds to latest version so current-state reads find v2. |
| 164 | + let pk = nodedb_columnar::pk_index::encode_pk(&Value::Integer(42)); |
| 165 | + let loc = engine.pk_index().get(&pk).expect("pk indexed"); |
| 166 | + // Second insert lands at memtable row index 1. |
| 167 | + assert_eq!(loc.row_index, 1); |
| 168 | +} |
0 commit comments