Skip to content

Commit bd05275

Browse files
committed
test(nodedb): add bitemporal retention tests and fix checkpoint paths
Add integration tests covering system-time retention for bitemporal timeseries collections, including backfill survival and non-bitemporal fallback behaviour. Update existing tests for the `max_system_ts` field added to `PartitionMeta` and fix stale source paths in checkpoint durability assertions that pointed to the pre-split module files.
1 parent 00fb860 commit bd05275

4 files changed

Lines changed: 173 additions & 3 deletions

File tree

nodedb/src/engine/timeseries/columnar_segment/tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,7 @@ fn legacy_partition_no_sparse_index() {
472472
interval_ms: 86_400_000,
473473
last_flushed_wal_lsn: 0,
474474
column_stats: std::collections::HashMap::new(),
475+
max_system_ts: 0,
475476
})
476477
.unwrap(),
477478
)

nodedb/src/engine/timeseries/partition_registry/tests.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ fn find_expired() {
102102

103103
// 40 days later, retention=30d → days 1-9 expired (but only 1-5 exist).
104104
let now = 40 * day_ms;
105-
let expired = reg.find_expired(now);
105+
let expired = reg.find_expired(now, false);
106106
assert_eq!(expired.len(), 5);
107107
}
108108

@@ -132,6 +132,7 @@ fn commit_merge_and_purge() {
132132
interval_ms: 3 * day_ms as u64,
133133
last_flushed_wal_lsn: 100,
134134
column_stats: std::collections::HashMap::new(),
135+
max_system_ts: 0,
135136
};
136137
reg.commit_merge(merged_meta, "ts-merged".into(), &starts);
137138

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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+
}

nodedb/tests/checkpoint_durability.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ fn timeseries_partition_registry_persist_is_durable() {
131131
// `PartitionRegistry::persist` writes the authoritative partition map via
132132
// the same tmp+rename pattern — recovery reads it on startup, so the
133133
// zero-file failure mode is identical to checkpoint writers.
134-
assert_durable_checkpoint_writer("nodedb/src/engine/timeseries/partition_registry.rs");
134+
assert_durable_checkpoint_writer("nodedb/src/engine/timeseries/partition_registry/persistence.rs");
135135
}
136136

137137
#[test]
@@ -156,7 +156,7 @@ fn regen_certs_writes_are_durable() {
156156
/// renamed-in directory. The swap helper must fsync both parent directories.
157157
#[test]
158158
fn timeseries_merge_directory_swap_is_durable() {
159-
let src = read("nodedb/src/engine/timeseries/merge.rs");
159+
let src = read("nodedb/src/engine/timeseries/merge/o3.rs");
160160
assert!(
161161
src.contains("atomic_swap_dirs_fsync") || src.contains("fsync_directory"),
162162
"timeseries merge directory swap must fsync the parent directory \

0 commit comments

Comments
 (0)