Skip to content

Commit 939f79b

Browse files
committed
feat(nodedb): wire bitemporal retention through dispatch, Event Plane, and WAL
Add the end-to-end plumbing for bitemporal audit-retention enforcement: - `MetaOp::TemporalPurge{EdgeStore,DocumentStrict,Columnar}` plan variants with Admin permission gating in the identity layer. - `BitemporalRetentionRegistry` added to `SharedState`; initialised in both cold-start and snapshot-restore paths. - `spawn_bitemporal_retention_loop` invoked from `EventPlane::start` with tick interval sourced from `TuningConfig`, keeping cadence operator- configurable without code changes. - Data Plane dispatch: existing `EnforceTimeseriesRetention` and `ApplyContinuousAggRetention` arms delegated to a new `dispatch/meta_retention/` sub-module to stay within the file-size budget; new `TemporalPurge*` arms handled there as well. - `WalManager::append_temporal_purge` emits a durable audit record after each successful purge batch. - WAL replay skips `RecordType::TemporalPurge` in the event-bus path (purge is idempotent on replay; the record exists for audit, not re-execution).
1 parent 8efe557 commit 939f79b

12 files changed

Lines changed: 648 additions & 141 deletions

File tree

nodedb/src/bridge/physical_plan/meta.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,44 @@ pub enum MetaOp {
124124
/// the cutoff. Called by the retention policy enforcement loop.
125125
EnforceTimeseriesRetention { collection: String, max_age_ms: i64 },
126126

127+
/// Bitemporal audit-retention purge for the graph edge store.
128+
///
129+
/// Deletes versioned edge rows (in `edge_store/temporal` layout) where
130+
/// `system_from_ms < cutoff_system_ms` AND a newer version of the same
131+
/// base key exists. Never deletes the single surviving (latest) version
132+
/// of a base key, even if it is older than the cutoff — "audit retain"
133+
/// reclaims only *superseded* history, not the current row.
134+
/// Emits one `RecordType::TemporalPurge` WAL record per purged batch.
135+
TemporalPurgeEdgeStore {
136+
tenant_id: u32,
137+
collection: String,
138+
cutoff_system_ms: i64,
139+
},
140+
141+
/// Bitemporal audit-retention purge for the DocumentStrict versioned
142+
/// tables (`documents_versioned` + `indexes_versioned`). Same semantics
143+
/// as `TemporalPurgeEdgeStore`: drop versions older than `cutoff_system_ms`
144+
/// when a newer version of the same `(tenant, coll, doc_id)` exists;
145+
/// never drop the single surviving version. Deletes index-version rows
146+
/// keyed to the purged document versions in the same transaction.
147+
TemporalPurgeDocumentStrict {
148+
tenant_id: u32,
149+
collection: String,
150+
cutoff_system_ms: i64,
151+
},
152+
153+
/// Bitemporal audit-retention purge for plain columnar collections.
154+
/// Reuses the timeseries max-system-ts axis on partition meta: any
155+
/// partition whose `max_system_ts < cutoff_system_ms` and whose max
156+
/// column version has been superseded by a later partition is removed.
157+
/// Non-bitemporal columnar collections are a no-op (collection is
158+
/// expected to be flagged bitemporal by the caller).
159+
TemporalPurgeColumnar {
160+
tenant_id: u32,
161+
collection: String,
162+
cutoff_system_ms: i64,
163+
},
164+
127165
/// Apply retention to continuous aggregate buckets managed by
128166
/// the aggregate manager. Drops materialized buckets older than
129167
/// each aggregate's configured retention_period_ms.

nodedb/src/control/security/identity.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,11 @@ pub fn required_permission(plan: &crate::bridge::envelope::PhysicalPlan) -> Perm
354354

355355
// Retention enforcement is admin-level (invoked by background tasks).
356356
PhysicalPlan::Meta(
357-
MetaOp::EnforceTimeseriesRetention { .. } | MetaOp::ApplyContinuousAggRetention,
357+
MetaOp::EnforceTimeseriesRetention { .. }
358+
| MetaOp::ApplyContinuousAggRetention
359+
| MetaOp::TemporalPurgeEdgeStore { .. }
360+
| MetaOp::TemporalPurgeDocumentStrict { .. }
361+
| MetaOp::TemporalPurgeColumnar { .. },
358362
) => Permission::Admin,
359363

360364
// Watermark query is admin-level (invoked by enforcement loop).

nodedb/src/control/state/fields.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,12 @@ pub struct SharedState {
273273
pub retention_policy_registry:
274274
Arc<crate::engine::timeseries::retention_policy::RetentionPolicyRegistry>,
275275

276+
/// Per-collection bitemporal audit-retention policy registry.
277+
/// Populated by DDL (`CREATE COLLECTION ... WITH BITEMPORAL RETENTION`),
278+
/// consumed by the background enforcement loop that dispatches
279+
/// `MetaOp::TemporalPurge*`.
280+
pub bitemporal_retention_registry: Arc<crate::engine::bitemporal::BitemporalRetentionRegistry>,
281+
276282
/// In-memory alert rule registry for threshold alerting.
277283
pub alert_registry: Arc<crate::event::alert::AlertRegistry>,
278284

nodedb/src/control/state/init.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ impl SharedState {
142142
retention_policy_registry: Arc::new(
143143
crate::engine::timeseries::retention_policy::RetentionPolicyRegistry::new(),
144144
),
145+
bitemporal_retention_registry: Arc::new(
146+
crate::engine::bitemporal::BitemporalRetentionRegistry::new(),
147+
),
145148
alert_registry: Arc::new(crate::event::alert::AlertRegistry::new()),
146149
alert_hysteresis: Arc::new(crate::event::alert::hysteresis::HysteresisManager::new()),
147150
schedule_registry: Arc::new(crate::event::scheduler::ScheduleRegistry::new()),
@@ -361,6 +364,9 @@ impl SharedState {
361364
catalog_path.parent().unwrap_or(std::path::Path::new(".")),
362365
)?),
363366
retention_policy_registry,
367+
bitemporal_retention_registry: Arc::new(
368+
crate::engine::bitemporal::BitemporalRetentionRegistry::new(),
369+
),
364370
alert_registry,
365371
alert_hysteresis,
366372
schedule_registry,
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
//! Plain columnar profile temporal-purge.
2+
//!
3+
//! Row-level audit purge on bitemporal plain-columnar collections.
4+
//! Walks sealed segments (via [`nodedb_columnar::SegmentReader`]) and the
5+
//! live memtable, groups rows by primary key, and marks every
6+
//! *superseded* row whose `_ts_system` is below the cutoff in the
7+
//! engine's per-segment delete bitmap.
8+
//!
9+
//! The single latest version per PK is always preserved — even if it is
10+
//! itself below the cutoff — so "AS OF" reads beyond the cutoff can still
11+
//! resolve each logical row's terminal state.
12+
13+
use std::collections::HashMap;
14+
15+
use nodedb_types::TenantId;
16+
17+
use crate::data::executor::core_loop::CoreLoop;
18+
use crate::data::executor::scan_normalize::decoded_col_to_value;
19+
20+
pub(super) struct RowVersion {
21+
pub seg_id: u32,
22+
pub row_idx: u32,
23+
pub pk_bytes: Vec<u8>,
24+
pub sys_ts: i64,
25+
}
26+
27+
impl CoreLoop {
28+
/// See module docs. Returns the number of rows tombstoned in
29+
/// per-segment delete bitmaps. No WAL records are appended here; the
30+
/// caller is responsible for the `RecordType::TemporalPurge` audit
31+
/// record that covers the batch.
32+
pub(super) fn plain_columnar_purge(
33+
&mut self,
34+
tid: TenantId,
35+
collection: &str,
36+
cutoff_system_ms: i64,
37+
) -> crate::Result<usize> {
38+
let key = (tid, collection.to_string());
39+
let engine = match self.columnar_engines.get(&key) {
40+
Some(e) => e,
41+
None => return Ok(0),
42+
};
43+
let schema = engine.schema();
44+
if !schema.is_bitemporal() {
45+
return Ok(0);
46+
}
47+
let ts_idx = schema
48+
.ts_system_idx()
49+
.ok_or_else(|| crate::Error::Storage {
50+
engine: "columnar".into(),
51+
detail: "bitemporal schema missing _ts_system column".into(),
52+
})?;
53+
let pk_indices: Vec<usize> = engine.pk_col_indices().to_vec();
54+
if pk_indices.is_empty() {
55+
return Err(crate::Error::Storage {
56+
engine: "columnar".into(),
57+
detail: "bitemporal collection without primary key columns".into(),
58+
});
59+
}
60+
61+
let mut versions: Vec<RowVersion> = Vec::new();
62+
63+
// Flushed segments: segment_id starts at 1 (memtable is id 0).
64+
if let Some(segments) = self.columnar_flushed_segments.get(&key) {
65+
for (seg_idx, seg_bytes) in segments.iter().enumerate() {
66+
let seg_id = seg_idx as u32 + 1;
67+
let reader = nodedb_columnar::SegmentReader::open(seg_bytes).map_err(|e| {
68+
crate::Error::Storage {
69+
engine: "columnar".into(),
70+
detail: format!("open segment for purge: {e}"),
71+
}
72+
})?;
73+
let ts_col = reader
74+
.read_column(ts_idx)
75+
.map_err(|e| crate::Error::Storage {
76+
engine: "columnar".into(),
77+
detail: format!("read _ts_system: {e}"),
78+
})?;
79+
let pk_cols: Vec<nodedb_columnar::reader::DecodedColumn> = pk_indices
80+
.iter()
81+
.map(|&i| reader.read_column(i))
82+
.collect::<Result<_, _>>()
83+
.map_err(|e| crate::Error::Storage {
84+
engine: "columnar".into(),
85+
detail: format!("read pk column: {e}"),
86+
})?;
87+
let row_count = reader.row_count() as usize;
88+
for row_idx in 0..row_count {
89+
let Some(sys_ts) = int64_from_decoded(&ts_col, row_idx) else {
90+
continue;
91+
};
92+
let pk_bytes = encode_pk_from_decoded_cols(&pk_cols, row_idx);
93+
versions.push(RowVersion {
94+
seg_id,
95+
row_idx: row_idx as u32,
96+
pk_bytes,
97+
sys_ts,
98+
});
99+
}
100+
}
101+
}
102+
103+
// Memtable rows.
104+
let memtable_seg_id = engine.memtable_segment_id();
105+
let rows: Vec<Vec<nodedb_types::value::Value>> = engine.scan_memtable_rows().collect();
106+
for (row_idx, row) in rows.iter().enumerate() {
107+
let sys_ts = match row.get(ts_idx) {
108+
Some(nodedb_types::value::Value::Integer(n)) => *n,
109+
_ => continue,
110+
};
111+
let pk_values: Vec<&nodedb_types::value::Value> =
112+
pk_indices.iter().filter_map(|&i| row.get(i)).collect();
113+
if pk_values.len() != pk_indices.len() {
114+
continue;
115+
}
116+
let pk_bytes = if pk_values.len() == 1 {
117+
nodedb_columnar::pk_index::encode_pk(pk_values[0])
118+
} else {
119+
nodedb_columnar::pk_index::encode_composite_pk(&pk_values)
120+
};
121+
versions.push(RowVersion {
122+
seg_id: memtable_seg_id,
123+
row_idx: row_idx as u32,
124+
pk_bytes,
125+
sys_ts,
126+
});
127+
}
128+
129+
// Find latest system_ts per PK.
130+
let mut latest: HashMap<Vec<u8>, i64> = HashMap::new();
131+
for v in &versions {
132+
latest
133+
.entry(v.pk_bytes.clone())
134+
.and_modify(|cur| {
135+
if v.sys_ts > *cur {
136+
*cur = v.sys_ts;
137+
}
138+
})
139+
.or_insert(v.sys_ts);
140+
}
141+
142+
// Victims: superseded AND below cutoff AND not already tombstoned.
143+
let mut victims_per_seg: HashMap<u32, Vec<u32>> = HashMap::new();
144+
for v in versions {
145+
let lat = latest.get(&v.pk_bytes).copied().unwrap_or(v.sys_ts);
146+
if v.sys_ts < cutoff_system_ms && v.sys_ts < lat {
147+
let already = engine
148+
.delete_bitmap(v.seg_id)
149+
.is_some_and(|bm| bm.is_deleted(v.row_idx));
150+
if !already {
151+
victims_per_seg.entry(v.seg_id).or_default().push(v.row_idx);
152+
}
153+
}
154+
}
155+
156+
if victims_per_seg.is_empty() {
157+
return Ok(0);
158+
}
159+
160+
let engine_mut = self
161+
.columnar_engines
162+
.get_mut(&key)
163+
.expect("engine vanished mid-purge");
164+
let mut total = 0usize;
165+
for (seg_id, mut row_indices) in victims_per_seg {
166+
row_indices.sort_unstable();
167+
row_indices.dedup();
168+
total += row_indices.len();
169+
engine_mut
170+
.delete_bitmap_mut(seg_id)
171+
.mark_deleted_batch(&row_indices);
172+
}
173+
Ok(total)
174+
}
175+
}
176+
177+
fn int64_from_decoded(col: &nodedb_columnar::reader::DecodedColumn, row_idx: usize) -> Option<i64> {
178+
use nodedb_columnar::reader::DecodedColumn;
179+
match col {
180+
DecodedColumn::Int64 { values, valid } | DecodedColumn::Timestamp { values, valid } => {
181+
(row_idx < valid.len() && valid[row_idx]).then(|| values[row_idx])
182+
}
183+
_ => None,
184+
}
185+
}
186+
187+
fn encode_pk_from_decoded_cols(
188+
cols: &[nodedb_columnar::reader::DecodedColumn],
189+
row_idx: usize,
190+
) -> Vec<u8> {
191+
let values: Vec<nodedb_types::value::Value> = cols
192+
.iter()
193+
.map(|c| decoded_col_to_value(c, row_idx))
194+
.collect();
195+
if values.len() == 1 {
196+
nodedb_columnar::pk_index::encode_pk(&values[0])
197+
} else {
198+
let refs: Vec<&nodedb_types::value::Value> = values.iter().collect();
199+
nodedb_columnar::pk_index::encode_composite_pk(&refs)
200+
}
201+
}

0 commit comments

Comments
 (0)