601 lines
26 KiB
Rust
601 lines
26 KiB
Rust
//! The group-aware failpoint model (scope 4-A3 deliverable 3, ruling 9.3).
|
|
//!
|
|
//! `levcs_protocol::oracle::append_publication_expectation` is a
|
|
//! *single-transaction* contract. A real group carries up to
|
|
//! `max_group_transactions` frames behind one fence, and a failpoint fires at
|
|
//! one victim frame inside it. This module layers the group answer on top of
|
|
//! the frozen oracle; per ruling 9.3 it does not extend the oracle.
|
|
//!
|
|
//! # The contract is a contiguous adopted prefix, not a per-frame answer
|
|
//!
|
|
//! Each individual unfenced frame is independently either-whole via page
|
|
//! writeback, so a *per-frame* formulation would permit "frame 3 absent, frame
|
|
//! 4 committed". Recovery step 6 (scope 3.8) forbids exactly that, and an
|
|
//! implementation that adopts a syntactically valid frame sitting after a hole
|
|
//! is the single most consequential recovery bug this phase can ship. This
|
|
//! module therefore answers with a window of legal prefix lengths:
|
|
//!
|
|
//! > there exists some *p* ≤ `victim_index` such that frames `0..p` are
|
|
//! > committed and frames `p..group_len` are absent; nothing at or after the
|
|
//! > victim is ever committed.
|
|
//!
|
|
//! [`verify_adopted_prefix`] is the checker that turns that statement into a
|
|
//! failure for an adopt-past-a-hole implementation, which a per-frame
|
|
//! formulation could not do.
|
|
//!
|
|
//! # Degeneracy
|
|
//!
|
|
//! [`group_failpoint_expectation`] must reduce to the frozen oracle at
|
|
//! `group_len == 1`, and per ruling 9.3's condition that reduction must hold
|
|
//! for **every** physical state class, not only the torn one. The proof lives
|
|
//! in `crash_matrix.rs` and iterates every failpoint, which covers every class
|
|
//! by construction because [`PhysicalStateClass::ALL`] is asserted to be
|
|
//! exactly the set of classes witnessed by some failpoint.
|
|
|
|
#![allow(dead_code)]
|
|
|
|
use levcs_protocol::oracle::{append_publication_expectation, RecoveredTailFact, RecoveryOutcome};
|
|
use levcs_store::failpoints::Failpoint;
|
|
|
|
/// The structural half of the prefix contract, shared verbatim with
|
|
/// `store-crash-driver` and `store-bench` rather than restated here.
|
|
///
|
|
/// The reconcilers and this model must not disagree about what "contiguous"
|
|
/// means; the review that produced this include found them disagreeing, with
|
|
/// the binaries carrying the weaker rule.
|
|
#[path = "../../src/bin/support/adopted_set.rs"]
|
|
pub mod adopted_set;
|
|
|
|
use adopted_set::{classify_adopted_set, SequenceFault};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Physical state classes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// What a failpoint leaves on the device, independent of what any in-memory
|
|
/// state believes.
|
|
///
|
|
/// This is the first of the crash matrix's two independent derivations: the
|
|
/// class determines the required recovery outcome through
|
|
/// [`PhysicalStateClass::required_outcome`], and the frozen oracle determines
|
|
/// it again through `append_publication_expectation`. The matrix asserts both
|
|
/// and requires them to agree.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
|
pub enum PhysicalStateClass {
|
|
/// Not one byte of the group reached the file.
|
|
NoBytes,
|
|
/// The victim frame's bytes are truncated. A short write is not an error;
|
|
/// it leaves a durable prefix (scope 3.7).
|
|
PartialFrame,
|
|
/// Every frame up to the victim is byte-complete, but no fence has
|
|
/// returned successfully. Whether any of it reached durable storage is
|
|
/// precisely what production recovery must decide.
|
|
WholeFrameUnfenced,
|
|
/// `fdatasync` returned success over the frames written before it.
|
|
WholeFrameFenced,
|
|
/// Fenced *and* the committed root was published, so an acknowledgment is
|
|
/// already permitted.
|
|
WholeFrameFencedAndPublished,
|
|
}
|
|
|
|
impl PhysicalStateClass {
|
|
pub const ALL: &'static [PhysicalStateClass] = &[
|
|
PhysicalStateClass::NoBytes,
|
|
PhysicalStateClass::PartialFrame,
|
|
PhysicalStateClass::WholeFrameUnfenced,
|
|
PhysicalStateClass::WholeFrameFenced,
|
|
PhysicalStateClass::WholeFrameFencedAndPublished,
|
|
];
|
|
|
|
/// The four classes ruling 9.3 names for the degeneracy condition. The
|
|
/// published class is a Wave B refinement of `WholeFrameFenced` and is
|
|
/// listed separately so the matrix can carry the scope 4-A3 table's
|
|
/// "`WholeFrameFenced` + published" rows verbatim.
|
|
pub const RULING_9_3_CLASSES: &'static [PhysicalStateClass] = &[
|
|
PhysicalStateClass::NoBytes,
|
|
PhysicalStateClass::PartialFrame,
|
|
PhysicalStateClass::WholeFrameUnfenced,
|
|
PhysicalStateClass::WholeFrameFenced,
|
|
];
|
|
|
|
/// Name as it appears in `tests/fixtures/phase1-failpoints.json`.
|
|
pub const fn name(self) -> &'static str {
|
|
match self {
|
|
PhysicalStateClass::NoBytes => "NoBytes",
|
|
PhysicalStateClass::PartialFrame => "PartialFrame",
|
|
PhysicalStateClass::WholeFrameUnfenced => "WholeFrameUnfenced",
|
|
PhysicalStateClass::WholeFrameFenced => "WholeFrameFenced",
|
|
PhysicalStateClass::WholeFrameFencedAndPublished => "WholeFrameFencedAndPublished",
|
|
}
|
|
}
|
|
|
|
/// Parse without a catch-all arm: a linear search over `ALL` rather than a
|
|
/// `match` with a fallback binding, so adding a class cannot silently
|
|
/// acquire a default.
|
|
pub fn from_name(name: &str) -> Option<Self> {
|
|
Self::ALL.iter().copied().find(|c| c.name() == name)
|
|
}
|
|
|
|
/// The class-derived half of the crash matrix's expectation.
|
|
///
|
|
/// Exactly scope 4-A3's table: absent bytes and torn bytes are absent and
|
|
/// retriable, unfenced whole bytes are `EitherWhole`, fenced whole bytes
|
|
/// are committed.
|
|
pub const fn required_outcome(self) -> RecoveryOutcome {
|
|
match self {
|
|
PhysicalStateClass::NoBytes => RecoveryOutcome::AbsentRetriable,
|
|
PhysicalStateClass::PartialFrame => RecoveryOutcome::AbsentRetriable,
|
|
PhysicalStateClass::WholeFrameUnfenced => RecoveryOutcome::EitherWhole,
|
|
PhysicalStateClass::WholeFrameFenced => RecoveryOutcome::Committed,
|
|
PhysicalStateClass::WholeFrameFencedAndPublished => RecoveryOutcome::Committed,
|
|
}
|
|
}
|
|
|
|
/// Whether an acknowledgment is already permitted when the failpoint
|
|
/// fires. Only the published class permits one; scope 4-A3's table marks
|
|
/// exactly those two rows "ack allowed".
|
|
pub const fn acknowledgment_allowed(self) -> bool {
|
|
match self {
|
|
PhysicalStateClass::NoBytes => false,
|
|
PhysicalStateClass::PartialFrame => false,
|
|
PhysicalStateClass::WholeFrameUnfenced => false,
|
|
PhysicalStateClass::WholeFrameFenced => false,
|
|
PhysicalStateClass::WholeFrameFencedAndPublished => true,
|
|
}
|
|
}
|
|
|
|
/// Which terminal outcome a `StagedProjection` payload's adoption pin must
|
|
/// carry when this class is produced (scope 6.2 item 9).
|
|
///
|
|
/// This is the class-derived half of the adoption expectation, and the
|
|
/// fixture's stated `adoption_outcome` is the other. The matrix requires
|
|
/// them to agree, exactly as it already does for `required_outcome`.
|
|
///
|
|
/// The rule is the physical one and nothing else: **whether a frame
|
|
/// binding these artifacts exists on the device.**
|
|
///
|
|
/// * No bytes reached the file, so no frame names the artifacts. They are
|
|
/// unreferenced and reclaiming them is correct, which is what
|
|
/// `DefinitivePreAppendFailure` licenses.
|
|
/// * A torn or unfenced frame is `AbsentRetriable` for the transaction,
|
|
/// but it is **not** `DefinitivePreAppendFailure` for the pin. Bytes are
|
|
/// on the device and recovery decides what they mean; telling staging to
|
|
/// reclaim now would race that decision. Only recovery can resolve it.
|
|
/// * A fenced frame is durable and binds the manifest, so the pin outlives
|
|
/// the process and recovery notifies staging of the resolution.
|
|
/// * Fenced *and* published means the committed root already references
|
|
/// the artifacts and the shard sequence is known, so the writer settled
|
|
/// `Adopted` before this failpoint could fire. A failure after that
|
|
/// point cannot un-adopt what a published root points into.
|
|
///
|
|
/// Note the asymmetry against `required_outcome`: `PartialFrame` and
|
|
/// `WholeFrameUnfenced` are `AbsentRetriable` there and
|
|
/// `TransferredToRecovery` here. A transaction that will not commit and a
|
|
/// pin whose artifacts may be referenced are different questions, and
|
|
/// collapsing them is how a retriable refusal would come to delete
|
|
/// content.
|
|
pub const fn adoption_outcome(self) -> AdoptionOutcomeExpectation {
|
|
match self {
|
|
PhysicalStateClass::NoBytes => AdoptionOutcomeExpectation::DefinitivePreAppendFailure,
|
|
PhysicalStateClass::PartialFrame => AdoptionOutcomeExpectation::TransferredToRecovery,
|
|
PhysicalStateClass::WholeFrameUnfenced => {
|
|
AdoptionOutcomeExpectation::TransferredToRecovery
|
|
}
|
|
PhysicalStateClass::WholeFrameFenced => {
|
|
AdoptionOutcomeExpectation::TransferredToRecovery
|
|
}
|
|
PhysicalStateClass::WholeFrameFencedAndPublished => AdoptionOutcomeExpectation::Adopted,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The terminal outcome expected of an adoption pin, as the matrix names it.
|
|
///
|
|
/// A test-side mirror of `ProjectionAdoptionOutcome` without the payload:
|
|
/// `Adopted` carries a committed shard sequence the matrix cannot predict, and
|
|
/// an expectation that had to guess it would assert less, not more.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
|
pub enum AdoptionOutcomeExpectation {
|
|
Adopted,
|
|
DefinitivePreAppendFailure,
|
|
TransferredToRecovery,
|
|
}
|
|
|
|
impl AdoptionOutcomeExpectation {
|
|
pub const ALL: &'static [AdoptionOutcomeExpectation] = &[
|
|
AdoptionOutcomeExpectation::Adopted,
|
|
AdoptionOutcomeExpectation::DefinitivePreAppendFailure,
|
|
AdoptionOutcomeExpectation::TransferredToRecovery,
|
|
];
|
|
|
|
/// Name as it appears in `tests/fixtures/phase1-failpoints.json`.
|
|
pub const fn name(self) -> &'static str {
|
|
match self {
|
|
AdoptionOutcomeExpectation::Adopted => "Adopted",
|
|
AdoptionOutcomeExpectation::DefinitivePreAppendFailure => "DefinitivePreAppendFailure",
|
|
AdoptionOutcomeExpectation::TransferredToRecovery => "TransferredToRecovery",
|
|
}
|
|
}
|
|
|
|
/// Parsed by linear search over `ALL`, for the reason
|
|
/// `PhysicalStateClass::from_name` is: adding a variant must not silently
|
|
/// acquire a default.
|
|
pub fn from_name(name: &str) -> Option<Self> {
|
|
Self::ALL.iter().copied().find(|o| o.name() == name)
|
|
}
|
|
}
|
|
|
|
/// Which payload a Wave B row drives the failpoint's location with.
|
|
///
|
|
/// The third independent axis of a submit row, alongside the location the
|
|
/// failpoint names and the action the driver chooses. It is an axis rather
|
|
/// than a second list of failpoints because the locations do not change: a
|
|
/// staged-projection adoption reaches every one of the eight Wave B locations,
|
|
/// and giving it its own rows would produce a parallel list that drifts from
|
|
/// this one the first time a location is added to either.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
|
pub enum PayloadKind {
|
|
/// Objects carried in the frame, which is every Wave B row today.
|
|
Inline,
|
|
/// A `StagedProjectionInstallV1` descriptor adopting sealed artifacts,
|
|
/// with an adoption pin whose terminal outcome is asserted.
|
|
StagedProjection,
|
|
}
|
|
|
|
impl PayloadKind {
|
|
pub const ALL: &'static [PayloadKind] = &[PayloadKind::Inline, PayloadKind::StagedProjection];
|
|
|
|
/// Name as it appears in `tests/fixtures/phase1-failpoints.json`.
|
|
pub const fn name(self) -> &'static str {
|
|
match self {
|
|
PayloadKind::Inline => "inline",
|
|
PayloadKind::StagedProjection => "staged_projection",
|
|
}
|
|
}
|
|
|
|
pub fn from_name(name: &str) -> Option<Self> {
|
|
Self::ALL.iter().copied().find(|k| k.name() == name)
|
|
}
|
|
|
|
/// Whether driving this kind requires an adoption pin whose outcome the
|
|
/// row must assert.
|
|
pub const fn carries_adoption(self) -> bool {
|
|
match self {
|
|
PayloadKind::Inline => false,
|
|
PayloadKind::StagedProjection => true,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The physical state class each failpoint produces.
|
|
///
|
|
/// One arm per failpoint, deliberately unfactored. Grouping the arms would
|
|
/// save lines and lose the property that adding a failpoint breaks the build
|
|
/// here rather than inheriting a neighbour's class — the failure mode contract
|
|
/// review 2026-07-24-A was about.
|
|
pub const fn physical_state_class(point: Failpoint) -> PhysicalStateClass {
|
|
match point {
|
|
Failpoint::BeforeAppend => PhysicalStateClass::NoBytes,
|
|
Failpoint::AfterMarkedResolving => PhysicalStateClass::NoBytes,
|
|
Failpoint::EvidenceHandoffFailure => PhysicalStateClass::NoBytes,
|
|
|
|
Failpoint::DuringFrameWriteTorn => PhysicalStateClass::PartialFrame,
|
|
|
|
Failpoint::AfterFrameWrite => PhysicalStateClass::WholeFrameUnfenced,
|
|
Failpoint::BeforeFence => PhysicalStateClass::WholeFrameUnfenced,
|
|
Failpoint::FenceFailed => PhysicalStateClass::WholeFrameUnfenced,
|
|
Failpoint::FenceAmbiguous => PhysicalStateClass::WholeFrameUnfenced,
|
|
Failpoint::WriterPanicBeforeFence => PhysicalStateClass::WholeFrameUnfenced,
|
|
|
|
Failpoint::AfterSuccessfulFence => PhysicalStateClass::WholeFrameFenced,
|
|
Failpoint::DuringCommittedRootBuild => PhysicalStateClass::WholeFrameFenced,
|
|
Failpoint::AllocationFailureBeforePublication => PhysicalStateClass::WholeFrameFenced,
|
|
Failpoint::BeforeRootCas => PhysicalStateClass::WholeFrameFenced,
|
|
Failpoint::DuringRootCasRetry => PhysicalStateClass::WholeFrameFenced,
|
|
Failpoint::WriterPanicAfterFence => PhysicalStateClass::WholeFrameFenced,
|
|
|
|
Failpoint::AfterRootCasBeforeWaiterWake => PhysicalStateClass::WholeFrameFencedAndPublished,
|
|
Failpoint::BeforeResponse => PhysicalStateClass::WholeFrameFencedAndPublished,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Victim placement
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Where in a group of `group_len` frames a failpoint's victim sits.
|
|
///
|
|
/// Recorded per row in the fixture so a reviewer can see that a post-fence row
|
|
/// is group-wide rather than assume it.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
|
pub enum VictimPlacement {
|
|
/// The fault precedes any frame, so the victim is frame 0 and no frame of
|
|
/// the group is written.
|
|
First,
|
|
/// The fault truncates one frame. The harness tears the last frame of the
|
|
/// group, which degenerates to frame 0 at `group_len == 1`.
|
|
Last,
|
|
/// The fault has no per-frame victim: the whole group's bytes were emitted
|
|
/// before it fired. The victim index is `group_len`, which is what makes
|
|
/// "∃ p ≤ victim_index" permit the whole group.
|
|
GroupWide,
|
|
}
|
|
|
|
impl VictimPlacement {
|
|
pub const ALL: &'static [VictimPlacement] = &[
|
|
VictimPlacement::First,
|
|
VictimPlacement::Last,
|
|
VictimPlacement::GroupWide,
|
|
];
|
|
|
|
pub const fn name(self) -> &'static str {
|
|
match self {
|
|
VictimPlacement::First => "first",
|
|
VictimPlacement::Last => "last",
|
|
VictimPlacement::GroupWide => "group-wide",
|
|
}
|
|
}
|
|
|
|
pub fn from_name(name: &str) -> Option<Self> {
|
|
Self::ALL.iter().copied().find(|p| p.name() == name)
|
|
}
|
|
|
|
/// Resolve to a concrete victim index for a group of `group_len` frames.
|
|
pub fn index(self, group_len: usize) -> usize {
|
|
match self {
|
|
VictimPlacement::First => 0,
|
|
VictimPlacement::Last => group_len.saturating_sub(1),
|
|
VictimPlacement::GroupWide => group_len,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The placement each physical state class implies.
|
|
///
|
|
/// This is derived from the class rather than stated per failpoint, because it
|
|
/// is a property of what the device holds, not of where in the code the fault
|
|
/// was injected.
|
|
pub const fn victim_placement(class: PhysicalStateClass) -> VictimPlacement {
|
|
match class {
|
|
PhysicalStateClass::NoBytes => VictimPlacement::First,
|
|
PhysicalStateClass::PartialFrame => VictimPlacement::Last,
|
|
PhysicalStateClass::WholeFrameUnfenced => VictimPlacement::GroupWide,
|
|
PhysicalStateClass::WholeFrameFenced => VictimPlacement::GroupWide,
|
|
PhysicalStateClass::WholeFrameFencedAndPublished => VictimPlacement::GroupWide,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The group expectation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A window of legal contiguous adopted prefixes.
|
|
///
|
|
/// Recovery is correct for this group iff it adopted frames `0..p` and nothing
|
|
/// else, for some `p` in `min_prefix..=max_prefix`. `max_prefix` never exceeds
|
|
/// the victim index, which is the "nothing at or after the victim is ever
|
|
/// committed" half of the contract.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
|
pub struct AdoptedPrefixExpectation {
|
|
pub point: Failpoint,
|
|
pub class: PhysicalStateClass,
|
|
pub group_len: usize,
|
|
pub victim_index: usize,
|
|
pub min_prefix: usize,
|
|
pub max_prefix: usize,
|
|
}
|
|
|
|
impl AdoptedPrefixExpectation {
|
|
/// The outcome the model permits for one frame of the group.
|
|
///
|
|
/// Below `min_prefix` the frame is committed under every legal `p`; at or
|
|
/// above `max_prefix` it is absent under every legal `p`; in between the
|
|
/// two the model is genuinely undetermined, which is `EitherWhole` and is
|
|
/// the only honest answer for an unfenced frame.
|
|
pub fn outcome_for_frame(&self, frame_index: usize) -> RecoveryOutcome {
|
|
if frame_index < self.min_prefix {
|
|
RecoveryOutcome::Committed
|
|
} else if frame_index >= self.max_prefix {
|
|
RecoveryOutcome::AbsentRetriable
|
|
} else {
|
|
RecoveryOutcome::EitherWhole
|
|
}
|
|
}
|
|
|
|
/// The outcome for the victim frame itself. Equal to the class's required
|
|
/// outcome by construction; the matrix asserts the equality rather than
|
|
/// assuming it.
|
|
pub fn victim_outcome(&self) -> RecoveryOutcome {
|
|
self.class.required_outcome()
|
|
}
|
|
|
|
/// Structural invariants the model must satisfy for any input.
|
|
pub fn check_invariants(&self) {
|
|
assert!(self.group_len >= 1, "a group has at least one frame");
|
|
assert!(
|
|
self.victim_index <= self.group_len,
|
|
"victim {} is outside a group of {}",
|
|
self.victim_index,
|
|
self.group_len
|
|
);
|
|
assert!(
|
|
self.min_prefix <= self.max_prefix,
|
|
"empty prefix window for {:?}",
|
|
self.point
|
|
);
|
|
assert!(
|
|
self.max_prefix <= self.victim_index,
|
|
"the model must never permit adopting the victim frame or anything \
|
|
after it: {:?} allows p={} with victim {}",
|
|
self.point,
|
|
self.max_prefix,
|
|
self.victim_index
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Layered group expectation. Scope 4-A3 deliverable 3, ruling 9.3.
|
|
///
|
|
/// `victim_index` is the index of the first frame the fault prevented from
|
|
/// completing; it equals `group_len` for a fault with no per-frame victim.
|
|
/// The function is total: every `(victim_index, group_len)` pair with
|
|
/// `victim_index <= group_len` has a meaningful answer, so the matrix never
|
|
/// has to special-case a row.
|
|
pub fn group_failpoint_expectation(
|
|
point: Failpoint,
|
|
victim_index: usize,
|
|
group_len: usize,
|
|
) -> AdoptedPrefixExpectation {
|
|
assert!(group_len >= 1, "a group has at least one frame");
|
|
assert!(
|
|
victim_index <= group_len,
|
|
"victim index {victim_index} is outside a group of {group_len}"
|
|
);
|
|
|
|
let class = physical_state_class(point);
|
|
let (min_prefix, max_prefix) = match class {
|
|
// No byte of the group reached the file, so p is pinned at 0
|
|
// regardless of where the caller places the victim.
|
|
PhysicalStateClass::NoBytes => (0, 0),
|
|
|
|
// Frames before the victim are byte-complete but unfenced, so each is
|
|
// independently either-whole; the victim is truncated and can never be
|
|
// adopted; everything after it was never written. p ranges over the
|
|
// whole window below the victim.
|
|
PhysicalStateClass::PartialFrame => (0, victim_index),
|
|
|
|
// Every frame below the victim is byte-complete and unfenced. Same
|
|
// window as the torn case — which is the point: an unfenced whole
|
|
// frame and an unfenced whole frame sitting before a tear are the same
|
|
// physical situation, and only the victim differs.
|
|
PhysicalStateClass::WholeFrameUnfenced => (0, victim_index),
|
|
|
|
// The fence returned success over everything written before it, which
|
|
// is frames `0..victim_index`. p is pinned, not a window: a fence that
|
|
// returned success and then lost data is a hardware finding surfaced
|
|
// by the external ACK journal (scope 3.8), never a legal outcome the
|
|
// model may admit.
|
|
PhysicalStateClass::WholeFrameFenced => (victim_index, victim_index),
|
|
PhysicalStateClass::WholeFrameFencedAndPublished => (victim_index, victim_index),
|
|
};
|
|
|
|
let expectation = AdoptedPrefixExpectation {
|
|
point,
|
|
class,
|
|
group_len,
|
|
victim_index,
|
|
min_prefix,
|
|
max_prefix,
|
|
};
|
|
expectation.check_invariants();
|
|
expectation
|
|
}
|
|
|
|
/// The expectation for a row driven with the canonical placement of its class.
|
|
pub fn canonical_group_expectation(point: Failpoint, group_len: usize) -> AdoptedPrefixExpectation {
|
|
let placement = victim_placement(physical_state_class(point));
|
|
group_failpoint_expectation(point, placement.index(group_len), group_len)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Prefix verification
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// How an observed adoption set fails the contiguous-prefix contract.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum PrefixViolation {
|
|
/// Recovery adopted a frame that sits after a gap. This is the bug the
|
|
/// whole prefix formulation exists to catch: recovery step 6 stops at the
|
|
/// first incomplete frame unconditionally, even when a later region holds
|
|
/// a syntactically complete, checksum-valid frame.
|
|
AdoptedPastAHole { expected_next: u64, observed: u64 },
|
|
/// The adopted set is not sorted/unique, so it is not a prefix at all.
|
|
NotAscendingUnique { observed: Vec<u64> },
|
|
/// Recovery started somewhere other than the group's first sequence.
|
|
WrongStart { expected: u64, observed: u64 },
|
|
/// More frames adopted than the failpoint can possibly have made durable.
|
|
PrefixTooLong { adopted: usize, max_prefix: usize },
|
|
/// Fewer frames adopted than a successful fence guarantees.
|
|
PrefixTooShort { adopted: usize, min_prefix: usize },
|
|
}
|
|
|
|
/// Prove an observed adoption set is a contiguous prefix inside the model's
|
|
/// window.
|
|
///
|
|
/// `observed` is the set of `shard_sequence` values recovery adopted **from
|
|
/// this group**, and `first_sequence` is the sequence assigned to frame 0.
|
|
/// Returns the adopted prefix length `p` on success.
|
|
pub fn verify_adopted_prefix(
|
|
observed: &[u64],
|
|
first_sequence: u64,
|
|
expectation: &AdoptedPrefixExpectation,
|
|
) -> Result<usize, PrefixViolation> {
|
|
let report = classify_adopted_set(observed);
|
|
if report.repeated_adoptions() != 0 {
|
|
return Err(PrefixViolation::NotAscendingUnique {
|
|
observed: observed.to_vec(),
|
|
});
|
|
}
|
|
|
|
if let Some(first) = observed.first() {
|
|
if *first != first_sequence {
|
|
return Err(PrefixViolation::WrongStart {
|
|
expected: first_sequence,
|
|
observed: *first,
|
|
});
|
|
}
|
|
}
|
|
|
|
if let Some(SequenceFault::ForwardGap {
|
|
expected_next,
|
|
observed,
|
|
}) = report.first_fault
|
|
{
|
|
return Err(PrefixViolation::AdoptedPastAHole {
|
|
expected_next,
|
|
observed,
|
|
});
|
|
}
|
|
|
|
let adopted = observed.len();
|
|
if adopted > expectation.max_prefix {
|
|
return Err(PrefixViolation::PrefixTooLong {
|
|
adopted,
|
|
max_prefix: expectation.max_prefix,
|
|
});
|
|
}
|
|
if adopted < expectation.min_prefix {
|
|
return Err(PrefixViolation::PrefixTooShort {
|
|
adopted,
|
|
min_prefix: expectation.min_prefix,
|
|
});
|
|
}
|
|
Ok(adopted)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Outcome admission
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Whether a concrete observed tail fact is admitted by an expected outcome.
|
|
///
|
|
/// `EitherWhole` is the only outcome that admits two facts, and it admits them
|
|
/// only through the frozen `resolve_ambiguous_tail`, so an implementation
|
|
/// cannot resolve an ambiguous fence deterministically before recovery re-reads
|
|
/// the device (scope 5 charter item 4).
|
|
pub fn outcome_admits(expected: RecoveryOutcome, fact: RecoveredTailFact) -> bool {
|
|
let resolved = levcs_protocol::oracle::resolve_ambiguous_tail(fact);
|
|
match expected {
|
|
RecoveryOutcome::AbsentRetriable => resolved == RecoveryOutcome::AbsentRetriable,
|
|
RecoveryOutcome::Committed => resolved == RecoveryOutcome::Committed,
|
|
RecoveryOutcome::EitherWhole => {
|
|
resolved == RecoveryOutcome::AbsentRetriable || resolved == RecoveryOutcome::Committed
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The frozen oracle's half of the matrix's two derivations.
|
|
pub fn oracle_recovery_outcome(point: Failpoint) -> RecoveryOutcome {
|
|
append_publication_expectation(point.into()).recovery_outcome
|
|
}
|