Skip to content

Commit 914d565

Browse files
committed
test: update tests for SystemTimeScope and add audit-log and bitemporal cluster coverage
Update all call sites that previously used `system_as_of_ms: None` or `system_as_of: None` to use `system_time: SystemTimeScope::Current`. New tests: - `slice_all_versions_emits_audit_log_ascending` — verifies that three system-time versions of the same array cell are emitted in ascending system-time order when `AllVersions` is requested. - `coord_agg_or_reduces_truncated_before_horizon` — coordinator-level test verifying the OR-reduction of the below-horizon flag across shards for aggregate fan-outs. - `handle_slice_propagates_truncated_before_horizon` — handler test verifying the executor's flag is forwarded into the wire response. - `versioned_scan_all` unit tests in `btree_versioned/tests.rs`. - `cluster_array_agg_as_of_returns_versioned_sum_across_shards` — end-to-end cluster test exercising the `ARRAY_AGG ... AS OF SYSTEM TIME` path across three nodes.
1 parent ab64001 commit 914d565

29 files changed

Lines changed: 459 additions & 63 deletions

nodedb-cluster-tests/tests/bitemporal_array_cluster.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,106 @@ fn parse_int_attr(row: &str) -> i64 {
8080
.unwrap_or_else(|| panic!("cannot extract attrs[0] as i64 from: {row}"))
8181
}
8282

83+
/// Wall-clock milliseconds since the Unix epoch.
84+
fn now_ms() -> i64 {
85+
std::time::SystemTime::now()
86+
.duration_since(std::time::UNIX_EPOCH)
87+
.expect("system time before epoch")
88+
.as_millis() as i64
89+
}
90+
91+
/// Extract the scalar aggregate value from an `ARRAY_AGG(..., 'sum')` result.
92+
///
93+
/// Robust to the exact projection shape: accepts a bare numeric `result`
94+
/// column (`"10"`) or a JSON object carrying a `result` key (`{"result":10}`),
95+
/// and skips any trailing `{"truncated_before_horizon": ...}` summary row that
96+
/// temporal aggregates append (it has no numeric `result`).
97+
async fn scalar_agg_sum(client: &tokio_postgres::Client, sql: &str) -> f64 {
98+
let rows = query_col0(client, sql).await;
99+
rows.iter()
100+
.find_map(|r| {
101+
r.parse::<f64>().ok().or_else(|| {
102+
serde_json::from_str::<serde_json::Value>(r)
103+
.ok()
104+
.and_then(|v| v.get("result").and_then(|x| x.as_f64()))
105+
})
106+
})
107+
.unwrap_or_else(|| panic!("no numeric aggregate result in rows: {rows:?}\n sql: {sql}"))
108+
}
109+
110+
/// Distributed bitemporal **aggregate** across shards.
111+
///
112+
/// Mirrors the slice test but exercises the `ARRAY_AGG ... AS OF SYSTEM TIME`
113+
/// path end-to-end: pgwire → coordinator `coord_agg` fan-out → shard
114+
/// `exec_agg` (which decodes the `(partials, truncated_before_horizon)` tuple)
115+
/// → partial merge → `finalize_agg_partials`. A wire-shape mismatch anywhere
116+
/// on that path (the class of bug that broke this silently when only mock
117+
/// executors were tested) surfaces here as a codec error or a wrong sum.
118+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
119+
async fn cluster_array_agg_as_of_returns_versioned_sum_across_shards() {
120+
let cluster = TestCluster::spawn_three()
121+
.await
122+
.expect("3-node cluster spawn");
123+
124+
let leader_idx = cluster
125+
.exec_ddl_on_any_leader(
126+
"CREATE ARRAY bt3 \
127+
DIMS (x INT64 [0..15]) \
128+
ATTRS (v INT64) \
129+
TILE_EXTENTS (16) \
130+
CELL_ORDER ROW_MAJOR",
131+
)
132+
.await
133+
.expect("CREATE ARRAY bt3");
134+
let client = &cluster.nodes[leader_idx].client;
135+
136+
// v1 = 10 at x=0.
137+
client
138+
.simple_query("INSERT INTO ARRAY bt3 COORDS (0) VALUES (10)")
139+
.await
140+
.expect("insert v1");
141+
client
142+
.simple_query("SELECT ARRAY_FLUSH('bt3')")
143+
.await
144+
.expect("flush v1");
145+
146+
// Capture a cutoff strictly between v1 and v2.
147+
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
148+
let cutoff_ms = now_ms();
149+
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
150+
151+
// v2 = 99 at x=0.
152+
client
153+
.simple_query("INSERT INTO ARRAY bt3 COORDS (0) VALUES (99)")
154+
.await
155+
.expect("insert v2");
156+
client
157+
.simple_query("SELECT ARRAY_FLUSH('bt3')")
158+
.await
159+
.expect("flush v2");
160+
161+
// Live aggregate sees the latest version: sum = v2 = 99.
162+
let live = scalar_agg_sum(client, "SELECT * FROM ARRAY_AGG('bt3', 'v', 'sum')").await;
163+
assert!(
164+
(live - 99.0).abs() < 1e-4,
165+
"live agg sum must be v2=99, got {live}"
166+
);
167+
168+
// AS OF between versions: the distributed aggregate must resolve the cell
169+
// ceiling to v1 on every shard and sum to 10.
170+
let as_of = scalar_agg_sum(
171+
client,
172+
&format!("SELECT * FROM ARRAY_AGG('bt3', 'v', 'sum') AS OF SYSTEM TIME {cutoff_ms}"),
173+
)
174+
.await;
175+
assert!(
176+
(as_of - 10.0).abs() < 1e-4,
177+
"AS OF SYSTEM TIME {cutoff_ms} agg sum must be v1=10, got {as_of}"
178+
);
179+
180+
cluster.shutdown().await;
181+
}
182+
83183
/// Bring up a 3-node cluster, write v1 then v2 of a cell at coord `x=0`,
84184
/// capturing the system-time cutoff between the two writes. Assert that
85185
/// every node returns v1 when queried `AS OF SYSTEM TIME <cutoff>`.

