Skip to content

Commit 00fb860

Browse files
committed
feat(nodedb): use system-time axis for bitemporal retention
Bitemporal partitions now evaluate retention staleness against `max_system_ts` (the maximum `_ts_system` value written to the partition) rather than `max_ts` (event-time). This prevents late-arriving backfill rows from being prematurely expired when their event-time predates the retention cutoff but their write-time does not. Non-bitemporal partitions are unaffected: `max_system_ts` defaults to 0 and the existing `max_ts` path is taken. Changes: - Add `max_system_ts` field to `PartitionMeta` (serde default = 0 for backward-compatible deserialization) - Populate `max_system_ts` on memtable drain by scanning the `_ts_system` column; propagate through segment writer, O3 merge, and partition merge - Add `is_bitemporal()` / `ts_system_idx()` helpers to `ColumnarSchema` and `ColumnarMemtableSchema` - Thread `bitemporal: bool` into `find_expired` and the compaction / retention-scan paths in the executor - Skip upsert-tombstone in columnar write for bitemporal collections so every version of a PK is preserved for `AS OF` queries
1 parent 772e04d commit 00fb860

14 files changed

Lines changed: 123 additions & 6 deletions

File tree

nodedb-columnar/src/mutation/write.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,19 @@ impl MutationEngine {
2828
let pk_bytes = self.extract_pk_bytes(values)?;
2929
let mut wal_records = Vec::with_capacity(2);
3030

31+
// Bitemporal collections preserve every version of a PK: each
32+
// write appends a new row with a distinct `_ts_system` stamp and
33+
// the prior row stays visible to `AS OF` queries. Skipping the
34+
// upsert-tombstone here keeps compaction lossless without
35+
// needing a separate "version-aware" delete bitmap. The PK
36+
// index is still rebound below so current-state reads see the
37+
// latest version.
38+
let bitemporal = self.schema.is_bitemporal();
39+
3140
// If a prior row exists for this PK, tombstone it in place so
3241
// subsequent scans skip the stale row. The PK index is rebound
3342
// below to the freshly-appended row.
34-
if let Some(prior) = self.pk_index.get(&pk_bytes).copied() {
43+
if !bitemporal && let Some(prior) = self.pk_index.get(&pk_bytes).copied() {
3544
let bitmap = self.delete_bitmaps.entry(prior.segment_id).or_default();
3645
bitmap.mark_deleted(prior.row_index);
3746
wal_records.push(ColumnarWalRecord::DeleteRows {

nodedb-types/src/columnar/schema.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,21 @@ impl ColumnarSchema {
305305
version: 1,
306306
})
307307
}
308+
309+
/// Whether this schema has the reserved `_ts_system` bitemporal column.
310+
///
311+
/// Detected by column name rather than a separate flag to keep the
312+
/// on-disk manifest format unchanged; `_ts_system` is only inserted
313+
/// by `prepend_bitemporal_columns` on the write path, so its
314+
/// presence is a reliable bitemporal signal.
315+
pub fn is_bitemporal(&self) -> bool {
316+
self.columns.iter().any(|c| c.name == "_ts_system")
317+
}
318+
319+
/// Position of the `_ts_system` column, or `None` for non-bitemporal.
320+
pub fn ts_system_idx(&self) -> Option<usize> {
321+
self.columns.iter().position(|c| c.name == "_ts_system")
322+
}
308323
}
309324

310325
#[cfg(test)]

nodedb-types/src/timeseries/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ mod tests {
164164
interval_ms: 86_400_000,
165165
last_flushed_wal_lsn: 42,
166166
column_stats: HashMap::new(),
167+
max_system_ts: 0,
167168
};
168169
assert!(meta.is_queryable());
169170
assert!(meta.overlaps(&TimeRange::new(1500, 2500)));
@@ -182,6 +183,7 @@ mod tests {
182183
interval_ms: 0,
183184
last_flushed_wal_lsn: 0,
184185
column_stats: HashMap::new(),
186+
max_system_ts: 0,
185187
};
186188
assert!(!meta.is_queryable());
187189
}

nodedb-types/src/timeseries/partition.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ pub struct PartitionMeta {
6666
/// Per-column statistics (codec, min/max/sum/count/cardinality).
6767
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
6868
pub column_stats: HashMap<String, nodedb_codec::ColumnStatistics>,
69+
/// Maximum `_ts_system` value across rows in this partition
70+
/// (bitemporal only; 0 for non-bitemporal partitions). Retention on
71+
/// bitemporal collections uses this instead of `max_ts` so that
72+
/// late-arriving backfill survives an event-time-based TTL.
73+
#[serde(default)]
74+
pub max_system_ts: i64,
6975
}
7076

7177
impl PartitionMeta {

nodedb/src/data/executor/dispatch/other.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,13 +327,25 @@ impl CoreLoop {
327327
let mut deleted = 0usize;
328328
let ts_base = self.data_dir.join("ts").join(collection.as_str());
329329

330+
// Bitemporal collections key retention on system-time
331+
// (`max_system_ts`) so backfill with old event-time but
332+
// current write-time survives an event-time-based TTL.
333+
// Non-bitemporal partitions have `max_system_ts == 0` and
334+
// fall through to the existing `max_ts` path.
335+
let bitemporal = self.is_bitemporal(task.request.tenant_id.as_u32(), collection);
336+
330337
let ts_key = (task.request.tenant_id, collection.to_string());
331338
if let Some(registry) = self.ts_registries.get_mut(&ts_key) {
332339
// Find partitions older than cutoff.
333340
let expired: Vec<(i64, String)> = registry
334341
.iter()
335342
.filter(|(_, e)| {
336-
e.meta.max_ts < cutoff
343+
let axis_ts = if bitemporal && e.meta.max_system_ts > 0 {
344+
e.meta.max_system_ts
345+
} else {
346+
e.meta.max_ts
347+
};
348+
axis_ts < cutoff
337349
&& e.meta.state != nodedb_types::timeseries::PartitionState::Deleted
338350
})
339351
.map(|(&start, e)| (start, e.dir_name.clone()))

nodedb/src/data/executor/handlers/compact.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,23 @@ impl CoreLoop {
261261
let max_per_pass = self.segment_compaction_config.max_segments_per_pass;
262262
let mut total_merged = 0;
263263

264-
for ((_, collection), registry) in &mut self.ts_registries {
264+
// Snapshot bitemporal flags per collection up front so the
265+
// mutable-borrow loop below can query them without re-borrowing `self`.
266+
let bitemporal_flags: std::collections::HashMap<(crate::types::TenantId, String), bool> =
267+
self.ts_registries
268+
.keys()
269+
.map(|(tid, col)| {
270+
let flag = self.is_bitemporal(tid.as_u32(), col);
271+
((*tid, col.clone()), flag)
272+
})
273+
.collect();
274+
275+
for ((tid, collection), registry) in &mut self.ts_registries {
276+
let bitemporal = bitemporal_flags
277+
.get(&(*tid, collection.clone()))
278+
.copied()
279+
.unwrap_or(false);
280+
265281
// Find mergeable partition groups, limited by config.
266282
let mut groups = registry.find_mergeable(now_ms);
267283
if !force {
@@ -275,7 +291,7 @@ impl CoreLoop {
275291
}
276292

277293
// Retention: find and purge expired partitions.
278-
let expired = registry.find_expired(now_ms);
294+
let expired = registry.find_expired(now_ms, bitemporal);
279295
for start_ts in &expired {
280296
registry.mark_deleted(*start_ts);
281297
}

nodedb/src/engine/timeseries/columnar_memtable.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ impl ColumnarSchema {
6464
.copied()
6565
.unwrap_or(nodedb_codec::ColumnCodec::Auto)
6666
}
67+
68+
/// Index of the reserved `_ts_system` column, or `None` for non-bitemporal.
69+
pub fn ts_system_idx(&self) -> Option<usize> {
70+
self.columns.iter().position(|(n, _)| n == "_ts_system")
71+
}
6772
}
6873

6974
// ---------------------------------------------------------------------------
@@ -455,13 +460,29 @@ impl ColumnarMemtable {
455460
}
456461
}
457462

463+
// Scan `_ts_system` column (if present) for retention's system-time axis.
464+
// For non-bitemporal partitions this is 0 and retention falls through
465+
// to the existing `max_ts` (event-time) path.
466+
let max_system_ts = self
467+
.schema
468+
.ts_system_idx()
469+
.and_then(|idx| drained_columns.get(idx))
470+
.map(|col| match col {
471+
ColumnData::Timestamp(v) | ColumnData::Int64(v) => {
472+
v.iter().copied().max().unwrap_or(0)
473+
}
474+
_ => 0,
475+
})
476+
.unwrap_or(0);
477+
458478
let result = ColumnarDrainResult {
459479
columns: drained_columns,
460480
schema: self.schema.clone(),
461481
symbol_dicts: drained_dicts,
462482
row_count: self.row_count,
463483
min_ts: self.min_ts,
464484
max_ts: self.max_ts,
485+
max_system_ts,
465486
series_row_counts: std::mem::take(&mut self.series_row_counts),
466487
};
467488

@@ -584,6 +605,10 @@ pub struct ColumnarDrainResult {
584605
pub row_count: u64,
585606
pub min_ts: i64,
586607
pub max_ts: i64,
608+
/// Maximum `_ts_system` value across rows (bitemporal only; 0 otherwise).
609+
/// Populated on drain by scanning the `_ts_system` column so retention
610+
/// can evaluate system-time staleness without re-reading the segment.
611+
pub max_system_ts: i64,
587612
pub series_row_counts: HashMap<SeriesId, u64>,
588613
}
589614

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ impl ColumnarSegmentWriter {
9898
interval_ms,
9999
last_flushed_wal_lsn: flush_wal_lsn,
100100
column_stats,
101+
max_system_ts: drain.max_system_ts,
101102
};
102103

103104
let meta_json = sonic_rs::to_vec(&meta)

nodedb/src/engine/timeseries/merge/o3.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ pub fn merge_o3_into_partition(
9999
.max()
100100
.unwrap_or(i64::MIN),
101101
),
102+
// O3 merge preserves the existing partition's system-time max;
103+
// O3Row does not carry `_ts_system` (O3 is a value-only buffer).
104+
max_system_ts: existing_meta.max_system_ts,
102105
series_row_counts: std::collections::HashMap::new(),
103106
};
104107

nodedb/src/engine/timeseries/merge/partitions.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,17 @@ pub fn merge_partitions(
102102
// Write merged partition.
103103
let writer = ColumnarSegmentWriter::new(base_dir);
104104

105+
// Preserve the merged partition's system-time max so retention's
106+
// bitemporal path sees the true latest write time, not just event-time.
107+
let max_system_ts = ref_schema
108+
.ts_system_idx()
109+
.and_then(|idx| merged_columns.get(idx))
110+
.map(|col| match col {
111+
ColumnData::Timestamp(v) | ColumnData::Int64(v) => v.iter().copied().max().unwrap_or(0),
112+
_ => 0,
113+
})
114+
.unwrap_or(0);
115+
105116
// Build a drain-like result for the writer.
106117
let drain = crate::engine::timeseries::columnar_memtable::ColumnarDrainResult {
107118
columns: merged_columns,
@@ -110,6 +121,7 @@ pub fn merge_partitions(
110121
row_count: total_rows,
111122
min_ts: global_min_ts,
112123
max_ts: global_max_ts,
124+
max_system_ts,
113125
series_row_counts: std::collections::HashMap::new(),
114126
};
115127

0 commit comments

Comments
 (0)