This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new 914bf23b7 feat(simulator): separate a vacuous run and assert through
the drain (#4191)
914bf23b7 is described below
commit 914bf23b74d14f5115f387eda5cf666f713429b0
Author: Krishna Vishal <[email protected]>
AuthorDate: Wed Sep 16 12:45:36 2026 +0530
feat(simulator): separate a vacuous run and assert through the drain (#4191)
---
core/consensus/src/impls.rs | 125 ++++++++++++++--
core/shard/src/lib.rs | 14 +-
core/simulator/src/bin/workload-fuzz.rs | 137 +++++++++++-------
core/simulator/src/lib.rs | 204 +++++++++++++++++++++++----
core/simulator/src/workload/mod.rs | 18 ++-
core/simulator/src/workload/oracle.rs | 27 +++-
core/simulator/src/workload/state_checker.rs | 66 +++++++--
7 files changed, 475 insertions(+), 116 deletions(-)
diff --git a/core/consensus/src/impls.rs b/core/consensus/src/impls.rs
index 2f2fa03e2..e31680eec 100644
--- a/core/consensus/src/impls.rs
+++ b/core/consensus/src/impls.rs
@@ -2947,13 +2947,8 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>>
VsrConsensus<B, P> {
actions
}
- /// Handle a received `DoViewChange` message (only relevant for primary
candidate).
- ///
- /// "When the new primary receives f + 1 DOVIEWCHANGE messages from
different
- /// replicas (including itself), it sets its view-number to that in the
messages
- /// and selects as the new log the one contained in the message with the
largest v'..."
- ///
- /// The `commit` this replica advertises in a `DoViewChange`.
+ /// The `commit` this replica advertises in a `DoViewChange`, and in any
+ /// `StartView` it announces.
///
/// `commit_max`, not `commit_min`: the new primary floors its pipeline
rebuild
/// at `max(commit)` across the quorum, and only `commit_max` bounds that
range
@@ -2964,7 +2959,8 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>>
VsrConsensus<B, P> {
/// the prepares and `DoViewChangeHeader::validate` rejects `commit > op`.
/// Lossless for the rebuild floor: quorum intersection guarantees some
sender
/// whose head covers the true commit point carries it.
- fn dvc_commit(&self) -> u64 {
+ #[must_use]
+ pub fn dvc_commit(&self) -> u64 {
let op = self.sequencer.current_sequence();
self.commit_max.get().min(op)
}
@@ -3235,12 +3231,14 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>>
VsrConsensus<B, P> {
vec![VsrAction::SendStartView {
view: self.view.get(),
op: self.sequencer.current_sequence(),
- commit: self.commit_max.get(),
+ commit: self.dvc_commit(),
incarnation: header.incarnation,
target: Some(header.replica),
- // A probe answer reports this primary's settled frontier, not a
- // freshly merged log, so there is no canonical suffix to publish.
- suffix: Vec::new(),
+ // A prober can hold a different entry at an op at or below this
+ // commit point, left from a view whose entry the change truncated.
+ // Without the canonical headers it cannot know, so it adopts the
+ // commit point and applies its own stale entry.
+ suffix: self.local_dvc_suffix().headers().to_vec(),
group: self.group,
}]
}
@@ -6002,3 +6000,106 @@ mod recovery_barrier_tests {
);
}
}
+
+#[cfg(test)]
+mod probe_answer_tests {
+ //! What a primary publishes when it answers a `RequestStartView` probe.
+ //!
+ //! The answer is not a bare frontier report. A prober can hold a different
+ //! entry at an op at or below the announced commit point, left from a view
+ //! whose entry the change truncated, and the canonical headers are its
only
+ //! way to find that out before it adopts the commit point.
+
+ use super::*;
+ use crate::LocalPipeline;
+ use crate::test_bus::NoopBus;
+ use crate::view_change_quorum::dvc_blank;
+
+ const PROBER: u8 = 1;
+
+ /// Replica 0 of 3, primary in view 0 with head `head` and commit point
+ /// `commit`, carrying a fresh suffix snapshot over `[commit + 1, head]`.
+ fn primary_with_suffix(head: u64, commit: u64) -> VsrConsensus<NoopBus,
LocalPipeline> {
+ let consensus = VsrConsensus::new(1, 0, 3, METADATA_GROUP, NoopBus,
LocalPipeline::new());
+ consensus.init();
+ consensus.sequencer().set_sequence(head);
+ consensus.advance_commit_max(commit);
+ // Tagged on `(head, commit)` as they stand now, so `local_dvc_suffix`
+ // returns it rather than falling back to empty.
+ let headers: Vec<PrepareHeader> = (commit +
1..=head).rev().map(dvc_blank).collect();
+ let present = (1u128 << headers.len()) - 1;
+ consensus.set_local_dvc_suffix(DvcSuffix::new(headers, 0, present));
+ consensus
+ }
+
+ #[allow(clippy::cast_possible_truncation)]
+ fn probe(view: u32) -> RequestStartViewHeader {
+ RequestStartViewHeader {
+ checksum: 0,
+ checksum_body: 0,
+ cluster: 1,
+ size: size_of::<RequestStartViewHeader>() as u32,
+ view,
+ release: 0,
+ command: Command::RequestStartView,
+ replica: PROBER,
+ reserved_frame: [0; 66],
+ group: METADATA_GROUP,
+ reserved: [0; 104],
+ incarnation: 0,
+ }
+ }
+
+ /// The suffix is the whole point: without it the prober cannot tell its
own
+ /// op from the view's op at the same number, adopts the commit point, and
+ /// commits the stale entry.
+ #[test]
+ fn
given_a_cached_suffix_when_answering_a_probe_should_publish_its_headers() {
+ let consensus = primary_with_suffix(7, 5);
+
+ let actions = consensus.handle_request_start_view(PlaneKind::Metadata,
&probe(0));
+
+ let [
+ VsrAction::SendStartView {
+ op,
+ commit,
+ suffix,
+ target,
+ ..
+ },
+ ] = &actions[..]
+ else {
+ panic!("a probe from a backup must be answered with one StartView:
{actions:?}");
+ };
+ assert_eq!(*op, 7);
+ assert_eq!(*commit, 5);
+ assert_eq!(*target, Some(PROBER));
+ assert_eq!(
+ suffix.iter().map(|header| header.op).collect::<Vec<_>>(),
+ vec![7, 6],
+ "the answer must carry the view's canonical headers, high op first"
+ );
+ }
+
+ /// `commit_max` legitimately runs ahead of the head, since a replica
learns
+ /// the commit point before it holds the prepares.
`StartViewHeader::validate`
+ /// refuses `commit > op`, and the dispatcher turns that refusal into a
panic,
+ /// so the announcement clamps the way `DoViewChange` already does.
+ #[test]
+ fn
given_a_commit_point_above_the_head_when_answering_a_probe_should_clamp_it() {
+ let consensus = primary_with_suffix(7, 5);
+ consensus.advance_commit_max(9);
+ assert!(consensus.commit_max() >
consensus.sequencer().current_sequence());
+
+ let actions = consensus.handle_request_start_view(PlaneKind::Metadata,
&probe(0));
+
+ let [VsrAction::SendStartView { op, commit, .. }] = &actions[..] else {
+ panic!("expected one StartView: {actions:?}");
+ };
+ assert!(
+ commit <= op,
+ "announced commit {commit} exceeds head {op}, which
StartViewHeader::validate \
+ rejects and the dispatcher panics on"
+ );
+ }
+}
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index f85c29ecd..803547fba 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -6181,7 +6181,7 @@ where
// number and a backup can sit above it. Splitting on the view's number
// would drop already-executed ops with no rollback, and silently.
let announced_commit = pending.as_ref().map_or(0, |pending|
pending.commit_max);
- let applied_floor = announced_commit.max(consensus.commit_min());
+ let applied_floor = consensus.commit_min();
let mut repairable_from: Option<u64> = None;
for canonical in pending.as_ref().map_or(&[][..], |pending|
&pending.headers) {
@@ -10397,13 +10397,15 @@ where
let action = VsrAction::SendStartView {
view: consensus.view(),
op: consensus.sequencer().current_sequence(),
- commit: consensus.commit_max(),
+ commit: consensus.dvc_commit(),
incarnation: 0,
target: None,
group: consensus.group(),
- // Correcting a peer on a stale view, not concluding a view change:
this
- // publishes the settled frontier, which the peer reaches by repair.
- suffix: Vec::new(),
+ // The headers, not just the frontier. Repair skips an op whose header
is
+ // already resident, so a peer holding a DIFFERENT entry at an op under
+ // this commit point never learns of it from repair alone: it adopts
the
+ // commit point and applies what it already has.
+ suffix: consensus.local_dvc_suffix().headers().to_vec(),
};
dispatch_vsr_actions::<B, P, J>(consensus, None, &[action]).await;
}
@@ -11330,7 +11332,7 @@ async fn reconcile_partition_view_divergence<B, SB>(
// Truncation is safe only above what this replica has *applied*, which is
not
// the view's commit point: a backup can sit above it.
let announced_commit = pending.map_or(0, |pending| pending.commit_max);
- let applied_floor =
announced_commit.max(partition.consensus().commit_min());
+ let applied_floor = partition.consensus().commit_min();
let mut repairable_from: Option<u64> = None;
for canonical in pending.map_or(&[][..], |pending| &pending.headers) {
diff --git a/core/simulator/src/bin/workload-fuzz.rs
b/core/simulator/src/bin/workload-fuzz.rs
index fb6ab966a..5f5e247ce 100644
--- a/core/simulator/src/bin/workload-fuzz.rs
+++ b/core/simulator/src/bin/workload-fuzz.rs
@@ -44,6 +44,11 @@
//! CI campaign wants: `none`/`light`/`heavy` are three points in parameter
space,
//! so a thousand seeds against `heavy` is the same network a thousand times.
The
//! drawn values print on the `network:` line and `--seed` replays them
exactly.
+//!
+//! Exit codes: `0` passed, `2` the configuration is unusable, `3` the run
proved
+//! nothing because a vacuity floor went unmet, and `101` an invariant or an
oracle
+//! failed. Only `101` is a bug. A campaign that cannot tell `3` from `101`
reports
+//! its own thin seeds as failures, which is what splitting them apart is for.
use clap::{Parser, ValueEnum};
use iggy_common::IggyByteSize;
@@ -53,10 +58,27 @@ use simulator::Simulator;
use simulator::client::SimClient;
use simulator::packet::{COMMAND_LABELS, PacketSimulatorOptions, PartitionMode,
PartitionSymmetry};
use simulator::workload::actions::Action;
+use simulator::workload::invariants::Invariants;
use simulator::workload::options::{ActionWeights, WorkloadOptions};
use simulator::workload::{FaultInjector, Workload, oracle, run_with_faults};
use strum::IntoEnumIterator;
+/// Exit code for a run that proved nothing.
+///
+/// Apart from the `101` a panic exits with, because a run under a vacuity
floor is
+/// not a failure: every oracle held and none of them had anything to compare.
+const EXIT_VACUOUS: i32 = 3;
+
+/// Report that the run proved nothing, then exit with [`EXIT_VACUOUS`].
+///
+/// On stdout beside the coverage numbers rather than on stderr, because this
is an
+/// outcome of the run and not a diagnostic of one. No panic, so the reproduce
line
+/// the panic hook prints stays reserved for failures worth reproducing.
+fn vacuous(reason: std::fmt::Arguments<'_>) -> ! {
+ println!("vacuous: {reason}");
+ std::process::exit(EXIT_VACUOUS)
+}
+
#[derive(Parser)]
#[command(about = "Deterministic workload fuzzer for the Iggy simulator")]
#[allow(clippy::struct_excessive_bools)]
@@ -119,14 +141,14 @@ struct Args {
/// than Iggy is.
#[arg(long)]
restore_partition_frontier: bool,
- /// Fail the run if the entity oracle did not hold at quiesce.
+ /// End the run as vacuous if the entity oracle did not hold at quiesce.
///
/// An eviction disarms it (the forgotten request's fate is unknown) and it
/// re-arms only once the shadow is proven equal to committed state again.
Without
/// this flag a run whose oracle stayed disarmed still exits 0.
#[arg(long)]
require_entity_oracle: bool,
- /// Committed workload operations this run must produce, or it fails.
+ /// Committed workload operations this run must produce, or it ends as
vacuous.
///
/// A run that commits nothing proved nothing: every oracle downstream
compares an
/// empty shadow against empty committed state and agrees. `0` opts out.
@@ -135,7 +157,7 @@ struct Args {
/// Committed ops, on EITHER plane, that must have been witnessed by more
than
/// one live replica, i.e. that exercised cross-replica agreement. Ignored
below
/// two live replicas, where the property is untestable rather than
untested.
- /// `0` opts out.
+ /// `0` opts out. An unmet floor exits [`EXIT_VACUOUS`], like every floor
here.
#[arg(long, default_value_t = 1)]
min_ops_compared: usize,
/// As `--min-ops-compared`, but METADATA ops only.
@@ -147,7 +169,8 @@ struct Args {
/// with `0`.
#[arg(long, default_value_t = 1)]
min_metadata_ops_compared: usize,
- /// Fail the run if crash or restart injection was requested but never
happened.
+ /// End the run as vacuous if crash or restart injection was requested but
never
+ /// happened.
/// Off by default, since a short run at low probability may legitimately
draw
/// none; on for a campaign where such a seed is silently wasted.
#[arg(long)]
@@ -518,13 +541,16 @@ fn validate_network_options(options:
&PacketSimulatorOptions) -> Result<(), Stri
/// compare the replicas against each other and against the oracle.
///
/// Split out of `main` only for length. Every assert here is a hard failure by
-/// design; see the individual comments for why each one is not a warning.
+/// design, and the individual comments say why each one is not a warning. The
+/// vacuity floors are the exception: they end the run at [`EXIT_VACUOUS`],
since a
+/// run that compared nothing has disproved nothing either.
fn run_quiesce_phase(
args: &Args,
sim: &mut Simulator,
workload: &mut Workload,
seed: u64,
replicas: u8,
+ invariants: &mut Invariants,
) {
// Liveness phase, opt-in: a drain against a handicapped cluster has no
// verdict, but healing unconditionally resolves the wedges worth
reporting.
@@ -546,7 +572,7 @@ fn run_quiesce_phase(
// unactionable; with the client resending, a request unanswered inside the
// budget is either a wedge or a liveness bug.
assert!(
- oracle::drive_to_quiesce(sim, workload, 50_000),
+ oracle::drive_to_quiesce(sim, workload, 50_000, invariants),
"{}",
oracle::quiesce_failure_report(sim, workload),
);
@@ -554,7 +580,7 @@ fn run_quiesce_phase(
// the leader as whichever live replica claims to be primary, so asserting
// mid-view-change finds none or finds a deposed one, both false failures.
assert!(
- oracle::settle_to_stable_view(sim, workload, 50_000),
+ oracle::settle_to_stable_view(sim, workload, 50_000, invariants),
"metadata views never converged after the drain\n{}",
oracle::quiesce_failure_report(sim, workload),
);
@@ -580,36 +606,37 @@ fn run_quiesce_phase(
convergence.replicas_compared,
convergence.namespaces_checked,
);
- assert!(
- !args.require_entity_oracle || workload.strict_outcome_oracle(),
- "--require-entity-oracle: the entity oracle was {entity_oracle}, so
this run \
- proved nothing about entity state (seed={seed:#x})"
- );
+ if args.require_entity_oracle && !workload.strict_outcome_oracle() {
+ vacuous(format_args!(
+ "--require-entity-oracle: the entity oracle was {entity_oracle},
so this run \
+ proved nothing about entity state (seed={seed:#x})"
+ ));
+ }
let live = usize::from(replicas) - sim.crashed.len();
// Either plane satisfies it: a partition-plane run commits almost no
metadata,
// so the metadata count alone called every such run vacuous.
let compared = convergence.ops_compared +
convergence.partition_ops_compared;
- assert!(
- args.min_ops_compared == 0 || live < 2 || compared >=
args.min_ops_compared,
- "--min-ops-compared {}: {live} replicas live but only {compared} op(s)
witnessed \
- by more than one ({} metadata, {} partition), so cross-replica
agreement went \
- untested (seed={seed:#x})",
- args.min_ops_compared,
- convergence.ops_compared,
- convergence.partition_ops_compared,
- );
+ if args.min_ops_compared > 0 && live >= 2 && compared <
args.min_ops_compared {
+ vacuous(format_args!(
+ "--min-ops-compared {}: {live} replicas live but only {compared}
op(s) witnessed \
+ by more than one ({} metadata, {} partition), so cross-replica
agreement went \
+ untested (seed={seed:#x})",
+ args.min_ops_compared, convergence.ops_compared,
convergence.partition_ops_compared,
+ ));
+ }
// The metadata half on its own: summing the planes above lets a
partition-only
// run clear that floor while the metadata oracle compares nothing.
- assert!(
- args.min_metadata_ops_compared == 0
- || live < 2
- || convergence.ops_compared >= args.min_metadata_ops_compared,
- "--min-metadata-ops-compared {}: {live} replicas live but only {}
committed metadata \
- op(s) witnessed by more than one, so the metadata oracle compared an
empty chain \
- (seed={seed:#x})",
- args.min_metadata_ops_compared,
- convergence.ops_compared,
- );
+ if args.min_metadata_ops_compared > 0
+ && live >= 2
+ && convergence.ops_compared < args.min_metadata_ops_compared
+ {
+ vacuous(format_args!(
+ "--min-metadata-ops-compared {}: {live} replicas live but only {}
committed metadata \
+ op(s) witnessed by more than one, so the metadata oracle compared
an empty chain \
+ (seed={seed:#x})",
+ args.min_metadata_ops_compared, convergence.ops_compared,
+ ));
+ }
// Again after the drain: the drain both answers outstanding requests and
// issues its own resends, so the pre-drain numbers are not the final ones.
print_coverage(workload);
@@ -679,6 +706,9 @@ fn main() {
let mut workload = Workload::new(options);
let mut injector = FaultInjector::new(seed, replicas);
+ // One checker for the whole run: the drain in `run_quiesce_phase`
continues with
+ // it, so a mark set during the active phase still holds the drain to
account.
+ let mut invariants = Invariants::new();
let replies = run_with_faults(
&mut sim,
&mut workload,
@@ -686,6 +716,7 @@ fn main() {
ticks,
u64::MAX,
&mut injector,
+ &mut invariants,
);
println!(
"ran {ticks} ticks; {replies} replies; crashes={} restarts={} still
down: {}",
@@ -700,7 +731,14 @@ fn main() {
print_coverage(&workload);
if quiesce {
- run_quiesce_phase(&args, &mut sim, &mut workload, seed, replicas);
+ run_quiesce_phase(
+ &args,
+ &mut sim,
+ &mut workload,
+ seed,
+ replicas,
+ &mut invariants,
+ );
}
// After the quiesce block, so the drain's own commits count. Rejections
are added
@@ -709,23 +747,26 @@ fn main() {
// full of them exercised the plane.
let stats = workload.auditor.stats();
let commits: u64 = stats.commits_per_action.iter().sum::<u64>() +
stats.committed_rejections;
- assert!(
- commits >= args.min_commits,
- "--min-commits {}: the run committed {commits} operation(s) on the
{plane:?} \
- plane, so every oracle above compared empty against empty
(seed={seed:#x})",
- args.min_commits,
- );
+ if commits < args.min_commits {
+ vacuous(format_args!(
+ "--min-commits {}: the run committed {commits} operation(s) on the
{plane:?} \
+ plane, so every oracle above compared empty against empty
(seed={seed:#x})",
+ args.min_commits,
+ ));
+ }
if args.require_faults {
- assert!(
- crash_prob <= 0.0 || injector.crashes() > 0,
- "--require-faults: --crash-prob {crash_prob} crashed nothing \
- (seed={seed:#x})"
- );
- assert!(
- args.restart_prob <= 0.0 || injector.restarts() > 0,
- "--require-faults: --restart-prob {} restarted nothing
(seed={seed:#x})",
- args.restart_prob,
- );
+ if crash_prob > 0.0 && injector.crashes() == 0 {
+ vacuous(format_args!(
+ "--require-faults: --crash-prob {crash_prob} crashed nothing \
+ (seed={seed:#x})"
+ ));
+ }
+ if args.restart_prob > 0.0 && injector.restarts() == 0 {
+ vacuous(format_args!(
+ "--require-faults: --restart-prob {} restarted nothing
(seed={seed:#x})",
+ args.restart_prob,
+ ));
+ }
}
print_command_coverage(&sim);
diff --git a/core/simulator/src/lib.rs b/core/simulator/src/lib.rs
index 31527f6ea..8077668ed 100644
--- a/core/simulator/src/lib.rs
+++ b/core/simulator/src/lib.rs
@@ -2605,11 +2605,19 @@ mod tests {
let mut wl = Workload::new(options);
let clients = [client];
- let replies = workload::run(&mut sim, &mut wl, &clients, 2_000,
u64::MAX);
+ let mut invariants = crate::workload::invariants::Invariants::new();
+ let replies = workload::run(
+ &mut sim,
+ &mut wl,
+ &clients,
+ 2_000,
+ u64::MAX,
+ &mut invariants,
+ );
assert!(replies > 0, "workload produced no replies");
assert!(
- oracle::drive_to_quiesce(&mut sim, &mut wl, 5_000),
+ oracle::drive_to_quiesce(&mut sim, &mut wl, 5_000, &mut
invariants),
"system did not drain within the tick budget"
);
// Cross-replica agreement + entity oracle (single client => strict).
@@ -2661,7 +2669,15 @@ mod tests {
let mut wl = Workload::new(options);
let clients = [client];
- let replies = workload::run(&mut sim, &mut wl, &clients, 3_000,
u64::MAX);
+ let mut invariants = crate::workload::invariants::Invariants::new();
+ let replies = workload::run(
+ &mut sim,
+ &mut wl,
+ &clients,
+ 3_000,
+ u64::MAX,
+ &mut invariants,
+ );
assert!(replies > 0, "workload produced no replies");
assert!(
!sim.crashed.is_empty(),
@@ -2669,7 +2685,7 @@ mod tests {
);
assert!(
- oracle::drive_to_quiesce(&mut sim, &mut wl, 5_000),
+ oracle::drive_to_quiesce(&mut sim, &mut wl, 5_000, &mut
invariants),
"surviving quorum did not drain within the tick budget"
);
oracle::assert_converged(&sim, &mut wl);
@@ -2722,7 +2738,15 @@ mod tests {
let mut wl = Workload::new(options);
let clients = [client];
- let replies = workload::run(&mut sim, &mut wl, &clients, 3_000,
u64::MAX);
+ let mut invariants = crate::workload::invariants::Invariants::new();
+ let replies = workload::run(
+ &mut sim,
+ &mut wl,
+ &clients,
+ 3_000,
+ u64::MAX,
+ &mut invariants,
+ );
assert!(replies > 0, "lossy workload produced no replies");
assert!(
wl.resends() > 0,
@@ -2731,7 +2755,7 @@ mod tests {
);
assert!(
- oracle::drive_to_quiesce(&mut sim, &mut wl, 20_000),
+ oracle::drive_to_quiesce(&mut sim, &mut wl, 20_000, &mut
invariants),
"{}",
oracle::quiesce_failure_report(&sim, &wl),
);
@@ -3822,7 +3846,15 @@ mod tests {
let mut wl = Workload::new(options);
let clients = [client];
// run() asserts the per-tick invariants every tick under injected
crashes.
- let replies = workload::run(&mut sim, &mut wl, &clients, 3_000,
u64::MAX);
+ let mut invariants = crate::workload::invariants::Invariants::new();
+ let replies = workload::run(
+ &mut sim,
+ &mut wl,
+ &clients,
+ 3_000,
+ u64::MAX,
+ &mut invariants,
+ );
let crashed = sim.crashed.len();
assert!(
@@ -4050,6 +4082,7 @@ mod tests {
let mut workload = Workload::new(options);
let mut injector = FaultInjector::new(seed, replica_count);
let clients = [client];
+ let mut invariants =
crate::workload::invariants::Invariants::new();
let replies = run_with_faults(
&mut sim,
&mut workload,
@@ -4057,6 +4090,7 @@ mod tests {
3_000,
u64::MAX,
&mut injector,
+ &mut invariants,
);
(
replies,
@@ -4564,7 +4598,15 @@ mod tests {
let mut wl = Workload::new(options);
let clients = [client];
- let replies = workload::run(&mut sim, &mut wl, &clients, 4_000,
u64::MAX);
+ let mut invariants = crate::workload::invariants::Invariants::new();
+ let replies = workload::run(
+ &mut sim,
+ &mut wl,
+ &clients,
+ 4_000,
+ u64::MAX,
+ &mut invariants,
+ );
assert!(replies > 0, "shell workload produced no replies");
let stats = wl.auditor.stats();
@@ -4580,7 +4622,7 @@ mod tests {
);
assert!(
- oracle::drive_to_quiesce(&mut sim, &mut wl, 20_000),
+ oracle::drive_to_quiesce(&mut sim, &mut wl, 20_000, &mut
invariants),
"{}",
oracle::quiesce_failure_report(&sim, &wl),
);
@@ -4662,7 +4704,16 @@ mod tests {
let clients = [client];
let mut injector = FaultInjector::new(seed, replica_count);
- run_with_faults(&mut sim, &mut wl, &clients, 1_500, u64::MAX, &mut
injector);
+ let mut invariants = crate::workload::invariants::Invariants::new();
+ run_with_faults(
+ &mut sim,
+ &mut wl,
+ &clients,
+ 1_500,
+ u64::MAX,
+ &mut injector,
+ &mut invariants,
+ );
assert!(
wl.auditor.stats().transient_rejections > 0,
@@ -4671,7 +4722,7 @@ mod tests {
);
assert!(
- oracle::drive_to_quiesce(&mut sim, &mut wl, 50_000),
+ oracle::drive_to_quiesce(&mut sim, &mut wl, 50_000, &mut
invariants),
"{}",
oracle::quiesce_failure_report(&sim, &wl),
);
@@ -4742,6 +4793,7 @@ mod tests {
let clients = [client];
let mut injector = FaultInjector::new(seed, replica_count);
+ let mut invariants = crate::workload::invariants::Invariants::new();
let _ = workload::run_with_faults(
&mut sim,
&mut workload,
@@ -4749,6 +4801,7 @@ mod tests {
4_000,
u64::MAX,
&mut injector,
+ &mut invariants,
);
assert!(
@@ -4756,7 +4809,7 @@ mod tests {
"no replica crashed, so no view change ran and this proves nothing"
);
assert!(
- oracle::drive_to_quiesce(&mut sim, &mut workload, 50_000),
+ oracle::drive_to_quiesce(&mut sim, &mut workload, 50_000, &mut
invariants),
"{}",
oracle::quiesce_failure_report(&sim, &workload),
);
@@ -4806,22 +4859,18 @@ mod tests {
let clients = [client];
let mut injector = FaultInjector::new(seed, replica_count);
+ // The checker is read afterwards, so it is declared here rather than
left to
+ // the driver: `chain` below is the accumulated canonical commit chain.
let mut invariants = Invariants::new();
- // Driven here rather than through `workload::run` so the accumulated
- // chain is readable afterwards; `run` builds its own `Invariants`.
- for _ in 0..4_000u32 {
- wl.tick();
- injector.step(&mut sim, &wl);
- workload::resubmit_due(&mut sim, &mut wl);
- if let Some((target, msg)) = wl.build_request(&clients[0]) {
- sim.submit_request(clients[0].client_id(), target,
msg.into_generic());
- }
- for reply in sim.step() {
- let cmds = wl.on_reply(&reply);
- workload::apply_sim_commands(&mut sim, &cmds);
- }
- invariants.check(&sim, &wl);
- }
+ workload::run_with_faults(
+ &mut sim,
+ &mut wl,
+ &clients,
+ 4_000,
+ u64::MAX,
+ &mut injector,
+ &mut invariants,
+ );
assert!(
injector.restarts() > 0,
@@ -4839,12 +4888,12 @@ mod tests {
);
assert!(
- oracle::drive_to_quiesce(&mut sim, &mut wl, 50_000),
+ oracle::drive_to_quiesce(&mut sim, &mut wl, 50_000, &mut
invariants),
"{}",
oracle::quiesce_failure_report(&sim, &wl),
);
assert!(
- oracle::settle_to_stable_view(&mut sim, &mut wl, 50_000),
+ oracle::settle_to_stable_view(&mut sim, &mut wl, 50_000, &mut
invariants),
"metadata views never converged after the drain"
);
oracle::assert_converged(&sim, &mut wl);
@@ -5222,11 +5271,19 @@ mod tests {
options.weights = ActionWeights::new(&[(Action::SendMessages, 100)]);
let mut wl = Workload::new(options);
let clients = [client];
- let replies = workload::run(&mut sim, &mut wl, &clients, 2_000,
u64::MAX);
+ let mut invariants = crate::workload::invariants::Invariants::new();
+ let replies = workload::run(
+ &mut sim,
+ &mut wl,
+ &clients,
+ 2_000,
+ u64::MAX,
+ &mut invariants,
+ );
assert!(replies > 0, "workload produced no replies");
assert!(
- oracle::drive_to_quiesce(&mut sim, &mut wl, 5_000),
+ oracle::drive_to_quiesce(&mut sim, &mut wl, 5_000, &mut
invariants),
"system did not drain within the tick budget"
);
oracle::assert_converged(&sim, &mut wl);
@@ -7985,3 +8042,88 @@ mod review_4092_dst_tests {
);
}
}
+
+#[cfg(test)]
+mod probe_answer_divergence_tests {
+ //! End-to-end seeds for the probe-answer `StartView`. The mechanism
itself is
+ //! pinned by `consensus::impls::probe_answer_tests`; these replay the
runs that
+ //! found it.
+
+ use super::*;
+
+ /// Seed 144 of the uniform swarm lane: replica 0 prepared op 7 in view 0
+ /// without acks, view 1 truncated it and prepared a different op 7, and
+ /// replica 0 then adopted view 1 through a probe answer that carried no
+ /// canonical headers, so it kept its own op 7 and committed that instead.
+ ///
+ /// Network faults only, no crash needed, which is why it lands at op 7 and
+ /// replays fast. The mechanism is pinned separately by
+ /// `consensus::impls::probe_answer_tests`; this is the end-to-end seed.
+ #[test]
+ fn
given_a_probe_adopted_view_when_the_head_diverges_should_not_commit_the_stale_entry()
{
+ use crate::workload::{
+ self, FaultInjector, Workload,
+ options::{ActionWeights, WorkloadOptions},
+ oracle,
+ };
+
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
+ enabled: false,
+ size: iggy_common::IggyByteSize::from(0u64),
+ bucket_capacity: 1,
+ });
+
+ let replica_count: u8 = 3;
+ let client_id: u128 = 1;
+ let seed = 144;
+ // Swarm, not a fixed profile: the asymmetric partitions and clogs this
+ // seed draws are what let a primary prepare an op it cannot get acked.
+ let mut network_opts = packet::PacketSimulatorOptions::swarm(seed);
+ network_opts.node_count = replica_count;
+ network_opts.client_count = 1;
+ let mut sim = Simulator::new(
+ usize::from(replica_count),
+ std::iter::once(client_id),
+ network_opts,
+ );
+ let client = SimClient::new(client_id);
+ let ns = IggyNamespace::new(1, 1, 0);
+ sim.init_partition(ns);
+ sim.register_client_with_primary(&client);
+
+ let mut options = WorkloadOptions::new(seed, replica_count, vec![ns]);
+ options.weights = ActionWeights::uniform();
+ options.crash_per_tick_ratio = 0.01;
+ options.restart_per_tick_ratio = 0.05;
+ let mut wl = Workload::new(options);
+
+ let mut injector = FaultInjector::new(seed, replica_count);
+ let mut invariants = crate::workload::invariants::Invariants::new();
+ // `run_with_faults` runs the per-tick invariants and the live state
+ // checker, which is where the divergence fired.
+ workload::run_with_faults(
+ &mut sim,
+ &mut wl,
+ &[client],
+ 6_000,
+ u64::MAX,
+ &mut injector,
+ &mut invariants,
+ );
+
+ assert!(
+ oracle::drive_to_quiesce(&mut sim, &mut wl, 50_000, &mut
invariants),
+ "{}",
+ oracle::quiesce_failure_report(&sim, &wl),
+ );
+ assert!(
+ oracle::settle_to_stable_view(&mut sim, &mut wl, 50_000, &mut
invariants),
+ "metadata views never converged after the drain"
+ );
+ let report = oracle::assert_converged(&sim, &mut wl);
+ assert!(
+ report.ops_compared > 0,
+ "no committed metadata op was witnessed on two replicas, so this
seed \
+ would pass on a diverged cluster"
+ );
+ }
+}
diff --git a/core/simulator/src/workload/mod.rs
b/core/simulator/src/workload/mod.rs
index 0995eb7d7..06a356b70 100644
--- a/core/simulator/src/workload/mod.rs
+++ b/core/simulator/src/workload/mod.rs
@@ -709,6 +709,7 @@ pub fn run(
clients: &[SimClient],
tick_budget: u64,
replies_target: u64,
+ invariants: &mut Invariants,
) -> u64 {
let mut injector = FaultInjector::new(workload.options.seed,
sim.replica_count);
run_with_faults(
@@ -718,11 +719,16 @@ pub fn run(
tick_budget,
replies_target,
&mut injector,
+ invariants,
)
}
/// [`run`] against a caller-owned [`FaultInjector`], so a test can assert what
/// was actually injected instead of trusting the probabilities to have fired.
+///
+/// [`Invariants`] is caller-owned for a second reason: the drain that follows
this
+/// call carries on with the same checker, and its high-water marks and
canonical
+/// commit chain are what let a regression spanning the two phases be seen at
all.
/// # Panics
/// If `injector` was built for a different replica count than `sim` has.
pub fn run_with_faults(
@@ -732,6 +738,7 @@ pub fn run_with_faults(
tick_budget: u64,
replies_target: u64,
injector: &mut FaultInjector,
+ invariants: &mut Invariants,
) -> u64 {
// The injector is caller-owned, and it sized `last_transition` from a
count
// nobody has checked against this simulator. Left unchecked the mismatch
@@ -747,7 +754,6 @@ pub fn run_with_faults(
sim.replica_count,
workload.options.seed,
);
- let mut invariants = Invariants::new();
let mut replies_seen = 0u64;
for _ in 0..tick_budget {
workload.tick();
@@ -1109,7 +1115,15 @@ mod tests {
// The recovery has to leave a usable session behind. Without a fresh
// registration the next request is refused with another eviction and
// nothing commits.
- let replies = run(&mut sim, &mut workload, &clients, 400, u64::MAX);
+ let mut invariants = Invariants::new();
+ let replies = run(
+ &mut sim,
+ &mut workload,
+ &clients,
+ 400,
+ u64::MAX,
+ &mut invariants,
+ );
assert!(replies > 0, "the recovered client got no replies");
assert!(
workload
diff --git a/core/simulator/src/workload/oracle.rs
b/core/simulator/src/workload/oracle.rs
index 18b4b714d..e3d1763ec 100644
--- a/core/simulator/src/workload/oracle.rs
+++ b/core/simulator/src/workload/oracle.rs
@@ -36,6 +36,7 @@
use crate::Simulator;
use crate::replica::Replica;
+use crate::workload::invariants::Invariants;
use crate::workload::shadow::Shadow;
use crate::workload::{Workload, apply_sim_commands, resubmit_due,
state_checker};
use consensus::{Consensus, MetadataHandle, Status};
@@ -102,8 +103,19 @@ impl CommittedMetadata {
///
/// Returns `true` once drained, `false` if `max_ticks` elapses with requests
/// still outstanding (a liveness failure the caller should surface).
+///
+/// `invariants` is the checker the active phase ran, carried in rather than
built
+/// here. A drain is up to 50,000 ticks of a cluster still repairing itself,
so a
+/// wedge that forms during it used to surface as nothing more than "did not
drain",
+/// and a fresh checker would start with an empty commit chain, which is the
memory
+/// that names a divergence.
#[must_use]
-pub fn drive_to_quiesce(sim: &mut Simulator, workload: &mut Workload,
max_ticks: u64) -> bool {
+pub fn drive_to_quiesce(
+ sim: &mut Simulator,
+ workload: &mut Workload,
+ max_ticks: u64,
+ invariants: &mut Invariants,
+) -> bool {
let mut drained = false;
for _ in 0..max_ticks {
// The drain keeps resending: a request lost on the way out is never
@@ -122,6 +134,7 @@ pub fn drive_to_quiesce(sim: &mut Simulator, workload: &mut
Workload, max_ticks:
for client_id in sim.take_evictions() {
workload.forget_evicted_client(client_id);
}
+ invariants.check(sim, workload);
if workload.total_in_flight() == 0 {
drained = true;
break;
@@ -135,6 +148,7 @@ pub fn drive_to_quiesce(sim: &mut Simulator, workload: &mut
Workload, max_ticks:
let cmds = workload.on_reply(&reply);
apply_sim_commands(sim, &cmds);
}
+ invariants.check(sim, workload);
}
true
}
@@ -251,8 +265,16 @@ pub fn quiesce_failure_report(sim: &Simulator, workload:
&Workload) -> String {
///
/// Returns `false` if the views never converge, which is a real liveness
failure
/// the caller should report rather than assert against an unsettled cluster.
+///
+/// Runs `invariants` per tick for the same reason [`drive_to_quiesce`] does:
this is
+/// another 50,000-tick window, and it is the one a view change wedges in.
#[must_use]
-pub fn settle_to_stable_view(sim: &mut Simulator, workload: &mut Workload,
max_ticks: u64) -> bool {
+pub fn settle_to_stable_view(
+ sim: &mut Simulator,
+ workload: &mut Workload,
+ max_ticks: u64,
+ invariants: &mut Invariants,
+) -> bool {
for _ in 0..max_ticks {
if views_are_settled(sim, workload) {
return true;
@@ -263,6 +285,7 @@ pub fn settle_to_stable_view(sim: &mut Simulator, workload:
&mut Workload, max_t
let cmds = workload.on_reply(&reply);
apply_sim_commands(sim, &cmds);
}
+ invariants.check(sim, workload);
}
views_are_settled(sim, workload)
}
diff --git a/core/simulator/src/workload/state_checker.rs
b/core/simulator/src/workload/state_checker.rs
index 53995a6ec..cc2e501dd 100644
--- a/core/simulator/src/workload/state_checker.rs
+++ b/core/simulator/src/workload/state_checker.rs
@@ -35,7 +35,7 @@ use crate::Simulator;
use iggy_binary_protocol::PrepareHeader;
use journal::Journal;
use server_common::sharding::IggyNamespace;
-use std::collections::{BTreeMap, BTreeSet};
+use std::collections::BTreeMap;
/// One op of the canonical committed chain.
#[derive(Debug)]
@@ -47,9 +47,16 @@ struct CanonicalCommit {
/// arriving header's `parent` must equal the canonical previous op's
`checksum`,
/// so keeping each entry's own parent would record a value nothing reads.
checksum: u128,
- /// Replicas observed committing this op, so the check can prove it
compared
- /// something rather than passing over an empty chain.
- replicas: BTreeSet<u8>,
+ /// Replicas observed committing this op, each against the metadata
incarnation
+ /// it held at the time, so the check can prove it compared something
rather
+ /// than passing over an empty chain.
+ ///
+ /// The incarnation is what separates two failure modes the checksum alone
+ /// reports identically: two replicas holding different history at one op,
and
+ /// ONE replica reporting a different header there after a restart. The
second
+ /// means it applied one entry and recovered another, which is narrower and
+ /// wants naming as such.
+ witnesses: BTreeMap<u8, u128>,
}
/// Canonical committed metadata chain, accumulated across ticks.
@@ -154,7 +161,14 @@ impl StateChecker {
);
return;
};
- self.record(replica_idx, op, &header, seed);
+ self.record(
+ replica_idx,
+ op,
+ &header,
+ replica.metadata_incarnation,
+ committed,
+ seed,
+ );
}
/// Number of ops in the canonical chain. Tests assert this is non-zero,
so a
@@ -170,11 +184,19 @@ impl StateChecker {
pub fn ops_compared(&self) -> usize {
self.commits
.values()
- .filter(|commit| commit.replicas.len() > 1)
+ .filter(|commit| commit.witnesses.len() > 1)
.count()
}
- fn record(&mut self, replica_idx: u8, op: u64, header: &PrepareHeader,
seed: u64) {
+ fn record(
+ &mut self,
+ replica_idx: u8,
+ op: u64,
+ header: &PrepareHeader,
+ incarnation: u128,
+ committed: u64,
+ seed: u64,
+ ) {
// Hash-chain link, checked before the identity comparison so a
diverged
// prefix is reported at the op where the chains part rather than at
the
// first op whose contents happen to differ.
@@ -207,21 +229,35 @@ impl StateChecker {
}
match self.commits.get_mut(&op) {
Some(canonical) => {
- assert_eq!(
- canonical.checksum, header.checksum,
- "replicas disagree on committed op {op}: canonical
checksum {:#x} \
- (committed by {:?}) vs replica {replica_idx}'s {:#x}. Two
replicas \
- committed different history at the same log position
(seed={seed:#x})",
- canonical.checksum, canonical.replicas, header.checksum,
+ // Named separately because the fix differs: a replica
disagreeing
+ // with its own earlier incarnation applied one entry and
recovered
+ // another, and no other replica has to be involved.
+ let restarted_since = canonical
+ .witnesses
+ .get(&replica_idx)
+ .is_some_and(|&recorded| recorded != incarnation);
+ assert!(
+ canonical.checksum == header.checksum,
+ "{} on committed op {op}: canonical checksum {:#x}
(witnesses \
+ {:?}) vs replica {replica_idx}'s {:#x} at incarnation \
+ {incarnation}, commit point {committed} (seed={seed:#x})",
+ if restarted_since {
+ "one replica disagrees with its own pre-restart
history"
+ } else {
+ "replicas committed different history at the same log
position"
+ },
+ canonical.checksum,
+ canonical.witnesses,
+ header.checksum,
);
- canonical.replicas.insert(replica_idx);
+ canonical.witnesses.insert(replica_idx, incarnation);
}
None => {
self.commits.insert(
op,
CanonicalCommit {
checksum: header.checksum,
- replicas: BTreeSet::from([replica_idx]),
+ witnesses: BTreeMap::from([(replica_idx,
incarnation)]),
},
);
}