Skip to content

Commit 8efe557

Browse files
committed
feat(engine): implement bitemporal temporal-purge across storage engines
Add per-engine purge logic that drops superseded bitemporal row versions (those with finite system_from_ms) once they fall outside the configured audit-retain window, while never removing the single surviving current version of any key. - Graph edge store (`edge_store/temporal/purge.rs`): scans versioned edge rows, purges stale non-current versions in keyed batches. - Sparse btree-versioned (`btree_versioned/purge.rs`): purges stale document-strict and metadata versions from redb versioned tables. - Columnar (`mutation/engine.rs`): expose `delete_bitmap_mut`, `memtable_segment_id`, and `pk_col_indices` accessors used by the columnar purge path to tombstone superseded segment rows without touching the main write pipeline. - New `engine/bitemporal/` module: `BitemporalRetentionRegistry` (per-collection policy store) and `enforcement` loop that computes cutoffs and dispatches `MetaOp::TemporalPurge*` plans.
1 parent 8ccecfb commit 8efe557

9 files changed

Lines changed: 1101 additions & 0 deletions

File tree

nodedb-columnar/src/mutation/engine.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,24 @@ impl MutationEngine {
9494
self.delete_bitmaps.get(&segment_id)
9595
}
9696

97+
/// Mutable access to a segment's delete bitmap. Creates an empty one
98+
/// on first access so callers can `mark_deleted_batch` unconditionally.
99+
/// Used by temporal-purge paths that tombstone superseded row positions
100+
/// without going through the single-row `insert` / `delete` paths.
101+
pub fn delete_bitmap_mut(&mut self, segment_id: u32) -> &mut DeleteBitmap {
102+
self.delete_bitmaps.entry(segment_id).or_default()
103+
}
104+
105+
/// The virtual segment id used for rows still in the memtable.
106+
pub fn memtable_segment_id(&self) -> u32 {
107+
self.memtable_segment_id
108+
}
109+
110+
/// The schema's primary-key column indices, in schema order.
111+
pub fn pk_col_indices(&self) -> &[usize] {
112+
&self.pk_col_indices
113+
}
114+
97115
/// Access all delete bitmaps.
98116
pub fn delete_bitmaps(&self) -> &HashMap<u32, DeleteBitmap> {
99117
&self.delete_bitmaps
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
//! Bitemporal audit-retention enforcement loop.
2+
//!
3+
//! Runs on the Event Plane (Tokio, `Send + Sync`). Every tick:
4+
//!
5+
//! 1. Snapshot the [`BitemporalRetentionRegistry`].
6+
//! 2. For each entry with `audit_retain_ms > 0`, compute
7+
//! `cutoff_system_ms = now - audit_retain_ms`.
8+
//! 3. Dispatch the appropriate `MetaOp::TemporalPurge{EdgeStore,
9+
//! DocumentStrict, Columnar}` to the owning Data Plane core via the
10+
//! shared `sync_dispatch` async path.
11+
//! 4. On success, parse the returned purge count from the response
12+
//! payload and append a `RecordType::TemporalPurge` record to the
13+
//! WAL for durable audit.
14+
//!
15+
//! NEVER does storage I/O directly — all physical work happens in the
16+
//! Data Plane. The only persistent side-effect emitted from this loop
17+
//! is the WAL audit record.
18+
19+
use std::sync::Arc;
20+
use std::time::Duration;
21+
22+
use tokio::sync::watch;
23+
use tracing::{info, warn};
24+
25+
use super::registry::{BitemporalEngineKind, BitemporalRetentionRegistry, Entry};
26+
use crate::bridge::envelope::PhysicalPlan;
27+
use crate::bridge::physical_plan::MetaOp;
28+
use crate::control::state::SharedState;
29+
30+
/// Default tick interval when no shorter deadline is needed. One hour
31+
/// matches the timeseries retention loop's default; operators can lower
32+
/// by providing a shorter `tick_interval`.
33+
const DEFAULT_TICK_MS: u64 = 3_600_000;
34+
35+
/// Dispatch deadline per purge op. Purges can scan many segments /
36+
/// redb ranges; a 30-second bound matches the existing retention path.
37+
const DISPATCH_DEADLINE_SECS: u64 = 30;
38+
39+
/// Startup delay so the loop doesn't race the Data Plane warm-up.
40+
const STARTUP_DELAY_SECS: u64 = 10;
41+
42+
/// Spawn the bitemporal-retention enforcement loop as a background
43+
/// Tokio task. Returns a `JoinHandle` for shutdown coordination.
44+
pub fn spawn_bitemporal_retention_loop(
45+
shared_state: Arc<SharedState>,
46+
registry: Arc<BitemporalRetentionRegistry>,
47+
shutdown: watch::Receiver<bool>,
48+
tick_interval: Option<Duration>,
49+
) -> tokio::task::JoinHandle<()> {
50+
let tick = tick_interval.unwrap_or(Duration::from_millis(DEFAULT_TICK_MS));
51+
tokio::spawn(async move {
52+
enforcement_loop(shared_state, registry, shutdown, tick).await;
53+
})
54+
}
55+
56+
async fn enforcement_loop(
57+
state: Arc<SharedState>,
58+
registry: Arc<BitemporalRetentionRegistry>,
59+
mut shutdown: watch::Receiver<bool>,
60+
tick: Duration,
61+
) {
62+
tokio::time::sleep(Duration::from_secs(STARTUP_DELAY_SECS)).await;
63+
64+
loop {
65+
tokio::select! {
66+
_ = tokio::time::sleep(tick) => {}
67+
_ = shutdown.changed() => {
68+
if *shutdown.borrow() {
69+
info!("bitemporal retention loop shutting down");
70+
return;
71+
}
72+
}
73+
}
74+
75+
let entries = registry.snapshot();
76+
if entries.is_empty() {
77+
continue;
78+
}
79+
for entry in entries {
80+
run_one(&state, &entry).await;
81+
}
82+
}
83+
}
84+
85+
async fn run_one(state: &Arc<SharedState>, entry: &Entry) {
86+
let audit_ms = entry.retention.audit_retain_ms;
87+
if audit_ms == 0 {
88+
return; // "retain forever" — no purge.
89+
}
90+
let now_ms = std::time::SystemTime::now()
91+
.duration_since(std::time::UNIX_EPOCH)
92+
.unwrap_or_default()
93+
.as_millis() as i64;
94+
let cutoff_system_ms = now_ms.saturating_sub(audit_ms as i64);
95+
96+
let tenant_id = entry.tenant_id;
97+
let plan = match entry.engine {
98+
BitemporalEngineKind::EdgeStore => PhysicalPlan::Meta(MetaOp::TemporalPurgeEdgeStore {
99+
tenant_id: tenant_id.as_u32(),
100+
collection: entry.collection.clone(),
101+
cutoff_system_ms,
102+
}),
103+
BitemporalEngineKind::DocumentStrict => {
104+
PhysicalPlan::Meta(MetaOp::TemporalPurgeDocumentStrict {
105+
tenant_id: tenant_id.as_u32(),
106+
collection: entry.collection.clone(),
107+
cutoff_system_ms,
108+
})
109+
}
110+
BitemporalEngineKind::Columnar => PhysicalPlan::Meta(MetaOp::TemporalPurgeColumnar {
111+
tenant_id: tenant_id.as_u32(),
112+
collection: entry.collection.clone(),
113+
cutoff_system_ms,
114+
}),
115+
};
116+
117+
match crate::control::server::pgwire::ddl::sync_dispatch::dispatch_async(
118+
state,
119+
tenant_id,
120+
&entry.collection,
121+
plan,
122+
Duration::from_secs(DISPATCH_DEADLINE_SECS),
123+
)
124+
.await
125+
{
126+
Ok(payload) => {
127+
let purged = parse_count_from_payload(entry.engine, &payload);
128+
if purged > 0
129+
&& let Err(e) = state.wal.append_temporal_purge(
130+
tenant_id,
131+
entry.engine.wire_tag(),
132+
&entry.collection,
133+
cutoff_system_ms,
134+
purged,
135+
)
136+
{
137+
warn!(
138+
tenant = tenant_id.as_u32(),
139+
collection = %entry.collection,
140+
error = %e,
141+
"temporal-purge wal append failed"
142+
);
143+
}
144+
if purged > 0 {
145+
info!(
146+
tenant = tenant_id.as_u32(),
147+
collection = %entry.collection,
148+
engine = ?entry.engine,
149+
purged,
150+
cutoff_ms = cutoff_system_ms,
151+
"bitemporal audit-retention purge"
152+
);
153+
}
154+
}
155+
Err(e) => {
156+
warn!(
157+
tenant = tenant_id.as_u32(),
158+
collection = %entry.collection,
159+
engine = ?entry.engine,
160+
error = %e,
161+
"bitemporal temporal-purge dispatch failed"
162+
);
163+
}
164+
}
165+
}
166+
167+
/// Decode the purge count from a response payload.
168+
///
169+
/// EdgeStore / Columnar → 8 bytes LE u64 (count).
170+
/// DocumentStrict → 16 bytes: docs LE u64 ++ index-entries LE u64; we
171+
/// sum them for the audit-record count so operators see total rows
172+
/// reclaimed across the two versioned tables.
173+
fn parse_count_from_payload(engine: BitemporalEngineKind, payload: &[u8]) -> u64 {
174+
match engine {
175+
BitemporalEngineKind::EdgeStore | BitemporalEngineKind::Columnar => {
176+
if payload.len() >= 8 {
177+
u64::from_le_bytes(payload[..8].try_into().unwrap_or([0; 8]))
178+
} else {
179+
0
180+
}
181+
}
182+
BitemporalEngineKind::DocumentStrict => {
183+
if payload.len() >= 16 {
184+
let docs = u64::from_le_bytes(payload[..8].try_into().unwrap_or([0; 8]));
185+
let idx = u64::from_le_bytes(payload[8..16].try_into().unwrap_or([0; 8]));
186+
docs.saturating_add(idx)
187+
} else {
188+
0
189+
}
190+
}
191+
}
192+
}
193+
194+
#[cfg(test)]
195+
mod tests {
196+
use super::*;
197+
198+
#[test]
199+
fn parse_count_edgestore_8_bytes() {
200+
let payload = 42u64.to_le_bytes().to_vec();
201+
assert_eq!(
202+
parse_count_from_payload(BitemporalEngineKind::EdgeStore, &payload),
203+
42
204+
);
205+
}
206+
207+
#[test]
208+
fn parse_count_columnar_8_bytes() {
209+
let payload = 7u64.to_le_bytes().to_vec();
210+
assert_eq!(
211+
parse_count_from_payload(BitemporalEngineKind::Columnar, &payload),
212+
7
213+
);
214+
}
215+
216+
#[test]
217+
fn parse_count_document_strict_sums_docs_and_idx() {
218+
let mut payload = Vec::with_capacity(16);
219+
payload.extend_from_slice(&5u64.to_le_bytes());
220+
payload.extend_from_slice(&11u64.to_le_bytes());
221+
assert_eq!(
222+
parse_count_from_payload(BitemporalEngineKind::DocumentStrict, &payload),
223+
16
224+
);
225+
}
226+
227+
#[test]
228+
fn parse_count_truncated_returns_zero() {
229+
assert_eq!(
230+
parse_count_from_payload(BitemporalEngineKind::EdgeStore, &[1, 2, 3]),
231+
0
232+
);
233+
assert_eq!(
234+
parse_count_from_payload(BitemporalEngineKind::DocumentStrict, &[1; 8]),
235+
0
236+
);
237+
}
238+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
//! Bitemporal audit-retention: per-collection configuration registry and
2+
//! background enforcement loop that purges superseded versions older than
3+
//! each collection's `audit_retain_ms` window, while preserving the single
4+
//! latest version of every logical row.
5+
//!
6+
//! Population of the registry is the DDL layer's responsibility (e.g.
7+
//! `CREATE COLLECTION ... WITH BITEMPORAL RETENTION (AUDIT_RETAIN = '7d')`).
8+
//! Enforcement runs on the Event Plane as a Tokio background loop and
9+
//! dispatches `MetaOp::TemporalPurge{EdgeStore,DocumentStrict,Columnar}`
10+
//! to the owning Data Plane core. Successful dispatches emit a durable
11+
//! `RecordType::TemporalPurge` audit record via `WalManager`.
12+
13+
pub mod enforcement;
14+
pub mod registry;
15+
16+
pub use enforcement::spawn_bitemporal_retention_loop;
17+
pub use registry::{BitemporalEngineKind, BitemporalRetentionRegistry, RegisterError};

0 commit comments

Comments
 (0)