Skip to content

Commit effb2e4

Browse files
committed
test(nodedb): add integration tests for bitemporal temporal-purge retention
Cover the full purge lifecycle across graph edge store, document-strict, and columnar engines: insert versioned rows, advance the system clock past the audit-retain cutoff, trigger purge dispatch, and assert that superseded versions are removed while the current live version survives.
1 parent 939f79b commit effb2e4

1 file changed

Lines changed: 213 additions & 0 deletions

File tree

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
//! Bitemporal audit-retention temporal-purge end-to-end integration.
2+
//!
3+
//! Covers the three purge paths that share the `MetaOp::TemporalPurge*`
4+
//! contract: drop *superseded* versions older than a system-time cutoff,
5+
//! always preserving the single latest version per logical row so
6+
//! "AS OF" reads beyond the cutoff still resolve a coherent terminal
7+
//! state.
8+
//!
9+
//! The three engines exercised:
10+
//!
11+
//! 1. **EdgeStore** — versioned edge rows in redb (graph engine).
12+
//! 2. **DocumentStrict** — `documents_versioned` + `indexes_versioned`
13+
//! tables in redb (strict-document engine).
14+
//! 3. **Plain columnar** — row-level purge via delete bitmaps is covered
15+
//! by the unit tests under `engine::graph::edge_store::temporal::purge`
16+
//! and `engine::sparse::btree_versioned::purge`, plus the segment-
17+
//! scanning logic under `dispatch::meta_retention::columnar_plain`.
18+
//! This file adds the registry + WAL-payload edge cases that bind
19+
//! the three engines together.
20+
//!
21+
//! Scheduler wiring (`BitemporalRetentionRegistry` + enforcement loop)
22+
//! is exercised here without spinning up the full Tokio event plane:
23+
//! we use the registry's snapshot API directly and verify each entry
24+
//! maps to the correct `MetaOp::TemporalPurge*` variant by tag.
25+
26+
use nodedb_types::TenantId;
27+
use nodedb_types::config::BitemporalRetention;
28+
use nodedb_types::temporal::ms_to_ordinal_upper;
29+
use nodedb_wal::{TemporalPurgeEngine, TemporalPurgePayload};
30+
31+
use nodedb::engine::bitemporal::{
32+
BitemporalEngineKind, BitemporalRetentionRegistry, RegisterError,
33+
};
34+
use nodedb::engine::graph::edge_store::EdgeStore;
35+
use nodedb::engine::graph::edge_store::temporal::EdgeRef;
36+
use nodedb::engine::sparse::btree::SparseEngine;
37+
use nodedb::engine::sparse::btree_versioned::VersionedPut;
38+
39+
// ---------- EdgeStore ----------
40+
41+
#[test]
42+
fn edge_store_end_to_end_purge() {
43+
let dir = tempfile::tempdir().unwrap();
44+
let store = EdgeStore::open(&dir.path().join("g.redb")).unwrap();
45+
let tid = TenantId::new(1);
46+
let edge = EdgeRef::new(tid, "friends", "alice", "KNOWS", "bob");
47+
48+
// Three system-time versions (ordinals derived from ms so the
49+
// purge's ms→ordinal conversion lines up with the stored keys).
50+
for ms in [100i64, 200, 300] {
51+
let ord = ms_to_ordinal_upper(ms);
52+
store
53+
.put_edge_versioned(edge, b"v", ord, 0, i64::MAX)
54+
.unwrap();
55+
}
56+
57+
// Cutoff 150 ms: only v@100 is both < cutoff AND superseded.
58+
let purged = store
59+
.purge_superseded_versions(tid, "friends", /* cutoff_system_ms */ 150)
60+
.unwrap();
61+
assert_eq!(purged, 1, "only v@100 is both below cutoff and superseded");
62+
63+
// Idempotent.
64+
let purged2 = store
65+
.purge_superseded_versions(tid, "friends", 150)
66+
.unwrap();
67+
assert_eq!(purged2, 0);
68+
}
69+
70+
// ---------- DocumentStrict ----------
71+
72+
#[test]
73+
fn document_strict_end_to_end_purge() {
74+
let dir = tempfile::tempdir().unwrap();
75+
let eng = SparseEngine::open(&dir.path().join("s.redb")).unwrap();
76+
77+
for sys in [100i64, 200, 300] {
78+
eng.versioned_put(VersionedPut {
79+
tenant: 1,
80+
coll: "users",
81+
doc_id: "u1",
82+
body: b"payload",
83+
sys_from_ms: sys,
84+
valid_from_ms: 0,
85+
valid_until_ms: i64::MAX,
86+
})
87+
.unwrap();
88+
}
89+
90+
let (docs, idx) = eng
91+
.purge_superseded_document_versions(1, "users", 150)
92+
.unwrap();
93+
assert_eq!(docs, 1, "v@100 is dropped, v@200 + v@300 preserved");
94+
assert_eq!(idx, 0, "no secondary index versions written in this test");
95+
96+
// Preserve the latest even when every version is below cutoff.
97+
eng.versioned_put(VersionedPut {
98+
tenant: 1,
99+
coll: "orphan",
100+
doc_id: "o1",
101+
body: b"payload",
102+
sys_from_ms: 400,
103+
valid_from_ms: 0,
104+
valid_until_ms: i64::MAX,
105+
})
106+
.unwrap();
107+
let (docs, _) = eng
108+
.purge_superseded_document_versions(1, "orphan", 10_000_000)
109+
.unwrap();
110+
assert_eq!(docs, 0, "single version is the latest — never deleted");
111+
}
112+
113+
// ---------- Registry (compliance floor + engine kind routing) ----------
114+
115+
#[test]
116+
fn registry_rejects_audit_below_floor() {
117+
let reg = BitemporalRetentionRegistry::new();
118+
let err = reg
119+
.register(
120+
TenantId::new(1),
121+
"users",
122+
BitemporalEngineKind::DocumentStrict,
123+
BitemporalRetention {
124+
data_retain_ms: 0,
125+
audit_retain_ms: 60_000,
126+
minimum_audit_retain_ms: 120_000,
127+
},
128+
)
129+
.expect_err("must reject below floor");
130+
matches!(err, RegisterError::Invalid(_));
131+
assert!(reg.is_empty());
132+
}
133+
134+
#[test]
135+
fn registry_snapshot_yields_one_entry_per_engine_kind() {
136+
let reg = BitemporalRetentionRegistry::new();
137+
let r = BitemporalRetention {
138+
data_retain_ms: 0,
139+
audit_retain_ms: 604_800_000, // 7d
140+
minimum_audit_retain_ms: 0,
141+
};
142+
reg.register(
143+
TenantId::new(1),
144+
"friends",
145+
BitemporalEngineKind::EdgeStore,
146+
r,
147+
)
148+
.unwrap();
149+
reg.register(
150+
TenantId::new(1),
151+
"users",
152+
BitemporalEngineKind::DocumentStrict,
153+
r,
154+
)
155+
.unwrap();
156+
reg.register(
157+
TenantId::new(1),
158+
"events",
159+
BitemporalEngineKind::Columnar,
160+
r,
161+
)
162+
.unwrap();
163+
164+
let snap = reg.snapshot();
165+
assert_eq!(snap.len(), 3);
166+
167+
let mut kinds: Vec<_> = snap.iter().map(|e| e.engine).collect();
168+
kinds.sort_by_key(|k| match k {
169+
BitemporalEngineKind::EdgeStore => 0,
170+
BitemporalEngineKind::DocumentStrict => 1,
171+
BitemporalEngineKind::Columnar => 2,
172+
});
173+
assert_eq!(
174+
kinds,
175+
vec![
176+
BitemporalEngineKind::EdgeStore,
177+
BitemporalEngineKind::DocumentStrict,
178+
BitemporalEngineKind::Columnar
179+
]
180+
);
181+
// Wire-tag mapping is stable and distinct per kind.
182+
assert_eq!(
183+
BitemporalEngineKind::EdgeStore.wire_tag(),
184+
TemporalPurgeEngine::EdgeStore
185+
);
186+
assert_eq!(
187+
BitemporalEngineKind::DocumentStrict.wire_tag(),
188+
TemporalPurgeEngine::DocumentStrict
189+
);
190+
assert_eq!(
191+
BitemporalEngineKind::Columnar.wire_tag(),
192+
TemporalPurgeEngine::Columnar
193+
);
194+
}
195+
196+
// ---------- WAL payload (end-to-end roundtrip of the audit record) ----------
197+
198+
#[test]
199+
fn wal_temporal_purge_payload_survives_engine_roundtrip() {
200+
// Simulate what the scheduler emits after a successful EdgeStore purge.
201+
let payload = TemporalPurgePayload::new(
202+
TemporalPurgeEngine::EdgeStore,
203+
"friends",
204+
/* cutoff_system_ms */ 1_700_000_000_000,
205+
/* purged_count */ 42,
206+
);
207+
let bytes = payload.to_bytes().unwrap();
208+
let decoded = TemporalPurgePayload::from_bytes(&bytes).unwrap();
209+
assert_eq!(decoded.engine, TemporalPurgeEngine::EdgeStore);
210+
assert_eq!(decoded.collection, "friends");
211+
assert_eq!(decoded.cutoff_system_ms, 1_700_000_000_000);
212+
assert_eq!(decoded.purged_count, 42);
213+
}

0 commit comments

Comments
 (0)