Skip to content

Commit fbd0bb0

Browse files
committed
feat(nodedb): implement bitemporal read and write in columnar and timeseries handlers
Columnar write: detect bitemporal collections and prepend the three reserved columns (`_ts_system`, `_ts_valid_from`, `_ts_valid_until`) to the schema on first write. `_ts_system` is always stamped with the current wall-clock time; `_ts_valid_from` / `_ts_valid_until` are taken from the client payload or default to the open interval `[i64::MIN, i64::MAX)`. Clients cannot supply `_ts_system`. Columnar read: resolve reserved column positions once per scan, then apply `bitemporal_row_visible` to each memtable row. The predicate enforces the system-time cutoff and valid-time point filter before any user-supplied predicates run. Timeseries scan: translate `system_as_of_ms` and `valid_at_ms` into `ScanFilter` entries (`_ts_system <= cutoff`, `_ts_valid_from <= point`, `_ts_valid_until > point`) so the segment reader's block-skip infrastructure automatically prunes blocks that cannot satisfy the bitemporal predicates. Dispatch: thread `system_as_of_ms` / `valid_at_ms` from the physical plan into both handler param structs.
1 parent 8975153 commit fbd0bb0

5 files changed

Lines changed: 214 additions & 10 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,8 @@ impl CoreLoop {
462462
filters,
463463
rls_filters,
464464
sort_keys,
465+
system_as_of_ms,
466+
valid_at_ms,
465467
}) => self.execute_columnar_scan(
466468
task,
467469
super::super::handlers::columnar_read::ColumnarScanParams {
@@ -471,6 +473,8 @@ impl CoreLoop {
471473
filters,
472474
rls_filters,
473475
sort_keys,
476+
system_as_of_ms: *system_as_of_ms,
477+
valid_at_ms: *valid_at_ms,
474478
},
475479
),
476480

@@ -510,6 +514,8 @@ impl CoreLoop {
510514
aggregates,
511515
gap_fill,
512516
computed_columns,
517+
system_as_of_ms,
518+
valid_at_ms,
513519
..
514520
}) => self.execute_timeseries_scan(
515521
super::super::handlers::timeseries::TimeseriesScanParams {
@@ -524,6 +530,8 @@ impl CoreLoop {
524530
aggregates,
525531
gap_fill,
526532
computed_columns,
533+
system_as_of_ms: *system_as_of_ms,
534+
valid_at_ms: *valid_at_ms,
527535
},
528536
),
529537

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ pub(in crate::data::executor) struct ColumnarScanParams<'a> {
2323
#[allow(dead_code)]
2424
pub rls_filters: &'a [u8],
2525
pub sort_keys: &'a [(String, bool)],
26+
/// Bitemporal system-time cutoff: drop rows with `_ts_system > cutoff`.
27+
/// `None` is a current-state read (no filter applied).
28+
pub system_as_of_ms: Option<i64>,
29+
/// Bitemporal valid-time point: drop rows whose
30+
/// `[_ts_valid_from, _ts_valid_until)` interval does not contain this
31+
/// point. `None` skips valid-time filtering entirely.
32+
pub valid_at_ms: Option<i64>,
2633
}
2734

