LeVCS/crates/levcs-store/tests/support/harness.rs

429 lines
16 KiB
Rust

//! Crash-matrix plumbing: fixture loading and child-process driving.
//!
//! The classifier itself is *not* here. It lives in `store-crash-driver`'s
//! `reconcile` subcommand, so the crash matrix and
//! `scripts/verify-store-recovery.sh` classify through the same code reading
//! the same bytes, and so recovery always runs in a fresh process with a fresh
//! descriptor (scope 3.7).
#![allow(dead_code)]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use levcs_protocol::oracle::RecoveryOutcome;
use levcs_store::failpoints::{Failpoint, Wave};
use super::group_model::{PhysicalStateClass, VictimPlacement};
// ---------------------------------------------------------------------------
// Fixture
// ---------------------------------------------------------------------------
pub const FIXTURE_PATH: &str = "tests/fixtures/phase1-failpoints.json";
/// The `wave` value a row carried while it could not be driven at all.
///
/// The same spelling Phase 0 used for its `not-exercised` adversarial rows,
/// and the string `scripts/check-phase1.sh` greps for once `engine.rs` is
/// implemented. **No row carries it any more** — B1's engine landed and scope
/// 6.6 deliverable 2 drove all eight — and the constant stays because the test
/// that proves the pending set is empty has to name what it is looking for.
/// Deleting it would leave the check searching for nothing and passing.
pub const PENDING_WAVE_B: &str = "pending-wave-b";
#[derive(Clone, Debug)]
pub struct DrivePlan {
pub action: String,
pub fault: String,
}
/// How a Wave B row is driven through `StoreEngine::submit`.
///
/// `actions` is a list rather than a single value because the failpoint enum
/// names a *location* and the driver chooses the *action*; the two axes are
/// independent, and the three publication-side rows must be exercised with
/// both `Fail` and `Panic`. A row that named one action could not express that
/// without a second row for the same location, which would break the
/// one-row-per-failpoint invariant the first test in the matrix pins.
#[derive(Clone, Debug)]
pub struct SubmitPlan {
pub actions: Vec<String>,
/// Whether the location is reachable only after a lost committed-root CAS.
/// True for exactly one row, and the matrix asserts that rather than
/// letting a second row quietly acquire it.
pub requires_root_cas_contention: bool,
}
#[derive(Clone, Debug)]
pub struct FixtureRow {
pub failpoint: String,
pub wave: String,
pub physical_state_class: String,
pub required_outcome: String,
pub victim_placement: String,
pub drive: Option<DrivePlan>,
pub submit: Option<SubmitPlan>,
pub rationale: String,
}
#[derive(Clone, Debug)]
pub struct Fixture {
pub rows: Vec<FixtureRow>,
pub wave_a_asserted: Vec<String>,
pub wave_a_unasserted: Vec<String>,
pub wave_b_asserted: Vec<String>,
/// By what path the root the Wave B rows exercise was created, as a short
/// stable token.
///
/// A **tripwire**, matched by exact equality in `crash_matrix.rs`. It was a
/// paragraph matched with `.contains("StoreEngine::open")`, and a substring
/// test is too weak for a tripwire: it keeps passing on a value that has
/// drifted to mean something else, because the substring survives the
/// drift. The history — that the non-production seeding weakness existed
/// and is now closed — lives in [`Fixture::root_seeded_by_reason`], which is
/// where an essay belongs; it is not in the value being matched.
pub root_seeded_by: String,
/// Why the token above is what it is. Required non-empty: a tripwire whose
/// justification can be deleted silently is a token nobody can audit.
pub root_seeded_by_reason: String,
pub wave_b_exit_conditions: Vec<String>,
}
fn string_field(value: &serde_json::Value, key: &str) -> String {
value
.get(key)
.and_then(|v| v.as_str())
.unwrap_or_else(|| panic!("fixture row is missing a string {key:?}: {value}"))
.to_string()
}
fn string_list(value: &serde_json::Value) -> Vec<String> {
value
.as_array()
.unwrap_or_else(|| panic!("expected a JSON array, got {value}"))
.iter()
.map(|entry| {
entry
.as_str()
.unwrap_or_else(|| panic!("expected a string, got {entry}"))
.to_string()
})
.collect()
}
pub fn load_fixture() -> Fixture {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(FIXTURE_PATH);
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("reading {}: {e}", path.display()));
let document: serde_json::Value =
serde_json::from_str(&text).unwrap_or_else(|e| panic!("parsing {}: {e}", path.display()));
assert_eq!(
document.get("schema_version").and_then(|v| v.as_u64()),
Some(1),
"unexpected crash-matrix fixture schema version"
);
let scope = document
.get("wave_a_assertion_scope")
.expect("the fixture must record which halves Wave A asserts");
let wave_a_asserted = string_list(scope.get("asserted").expect("asserted"));
let wave_a_unasserted = string_list(scope.get("unasserted").expect("unasserted"));
assert!(
scope
.get("reason")
.and_then(|v| v.as_str())
.is_some_and(|r| !r.is_empty()),
"the unasserted halves must carry their reason, not merely be listed"
);
let wave_b_scope = document
.get("wave_b_assertion_scope")
.expect("the fixture must record which halves Wave B asserts");
let wave_b_asserted = string_list(wave_b_scope.get("asserted").expect("asserted"));
let root_seeded_by = wave_b_scope
.get("root_seeded_by")
.and_then(|v| v.as_str())
.filter(|r| !r.is_empty())
.expect(
"the fixture must say by what path the root the Wave B rows exercise was \
created. Scope 5 charter item 8 is about exactly this: a property asserted \
against a store that production never built is asserted against the wrong \
store, and a reader of the fixture must be able to tell which it is \
without reading the harness.",
)
.to_string();
let root_seeded_by_reason = wave_b_scope
.get("root_seeded_by_reason")
.and_then(|v| v.as_str())
.filter(|r| !r.is_empty())
.expect(
"root_seeded_by is a short token matched by exact equality, so the reason it \
carries that value must live beside it. A tripwire whose justification can \
be dropped without anything failing is a token nobody can audit.",
)
.to_string();
let wave_b_exit_conditions = string_list(
document
.get("wave_b_exit_conditions")
.expect("wave_b_exit_conditions"),
);
let rows = document
.get("rows")
.and_then(|v| v.as_array())
.expect("rows must be an array")
.iter()
.map(|row| FixtureRow {
failpoint: string_field(row, "failpoint"),
wave: string_field(row, "wave"),
physical_state_class: string_field(row, "physical_state_class"),
required_outcome: string_field(row, "required_outcome"),
victim_placement: string_field(row, "victim_placement"),
drive: row.get("drive").and_then(|plan| {
plan.as_object().map(|_| DrivePlan {
action: string_field(plan, "action"),
fault: string_field(plan, "fault"),
})
}),
submit: row.get("submit").and_then(|plan| {
plan.as_object().map(|_| SubmitPlan {
actions: string_list(plan.get("actions").expect("submit.actions")),
requires_root_cas_contention: plan
.get("requires_root_cas_contention")
.and_then(|v| v.as_bool())
.expect("submit.requires_root_cas_contention"),
})
}),
rationale: string_field(row, "rationale"),
})
.collect();
Fixture {
rows,
wave_a_asserted,
wave_a_unasserted,
wave_b_asserted,
root_seeded_by,
root_seeded_by_reason,
wave_b_exit_conditions,
}
}
// ---------------------------------------------------------------------------
// Name mapping, without catch-all arms
// ---------------------------------------------------------------------------
/// The fixture spells `Wave::A` as `"A"` and `Wave::B` as `"B"`.
///
/// Wave B used to be spelled `pending-wave-b`, because a row that could not be
/// driven was an outstanding obligation rather than merely a later one. The
/// obligation is met: every Wave B row is now driven through
/// `StoreEngine::submit`, so the wave is just a wave and the pending spelling
/// belongs to no row.
pub fn wave_name(wave: Wave) -> &'static str {
match wave {
Wave::A => "A",
Wave::B => "B",
}
}
pub fn recovery_outcome_name(outcome: RecoveryOutcome) -> &'static str {
match outcome {
RecoveryOutcome::AbsentRetriable => "AbsentRetriable",
RecoveryOutcome::Committed => "Committed",
RecoveryOutcome::EitherWhole => "EitherWhole",
}
}
pub const RECOVERY_OUTCOMES: &[RecoveryOutcome] = &[
RecoveryOutcome::AbsentRetriable,
RecoveryOutcome::Committed,
RecoveryOutcome::EitherWhole,
];
/// Parse by searching the enumerated set rather than by a `match` with a
/// fallback binding, so no name can acquire a default meaning.
pub fn recovery_outcome_from_name(name: &str) -> Option<RecoveryOutcome> {
RECOVERY_OUTCOMES
.iter()
.copied()
.find(|o| recovery_outcome_name(*o) == name)
}
/// The fixture's own view of a row, resolved to typed values.
pub struct ResolvedRow {
pub point: Failpoint,
pub class: PhysicalStateClass,
pub required_outcome: RecoveryOutcome,
pub placement: VictimPlacement,
}
pub fn resolve(row: &FixtureRow) -> ResolvedRow {
ResolvedRow {
point: Failpoint::from_name(&row.failpoint)
.unwrap_or_else(|| panic!("fixture names an unknown failpoint {:?}", row.failpoint)),
class: PhysicalStateClass::from_name(&row.physical_state_class).unwrap_or_else(|| {
panic!(
"fixture names an unknown physical state class {:?}",
row.physical_state_class
)
}),
required_outcome: recovery_outcome_from_name(&row.required_outcome).unwrap_or_else(|| {
panic!(
"fixture names an unknown recovery outcome {:?}",
row.required_outcome
)
}),
placement: VictimPlacement::from_name(&row.victim_placement).unwrap_or_else(|| {
panic!(
"fixture names an unknown victim placement {:?}",
row.victim_placement
)
}),
}
}
// ---------------------------------------------------------------------------
// Driving the child
// ---------------------------------------------------------------------------
pub const DRIVER: &str = env!("CARGO_BIN_EXE_store-crash-driver");
/// A child run's machine-readable output plus how it died.
#[derive(Clone, Debug)]
pub struct ChildRun {
pub keys: BTreeMap<String, String>,
pub exit_code: Option<i32>,
pub signal: Option<i32>,
pub stderr: String,
}
impl ChildRun {
fn from_output(output: Output) -> Self {
use std::os::unix::process::ExitStatusExt;
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let mut keys = BTreeMap::new();
for line in stdout.lines() {
if let Some((key, value)) = line.split_once('=') {
keys.insert(key.to_string(), value.to_string());
}
}
Self {
keys,
exit_code: output.status.code(),
signal: output.status.signal(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
}
}
pub fn get(&self, key: &str) -> Option<&str> {
self.keys.get(key).map(|v| v.as_str())
}
/// Sequence list fields are comma separated and may legitimately be empty.
pub fn sequences(&self, key: &str) -> Vec<u64> {
match self.get(key) {
Some(value) if !value.is_empty() => value
.split(',')
.map(|entry| {
entry
.parse::<u64>()
.unwrap_or_else(|e| panic!("bad sequence {entry:?} in {key}: {e}"))
})
.collect(),
Some(_) => Vec::new(),
None => Vec::new(),
}
}
}
/// Arguments for one scripted append that is expected to crash.
pub struct AppendRun<'a> {
pub root: &'a Path,
pub shard: u16,
pub shard_count: u16,
pub seed: u64,
pub group_len: usize,
pub victim: usize,
pub point: Failpoint,
pub action: &'a str,
pub fault: &'a str,
pub ack_journal: &'a Path,
pub create: bool,
}
pub fn run_append(run: &AppendRun<'_>) -> ChildRun {
let mut command = Command::new(DRIVER);
command
.arg("append")
.arg("--root")
.arg(run.root)
.arg("--shard")
.arg(run.shard.to_string())
.arg("--shard-count")
.arg(run.shard_count.to_string())
.arg("--seed")
.arg(run.seed.to_string())
.arg("--group-len")
.arg(run.group_len.to_string())
.arg("--victim")
.arg(run.victim.to_string())
.arg("--point")
.arg(run.point.name())
.arg("--action")
.arg(run.action)
.arg("--fault")
.arg(run.fault)
.arg("--ack-journal")
.arg(run.ack_journal)
.arg("--path")
.arg("drive");
if run.create {
command.arg("--create");
}
ChildRun::from_output(command.output().expect("spawning store-crash-driver"))
}
pub fn run_reconcile(root: &Path, shard: u16, ack_journal: &Path) -> ChildRun {
let output = Command::new(DRIVER)
.arg("reconcile")
.arg("--root")
.arg(root)
.arg("--shard")
.arg(shard.to_string())
.arg("--ack-journal")
.arg(ack_journal)
.output()
.expect("spawning store-crash-driver reconcile");
ChildRun::from_output(output)
}
// ---------------------------------------------------------------------------
// The drive-seam tripwire
// ---------------------------------------------------------------------------
/// Whether A1's `drive.rs` bodies have landed.
///
/// Wave A's driving half cannot run against `unimplemented!()`. Rather than
/// silently passing, the matrix's driving test is `#[ignore]`d with a reason
/// and this probe backs a tripwire test that *fails* the moment the seam
/// becomes real, so the ignore cannot be forgotten. That is the same mechanism
/// the `pending-wave-b` rows use, applied to the other blocked dependency.
pub fn drive_seam_is_implemented() -> bool {
let directory = tempfile::tempdir().expect("tempdir");
let path = directory.path().to_path_buf();
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let probed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = levcs_store::drive::ShardDrive::create(&path, 0, 1);
}));
std::panic::set_hook(previous);
// Any return at all — success or a genuine `StoreError` — means the body
// exists. Only an `unimplemented!()` unwind means A1 has not landed it.
probed.is_ok()
}