Replace pointwise scenario testing with a reusable conformance kit in tests/common: a deterministic trace harness (input alphabet, seeded generator, naive shrinker), an invariant oracle covering twenty black-box guarantees (identity, correlation, dead-hold, attempt-fact ownership, quiescence no-op, monotonic generation, fair convergence, bounded replacement), and a fair-scheduler tail asserting eventual reconciliation. Three conformance levels run the same battery: - FakeBackend: the reference in-memory substrate (256 seeds x 2 modes) - PluginBackendAdapter over FakePlugin: seam contracts plus the battery - ProcessPlugin: real child processes, faults as real signals/errors; "no double-create" and "converged leaks nothing" verified by counting live PIDs (16 seeds) Also documents two seam findings the battery surfaced: ProvisionPlugin cannot express ambiguity (kit convention: AMBIGUOUS_FAULT_MARKER error reclassified by the adapter; definite classification leaks provider resources) and spawn_effect closures form a spawner Arc cycle that leaks backends under queue-based spawners (kit breaks it at harness drop).
217 lines
8.6 KiB
Rust
217 lines
8.6 KiB
Rust
//! Stateful property tests for the cluster reconciler over the kit's
|
|
//! reference `FakeBackend`. The harness, oracle, and generator live in
|
|
//! `common`; this file is a thin client pinning named guarantees.
|
|
|
|
mod common;
|
|
|
|
use std::time::{Duration, UNIX_EPOCH};
|
|
|
|
use common::{
|
|
check_invariants, gen_trace, group, group_with_role, run_trace, sanitized, shape, BootEvent,
|
|
Harness, Input, RecordingExecutor, Reply, RunOrder,
|
|
};
|
|
use provisioning::*;
|
|
|
|
// ── plain deterministic tests ─────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn replayed_traces_are_identical() {
|
|
for seed in 0..32 {
|
|
let trace = gen_trace(seed, 48);
|
|
let first = run_trace(seed, &trace, false, RunOrder::Fifo);
|
|
let second = run_trace(seed, &trace, false, RunOrder::Fifo);
|
|
assert_eq!(*first.state(), *second.state(), "seed {seed}");
|
|
assert_eq!(first.backend.calls(), second.backend.calls(), "seed {seed}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn happy_path_converges() {
|
|
let mut harness = Harness::new_with_backend(0, shape(1, vec![group("g0", 1)]), common::FakeBackend::default());
|
|
harness.step(Input::Run); // dispatch create lease
|
|
harness.step(Input::Run); // execute create, dispatch bootstrap start
|
|
harness.step(Input::Run); // execute bootstrap start, session active
|
|
harness.step(Input::Boot(BootEvent::Joined));
|
|
harness.step(Input::Boot(BootEvent::Closed));
|
|
harness.step(Input::Run); // bootstrap convergence accepted
|
|
assert!(harness.driver.is_converged());
|
|
assert!(harness.backend.calls().iter().any(|effect| {
|
|
matches!(effect.command, NodeManagerCommand::CreateLease(_))
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn ambiguous_create_is_adopted_and_converges() {
|
|
let mut harness = Harness::new_with_backend(0, shape(1, vec![group("g0", 1)]), common::FakeBackend::default());
|
|
harness.step(Input::Reply(Reply::Ambiguous("create timed out")));
|
|
harness.step(Input::Run); // create fails ambiguously, backoff starts
|
|
harness.step(Input::Tick(Duration::from_secs(10))); // retry/adopt
|
|
harness.fair_tail();
|
|
assert!(harness.driver.is_converged());
|
|
}
|
|
|
|
#[test]
|
|
fn shape_shrink_mid_lifecycle_converges() {
|
|
let mut harness = Harness::new_with_backend(0, shape(1, vec![group("g0", 2)]), common::FakeBackend::default());
|
|
harness.step(Input::Run);
|
|
harness.step(Input::Run);
|
|
harness.step(Input::Boot(BootEvent::Joined));
|
|
// Node g0-1 may be mid-bootstrap when the shape shrinks to one node.
|
|
harness.step(Input::Shape(shape(2, vec![group("g0", 1)])));
|
|
harness.fair_tail();
|
|
assert!(harness.driver.is_converged());
|
|
assert_eq!(harness.state().nodes.len(), 1);
|
|
assert!(harness
|
|
.state()
|
|
.nodes
|
|
.contains_key(&LogicalNodeId("g0-0".to_owned())));
|
|
}
|
|
|
|
#[test]
|
|
fn same_generation_same_content_is_accepted() {
|
|
let mut driver = ClusterDriver::new(shape(1, vec![group("g0", 1)]), RetryPolicy::default())
|
|
.expect("driver builds");
|
|
let identical = driver.desired().clone();
|
|
assert!(driver.update_desired(identical).is_ok());
|
|
let mut changed = driver.desired().clone();
|
|
changed.groups[0].count = 2;
|
|
assert!(driver.update_desired(changed).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn deadline_expires_exactly_at_deadline() {
|
|
let mut harness = Harness::new_with_backend(0, shape(1, vec![group("g0", 1)]), common::FakeBackend::default());
|
|
harness.settle(); // create dispatched at the epoch
|
|
let timeout = RetryPolicy::default().operation_timeout;
|
|
let node = harness.state().nodes.values().next().expect("node exists");
|
|
let pending = node.pending.as_ref().expect("create is pending");
|
|
assert_eq!(pending.deadline, UNIX_EPOCH + timeout);
|
|
|
|
// One tick before the deadline: still pending, nothing expired.
|
|
harness.step(Input::Tick(timeout - Duration::from_secs(1)));
|
|
let node = harness.state().nodes.values().next().expect("node exists");
|
|
assert!(node.pending.is_some(), "operation expired before its deadline");
|
|
assert_eq!(node.retry.ambiguous_operation, None);
|
|
|
|
// Exactly at the deadline: expired, classified ambiguous, never ran.
|
|
harness.step(Input::Tick(Duration::from_secs(1)));
|
|
let node = harness.state().nodes.values().next().expect("node exists");
|
|
assert!(node.pending.is_none(), "operation did not expire at its deadline");
|
|
assert_eq!(node.retry.ambiguous_operation, Some(OperationKind::CreateLease));
|
|
assert!(
|
|
harness.backend.calls().is_empty(),
|
|
"expired operation must not reach the backend"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn clock_extremes_do_not_panic_or_corrupt_state() {
|
|
// Near the end of representable time the operation timeout saturates
|
|
// (deadline collapses to `now`, i.e. immediately due) while retry
|
|
// backoffs still fit; the machine must keep making progress without
|
|
// panicking and without corrupting state.
|
|
let mut now = UNIX_EPOCH + Duration::from_secs(i64::MAX as u64 - 100);
|
|
let step = Duration::from_secs(2);
|
|
let mut driver = ClusterDriver::new(shape(1, vec![group("g0", 1)]), RetryPolicy::default())
|
|
.expect("driver builds");
|
|
let mut executor = RecordingExecutor::default();
|
|
let mut guard = 0;
|
|
while executor.submitted < 8 {
|
|
guard += 1;
|
|
assert!(guard <= 64, "driver stopped making progress at clock extremes");
|
|
driver.trigger_if_due(now);
|
|
driver
|
|
.drive_until_blocked(now, &mut executor)
|
|
.expect("drive near the end of time");
|
|
for operation in driver.pending_operations_due(now) {
|
|
assert!(driver.operation_timed_out(&operation, "extreme clock", now));
|
|
}
|
|
check_invariants(driver.state(), &driver.desired().expand().expect("expands"))
|
|
.unwrap_or_else(|violation| panic!("invariant broken at clock extreme: {violation}"));
|
|
now = now.checked_add(step).expect("probe clock still representable");
|
|
if let Some(requeue) = driver.requeue_at()
|
|
&& requeue > now
|
|
{
|
|
now = requeue
|
|
.checked_add(step)
|
|
.expect("requeue still representable");
|
|
}
|
|
}
|
|
assert_eq!(executor.submitted, 8);
|
|
let policy = RetryPolicy::default();
|
|
assert_eq!(policy.delay_for_failure(u32::MAX), policy.max_delay);
|
|
}
|
|
|
|
#[test]
|
|
fn attempt_allocator_exhaustion_is_reported() {
|
|
let observed = ClusterState {
|
|
next_attempt_id: u64::MAX,
|
|
..ClusterState::default()
|
|
};
|
|
let error = reconcile(&observed, &shape(1, vec![group("g0", 1)]), UNIX_EPOCH)
|
|
.expect_err("allocator must be exhausted");
|
|
assert!(
|
|
error.reason.contains("exhausted"),
|
|
"unexpected error: {error:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn latest_desired_wins() {
|
|
for seed in 0..16 {
|
|
let trace = sanitized(&gen_trace(seed, 32));
|
|
let mut harness = Harness::new_with_backend(seed, shape(1, vec![group("g0", 1)]), common::FakeBackend::default());
|
|
for input in &trace {
|
|
harness.step(input.clone());
|
|
}
|
|
// A late shape change at a higher generation must win: the final
|
|
// state converges to it, never to any earlier generation.
|
|
let generation = harness.driver.desired().generation + 1;
|
|
harness.step(Input::Shape(shape(
|
|
generation,
|
|
vec![group_with_role("g0", 2, "worker-late")],
|
|
)));
|
|
harness.fair_tail();
|
|
assert!(harness.driver.is_converged(), "seed {seed}");
|
|
assert_eq!(harness.driver.state().observed_generation, generation);
|
|
assert_eq!(harness.state().nodes.len(), 2, "seed {seed}");
|
|
for node in harness.state().nodes.values() {
|
|
assert_eq!(
|
|
node.record.desired.role,
|
|
RoleId("worker-late".to_owned()),
|
|
"seed {seed}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn run_order_confluence() {
|
|
for seed in 0..16 {
|
|
let trace = sanitized(&gen_trace(seed, 48));
|
|
let fifo = run_trace(seed, &trace, true, RunOrder::Fifo);
|
|
let lifo = run_trace(seed, &trace, true, RunOrder::Lifo);
|
|
assert_eq!(*fifo.state(), *lifo.state(), "seed {seed}");
|
|
}
|
|
}
|
|
|
|
// ── stateful property tests ───────────────────────────────────────────
|
|
|
|
const SEEDS: u64 = 256;
|
|
const TRACE_LEN: usize = 64;
|
|
|
|
#[test]
|
|
fn adversarial_traces_hold_invariants() {
|
|
for seed in 0..SEEDS {
|
|
let trace = gen_trace(seed, TRACE_LEN);
|
|
common::assert_trace(common::FakeBackend::default, seed, &trace, false);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fair_traces_converge() {
|
|
for seed in 0..SEEDS {
|
|
let trace = gen_trace(seed, TRACE_LEN);
|
|
common::assert_trace(common::FakeBackend::default, seed, &trace, true);
|
|
}
|
|
}
|