2835
impl CoreLoop {
@@ -39,6 +46,8 @@ impl CoreLoop {
3946
filters,
4047
rls_filters: _,
4148
sort_keys,
49+
system_as_of_ms,
50+
valid_at_ms,
4251
} = params;
4352
let limit = if limit == 0 { 1000 } else { limit };
4453

@@ -86,7 +95,33 @@ impl CoreLoop {
8695
} else {
8796
usize::MAX
8897
};
98+
// Resolve hidden bitemporal column positions once; `None` means
99+
// the collection is not bitemporal, so the per-row filter is a
100+
// no-op regardless of `system_as_of_ms` / `valid_at_ms` values.
101+
let ts_system_idx = schema
102+
.columns
103+
.iter()
104+
.position(|c| c.name == "_ts_system");
105+
let ts_valid_from_idx = schema
106+
.columns
107+
.iter()
108+
.position(|c| c.name == "_ts_valid_from");
109+
let ts_valid_until_idx = schema
110+
.columns
111+
.iter()
112+
.position(|c| c.name == "_ts_valid_until");
113+
89114
for row in engine.scan_memtable_rows().take(scan_budget) {
115+
if !bitemporal_row_visible(
116+
&row,
117+
ts_system_idx,
118+
ts_valid_from_idx,
119+
ts_valid_until_idx,
120+
system_as_of_ms,
121+
valid_at_ms,
122+
) {
123+
continue;
124+
}
90125
if !filter_predicates.is_empty()
91126
&& !row_matches_filters(&row, schema, &filter_predicates)
92127
{
@@ -345,3 +380,60 @@ pub(in crate::data::executor) fn emit_column_value(
345380
}
346381
}
347382
}
383+
384+
/// Bitemporal row-level visibility predicate.
385+
///
386+
/// - `system_as_of_ms`: when `Some`, any row whose `_ts_system` value
387+
/// exceeds the cutoff is hidden (write happened after the query's
388+
/// system-time horizon).
389+
/// - `valid_at_ms`: when `Some`, the row's
390+
/// `[_ts_valid_from, _ts_valid_until)` interval must contain this
391+
/// point.
392+
///
393+
/// Rows from non-bitemporal collections have no `_ts_system` column and
394+
/// all three indices are `None`; the function returns `true` unconditionally.
395+
fn bitemporal_row_visible(
396+
row: &[nodedb_types::value::Value],
397+
ts_system_idx: Option<usize>,
398+
ts_valid_from_idx: Option<usize>,
399+
ts_valid_until_idx: Option<usize>,
400+
system_as_of_ms: Option<i64>,
401+
valid_at_ms: Option<i64>,
402+
) -> bool {
403+
use nodedb_types::value::Value;
404+
if let Some(cutoff) = system_as_of_ms
405+
&& let Some(idx) = ts_system_idx
406+
&& let Some(v) = row.get(idx)
407+
{
408+
let ts = match v {
409+
Value::Integer(i) => *i,
410+
Value::DateTime(dt) => dt.micros / 1000,
411+
_ => return false,
412+
};
413+
if ts > cutoff {
414+
return false;
415+
}
416+
}
417+
if let Some(point) = valid_at_ms {
418+
let vf = ts_valid_from_idx
419+
.and_then(|i| row.get(i))
420+
.and_then(|v| match v {
421+
Value::Integer(i) => Some(*i),
422+
Value::DateTime(dt) => Some(dt.micros / 1000),
423+
_ => None,
424+
})
425+
.unwrap_or(i64::MIN);
426+
let vu = ts_valid_until_idx
427+
.and_then(|i| row.get(i))
428+
.and_then(|v| match v {
429+
Value::Integer(i) => Some(*i),
430+
Value::DateTime(dt) => Some(dt.micros / 1000),
431+
_ => None,
432+
})
433+
.unwrap_or(i64::MAX);
434+
if point < vf || point >= vu {
435+
return false;
436+
}
437+
}
438+
true
439+
}

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

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,16 @@ impl CoreLoop {
6363
}
6464

6565
let engine_key = (task.request.tenant_id, collection.to_string());
66+
let tid = task.request.tenant_id.as_u32();
67+
let bitemporal = self.is_bitemporal(tid, collection);
6668
// Ensure MutationEngine exists (auto-create on first write).
6769
if !self.columnar_engines.contains_key(&engine_key) {
68-
let schema = infer_schema_from_value(&ndb_rows[0]);
70+
let base_schema = infer_schema_from_value(&ndb_rows[0]);
71+
let schema = if bitemporal {
72+
prepend_bitemporal_columns(base_schema)
73+
} else {
74+
base_schema
75+
};
6976
let engine = MutationEngine::new(collection.to_string(), schema);
7077
self.columnar_engines.insert(engine_key.clone(), engine);
7178
}
@@ -89,11 +96,33 @@ impl CoreLoop {
8996
_ => continue,
9097
};
9198

92-
// Build Value slice in schema order.
99+
// Build Value slice in schema order. For bitemporal
100+
// collections, the three reserved columns are auto-populated
101+
// when absent from the user payload: `_ts_system` is always
102+
// clamped to the current wall-clock time (clients cannot
103+
// forge system time), `_ts_valid_from` / `_ts_valid_until`
104+
// default to the open interval `[i64::MIN, i64::MAX)` if
105+
// missing.
106+
let sys_now = if bitemporal {
107+
self.bitemporal_now_ms()
108+
} else {
109+
0
110+
};
93111
let values: Vec<Value> = schema
94112
.columns
95113
.iter()
96-
.map(|col| ndb_field_to_value(obj.get(&col.name), &col.column_type))
114+
.map(|col| match col.name.as_str() {
115+
"_ts_system" if bitemporal => Value::Integer(sys_now),
116+
"_ts_valid_from" if bitemporal => match obj.get("_ts_valid_from") {
117+
Some(Value::Integer(i)) => Value::Integer(*i),
118+
_ => Value::Integer(i64::MIN),
119+
},
120+
"_ts_valid_until" if bitemporal => match obj.get("_ts_valid_until") {
121+
Some(Value::Integer(i)) => Value::Integer(*i),
122+
_ => Value::Integer(i64::MAX),
123+
},
124+
_ => ndb_field_to_value(obj.get(&col.name), &col.column_type),
125+
})
97126
.collect();
98127

99128
// Resolve the actual row to write (merged for ON CONFLICT DO
@@ -347,8 +376,19 @@ impl CoreLoop {
347376
obj: &serde_json::Map<String, serde_json::Value>,
348377
) {
349378
let engine_key = (crate::types::TenantId::new(tid), collection.to_string());
379+
let bitemporal = self.is_bitemporal(tid, collection);
380+
let sys_now = if bitemporal {
381+
self.bitemporal_now_ms()
382+
} else {
383+
0
384+
};
350385
if !self.columnar_engines.contains_key(&engine_key) {
351-
let schema = infer_schema_from_json(&serde_json::Value::Object(obj.clone()));
386+
let base_schema = infer_schema_from_json(&serde_json::Value::Object(obj.clone()));
387+
let schema = if bitemporal {
388+
prepend_bitemporal_columns(base_schema)
389+
} else {
390+
base_schema
391+
};
352392
let engine = MutationEngine::new(collection.to_string(), schema);
353393
self.columnar_engines.insert(engine_key.clone(), engine);
354394
}
@@ -365,7 +405,18 @@ impl CoreLoop {
365405
let values: Vec<Value> = schema
366406
.columns
367407
.iter()
368-
.map(|col| ndb_field_to_value(ndb_obj.get(&col.name), &col.column_type))
408+
.map(|col| match col.name.as_str() {
409+
"_ts_system" if bitemporal => Value::Integer(sys_now),
410+
"_ts_valid_from" if bitemporal => match ndb_obj.get("_ts_valid_from") {
411+
Some(Value::Integer(i)) => Value::Integer(*i),
412+
_ => Value::Integer(i64::MIN),
413+
},
414+
"_ts_valid_until" if bitemporal => match ndb_obj.get("_ts_valid_until") {
415+
Some(Value::Integer(i)) => Value::Integer(*i),
416+
_ => Value::Integer(i64::MAX),
417+
},
418+
_ => ndb_field_to_value(ndb_obj.get(&col.name), &col.column_type),
419+
})
369420
.collect();
370421

371422
let _ = engine.insert(&values);
@@ -497,6 +548,20 @@ fn infer_schema_from_value(row: &Value) -> ColumnarSchema {
497548
ColumnarSchema::new(columns).expect("inferred schema must be valid")
498549
}
499550

551+
/// Prepend the three reserved bitemporal columns (`_ts_system`,
552+
/// `_ts_valid_from`, `_ts_valid_until`) at positions 0/1/2 of a columnar
553+
/// schema. All three are required Int64; `_ts_system` is engine-stamped
554+
/// on every write, the valid-time pair is client-provided (or defaults
555+
/// to the open interval).
556+
fn prepend_bitemporal_columns(base: ColumnarSchema) -> ColumnarSchema {
557+
let mut cols = Vec::with_capacity(3 + base.columns.len());
558+
cols.push(ColumnDef::required("_ts_system", ColumnType::Int64));
559+
cols.push(ColumnDef::required("_ts_valid_from", ColumnType::Int64));
560+
cols.push(ColumnDef::required("_ts_valid_until", ColumnType::Int64));
561+
cols.extend(base.columns);
562+
ColumnarSchema::new(cols).expect("bitemporal columnar schema must be valid")
563+
}
564+
500565
/// Infer a columnar schema from a JSON object — used by the spatial insert path.
501566
pub(super) fn infer_schema_from_json(row: &serde_json::Value) -> ColumnarSchema {
502567
let ndb: Value = row.clone().into();

nodedb/src/data/executor/handlers/timeseries/mod.rs

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ pub(in crate::data::executor) struct TimeseriesScanParams<'a> {
2727
pub gap_fill: &'a str,
2828
/// Serialized computed columns for scalar projection expressions.
2929
pub computed_columns: &'a [u8],
30+
/// Bitemporal system-time cutoff: rows whose `_ts_system` exceeds
31+
/// this cutoff are hidden. `None` on non-bitemporal collections.
32+
pub system_as_of_ms: Option<i64>,
33+
/// Bitemporal valid-time point. `None` skips valid-time filtering.
34+
pub valid_at_ms: Option<i64>,
3035
}
3136

3237
impl CoreLoop {
@@ -52,16 +57,48 @@ impl CoreLoop {
5257
aggregates,
5358
gap_fill,
5459
computed_columns,
60+
system_as_of_ms,
61+
valid_at_ms,
5562
} = params;
5663

5764
// Lazy-load partition registry from disk if not yet loaded.
5865
self.ensure_ts_registry(tid, collection);
5966

60-
let filter_predicates: Vec<crate::bridge::scan_filter::ScanFilter> = if filters.is_empty() {
61-
Vec::new()
62-
} else {
63-
zerompk::from_msgpack(filters).unwrap_or_default()
64-
};
67+
let mut filter_predicates: Vec<crate::bridge::scan_filter::ScanFilter> =
68+
if filters.is_empty() {
69+
Vec::new()
70+
} else {
71+
zerompk::from_msgpack(filters).unwrap_or_default()
72+
};
73+
// Bitemporal cutoffs: translate to column-level predicates on
74+
// `_ts_system` / `_ts_valid_from` / `_ts_valid_until`. The
75+
// segment reader's block-skip infrastructure applies these
76+
// against per-block min/max automatically.
77+
if let Some(cutoff) = system_as_of_ms {
78+
filter_predicates.push(crate::bridge::scan_filter::ScanFilter {
79+
field: "_ts_system".into(),
80+
op: crate::bridge::scan_filter::FilterOp::Lte,
81+
value: nodedb_types::Value::Integer(cutoff),
82+
clauses: Vec::new(),
83+
expr: None,
84+
});
85+
}
86+
if let Some(point) = valid_at_ms {
87+
filter_predicates.push(crate::bridge::scan_filter::ScanFilter {
88+
field: "_ts_valid_from".into(),
89+
op: crate::bridge::scan_filter::FilterOp::Lte,
90+
value: nodedb_types::Value::Integer(point),
91+
clauses: Vec::new(),
92+
expr: None,
93+
});
94+
filter_predicates.push(crate::bridge::scan_filter::ScanFilter {
95+
field: "_ts_valid_until".into(),
96+
op: crate::bridge::scan_filter::FilterOp::Gt,
97+
value: nodedb_types::Value::Integer(point),
98+
clauses: Vec::new(),
99+
expr: None,
100+
});
101+
}
65102
let has_filters = !filter_predicates.is_empty();
66103
let is_aggregate = !aggregates.is_empty();
67104
let has_time_range = time_range.0 > 0 || time_range.1 < i64::MAX;

nodedb/src/event/alert/executor.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ async fn execute_aggregate_scan(
172172
gap_fill: String::new(),
173173
computed_columns: Vec::new(),
174174
rls_filters: Vec::new(),
175+
system_as_of_ms: None,
176+
valid_at_ms: None,
175177
});
176178

177179
let payload = sync_dispatch::dispatch_async(

0 commit comments

Comments
 (0)