281 lines
9.1 KiB
Rust
281 lines
9.1 KiB
Rust
|
|
//! TESTING_SPEC §8 — Causality and time invariants.
|
||
|
|
//!
|
||
|
|
//! All five checks read a bundle the reference scenario produces and
|
||
|
|
//! assert structural invariants over the record stream: monotonic
|
||
|
|
//! sequence numbers, non-decreasing wall_ms modulo declared clock
|
||
|
|
//! jumps, send-precedes-receive matching, snapshot-id uniqueness,
|
||
|
|
//! and deterministic per-tick executor ordering.
|
||
|
|
|
||
|
|
#[path = "common.rs"]
|
||
|
|
mod common;
|
||
|
|
|
||
|
|
use common::{bundle_node_dirs, flatten_events, load_record_files, run_reference_scenario};
|
||
|
|
use std::collections::{BTreeMap, BTreeSet};
|
||
|
|
|
||
|
|
// ── §8.1 — monotonic_seq strictly increasing ───────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn monotonic_seq_strictly_increasing() {
|
||
|
|
let bundle = run_reference_scenario();
|
||
|
|
for node_dir in bundle_node_dirs(&bundle.root) {
|
||
|
|
let mut per_boot: BTreeMap<i64, Vec<u64>> = BTreeMap::new();
|
||
|
|
for rec in records_with_envelope_metadata(&node_dir) {
|
||
|
|
per_boot.entry(rec.boot_sequence).or_default().push(rec.monotonic_seq);
|
||
|
|
}
|
||
|
|
for (boot_seq, mut seq) in per_boot {
|
||
|
|
seq.sort();
|
||
|
|
let mut prev: Option<u64> = None;
|
||
|
|
for s in &seq {
|
||
|
|
if let Some(p) = prev {
|
||
|
|
assert!(
|
||
|
|
*s > p,
|
||
|
|
"{}: monotonic_seq not strictly increasing under \
|
||
|
|
boot_sequence {boot_seq} (saw {p} then {s})",
|
||
|
|
node_dir.display()
|
||
|
|
);
|
||
|
|
}
|
||
|
|
prev = Some(*s);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── §8.2 — wall_ms non-decreasing modulo clock jumps ───────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn wall_ms_non_decreasing() {
|
||
|
|
let bundle = run_reference_scenario();
|
||
|
|
for node_dir in bundle_node_dirs(&bundle.root) {
|
||
|
|
let events = flatten_events(&node_dir);
|
||
|
|
let mut prev: Option<i64> = None;
|
||
|
|
for rec in events {
|
||
|
|
let wall = rec
|
||
|
|
.get("wall_ms")
|
||
|
|
.and_then(|v| v.as_i64())
|
||
|
|
.expect("every record carries wall_ms");
|
||
|
|
let is_clock_jump = rec.get("variant").and_then(|v| v.as_str()) == Some("Custom")
|
||
|
|
&& rec.get("user_kind").and_then(|v| v.as_str()) == Some("clock_jump");
|
||
|
|
if let Some(p) = prev {
|
||
|
|
if !is_clock_jump {
|
||
|
|
assert!(
|
||
|
|
wall >= p,
|
||
|
|
"{}: wall_ms regressed from {p} to {wall} without clock_jump",
|
||
|
|
node_dir.display()
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
prev = Some(wall);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── §8.3 — send precedes receive ───────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn send_precedes_receive() {
|
||
|
|
let bundle = run_reference_scenario();
|
||
|
|
// Gather every MessageSent across the whole bundle keyed by
|
||
|
|
// (sender, peer, kind, size). For every MessageReceived assert
|
||
|
|
// that some send earlier in virtual time matches.
|
||
|
|
let mut sends: Vec<MessageEvent> = Vec::new();
|
||
|
|
let mut receives: Vec<MessageEvent> = Vec::new();
|
||
|
|
for node_dir in bundle_node_dirs(&bundle.root) {
|
||
|
|
let node_name = node_dir
|
||
|
|
.file_name()
|
||
|
|
.and_then(|s| s.to_str())
|
||
|
|
.expect("node dir name")
|
||
|
|
.to_string();
|
||
|
|
for rec in flatten_events(&node_dir) {
|
||
|
|
let Some(variant) = rec.get("variant").and_then(|v| v.as_str()) else {
|
||
|
|
continue;
|
||
|
|
};
|
||
|
|
if variant != "MessageSent" && variant != "MessageReceived" {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
let event = MessageEvent {
|
||
|
|
node: node_name.clone(),
|
||
|
|
peer: rec
|
||
|
|
.get("peer")
|
||
|
|
.and_then(|v| v.as_str())
|
||
|
|
.map(String::from)
|
||
|
|
.unwrap_or_default(),
|
||
|
|
kind: rec
|
||
|
|
.get("kind")
|
||
|
|
.and_then(|v| v.as_str())
|
||
|
|
.map(String::from)
|
||
|
|
.unwrap_or_default(),
|
||
|
|
size: rec.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
|
||
|
|
wall_ms: rec.get("wall_ms").and_then(|v| v.as_i64()).unwrap_or(0),
|
||
|
|
};
|
||
|
|
if variant == "MessageSent" {
|
||
|
|
sends.push(event);
|
||
|
|
} else {
|
||
|
|
receives.push(event);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
for r in &receives {
|
||
|
|
let matched = sends.iter().any(|s| {
|
||
|
|
s.peer == r.node
|
||
|
|
&& s.kind == r.kind
|
||
|
|
&& s.size == r.size
|
||
|
|
&& s.wall_ms <= r.wall_ms
|
||
|
|
});
|
||
|
|
assert!(
|
||
|
|
matched,
|
||
|
|
"MessageReceived on {} from {} (kind={}, size={}, wall_ms={}) \
|
||
|
|
has no causal MessageSent within the bundle",
|
||
|
|
r.node, r.peer, r.kind, r.size, r.wall_ms
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── §8.4 — snapshot_id uniqueness ──────────────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn snapshot_id_unique() {
|
||
|
|
let bundle = run_reference_scenario();
|
||
|
|
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||
|
|
for node_dir in bundle_node_dirs(&bundle.root) {
|
||
|
|
for snap in load_record_files(&node_dir, "snapshots") {
|
||
|
|
let id = snap
|
||
|
|
.get("snapshot_id")
|
||
|
|
.and_then(|v| v.as_str())
|
||
|
|
.expect("snapshot.snapshot_id is a string")
|
||
|
|
.to_string();
|
||
|
|
assert!(
|
||
|
|
seen.insert(id.clone()),
|
||
|
|
"duplicate snapshot_id={id} (across the bundle)"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── §8.5 — executor tiebreaker determinism ─────────────────────────
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn executor_tiebreaker_deterministic() {
|
||
|
|
// Construct a scenario in which multiple events fire at the
|
||
|
|
// same virtual tick. The scenario is built inline so the test
|
||
|
|
// is hermetic; the per-tick resolution order must be identical
|
||
|
|
// across two runs of the same scenario.
|
||
|
|
let spec = tiebreaker_scenario_toml();
|
||
|
|
let b1 = common::run_engine(&spec, 7);
|
||
|
|
let b2 = common::run_engine(&spec, 7);
|
||
|
|
|
||
|
|
// Compare the orchestrator's event stream tick-by-tick.
|
||
|
|
let orch1 = flatten_events(&b1.root.join("orchestrator"));
|
||
|
|
let orch2 = flatten_events(&b2.root.join("orchestrator"));
|
||
|
|
assert_eq!(
|
||
|
|
orch1.len(),
|
||
|
|
orch2.len(),
|
||
|
|
"scenario produced different event counts across two runs"
|
||
|
|
);
|
||
|
|
for (i, (a, b)) in orch1.iter().zip(orch2.iter()).enumerate() {
|
||
|
|
assert_eq!(
|
||
|
|
a, b,
|
||
|
|
"event #{i} diverged across two runs of the same (spec, seed)"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Helpers ────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
struct RecordMeta {
|
||
|
|
boot_sequence: i64,
|
||
|
|
monotonic_seq: u64,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug)]
|
||
|
|
struct MessageEvent {
|
||
|
|
node: String,
|
||
|
|
peer: String,
|
||
|
|
kind: String,
|
||
|
|
size: u64,
|
||
|
|
wall_ms: i64,
|
||
|
|
}
|
||
|
|
|
||
|
|
fn records_with_envelope_metadata(node_dir: &std::path::Path) -> Vec<RecordMeta> {
|
||
|
|
let mut out = Vec::new();
|
||
|
|
for envelope in load_record_files(node_dir, "events") {
|
||
|
|
let boot_seq = envelope
|
||
|
|
.get("boot_sequence")
|
||
|
|
.and_then(|v| v.as_i64())
|
||
|
|
.expect("event envelope carries boot_sequence");
|
||
|
|
for rec in envelope
|
||
|
|
.get("records")
|
||
|
|
.and_then(|r| r.as_array())
|
||
|
|
.into_iter()
|
||
|
|
.flatten()
|
||
|
|
{
|
||
|
|
let mono = rec
|
||
|
|
.get("monotonic_seq")
|
||
|
|
.and_then(|v| v.as_u64())
|
||
|
|
.expect("record carries monotonic_seq");
|
||
|
|
out.push(RecordMeta {
|
||
|
|
boot_sequence: boot_seq,
|
||
|
|
monotonic_seq: mono,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for envelope in load_record_files(node_dir, "snapshots") {
|
||
|
|
let boot_seq = envelope
|
||
|
|
.get("boot_sequence")
|
||
|
|
.and_then(|v| v.as_i64())
|
||
|
|
.expect("snapshot carries boot_sequence");
|
||
|
|
let mono = envelope
|
||
|
|
.get("monotonic_seq")
|
||
|
|
.and_then(|v| v.as_u64())
|
||
|
|
.expect("snapshot carries monotonic_seq");
|
||
|
|
out.push(RecordMeta {
|
||
|
|
boot_sequence: boot_seq,
|
||
|
|
monotonic_seq: mono,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
out
|
||
|
|
}
|
||
|
|
|
||
|
|
fn tiebreaker_scenario_toml() -> String {
|
||
|
|
// Three events fire at virtual tick `at_ms=1000` on the same
|
||
|
|
// node — partition + heal + restart, all at the same tick.
|
||
|
|
// Per SPEC §2.3 the executor resolves them by
|
||
|
|
// `(node_id, fiber_id, event_seq)`; the test asserts the same
|
||
|
|
// resolution order across two runs.
|
||
|
|
r#"
|
||
|
|
run_id = "tiebreaker-001"
|
||
|
|
seed = 7
|
||
|
|
duration_ms = 2000
|
||
|
|
|
||
|
|
[[hosts]]
|
||
|
|
name = "alpha"
|
||
|
|
role = "stage"
|
||
|
|
stage_index = 0
|
||
|
|
start_at_ms = 0
|
||
|
|
stop_at_ms = 2000
|
||
|
|
|
||
|
|
[[hosts]]
|
||
|
|
name = "beta"
|
||
|
|
role = "stage"
|
||
|
|
stage_index = 1
|
||
|
|
start_at_ms = 0
|
||
|
|
stop_at_ms = 2000
|
||
|
|
|
||
|
|
[[mutations]]
|
||
|
|
kind = "partition"
|
||
|
|
at_ms = 1000
|
||
|
|
edges = [["alpha", "beta"]]
|
||
|
|
|
||
|
|
[[mutations]]
|
||
|
|
kind = "heal"
|
||
|
|
at_ms = 1000
|
||
|
|
edges = [["alpha", "beta"]]
|
||
|
|
|
||
|
|
[[mutations]]
|
||
|
|
kind = "restart"
|
||
|
|
at_ms = 1000
|
||
|
|
node = "alpha"
|
||
|
|
"#
|
||
|
|
.to_string()
|
||
|
|
}
|