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

1188 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Driving a crash-matrix row through the production `StoreEngine::submit`.
//!
//! **Owned by B4 StoreHarnessB** (scope 2.1, 6.6 deliverable 2).
//!
//! Wave A could assert only the physical-state-class and `recovery_outcome`
//! halves of a [`FailpointExpectation`], because `drive.rs` has no status
//! root, no sequencer, and no acknowledgment path. This module supplies the
//! other four. Every observation here is made through a method a *consumer*
//! calls — `StoreEngine::submit`, `StoreEngine::transaction_status`,
//! `StoreEngine::open`, `StoreEngine::durability_counters` — and never through
//! an internal helper, because scope 5 charter item 8 is precisely about a
//! safety property that is asserted on a function production does not call.
//!
//! # Why this is in-process and the Wave A rows are not
//!
//! A Wave A row's action set includes `HardExit`, which is `_exit(3)`: it can
//! only be observed from a parent process. The eight Wave B rows are driven
//! with `Fail` and `Panic`, both of which leave the process alive, and the
//! four fields they add — `shard_poisoned`, `immediate_status`,
//! `acknowledgment_allowed`, `later_append_allowed_before_recovery` — are
//! statements about a *live* engine that no post-mortem reopen can make. A
//! child process that has already died cannot be asked whether its shard would
//! have accepted another append.
//!
//! `recovery_outcome` is still observed the Wave A way — close the engine and
//! reopen through production recovery in a fresh `StoreEngine::open` — so the
//! two waves answer that half by the same means.
//!
//! # No catch-all arm
//!
//! Same rule as `crash_matrix.rs`. Every `TransactionStatus` variant, every
//! `FailpointAction`, and every `ImmediateStatus` is named. A fallback binding
//! here would silently classify a status nobody expected as the one the oracle
//! happened to want.
#![allow(dead_code)]
use std::future::Future;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use levcs_core::{ObjectId, ObjectType};
use levcs_protocol::oracle::{ImmediateStatus, RecoveredTailFact};
use levcs_protocol::v2::TransactionEvidenceV1;
use levcs_store::failpoints::{Failpoint, FailpointAction};
use levcs_store::options::StoreOptions;
use levcs_store::segment::RootLayout;
use levcs_store::transaction::StagedObject;
use levcs_store::types::{
CommitEvidenceSigner, CommitReceipt, NamespaceId, OperationId, SignerError, StoreError,
TransactionStatus,
};
use levcs_store::{StoreEngine, ValidatedTransaction};
// ---------------------------------------------------------------------------
// Building the root through production
// ---------------------------------------------------------------------------
/// Create the root **through the entry point a consumer calls**, and prove that
/// is what happened.
///
/// # What this replaces, and why the replacement is the point
///
/// Until B1 landed startup state 1, `StoreEngine::open` refused to initialize
/// an absent root, so a store about to be exercised through the production
/// `submit` could not be *created* through the production `open`. These rows
/// seeded it with `segment::initialize_root` instead, and that weakening was
/// disclosed here, in the fixture, and in three places in the B4 report:
/// everything below asserted against the production path over a root
/// production had not built. Charter item 8 is precisely about that shape.
///
/// State 1 exists now, so the weakening is closed rather than merely
/// re-described. The assertions around the call are what make this a check and
/// not a rename: the root is confirmed absent before, and the `FORMAT` marker
/// is confirmed present after, so a future `open` that quietly stopped
/// initializing would fail here instead of silently returning the harness to
/// seeding its own store.
pub fn open_absent_root(root: &Path, shard_count: u16, max_index_runs: u32) -> StoreEngine {
let layout = RootLayout::new(root);
assert!(
!layout.format_path().exists(),
"the row must build its store through StoreEngine::open, so the root must be \
absent before it: {}",
root.display()
);
let engine = StoreEngine::open(options_with_index_runs(root, shard_count, max_index_runs))
.expect("StoreEngine::open initializes an absent root (startup state 1)");
assert!(
layout.format_path().exists(),
"StoreEngine::open returned without writing FORMAT, so the store these rows \
exercise was not built by the production entry point after all"
);
engine
}
// ---------------------------------------------------------------------------
// The injected signer
// ---------------------------------------------------------------------------
/// A deterministic stand-in for instance composition's signer.
///
/// The store never verifies a signature (plan §5.1 forbids it from deciding
/// who may sign), so this does not need to be Ed25519. It needs to be a pure
/// function of the digest it is handed, and it needs to count its calls, so a
/// row that claims nothing was signed can be checked rather than believed.
#[derive(Default)]
pub struct CountingSigner {
pub calls: AtomicU64,
}
impl CommitEvidenceSigner for CountingSigner {
fn key_epoch(&self) -> u64 {
11
}
fn public_key(&self) -> [u8; 32] {
[0x5a; 32]
}
fn sign_event(&self, signing_digest: &ObjectId) -> Result<[u8; 64], SignerError> {
self.calls.fetch_add(1, Ordering::SeqCst);
let mut signature = [0u8; 64];
let first = blake3::hash(signing_digest.as_bytes());
let second = blake3::hash(first.as_bytes());
signature[..32].copy_from_slice(first.as_bytes());
signature[32..].copy_from_slice(second.as_bytes());
Ok(signature)
}
}
/// `StoreOptions::max_index_runs`, at the value a deployment runs.
///
/// **The weakening this note used to disclose is gone.** It said that B1's
/// slice sealed no delta layer into an `IndexRun`, so `submit` refused
/// `NotImplemented` after exactly `max_index_runs` group publications and a
/// driver needing more had to raise the ceiling to run at all. Sealing is
/// implemented: the ceiling is drained by sealing rather than reached by
/// refusing, no `NotImplemented` refusal remains in the engine's production
/// paths, and a 45-second soak at this value published 5,192 groups — 81× the
/// ceiling — at a flat rate.
///
/// So the default is what every driver here uses, which is the point: a row
/// driven under a non-default ceiling is a row driven against a store nobody
/// ships.
pub const DEFAULT_MAX_INDEX_RUNS: u32 = 64;
pub fn options(root: &Path, shard_count: u16) -> StoreOptions {
options_with_index_runs(root, shard_count, DEFAULT_MAX_INDEX_RUNS)
}
pub fn options_with_index_runs(root: &Path, shard_count: u16, max_index_runs: u32) -> StoreOptions {
let mut options = StoreOptions::new(root);
options.shard_count = shard_count;
options.max_index_runs = max_index_runs;
options.max_open_index_runs = options.max_open_index_runs.min(max_index_runs);
// A group of one, formed promptly. Every Wave B row is about where in the
// publication of *this* group the fault lands, so a group that waits for
// company would only add scheduling noise to the observation.
options.max_group_transactions = 1;
options.max_group_bytes = 64 * 1024;
options.max_group_idle = Duration::from_millis(5);
options.journal_preallocate_bytes = 4 * 1024 * 1024;
options.signer = Some(Arc::new(CountingSigner::default()));
options
}
// ---------------------------------------------------------------------------
// Transactions
// ---------------------------------------------------------------------------
/// The client principal, deliberately not the signer's public key.
const EVIDENCE_ACTOR: [u8; 32] = [0x7e; 32];
/// Public because an adopting transaction cannot be assembled from
/// [`create_transaction`] or [`push_transaction`]: it carries no inline
/// objects, so B1's projection-adoption tests build their own and need the same
/// evidence, deadline, and genesis authority these two produce. Two independent
/// notions of "the authority for this namespace" in one test binary would make
/// a mismatched-authority refusal look like a harness bug.
pub fn evidence() -> TransactionEvidenceV1 {
TransactionEvidenceV1::AdministrativeV1 {
actor: EVIDENCE_ACTOR,
actor_key_epoch: 11,
command_digest: ObjectId([0x33; 32]),
signature: [0x44; 64],
}
}
/// Public for the same reason as [`evidence`]: staging's `begin`, `put_chunk`,
/// `seal`, and `finalize` all take a caller-supplied clock reading.
pub fn now_micros() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_micros() as i64)
.unwrap_or(0)
}
pub fn deadline() -> i64 {
now_micros() + 600_000_000
}
pub fn genesis_id(namespace: &NamespaceId) -> ObjectId {
let mut bytes = [0u8; 32];
let mut hasher = blake3::Hasher::new();
hasher.update(b"levcs-store/b4/genesis-authority/v1\0");
hasher.update(namespace.as_bytes());
hasher.finalize_xof().fill(&mut bytes);
ObjectId(bytes)
}
/// A namespace that routes to `shard`, found by search rather than by
/// arithmetic over a routing rule this file would then own a second copy of.
pub fn namespace_on_shard(shard: u16, shard_count: u16, salt: u64) -> NamespaceId {
for attempt in 0..4096u64 {
let mut bytes = [0u8; 32];
let mut hasher = blake3::Hasher::new();
hasher.update(b"levcs-store/b4/namespace/v1\0");
hasher.update(&salt.to_le_bytes());
hasher.update(&attempt.to_le_bytes());
hasher.finalize_xof().fill(&mut bytes);
let namespace = NamespaceId(bytes);
if StoreOptions::shard_of(&namespace, shard_count) == shard {
return namespace;
}
}
panic!("no namespace routed to shard {shard} of {shard_count} in 4096 attempts");
}
pub fn create_transaction(namespace: NamespaceId, operation: u8) -> ValidatedTransaction {
let id = genesis_id(&namespace);
ValidatedTransaction::builder(privileged())
.namespace(namespace)
.operation(
OperationId([operation; 16]),
ObjectId([operation; 32]),
deadline(),
)
.create_repository(id)
.objects(vec![StagedObject {
id,
object_type: ObjectType::Authority,
raw: vec![0xa1; 32],
}])
.refs(Vec::new())
.authority(None, Some(id))
.evidence(evidence())
.build()
.expect("a complete create transaction")
}
pub fn push_transaction(namespace: NamespaceId, operation: u8, blob: u8) -> ValidatedTransaction {
let authority = genesis_id(&namespace);
ValidatedTransaction::builder(privileged())
.namespace(namespace)
.operation(
OperationId([operation; 16]),
ObjectId([operation; 32]),
deadline(),
)
.objects(vec![StagedObject {
id: ObjectId([blob; 32]),
object_type: ObjectType::Blob,
raw: vec![blob; 64],
}])
.refs(Vec::new())
.authority(Some(authority), Some(authority))
.evidence(evidence())
.build()
.expect("a complete push transaction")
}
/// Stage and finalize a two-chunk projection through the engine's own staging.
///
/// Two chunks rather than one for the same reason the B1 acceptance test uses
/// two: it is the smallest projection where per-chunk ownership is load-bearing,
/// and a crash row that only ever adopted one chunk would not exercise the
/// partitioned run set at all.
pub fn stage_projection_for(
engine: &StoreEngine,
namespace: NamespaceId,
seed: u8,
) -> levcs_store::staging::StagedProjectionAdoption {
let (binding, chunks) = projection_binding(namespace, seed);
let session = engine
.staging()
.begin(binding, now_micros())
.expect("staging admits the row's session");
for chunk in &chunks {
session
.put_chunk(chunk, now_micros())
.expect("the row's chunk lands");
}
session.seal(now_micros()).expect("the row's session seals");
session
.finalize(now_micros())
.expect("the row's sealed session yields an adoption pin")
}
/// The victim transaction when the row is driving the staged-projection axis.
pub fn adopting_transaction(
namespace: NamespaceId,
operation: u8,
adoption: levcs_store::staging::StagedProjectionAdoption,
) -> ValidatedTransaction {
let authority = genesis_id(&namespace);
ValidatedTransaction::builder(privileged())
.namespace(namespace)
.operation(
OperationId([operation; 16]),
ObjectId([operation; 32]),
deadline(),
)
// Empty: a frame carries inline objects or an install descriptor, never
// both, so the projection is this transaction's whole payload.
.objects(Vec::new())
.refs(Vec::new())
.authority(Some(authority), Some(authority))
.evidence(evidence())
.adopt_projection(adoption)
.expect("one adoption is admitted")
.build()
.expect("a complete adopting transaction")
}
fn projection_binding(
namespace: NamespaceId,
seed: u8,
) -> (
levcs_store::staging::ProjectionStageBinding,
Vec<levcs_protocol::v2::ProjectionStageChunkV1>,
) {
use levcs_core::{blake3_hash, ObjectHeader, FORMAT_VERSION};
use levcs_protocol::v2::{
ProjectionMode, ProjectionStageChunkV1, ProjectionStageManifestV1,
ProjectionStageSessionV1, StageSourceKindV1, StagedChunkObjectV1, StagedObjectV1,
};
const CHUNKS: u32 = 2;
const PER_CHUNK: usize = 1;
let session_id = [seed; 16];
let staged = |index: usize| -> StagedChunkObjectV1 {
let body = format!("row-staged-{seed:02x}-{index}");
let mut raw = ObjectHeader {
object_type: ObjectType::Blob,
format_version: FORMAT_VERSION,
body_len: body.len() as u64,
}
.encode()
.to_vec();
raw.extend_from_slice(body.as_bytes());
let id = blake3_hash(&raw);
StagedChunkObjectV1 {
descriptor: StagedObjectV1 {
object_id: id,
object_type: ObjectType::Blob as u8,
raw_len: raw.len() as u64,
raw_digest: id,
},
raw_bytes: raw,
}
};
let mut objects: Vec<StagedChunkObjectV1> =
(0..CHUNKS as usize * PER_CHUNK).map(staged).collect();
// The manifest is the ordered concatenation of the chunks and must be
// strictly sorted, so the sort happens before the split.
objects.sort_by(|left, right| left.descriptor.cmp(&right.descriptor));
let total_object_bytes = objects
.iter()
.map(|object| object.descriptor.raw_len)
.sum::<u64>();
let descriptors: Vec<StagedObjectV1> = objects
.iter()
.map(|object| object.descriptor.clone())
.collect();
let chunks: Vec<ProjectionStageChunkV1> = (0..CHUNKS)
.map(|ordinal| ProjectionStageChunkV1 {
session_id,
ordinal,
chunk_count: CHUNKS,
objects: objects[ordinal as usize * PER_CHUNK..][..PER_CHUNK].to_vec(),
})
.collect();
let chunk_digests: Vec<ObjectId> = chunks
.iter()
.map(|chunk| chunk.chunk_digest().expect("chunk digest"))
.collect();
let membership_root = blake3_hash(&session_id[..]);
let manifest = ProjectionStageManifestV1 {
session_id,
chunk_digests,
objects: descriptors,
membership_root,
};
let manifest_digest = manifest.manifest_digest().expect("manifest digest");
let authority = genesis_id(&namespace);
(
levcs_store::staging::ProjectionStageBinding {
session: ProjectionStageSessionV1 {
session_id,
destination_repo: ObjectId(*namespace.as_bytes()),
destination_genesis: authority,
expected_authority: authority,
projection: ProjectionMode::Full,
source_kind: StageSourceKindV1::Mirror,
actor: [0x21; 32],
actor_key_epoch: 3,
source_generation_digest: ObjectId([12; 32]),
fork_proof: None,
final_operation_id: [13; 16],
final_operation_digest: ObjectId([14; 32]),
final_evidence_digest: ObjectId([15; 32]),
total_object_count: objects.len() as u64,
total_object_bytes,
chunk_count: CHUNKS,
manifest_digest,
expires_at_micros: now_micros() + 3_600_000_000,
},
membership_root,
},
chunks,
)
}
fn privileged() -> levcs_store::types::PrivilegedConstruction {
levcs_store::types::PrivilegedConstruction::assert_validated()
}
// ---------------------------------------------------------------------------
// A local executor
// ---------------------------------------------------------------------------
/// Drive one future on this thread until it completes or `deadline` passes.
///
/// `None` is a real answer, not a failure: scope 6.3 says a failure to wake a
/// waiter leaves a *hung request*, never an absent transaction, and a harness
/// that cannot represent a hang cannot assert the difference.
/// `levcs-store` starts no runtime, so neither does this.
pub fn block_on_until<F: Future>(future: F, deadline: Duration) -> Option<F::Output> {
struct ThreadWaker(std::thread::Thread);
impl Wake for ThreadWaker {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
fn wake_by_ref(self: &Arc<Self>) {
self.0.unpark();
}
}
let waker = Waker::from(Arc::new(ThreadWaker(std::thread::current())));
let mut context = Context::from_waker(&waker);
let mut future = std::pin::pin!(future);
let started = Instant::now();
loop {
match future.as_mut().poll(&mut context) {
Poll::Ready(output) => return Some(output),
Poll::Pending => {
let elapsed = started.elapsed();
if elapsed >= deadline {
return None;
}
std::thread::park_timeout(deadline - elapsed);
}
}
}
}
/// How long a submit is given before it is called hung.
///
/// Generous, because the alternative failure mode is a flaky matrix: a
/// deadline tuned close to the observed latency turns an unrelated scheduling
/// hiccup into a "hung request" finding. A committing submit in this harness
/// takes single-digit milliseconds, so the margin is three orders of
/// magnitude. Only `AfterRootCasBeforeWaiterWake` with `Fail` is expected to
/// reach it — that row's whole claim is that the waiter never wakes.
pub const SUBMIT_DEADLINE: Duration = Duration::from_secs(5);
// ---------------------------------------------------------------------------
// One submit, with its outcome fully enumerated
// ---------------------------------------------------------------------------
/// What one call to `StoreEngine::submit` did. Every case is named; there is
/// no residual "something else" arm.
#[derive(Debug)]
pub enum SubmitOutcome {
/// The engine returned a receipt.
Committed(CommitReceipt),
/// The engine returned a typed refusal.
Refused(StoreError),
/// The call panicked in the caller's task and the panic was caught.
CallerPanicked,
/// The future never completed within `SUBMIT_DEADLINE`.
Hung,
}
impl SubmitOutcome {
pub fn receipt(&self) -> Option<&CommitReceipt> {
match self {
SubmitOutcome::Committed(receipt) => Some(receipt),
SubmitOutcome::Refused(_) => None,
SubmitOutcome::CallerPanicked => None,
SubmitOutcome::Hung => None,
}
}
pub fn error(&self) -> Option<&StoreError> {
match self {
SubmitOutcome::Refused(error) => Some(error),
SubmitOutcome::Committed(_) => None,
SubmitOutcome::CallerPanicked => None,
SubmitOutcome::Hung => None,
}
}
/// Whether the shard named itself poisoned in this call's own answer.
pub fn names_shard_poisoned(&self) -> bool {
matches!(self.error(), Some(StoreError::ShardPoisoned { .. }))
}
}
/// Submit one transaction, catching a panic raised in the caller's task.
///
/// `BeforeResponse` fires on the caller's side of the completion, so a
/// `Panic` there unwinds *here* and not on a writer thread. Catching it is not
/// leniency: the row's claim is that the transaction stays committed and its
/// receipt stays queryable even then, and a harness that dies with the caller
/// cannot check that.
pub fn submit(engine: &StoreEngine, transaction: ValidatedTransaction) -> SubmitOutcome {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
block_on_until(engine.submit(transaction), SUBMIT_DEADLINE)
}));
std::panic::set_hook(previous);
match caught {
Ok(Some(Ok(receipt))) => SubmitOutcome::Committed(receipt),
Ok(Some(Err(error))) => SubmitOutcome::Refused(error),
Ok(None) => SubmitOutcome::Hung,
Err(_) => SubmitOutcome::CallerPanicked,
}
}
// ---------------------------------------------------------------------------
// Status classification, exhaustively
// ---------------------------------------------------------------------------
/// Map a `TransactionStatus` onto the oracle's `ImmediateStatus`.
///
/// `Unknown` is `DefinitiveAbsent` and that is a claim worth stating rather
/// than assuming. `transaction_status` performs plan §5.1's two-root read: it
/// loads the committed root, then the status root, then the committed root
/// again. A reader that observes no entry in any of the three has synchronized
/// with the store that removed it, so "nothing is recorded" is a positive
/// finding about a linearizable read, not an absence of information.
///
/// `Pending` and `Expired` are enumerated and refused. Neither can arise from
/// a Wave B row — a group is marked `Resolving` before any byte is written and
/// a deadline is rechecked immediately before that — so seeing one is a
/// finding, and mapping it into a neighbouring bucket would hide it.
pub fn immediate_status_of(status: &TransactionStatus, row: &str) -> ImmediateStatus {
match status {
TransactionStatus::Committed(_) => ImmediateStatus::Committed,
TransactionStatus::Resolving { .. } => ImmediateStatus::Resolving,
TransactionStatus::Unknown => ImmediateStatus::DefinitiveAbsent,
TransactionStatus::Pending { phase, .. } => panic!(
"row {row}: transaction_status reported Pending({phase:?}); no Wave B failpoint \
can leave an operation in a pre-Resolving phase, so this is a finding rather \
than a status to classify"
),
TransactionStatus::Expired { .. } => panic!(
"row {row}: transaction_status reported Expired; the retry deadline in this \
harness is ten minutes out, so an expiry here means the deadline recheck ran \
against the wrong clock"
),
}
}
pub fn immediate_status_name(status: ImmediateStatus) -> &'static str {
match status {
ImmediateStatus::DefinitiveAbsent => "DefinitiveAbsent",
ImmediateStatus::Resolving => "Resolving",
ImmediateStatus::Committed => "Committed",
}
}
/// What a reopened store says about an operation, as a tail fact the frozen
/// `outcome_admits` relation can be applied to.
pub fn recovered_fact(status: &TransactionStatus, row: &str) -> RecoveredTailFact {
match status {
TransactionStatus::Committed(_) => RecoveredTailFact::CompleteChecksumValid,
TransactionStatus::Unknown => RecoveredTailFact::AbsentOrTorn,
TransactionStatus::Resolving { .. } => panic!(
"row {row}: an operation is still Resolving after a close and reopen through \
production recovery. Recovery publishes a committed root and a receipt table \
exactly once (scope 3.8 step 11); a surviving Resolving entry would mean the \
status root outlived the process that owned it"
),
TransactionStatus::Pending { .. } => panic!(
"row {row}: a reopened store reported Pending, which no recovery path constructs"
),
TransactionStatus::Expired { .. } => panic!(
"row {row}: a reopened store reported Expired for an operation whose deadline is \
ten minutes out"
),
}
}
// ---------------------------------------------------------------------------
// Arming
// ---------------------------------------------------------------------------
pub fn action_name(action: FailpointAction) -> &'static str {
match action {
FailpointAction::Continue => "continue",
FailpointAction::Fail => "fail",
FailpointAction::Panic => "panic",
FailpointAction::HardExit => "hard-exit",
}
}
pub fn action_from_name(name: &str) -> Option<FailpointAction> {
[
FailpointAction::Continue,
FailpointAction::Fail,
FailpointAction::Panic,
FailpointAction::HardExit,
]
.into_iter()
.find(|action| action_name(*action) == name)
}
/// Exclusive use of the process-global failpoint and fault registries.
///
/// The same token `drive.rs` hands the Wave A driver, taken for the same
/// reason: the registries are one-shot globals, and a test that merely submits
/// can consume an arming another test placed.
pub type Serial = levcs_store::drive::faults::FaultSerial;
pub fn serial() -> Serial {
levcs_store::drive::faults::serial()
}
pub fn arm(serial: &Serial, point: Failpoint, action: FailpointAction) {
levcs_store::failpoints::arm(serial, point, action);
}
pub fn disarm(serial: &Serial) {
levcs_store::failpoints::disarm(serial);
}
// ---------------------------------------------------------------------------
// Reopening after a close
// ---------------------------------------------------------------------------
/// Reopen a root after its engine was dropped. **One attempt.**
///
/// # This is an assertion, and it used to be a wait
///
/// `StoreEngine::drop` closes every shard channel and joins every writer
/// thread, so the root lock is released by the time `drop` returns. Measured
/// against the code as it stood, it was not: reopening immediately failed
/// `AlreadyLocked` in roughly one run in six, and this function waited up to
/// thirty seconds for the lock while publishing the attempt count.
///
/// Commit `e050b6d` fixed the cause rather than the symptom. `flock` locks live
/// on the *open file description*, so a concurrently forked child that
/// inherited the descriptor kept the lock alive past the close;
/// `segment::lock_root` now returns an RAII `RootLock` that issues an explicit
/// `LOCK_UN` before dropping the descriptor. The wait was correct while the
/// defect stood, and it must not outlive it: scope 3.1 says `AlreadyLocked` is
/// a refusal and **never a wait**, so a harness that waits on it asserts
/// something the store does not promise, and a wait that succeeds on the second
/// try is exactly how a recurrence of this defect would stay invisible.
///
/// What this buys, stated because it is the point: the lead's own one-attempt
/// assertion sits in `segment.rs` against `lock_root`, one level *below* the
/// entry point a consumer calls. This one is at production altitude, through
/// `StoreEngine::open` — charter item 8. Nothing else asserts it there.
pub fn reopen_after_close(root: &Path, shard_count: u16, max_index_runs: u32) -> StoreEngine {
match StoreEngine::open(options_with_index_runs(root, shard_count, max_index_runs)) {
Ok(engine) => engine,
Err(StoreError::AlreadyLocked) => panic!(
"the root lock was still held on the first StoreEngine::open after \
StoreEngine::drop returned. Commit e050b6d releases it explicitly in \
RootLock::drop, and scope 3.1 makes AlreadyLocked a refusal rather than a \
wait, so this is a recurrence of that defect and not a slow reclaim to \
sleep through."
),
Err(other) => panic!("reopen through production recovery failed: {other:?}"),
}
}
// ---------------------------------------------------------------------------
// One driven row
// ---------------------------------------------------------------------------
/// Everything one Wave B row's run observed, through consumer-callable methods
/// only.
///
/// The four fields Wave A could not reach are derived here from **four
/// different observations**, not from one restated four ways:
///
/// | field | observation |
/// |---|---|
/// | `shard_poisoned` | the store names itself poisoned — a `StoreError::ShardPoisoned` from the victim's own answer or the probe's |
/// | `immediate_status` | `transaction_status` on the victim operation, which is plan §5.1's two-root read |
/// | `acknowledgment_allowed` | a `CommitReceipt` is obtainable for the victim, from `submit`'s return value or from a retrying status read |
/// | `later_append_allowed_before_recovery` | a *different* transaction submitted to the same shard commits |
///
/// The last two are genuinely distinct from the first two. A shard can be
/// unpoisoned and still refuse a later append, because a dead writer thread
/// makes `later_append_allowed_before_recovery` false whatever the error says —
/// that is the defect B1's phase-aware panic ownership closes, and folding the
/// two into one observation would have made it invisible here.
pub struct RowObservation {
pub row: String,
pub action: FailpointAction,
pub victim: SubmitOutcome,
pub immediate: TransactionStatus,
pub probe: SubmitOutcome,
pub recovered: TransactionStatus,
/// The transaction committed *before* the failpoint was armed. Every row
/// also proves an already-fenced, already-acknowledged transaction
/// survives, exactly as the Wave A rows prove it for a prior group.
pub prior_recovered: TransactionStatus,
/// `fdatasync` on the victim's shard, immediately before the failpoint was
/// armed and immediately after the victim's submit resolved. Charter item
/// 7: the physical state class is checked against a syscall count, not
/// against a comment about where the fence sits.
pub fences_before: u64,
pub fences_after: u64,
/// Staging's four adoption-pin terminal counters, read on the live engine
/// **before** it is dropped. A pin settled by an unwind or by a slot's
/// `Drop` is recorded here and nowhere else: the counters live in the
/// engine-owned staging and go with it, and reopening builds fresh ones.
///
/// `None` for an inline row, which takes no pin. That is a different fact
/// from "took a pin and settled it zero times", and collapsing the two
/// would let a row that silently stopped adopting keep passing.
pub adoption: Option<AdoptionCounters>,
}
/// The four ways an admitted pin can end, as staging counts them.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct AdoptionCounters {
pub adopted: u64,
pub released: u64,
pub transferred: u64,
pub dropped: u64,
}
impl AdoptionCounters {
/// Which terminal outcome this row's pin actually reached.
///
/// `None` when no pin settled at all, and an error when more than one did:
/// a single adoption has exactly one outcome, and two counters moving means
/// the pin was settled twice or two pins were admitted where one was
/// expected.
pub fn sole_outcome(&self) -> Result<Option<&'static str>, String> {
let mut seen: Vec<&'static str> = Vec::new();
for (count, name) in [
(self.adopted, "Adopted"),
(self.released, "DefinitivePreAppendFailure"),
(self.transferred, "TransferredToRecovery"),
(self.dropped, "DroppedWithoutOutcome"),
] {
if count > 1 {
return Err(format!(
"{name} was recorded {count} times for one adoption"
));
}
if count == 1 {
seen.push(name);
}
}
match seen.as_slice() {
[] => Ok(None),
[one] => Ok(Some(one)),
many => Err(format!("one adoption reached {many:?}")),
}
}
}
impl RowObservation {
pub fn shard_poisoned_observed(&self) -> bool {
self.victim.names_shard_poisoned() || self.probe.names_shard_poisoned()
}
pub fn later_append_allowed_observed(&self) -> bool {
matches!(self.probe, SubmitOutcome::Committed(_))
}
/// A receipt is obtainable for the victim operation through a path a
/// consumer calls.
///
/// `submit` returning one is the direct case. A status read returning
/// `Committed` is the retry case, and it is not redundant: a hung waiter
/// gets no return value at all, and the row's claim is precisely that the
/// transaction is still acknowledgeable then.
pub fn acknowledgment_allowed_observed(&self) -> bool {
self.victim.receipt().is_some() || matches!(self.immediate, TransactionStatus::Committed(_))
}
pub fn fences_during_victim(&self) -> u64 {
self.fences_after - self.fences_before
}
}
/// Drive one Wave B row on a single-shard root.
///
/// The shape mirrors the Wave A driving loop: a transaction that commits
/// cleanly first, then the victim with the failpoint armed, then the
/// classification. The difference is that the classification happens on a live
/// engine as well as on a reopened one.
pub fn drive_submit_row(
serial: &Serial,
point: Failpoint,
action: FailpointAction,
adopts: bool,
) -> RowObservation {
let row = format!(
"{} [{}] [{}]",
point.name(),
action_name(action),
if adopts {
"staged_projection"
} else {
"inline"
}
);
let directory = tempfile::tempdir().expect("tempdir");
let root = directory.path().join("root");
let namespace = namespace_on_shard(0, 1, 0x5643_5342);
let prior_operation = OperationId([0x11; 16]);
let victim_operation = OperationId([0x22; 16]);
// Built by production, not merely exercised through it.
let engine = open_absent_root(&root, 1, DEFAULT_MAX_INDEX_RUNS);
let prior = submit(&engine, create_transaction(namespace, 0x11));
assert!(
matches!(prior, SubmitOutcome::Committed(_)),
"row {row}: the transaction before the fault must commit cleanly, or the row \
proves nothing about what the fault cost: {prior:?}"
);
let fences_before = engine
.durability_counters(0)
.expect("shard 0 has a writer")
.fdatasync;
// Staged *before* the failpoint is armed. Staging writes chunks through
// maintenance workers, and arming first would let the row's fault fire on
// staging's own I/O rather than on the submit under test.
let staged = adopts.then(|| stage_projection_for(&engine, namespace, 0x22));
arm(serial, point, action);
let victim = match staged {
Some(adoption) => submit(&engine, adopting_transaction(namespace, 0x22, adoption)),
None => submit(&engine, push_transaction(namespace, 0x22, 0xb2)),
};
let fences_after = engine
.durability_counters(0)
.expect("shard 0 has a writer")
.fdatasync;
let immediate = engine
.transaction_status(namespace, victim_operation)
.expect("transaction_status is a read and never fails on an open engine");
// Read while the engine is alive: staging's counters are engine-owned and a
// reopen builds fresh ones, so a pin settled by an unwind is only visible
// here.
let adoption = adopts.then(|| {
let counters = engine.staging().counters().snapshot();
AdoptionCounters {
adopted: counters.sessions_adopted,
released: counters.adoption_pins_released,
transferred: counters.adoption_pins_transferred,
dropped: counters.adoption_pins_dropped,
}
});
let probe = submit(&engine, push_transaction(namespace, 0x33, 0xb3));
// Whatever happened, nothing may stay armed for the next row: the registry
// is a one-shot global and a row that did not fire would otherwise hand its
// arming to whoever submits next.
disarm(serial);
drop(engine);
let reopened = reopen_after_close(&root, 1, DEFAULT_MAX_INDEX_RUNS);
let recovered = reopened
.transaction_status(namespace, victim_operation)
.expect("status read");
let prior_recovered = reopened
.transaction_status(namespace, prior_operation)
.expect("status read");
drop(reopened);
RowObservation {
row,
action,
victim,
immediate,
probe,
recovered,
prior_recovered,
fences_before,
fences_after,
adoption,
}
}
// ---------------------------------------------------------------------------
// DuringRootCasRetry: the one location behind a lost compare-and-swap
// ---------------------------------------------------------------------------
/// Shards used by the contention driver.
///
/// The committed root is one `ArcSwap` for the whole store, so contention on
/// it is contention *between shards*. One shard can never reach
/// `DuringRootCasRetry`, however long it runs, because nothing else ever
/// swaps the root out from under it.
pub const CONTENTION_SHARDS: u16 = 8;
/// Objects per contending transaction.
///
/// The window this row needs is the interval between `publish_subtree`'s load
/// of the committed root and its compare-and-swap, and the work inside that
/// window is `CommittedRoot::merge`. A transaction carrying one object leaves a
/// window of a few hundred nanoseconds; carrying this many leaves a window
/// wide enough that eight shards publishing concurrently collide within
/// seconds rather than within a statistical hope. This widens a real window in
/// the production path — it does not add one.
const CONTENTION_OBJECTS: usize = 48;
fn contending_transaction(namespace: NamespaceId, operation: u64) -> ValidatedTransaction {
let authority = genesis_id(&namespace);
let mut operation_id = [0u8; 16];
operation_id[..8].copy_from_slice(&operation.to_le_bytes());
operation_id[8..].copy_from_slice(namespace.as_bytes()[..8].try_into().expect("8 bytes"));
let objects = (0..CONTENTION_OBJECTS as u64)
.map(|index| {
let mut bytes = [0u8; 32];
let mut hasher = blake3::Hasher::new();
hasher.update(b"levcs-store/b4/contention-object/v1\0");
hasher.update(namespace.as_bytes());
hasher.update(&operation.to_le_bytes());
hasher.update(&index.to_le_bytes());
hasher.finalize_xof().fill(&mut bytes);
StagedObject {
id: ObjectId(bytes),
object_type: ObjectType::Blob,
raw: bytes.to_vec(),
}
})
.collect();
let mut digest = [0u8; 32];
digest[..16].copy_from_slice(&operation_id);
ValidatedTransaction::builder(privileged())
.namespace(namespace)
.operation(OperationId(operation_id), ObjectId(digest), deadline())
.objects(objects)
.refs(Vec::new())
.authority(Some(authority), Some(authority))
.evidence(evidence())
.build()
.expect("a complete contending transaction")
}
/// A submit with no panic-hook manipulation, for use from several threads at
/// once.
///
/// The single-row driver installs a silent hook so a `Panic` row does not spray
/// a backtrace over the test output. Doing that from eight threads would be a
/// race on a process-global, so the contention driver accepts the noise
/// instead. `DuringRootCasRetry` panics on a *writer* thread, never on the
/// caller's, so nothing here needs catching.
fn submit_plain(engine: &StoreEngine, transaction: ValidatedTransaction) -> SubmitOutcome {
match block_on_until(engine.submit(transaction), SUBMIT_DEADLINE) {
Some(Ok(receipt)) => SubmitOutcome::Committed(receipt),
Some(Err(error)) => SubmitOutcome::Refused(error),
None => SubmitOutcome::Hung,
}
}
/// What the contention driver measured, whether or not it fired.
pub struct ContentionRun {
pub observation: Option<RowObservation>,
/// A refusal seen during the run that is not the one this location
/// produces. Non-`None` means the run observed something the row does not
/// describe, and the row must not be asserted from it.
pub anomaly: Option<(u16, String)>,
/// Transactions submitted across every shard before the location was
/// reached. Reported so "it fired" is a measurement rather than a claim,
/// and so a regression that makes the window narrower shows up as a number
/// climbing rather than as an intermittent failure.
pub attempts: u64,
pub elapsed: Duration,
}
/// Drive `DuringRootCasRetry` by manufacturing a genuinely lost
/// compare-and-swap.
///
/// **This is the one Wave B row whose location is not reachable by submitting
/// a transaction.** It sits inside `publish_subtree`'s retry loop, after a
/// failed `compare_and_swap`, so it is reached only when another shard
/// publishes between this shard's load and its swap. B1 disclosed that the
/// failpoint is never armed by any existing test; this is what arms it.
///
/// The contention is real, not simulated: eight shard writer threads publish
/// into the same `ArcSwap<CommittedRoot>` through the production path, and the
/// only thing the harness chooses is how much work each publication carries
/// into the window. Nothing here reaches into the engine.
pub fn drive_root_cas_retry_row(
serial: &Serial,
action: FailpointAction,
budget: Duration,
) -> ContentionRun {
use std::sync::atomic::AtomicBool;
use std::sync::Mutex;
let directory = tempfile::tempdir().expect("tempdir");
let root = directory.path().join("root");
let namespaces: Vec<NamespaceId> = (0..CONTENTION_SHARDS)
.map(|shard| namespace_on_shard(shard, CONTENTION_SHARDS, 0x0CA5_0000 + shard as u64))
.collect();
let engine = open_absent_root(&root, CONTENTION_SHARDS, DEFAULT_MAX_INDEX_RUNS);
for (index, namespace) in namespaces.iter().enumerate() {
let created = submit_plain(&engine, create_transaction(*namespace, 0x40 + index as u8));
assert!(
matches!(created, SubmitOutcome::Committed(_)),
"the repository on shard {index} must be created before the contention run: \
{created:?}"
);
}
arm(serial, Failpoint::DuringRootCasRetry, action);
let stop = AtomicBool::new(false);
let attempts = AtomicU64::new(0);
#[allow(clippy::type_complexity)]
let victim: Mutex<Option<(u16, NamespaceId, OperationId, SubmitOutcome, u64)>> =
Mutex::new(None);
// A refusal that is *not* the row's. The location poisons the shard, so the
// only answer this run's victim can give is `ShardPoisoned`; anything else
// is a different event that happened to land in the same loop, and adopting
// it as the victim would assert this row against a transaction the
// failpoint never touched. Recorded rather than skipped: a run that meets
// one is not a run that can be repeated until it does not.
let anomaly: Mutex<Option<(u16, String)>> = Mutex::new(None);
let started = Instant::now();
std::thread::scope(|scope| {
for shard in 0..CONTENTION_SHARDS {
let namespace = namespaces[shard as usize];
let engine = &engine;
let stop = &stop;
let attempts = &attempts;
let victim = &victim;
let anomaly = &anomaly;
scope.spawn(move || {
let mut operation = 0u64;
while !stop.load(Ordering::Relaxed) && started.elapsed() < budget {
operation += 1;
let transaction = contending_transaction(namespace, operation);
let mut operation_id = [0u8; 16];
operation_id[..8].copy_from_slice(&operation.to_le_bytes());
operation_id[8..]
.copy_from_slice(namespace.as_bytes()[..8].try_into().expect("8 bytes"));
attempts.fetch_add(1, Ordering::Relaxed);
// Read immediately before the call under test. Only this
// shard's writer advances this shard's counter and only
// this thread submits to this shard, so the difference
// across the submit is exactly the fences that submit
// caused — no hook, no shared counter, no inference.
let fences_before = engine
.durability_counters(shard)
.expect("every shard has a writer")
.fdatasync;
let outcome = submit_plain(engine, transaction);
match outcome {
SubmitOutcome::Committed(_) => {}
SubmitOutcome::Refused(_) | SubmitOutcome::Hung => {
if !outcome.names_shard_poisoned() {
let mut slot = anomaly.lock().expect("anomaly mutex");
if slot.is_none() {
*slot = Some((shard, format!("{outcome:?}")));
}
stop.store(true, Ordering::Relaxed);
return;
}
let mut slot = victim.lock().expect("victim mutex");
if slot.is_none() {
*slot = Some((
shard,
namespace,
OperationId(operation_id),
outcome,
fences_before,
));
}
stop.store(true, Ordering::Relaxed);
return;
}
SubmitOutcome::CallerPanicked => unreachable!(
"submit_plain does not catch, so a caller panic would have \
unwound this thread"
),
}
}
});
}
});
let elapsed = started.elapsed();
let attempts = attempts.load(Ordering::Relaxed);
let victim = victim.into_inner().expect("victim mutex");
let anomaly = anomaly.into_inner().expect("anomaly mutex");
let Some((shard, namespace, victim_operation, victim_outcome, fences_before)) = victim else {
disarm(serial);
drop(engine);
return ContentionRun {
observation: None,
anomaly,
attempts,
elapsed,
};
};
let fences_after = engine
.durability_counters(shard)
.expect("the poisoned shard still has a counter")
.fdatasync;
let immediate = engine
.transaction_status(namespace, victim_operation)
.expect("status read");
let probe = submit_plain(&engine, contending_transaction(namespace, u64::MAX / 2));
disarm(serial);
drop(engine);
let reopened = reopen_after_close(&root, CONTENTION_SHARDS, DEFAULT_MAX_INDEX_RUNS);
let recovered = reopened
.transaction_status(namespace, victim_operation)
.expect("status read");
// The repository-create on the same shard is the transaction acknowledged
// before the fault; it must survive.
let prior_recovered = reopened
.transaction_status(namespace, OperationId([0x40 + shard as u8; 16]))
.expect("status read");
drop(reopened);
ContentionRun {
anomaly,
attempts,
elapsed,
observation: Some(RowObservation {
row: format!("DuringRootCasRetry [{}]", action_name(action)),
action,
victim: victim_outcome,
immediate,
probe,
recovered,
prior_recovered,
fences_before,
fences_after,
// The contention driver races shards against each other for the
// root CAS and never adopts; the payload axis is driven by
// `drive_submit_row`.
adoption: None,
}),
}
}