nodedb-cluster-tests/tests/cluster_procedures.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ fn procedure_reads_not_replicated() {
5252
projection: vec![],
5353
computed_columns: vec![],
5454
window_functions: vec![],
55-
system_as_of_ms: None,
55+
system_time: nodedb_types::SystemTimeScope::Current,
5656
valid_at_ms: None,
5757
prefilter: None,
5858
});

nodedb-cluster-tests/tests/cluster_triggers.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ fn read_ops_not_replicated() {
204204
collection: "orders".into(),
205205
document_id: "doc-1".into(),
206206
rls_filters: vec![],
207-
system_as_of_ms: None,
207+
system_time: nodedb_types::SystemTimeScope::Current,
208208
valid_at_ms: None,
209209
surrogate: nodedb_types::Surrogate::ZERO,
210210
pk_bytes: Vec::new(),

nodedb/src/data/executor/dispatch/array/tests_dispatch.rs

Lines changed: 201 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,11 @@ fn json_to_value(v: serde_json::Value) -> Value {
214214
.cloned()
215215
.map(json_to_value)
216216
.collect();
217-
Value::ArrayCell(ArrayCell { coords, attrs })
217+
Value::ArrayCell(ArrayCell {
218+
coords,
219+
attrs,
220+
system_time: None,
221+
})
218222
}
219223
serde_json::Value::Number(n) => {
220224
if let Some(i) = n.as_i64() {
@@ -264,7 +268,7 @@ fn slice_returns_only_cells_in_range() {
264268
limit: 0,
265269
cell_filter: None,
266270
hilbert_range: None,
267-
system_as_of: None,
271+
system_time: nodedb_types::SystemTimeScope::Current,
268272
valid_at_ms: None,
269273
});
270274
assert_eq!(r.status, Status::Ok, "slice failed: {r:?}");
@@ -283,6 +287,79 @@ fn slice_returns_only_cells_in_range() {
283287
assert!((sums - 7.0).abs() < 1e-9);
284288
}
285289

290+
#[test]
291+
fn slice_all_versions_emits_audit_log_ascending() {
292+
let mut h = Harness::new();
293+
let s = schema_2d_f64("t6_audit");
294+
let aid = ArrayId::new(TenantId::new(1), "t6_audit");
295+
h.open(&aid, &s, 0xA7);
296+
297+
// Three system-time versions of the same cell (0,0), each sealed into
298+
// its own segment so they are distinct retained tile-versions.
299+
let mk = |v: f64, sys: i64| ArrayPutCell {
300+
coord: vec![CoordValue::Int64(0), CoordValue::Int64(0)],
301+
attrs: vec![CellValue::Float64(v)],
302+
surrogate: Surrogate::ZERO,
303+
system_from_ms: sys,
304+
valid_from_ms: 0,
305+
valid_until_ms: i64::MAX,
306+
};
307+
h.put(&aid, vec![mk(1.0, 100)], 1);
308+
h.flush(&aid);
309+
h.put(&aid, vec![mk(2.0, 200)], 2);
310+
h.flush(&aid);
311+
h.put(&aid, vec![mk(3.0, 300)], 3);
312+
h.flush(&aid);
313+
314+
let slice = ArraySlice::new(vec![None, None]);
315+
let slice_bytes = zerompk::to_msgpack_vec(&slice).unwrap();
316+
let r = h.send(ArrayOp::Slice {
317+
array_id: aid.clone(),
318+
slice_msgpack: slice_bytes,
319+
attr_projection: vec![],
320+
limit: 0,
321+
cell_filter: None,
322+
hilbert_range: None,
323+
system_time: nodedb_types::SystemTimeScope::AllVersions,
324+
valid_at_ms: None,
325+
});
326+
assert_eq!(r.status, Status::Ok, "all-versions slice failed: {r:?}");
327+
328+
// Read rows as raw JSON: `json_to_value` drops the injected `_ts_system`
329+
// column, so inspect the envelope directly.
330+
use crate::data::executor::response_codec::ArraySliceResponse;
331+
let env: ArraySliceResponse =
332+
zerompk::from_msgpack(r.payload.as_ref()).expect("ArraySliceResponse envelope");
333+
assert!(
334+
!env.truncated_before_horizon,
335+
"AllVersions applies no system-time horizon"
336+
);
337+
let json = nodedb_types::msgpack_to_json_string(&env.rows_msgpack).expect("rows msgpack→json");
338+
let rows: Vec<serde_json::Value> = serde_json::from_str(&json).expect("rows json parse");
339+
340+
let got: Vec<(i64, f64)> = rows
341+
.iter()
342+
.map(|row| {
343+
let ts = row
344+
.get("_ts_system")
345+
.and_then(|v| v.as_i64())
346+
.expect("_ts_system column present on audit-log rows");
347+
let v = row
348+
.get("attrs")
349+
.and_then(|a| a.as_array())
350+
.and_then(|a| a.first())
351+
.and_then(|v| v.as_f64())
352+
.expect("attr value");
353+
(ts, v)
354+
})
355+
.collect();
356+
assert_eq!(
357+
got,
358+
vec![(100, 1.0), (200, 2.0), (300, 3.0)],
359+
"audit log must return every version ascending by system time"
360+
);
361+
}
362+
286363
#[test]
287364
fn aggregate_sum_scalar_across_multiple_tiles() {
288365
let mut h = Harness::new();
@@ -317,6 +394,127 @@ fn aggregate_sum_scalar_across_multiple_tiles() {
317394
assert!((f - 10.0).abs() < 1e-9, "sum got {f}");
318395
}
319396

397+
#[test]
398+
fn aggregate_return_partial_emits_tuple_wire_shape() {
399+
// Distributed shard aggregates set `return_partial = true`. The Data Plane
400+
// MUST emit the `(partials, truncated_before_horizon)` tuple — the same
401+
// shape the bitemporal path uses — so the cluster `exec_agg` (which always
402+
// decodes a tuple) can read non-temporal aggregates too. A bare `Vec` here
403+
// (the old `encode_partials` shape) would fail that decode.
404+
let mut h = Harness::new();
405+
let s = schema_2d_f64("t6_partial");
406+
let aid = ArrayId::new(TenantId::new(1), "t6_partial");
407+
h.open(&aid, &s, 0xA9);
408+
h.put(&aid, vec![cell(0, 0, 1.0), cell(1, 1, 2.0)], 1);
409+
410+
let r = h.send(ArrayOp::Aggregate {
411+
array_id: aid.clone(),
412+
attr_idx: 0,
413+
reducer: ArrayReducer::Sum,
414+
group_by_dim: -1,
415+
cell_filter: None,
416+
return_partial: true,
417+
hilbert_range: None,
418+
system_as_of: None,
419+
valid_at_ms: None,
420+
});
421+
assert_eq!(r.status, Status::Ok, "agg failed: {r:?}");
422+
423+
// Must decode as a 2-tuple (partials, flag), NOT a bare Vec.
424+
let (partials, truncated): (
425+
Vec<nodedb_cluster::distributed_array::merge::ArrayAggPartial>,
426+
bool,
427+
) = zerompk::from_msgpack(r.payload.as_ref())
428+
.expect("return_partial payload must be a (Vec<ArrayAggPartial>, bool) tuple");
429+
assert_eq!(partials.len(), 1);
430+
assert!(
431+
(partials[0].sum - 3.0).abs() < 1e-9,
432+
"sum got {}",
433+
partials[0].sum
434+
);
435+
assert!(
436+
!truncated,
437+
"non-temporal current-state read is never below-horizon"
438+
);
439+
}
440+
441+
#[test]
442+
fn aggregate_temporal_appends_horizon_summary_row() {
443+
// Temporal aggregates surface the below-horizon signal as a trailing
444+
// {"truncated_before_horizon": bool} summary row — the same shape the
445+
// cluster `finalize_agg_partials` produces. Non-temporal aggregates (tested
446+
// above) carry no such row, so the two modes stay distinguishable.
447+
let mut h = Harness::new();
448+
let s = schema_2d_f64("t6_agg_ts");
449+
let aid = ArrayId::new(TenantId::new(1), "t6_agg_ts");
450+
h.open(&aid, &s, 0xAB);
451+
let mk = |x: i64, y: i64, v: f64, sys: i64| ArrayPutCell {
452+
coord: vec![CoordValue::Int64(x), CoordValue::Int64(y)],
453+
attrs: vec![CellValue::Float64(v)],
454+
surrogate: Surrogate::ZERO,
455+
system_from_ms: sys,
456+
valid_from_ms: 0,
457+
valid_until_ms: i64::MAX,
458+
};
459+
h.put(&aid, vec![mk(0, 0, 10.0, 100), mk(1, 1, 20.0, 100)], 1);
460+
h.flush(&aid);
461+
462+
// AS OF after the writes: both cells visible, not below horizon.
463+
let r = h.send(ArrayOp::Aggregate {
464+
array_id: aid.clone(),
465+
attr_idx: 0,
466+
reducer: ArrayReducer::Sum,
467+
group_by_dim: -1,
468+
cell_filter: None,
469+
return_partial: false,
470+
hilbert_range: None,
471+
system_as_of: Some(150),
472+
valid_at_ms: None,
473+
});
474+
assert_eq!(r.status, Status::Ok, "temporal agg failed: {r:?}");
475+
let rows = decode_agg_rows(r.payload.as_ref());
476+
assert_eq!(
477+
rows.len(),
478+
2,
479+
"temporal scalar agg = result row + summary row"
480+
);
481+
let sum = rows[0]
482+
.get("result")
483+
.and_then(|v| v.as_f64())
484+
.expect("result");
485+
assert!((sum - 30.0).abs() < 1e-9, "sum got {sum}");
486+
assert_eq!(
487+
rows[1]
488+
.get("truncated_before_horizon")
489+
.and_then(|v| v.as_bool()),
490+
Some(false),
491+
"cutoff after all data is not below-horizon"
492+
);
493+
494+
// AS OF before the writes: below horizon → trailing flag is true.
495+
let r2 = h.send(ArrayOp::Aggregate {
496+
array_id: aid.clone(),
497+
attr_idx: 0,
498+
reducer: ArrayReducer::Sum,
499+
group_by_dim: -1,
500+
cell_filter: None,
501+
return_partial: false,
502+
hilbert_range: None,
503+
system_as_of: Some(50),
504+
valid_at_ms: None,
505+
});
506+
assert_eq!(r2.status, Status::Ok, "below-horizon agg failed: {r2:?}");
507+
let rows2 = decode_agg_rows(r2.payload.as_ref());
508+
assert_eq!(
509+
rows2
510+
.last()
511+
.and_then(|row| row.get("truncated_before_horizon"))
512+
.and_then(|v| v.as_bool()),
513+
Some(true),
514+
"cutoff before all data must report below-horizon"
515+
);
516+
}
517+
320518
#[test]
321519
fn aggregate_group_by_dim_buckets_per_x() {
322520
let mut h = Harness::new();
@@ -428,7 +626,7 @@ fn slice_cell_filter_excludes_non_member_surrogates() {
428626
limit: 0,
429627
cell_filter: Some(bm),
430628
hilbert_range: None,
431-
system_as_of: None,
629+
system_time: nodedb_types::SystemTimeScope::Current,
432630
valid_at_ms: None,
433631
});
434632
assert_eq!(r.status, Status::Ok, "slice+filter failed: {r:?}");

0 commit comments

Comments
 (0)