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 a2a4a2cb9 fix(consensus): keep a replica off a hole in its committed
prefix (#4073)
a2a4a2cb9 is described below
commit a2a4a2cb987b7c92d232c073c2138ce1b2237251
Author: Krishna Vishal <[email protected]>
AuthorDate: Wed Sep 9 03:16:18 2026 +0530
fix(consensus): keep a replica off a hole in its committed prefix (#4073)
A replica could be promoted to primary while missing operations the
cluster had already committed. The promotion coverage scan opened at
the merged commit point and never looked below it, so a hole under
that point went unseen. The replica then served reads from incomplete
state and walked commit_min over it, failing a swallowed assert.
The scan, its repair requests and its retries now open at the lower
of the merged commit point and commit_min + 1, and the scan reads the
evicted repair ring, so eviction no longer looks like a gap. Repair
sessions clear when satisfied, reopen where they were armed, and
rotate their sources as a ring. Commit advances only over a contiguous
run from commit_min + 1, and both journal walks stop below the
pipeline head, the only entry that can answer the caller. A view
change re-decides the recovery barrier it can truncate, so no primary
waits out operations nobody will re-prepare.
The simulator compares partition journal contents, not only commit
positions, and fails a run on a wedged barrier, a commit hold that
never clears, or a fenced partition task.
---
core/consensus/src/impls.rs | 440 +++++++++---
core/consensus/src/plane_helpers.rs | 169 ++++-
core/metadata/src/impls/metadata.rs | 18 +
core/partitions/src/iggy_partition.rs | 150 +++-
core/server/src/http/reads.rs | 10 +-
core/shard/src/lib.rs | 983 +++++++++++++++++++++++----
core/shard/src/router.rs | 9 +
core/simulator/src/bin/workload-fuzz.rs | 44 +-
core/simulator/src/lib.rs | 138 +++-
core/simulator/src/workload/invariants.rs | 164 ++++-
core/simulator/src/workload/oracle.rs | 14 +
core/simulator/src/workload/state_checker.rs | 170 ++++-
12 files changed, 2034 insertions(+), 275 deletions(-)
diff --git a/core/consensus/src/impls.rs b/core/consensus/src/impls.rs
index 159e0ca5e..0d1ebce94 100644
--- a/core/consensus/src/impls.rs
+++ b/core/consensus/src/impls.rs
@@ -1729,6 +1729,41 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>>
VsrConsensus<B, P> {
self.recovery_barrier.set(required_commit);
}
+ /// Re-decide the barrier against a log head the cluster just settled.
+ ///
+ /// Boot arms it at the recovered journal head: those ops were acked
before the
+ /// restart, so admitting writes before they re-commit rolls back committed
+ /// history. It otherwise clears only by `commit_max` passing it, which
never
+ /// happens when a view change discards the suffix instead of
re-committing it.
+ /// The boot re-pipeline already ran, so nothing re-prepares those ops,
+ /// `is_caught_up_primary` stays shut, and the primary drops the very
requests
+ /// that would raise `commit_max`.
+ ///
+ /// Call this wherever the head is authoritatively re-decided: a merged
log at
+ /// view start, an adopted `StartView`. `head` lowers the barrier when the
view
+ /// truncated the suffix, keeps it when the suffix survived.
+ ///
+ /// Lowered, never cleared. `is_caught_up_primary` reads it against
`commit_max`
+ /// and a met barrier costs it nothing, but `await_recovery_barrier` reads
it
+ /// against `commit_min`, and adoption raises `commit_max` before walking
the
+ /// suffix into the state machine. Zeroing a met barrier would open that
read
+ /// gate over the unapplied window it exists to hold.
+ ///
+ /// `head == 0` returns instead of lowering: zero is the DISARMED value,
not a
+ /// met barrier. `barrier_state` (`server/src/http/reads.rs`) reads zero as
+ /// "nothing gated" and skips the `commit_min` comparison, so writing it
would
+ /// open the gate rather than lower it. Reachable on the wire:
+ /// `adopt_start_view_suffix` returns `StartViewHeader.op` verbatim when
the
+ /// suffix fails to decode, `validate` does not require `op >= 1`, and the
+ /// low-op guard binds only while `msg_view == log_view`.
+ fn redecide_recovery_barrier(&self, head: u64) {
+ let barrier = self.recovery_barrier.get();
+ if barrier == 0 || head == 0 {
+ return;
+ }
+ self.recovery_barrier.set(barrier.min(head));
+ }
+
/// Deadline paired with [`Self::recovery_barrier`]; only meaningful while
the
/// barrier is armed (non-zero).
#[must_use]
@@ -3363,6 +3398,9 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>>
VsrConsensus<B, P> {
// frame, and either value leaves this replica chasing an unservable
head.
let announced = self.adopt_start_view_suffix(header, suffix_body);
self.sequencer.set_sequence(announced);
+ // Settle a gated suffix's fate as a backup too, so a later election
inherits
+ // a decided barrier rather than a latched one.
+ self.redecide_recovery_barrier(announced);
// Update timeouts for normal backup operation
{
@@ -3654,20 +3692,46 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>>
VsrConsensus<B, P> {
/// and the view change is blocked on the round-trip.
#[must_use]
pub fn pending_view_body_sources(&self, op: u64) -> Vec<u8> {
+ self.pending_view_sources(|dvc| {
+ dvc.suffix
+ .index_of(dvc.op, op)
+ .is_some_and(|index| dvc.suffix.offers_body(index))
+ })
+ }
+
+ /// Recorded `DoViewChange` senders that `serves` accepts,
most-recent-`log_view`
+ /// first, never this replica.
+ ///
+ /// Ordering and self-exclusion are the same for every source list; only
the
+ /// "can this sender serve the op" predicate differs.
+ fn pending_view_sources(&self, serves: impl Fn(&StoredDvc) -> bool) ->
Vec<u8> {
let quorum = self.do_view_change_from_all_replicas.borrow();
let mut sources: Vec<(u32, u8)> = dvc_iter(&quorum)
.filter(|dvc| dvc.replica != self.replica)
- .filter_map(|dvc| {
- let index = dvc.suffix.index_of(dvc.op, op)?;
- dvc.suffix
- .offers_body(index)
- .then_some((dvc.log_view, dvc.replica))
- })
+ .filter(|dvc| serves(dvc))
+ .map(|dvc| (dvc.log_view, dvc.replica))
.collect();
sources.sort_unstable_by_key(|(log_view, _)|
std::cmp::Reverse(*log_view));
sources.into_iter().map(|(_, replica)| replica).collect()
}
+ /// Replicas that committed `op`, most-recent-`log_view` first.
+ ///
+ /// The fallback for an op below the DVC suffixes. A suffix spans
`commit..=op`,
+ /// so [`Self::pending_view_body_sources`] answers nothing about the
committed
+ /// prefix and a merged log whose coverage gap sits there would have no
source
+ /// at all. A sender that committed the op either still journals it or has
+ /// compacted it under a checkpoint, and both answers move the requester
+ /// forward: the prepare, or the `RangeEvicted` that says repair cannot
close
+ /// this gap.
+ ///
+ /// Presence is not proven the way an offered body is, so prefer
+ /// [`Self::pending_view_body_sources`] wherever it returns anything.
+ #[must_use]
+ pub fn pending_view_commit_sources(&self, op: u64) -> Vec<u8> {
+ self.pending_view_sources(|dvc| dvc.commit >= op)
+ }
+
/// Finish the parked view change: this replica's journal now covers the
merged
/// log, so it can serve any op it is about to announce.
///
@@ -3703,6 +3767,8 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>>
VsrConsensus<B, P> {
self.status.set(Status::Normal);
self.ceded_primaryship.set(false);
self.sequencer.set_sequence(new_op);
+ // Only place a promotion learns what the merge did to a gated suffix.
+ self.redecide_recovery_barrier(new_op);
if let Some(head) = merged.headers.first() {
// Keep the hash chain continuous: the next prepare must chain
onto the
// head this view adopted, not onto whatever was appended last.
@@ -4418,31 +4484,48 @@ mod pipeline_entry_tests {
}
}
+/// Fixtures every consensus test module needs.
#[cfg(test)]
-mod timestamp_clamp_tests {
- //! Pin the monotonic-floor contract: a new primary must never stamp a
- //! prepare below timestamps already in the replicated log, even when its
- //! wall clock lags the predecessor's.
-
- use super::*;
- use crate::LocalPipeline;
- use message_bus::BusMessage;
+pub mod test_bus {
+ use super::{Command, METADATA_GROUP, Message, StartViewHeader};
+ use message_bus::{BusMessage, MessageBus};
use server_common::MESSAGE_ALIGN;
use server_common::iobuf::Frozen;
- /// Clock frozen at a fixed instant, standing in for a lagging wall
- /// clock on a freshly elected primary.
- struct FixedClock(u64);
-
- impl clock::Clock for FixedClock {
- type Realtime = IggyTimestamp;
-
- fn realtime(&self) -> Self::Realtime {
- IggyTimestamp::from(self.0)
- }
+ /// A `StartView` at `view` announcing head `op`. `commit == op` is the
steady
+ /// case (no suffix); a lower `commit` keeps an uncommitted suffix.
+ ///
+ /// # Panics
+ /// Never: a zeroed buffer of the right size is a valid `StartViewHeader`.
+ #[must_use]
+ #[allow(clippy::cast_possible_truncation)]
+ pub fn make_start_view(
+ view: u32,
+ op: u64,
+ commit: u64,
+ replica: u8,
+ incarnation: u128,
+ ) -> Message<StartViewHeader> {
+ let size = std::mem::size_of::<StartViewHeader>();
+ let mut msg = Message::<StartViewHeader>::new(size);
+ let header = bytemuck::checked::try_from_bytes_mut::<StartViewHeader>(
+ &mut msg.as_mut_slice()[..size],
+ )
+ .expect("zeroed bytes are a valid StartViewHeader");
+ header.command = Command::StartView;
+ header.cluster = 1;
+ header.view = view;
+ header.op = op;
+ header.commit = commit;
+ header.replica = replica;
+ header.incarnation = incarnation;
+ header.group = METADATA_GROUP;
+ header.size = size as u32;
+ msg
}
- struct NoopBus;
+ /// A [`MessageBus`] that accepts everything and remembers nothing.
+ pub struct NoopBus;
impl MessageBus for NoopBus {
async fn send_to_client(
@@ -4466,6 +4549,30 @@ mod timestamp_clamp_tests {
fn set_client_forward_fn(&self, _f: message_bus::ClientForwardFn) {}
fn track_background(&self, _handle: message_bus::JoinHandle<()>) {}
}
+}
+
+#[cfg(test)]
+mod timestamp_clamp_tests {
+ //! Pin the monotonic-floor contract: a new primary must never stamp a
+ //! prepare below timestamps already in the replicated log, even when its
+ //! wall clock lags the predecessor's.
+
+ use super::*;
+ use crate::LocalPipeline;
+
+ /// Clock frozen at a fixed instant, standing in for a lagging wall
+ /// clock on a freshly elected primary.
+ struct FixedClock(u64);
+
+ impl clock::Clock for FixedClock {
+ type Realtime = IggyTimestamp;
+
+ fn realtime(&self) -> Self::Realtime {
+ IggyTimestamp::from(self.0)
+ }
+ }
+
+ use crate::test_bus::{NoopBus, make_start_view};
#[test]
fn observed_log_timestamp_floors_new_primary_stamps() {
@@ -4519,31 +4626,6 @@ mod timestamp_clamp_tests {
);
}
- #[allow(clippy::cast_possible_truncation)]
- fn make_start_view(
- view: u32,
- op: u64,
- replica: u8,
- incarnation: u128,
- ) -> Message<StartViewHeader> {
- let size = std::mem::size_of::<StartViewHeader>();
- let mut msg = Message::<StartViewHeader>::new(size);
- let header = bytemuck::checked::try_from_bytes_mut::<StartViewHeader>(
- &mut msg.as_mut_slice()[..size],
- )
- .expect("zeroed bytes are a valid StartViewHeader");
- header.command = Command::StartView;
- header.cluster = 1;
- header.view = view;
- header.op = op;
- header.commit = op;
- header.replica = replica;
- header.incarnation = incarnation;
- header.group = METADATA_GROUP;
- header.size = size as u32;
- msg
- }
-
#[test]
fn
given_recovering_replica_when_start_view_incarnation_foreign_should_ignore() {
// A StartView addressed to a PREVIOUS incarnation, still in flight
when the
@@ -4571,7 +4653,7 @@ mod timestamp_clamp_tests {
assert_eq!(consensus.status(), Status::Recovering);
// Same view, head behind ours, foreign incarnation: ignored.
- let stale = make_start_view(1, 4, 1, STALE);
+ let stale = make_start_view(1, 4, 4, 1, STALE);
assert!(
consensus
.handle_start_view(PlaneKind::Metadata, stale.header(), &[])
@@ -4591,7 +4673,7 @@ mod timestamp_clamp_tests {
// Same view and head but echoing our current incarnation: adopted,
since
// the match proves the reply post-dates our restart.
- let fresh = make_start_view(1, 4, 1, CURRENT);
+ let fresh = make_start_view(1, 4, 4, 1, CURRENT);
assert!(
!consensus
.handle_start_view(PlaneKind::Metadata, fresh.header(), &[])
@@ -4688,7 +4770,7 @@ mod timestamp_clamp_tests {
consensus
.handle_start_view(
PlaneKind::Metadata,
- make_start_view(7, 104, 1, 0).header(),
+ make_start_view(7, 104, 104, 1, 0).header(),
&[]
)
.is_empty(),
@@ -4706,7 +4788,7 @@ mod timestamp_clamp_tests {
!consensus
.handle_start_view(
PlaneKind::Metadata,
- make_start_view(7, 105, 1, 0).header(),
+ make_start_view(7, 105, 105, 1, 0).header(),
&[]
)
.is_empty(),
@@ -5075,6 +5157,8 @@ mod vsr_consensus_tests {
// now measuring op 2 rather than carrying op 1's elapsed ticks.
consensus.advance_commit_max(1);
assert_eq!(drain_committable_prefix(&consensus).len(), 1);
+ // As real callers do, per entry: the next drain starts at the op now
owed.
+ consensus.advance_commit_min(1);
assert!(
prepare_ticking(&consensus),
"a remaining prepare keeps the timer armed"
@@ -5082,6 +5166,7 @@ mod vsr_consensus_tests {
consensus.advance_commit_max(2);
assert_eq!(drain_committable_prefix(&consensus).len(), 1);
+ consensus.advance_commit_min(2);
assert!(
!prepare_ticking(&consensus),
"draining the last prepare disarms the timer without waiting for
it to fire"
@@ -5335,34 +5420,8 @@ mod quorum_tests {
use super::*;
use crate::LocalPipeline;
- use message_bus::BusMessage;
- use server_common::MESSAGE_ALIGN;
- use server_common::iobuf::Frozen;
- struct NoopBus;
-
- impl MessageBus for NoopBus {
- async fn send_to_client(
- &self,
- _client_id: u128,
- _data: impl Into<BusMessage>,
- ) -> Result<(), message_bus::SendError> {
- Ok(())
- }
-
- async fn send_to_replica(
- &self,
- _replica: u8,
- _data: Frozen<MESSAGE_ALIGN>,
- ) -> Result<(), message_bus::SendError> {
- Ok(())
- }
-
- fn set_connection_lost_fn(&self, _f: message_bus::ConnectionLostFn) {}
- fn set_replica_forward_fn(&self, _f: message_bus::ReplicaForwardFn) {}
- fn set_client_forward_fn(&self, _f: message_bus::ClientForwardFn) {}
- fn track_background(&self, _handle: message_bus::JoinHandle<()>) {}
- }
+ use crate::test_bus::NoopBus;
fn consensus_with_replica_count(replica_count: u8) ->
VsrConsensus<NoopBus, LocalPipeline> {
VsrConsensus::new(
@@ -5404,3 +5463,222 @@ mod quorum_tests {
}
};
}
+
+#[cfg(test)]
+mod view_source_tests {
+ //! Who a primary-elect may ask for an op its merged log names. A
`DoViewChange`
+ //! suffix spans `commit..=op`, so the two selectors cover disjoint halves
of
+ //! the merged log and the split is what keeps a coverage gap under the
merged
+ //! commit point askable at all.
+
+ use super::*;
+ use crate::LocalPipeline;
+ use crate::view_change_quorum::{DvcSuffix, StoredDvc, dvc_record};
+
+ use crate::test_bus::NoopBus;
+
+ fn consensus() -> VsrConsensus<NoopBus, LocalPipeline> {
+ VsrConsensus::new(1, 0, 3, METADATA_GROUP, NoopBus,
LocalPipeline::new())
+ }
+
+ /// A sender whose suffix runs `commit..=op` with every body offered,
which is
+ /// the widest window a real `DoViewChange` can carry.
+ fn sender(replica: u8, log_view: u32, op: u64, commit: u64) -> StoredDvc {
+ let headers: Vec<PrepareHeader> = (commit..=op)
+ .rev()
+ .map(|op| PrepareHeader {
+ command: Command::Prepare,
+ op,
+ view: log_view,
+ ..Default::default()
+ })
+ .collect();
+ let present = (1u128 << headers.len()) - 1;
+ StoredDvc {
+ replica,
+ log_view,
+ op,
+ commit,
+ suffix: DvcSuffix::new(headers, 0, present),
+ }
+ }
+
+ fn record(consensus: &VsrConsensus<NoopBus, LocalPipeline>, senders:
[StoredDvc; 2]) {
+ let mut quorum =
consensus.do_view_change_from_all_replicas.borrow_mut();
+ for dvc in senders {
+ assert!(dvc_record(&mut quorum, dvc));
+ }
+ }
+
+ #[test]
+ fn
given_an_op_inside_the_suffixes_when_selecting_should_return_the_body_offers() {
+ let consensus = consensus();
+ record(&consensus, [sender(1, 5, 12, 10), sender(2, 4, 12, 10)]);
+
+ assert_eq!(
+ consensus.pending_view_body_sources(11),
+ vec![1, 2],
+ "both senders offer op 11, freshest log_view first"
+ );
+ }
+
+ #[test]
+ fn
given_an_op_below_every_commit_point_when_selecting_should_need_the_committers()
{
+ let consensus = consensus();
+ record(&consensus, [sender(1, 5, 12, 10), sender(2, 4, 12, 10)]);
+
+ assert!(
+ consensus.pending_view_body_sources(7).is_empty(),
+ "a suffix spans commit..=op, so it says nothing about op 7"
+ );
+ assert_eq!(
+ consensus.pending_view_commit_sources(7),
+ vec![1, 2],
+ "a sender that committed op 7 holds it or compacted it, and either
\
+ answer moves the requester forward"
+ );
+ }
+
+ #[test]
+ fn given_a_sender_behind_the_op_when_selecting_committers_should_skip_it()
{
+ let consensus = consensus();
+ record(&consensus, [sender(1, 5, 12, 10), sender(2, 6, 6, 5)]);
+
+ assert_eq!(
+ consensus.pending_view_commit_sources(7),
+ vec![1],
+ "replica 2 never committed op 7, so asking it wastes a retry
interval \
+ on a RangeEvicted it has no standing to send"
+ );
+ }
+
+ #[test]
+ fn
given_this_replica_in_the_quorum_when_selecting_should_never_return_self() {
+ let consensus = consensus();
+ record(&consensus, [sender(0, 5, 12, 10), sender(2, 4, 12, 10)]);
+
+ assert_eq!(
+ consensus.pending_view_commit_sources(7),
+ vec![2],
+ "a replica cannot repair from itself"
+ );
+ }
+}
+
+#[cfg(test)]
+mod recovery_barrier_tests {
+ //! The gate holding a restarted replica's reads and writes until the
recovered
+ //! WAL suffix re-commits. Boot arms it at the recovered head; only a view
that
+ //! settles that suffix's fate may move it, and only downward.
+
+ use super::*;
+ use crate::LocalPipeline;
+ use crate::test_bus::{NoopBus, make_start_view};
+
+ /// Recovered at head 120, proven committed only through 100, in view 7.
+ fn recovered_with_gated_suffix() -> VsrConsensus<NoopBus, LocalPipeline> {
+ let mut consensus =
+ VsrConsensus::new(1, 0, 3, METADATA_GROUP, NoopBus,
LocalPipeline::new());
+ consensus.set_view(7);
+ consensus.set_log_view(7);
+ consensus.sequencer().set_sequence(120);
+ consensus.restore_commit_state(100, 100);
+ consensus.set_recovery_barrier(120);
+ consensus
+ }
+
+ /// The `commit_max >= recovery_barrier` clause of `is_caught_up_primary`,
read
+ /// directly so these tests need not satisfy the primary/status clauses.
+ fn write_gate_open<B: MessageBus, P>(consensus: &VsrConsensus<B, P>) ->
bool
+ where
+ P: Pipeline<Entry = PipelineEntry>,
+ {
+ consensus.commit_max() >= consensus.recovery_barrier()
+ }
+
+ /// The wedge `redecide_recovery_barrier` exists for: a view change
discards the
+ /// recovered suffix, the replica later wins an election, and a barrier
pinned
+ /// to a head that no longer exists shuts admission.
+ #[test]
+ fn
given_a_discarded_suffix_when_adopting_a_view_should_lower_the_barrier() {
+ let consensus = recovered_with_gated_suffix();
+
+ // The view's head is 105: 106..=120 committed nowhere, so the view
drops
+ // them and nothing re-prepares them.
+ assert!(
+ !consensus
+ .handle_start_view(
+ PlaneKind::Metadata,
+ make_start_view(7, 105, 105, 1, 0).header(),
+ &[]
+ )
+ .is_empty(),
+ "the StartView at the commit floor must be adopted"
+ );
+ assert_eq!(
+ consensus.recovery_barrier(),
+ 105,
+ "the adopted head settled the suffix's fate, so the barrier must
fall to \
+ it rather than latch at a head the view discarded"
+ );
+ assert!(
+ write_gate_open(&consensus),
+ "a barrier the adopted commit point covers must stop gating
admission"
+ );
+ assert!(
+ consensus.commit_min() < consensus.recovery_barrier(),
+ "adoption raises commit_max before applying, so the local read
gate \
+ (which reads commit_min) must still hold"
+ );
+ }
+
+ /// The other half: lowering the barrier to the adopted head must not read
as
+ /// clearing it while the suffix survives.
+ #[test]
+ fn given_a_surviving_suffix_when_adopting_a_view_should_keep_the_barrier()
{
+ let consensus = recovered_with_gated_suffix();
+
+ // Head 120, commit still 100: 101..=120 re-replicate under the new
view.
+ assert!(
+ !consensus
+ .handle_start_view(
+ PlaneKind::Metadata,
+ make_start_view(7, 120, 100, 1, 0).header(),
+ &[]
+ )
+ .is_empty(),
+ "the StartView carrying the surviving suffix must be adopted"
+ );
+ assert_eq!(
+ consensus.recovery_barrier(),
+ 120,
+ "a suffix the view kept is still unproven, so its gate must stand"
+ );
+ assert!(
+ !write_gate_open(&consensus),
+ "an unproven suffix must keep admission shut"
+ );
+ }
+
+ #[test]
+ fn given_no_recovered_suffix_when_redeciding_should_stay_disarmed() {
+ // Nothing gated, so no view change may invent a gate.
+ let consensus = VsrConsensus::new(1, 0, 3, METADATA_GROUP, NoopBus,
LocalPipeline::new());
+ consensus.redecide_recovery_barrier(9);
+ assert_eq!(consensus.recovery_barrier(), 0);
+ }
+
+ /// Zero is disarmed, not met: `barrier_state` skips the `commit_min`
comparison
+ /// on it, so writing it opens the HTTP read gate over the window the
barrier
+ /// holds.
+ #[test]
+ fn given_a_zero_head_when_redeciding_should_leave_the_barrier_armed() {
+ let consensus = recovered_with_gated_suffix();
+ consensus.redecide_recovery_barrier(0);
+ assert_eq!(
+ consensus.recovery_barrier(),
+ 120,
+ "a zero head must not disarm the gate"
+ );
+ }
+}
diff --git a/core/consensus/src/plane_helpers.rs
b/core/consensus/src/plane_helpers.rs
index 195120af8..a72995cf0 100644
--- a/core/consensus/src/plane_helpers.rs
+++ b/core/consensus/src/plane_helpers.rs
@@ -433,10 +433,48 @@ where
false
}
+/// Whether a repair session may still run.
+///
+/// `Normal` is the ordinary case. The exception is a primary-elect that
parked a
+/// merged log: its repair runs in `ViewChange` by design, because the log it
is
+/// repairing toward is one nobody has started yet. Reading `is_normal` alone
at an
+/// ingest site drops the frame AND the session, so the coverage scan re-arms
every
+/// tick and nothing it fetches can ever land.
+///
+/// Every site that fences a repair session on status must use this, so the
arming
+/// side and the ingest side cannot drift apart.
+pub fn repair_session_live<B, P>(consensus: &VsrConsensus<B, P>) -> bool
+where
+ B: MessageBus,
+ P: Pipeline<Entry = PipelineEntry>,
+{
+ consensus.is_normal()
+ || (consensus.view_log_is_pending() &&
consensus.is_primary_for_view(consensus.view()))
+}
+
/// Drain and return committable prepares from the pipeline head.
///
-/// Entries are drained only from the head and only while their op is covered
-/// by the current commit frontier.
+/// Entries are drained from the head, while covered by the commit frontier,
and
+/// only as a contiguous run starting at the next op owed to the state machine.
+///
+/// Callers `advance_commit_min` per entry, so a run starting above
+/// `commit_min + 1` or breaking partway hits that counter's sequential-advance
+/// assert. Pipeline-side twin of `commit_journal`'s gap-stop.
+///
+/// Reported, not asserted: a promoted partition primary reaches it
legitimately
+/// while its journal walk clears the apply backlog. Repair arms on the level,
and
+/// a hold that never clears fails the simulator's contiguity invariant.
+///
+/// Do NOT read that as "the journal walk always finishes". It can stall on the
+/// partition plane: `committed_headers_from` reads resident headers only,
while
+/// `commit_messages` evicts up to `commit_max` (the cluster frontier), so ops
past
+/// the per-call cap can be flushed out from under the walk. Pre-existing; see
+/// `IggyPartition::collect_committable_from_journal`.
+///
+/// A head at or below `commit_min` is the other shape: applied already, no
repair
+/// owed, only a pop that can no longer happen. The journal walks stop below
the
+/// head so they cannot create it, but a `set_commit_floor` jump past a live
+/// pipeline still can, so it is logged apart rather than treated as
impossible.
///
/// # Panics
/// If `head()` returns `Some` but `pop()` returns `None` (unreachable).
@@ -446,18 +484,31 @@ where
P: Pipeline<Entry = PipelineEntry>,
{
let commit = consensus.commit_max();
+ let commit_min = consensus.commit_min();
let mut drained = Vec::new();
consensus.with_pipeline_mut(|pipeline| {
+ let mut next = commit_min + 1;
while let Some(head_op) = pipeline.head().map(|entry| entry.header.op)
{
if head_op > commit {
break;
}
+ if head_op != next {
+ report_uncommittable_head(
+ consensus.replica(),
+ head_op,
+ commit_min,
+ commit,
+ drained.len(),
+ );
+ break;
+ }
let entry = pipeline
.pop()
.expect("drain_committable_prefix: head exists");
drained.push(entry);
+ next += 1;
}
});
@@ -473,7 +524,8 @@ where
drained
}
-/// Header of the pipeline head, iff its op is covered by the commit frontier.
+/// Header of the pipeline head, iff its op is the next one this replica owes
its
+/// state machine and is covered by the commit frontier.
///
/// Peek-only counterpart of [`drain_committable_prefix`] for commit paths that
/// must survive their driving future being canceled between "committable" and
@@ -482,15 +534,68 @@ where
/// revalidates that the head is still this exact entry before popping and
/// applying it. A driver dropped at an await strands nothing; a sibling driver
/// that committed the op first fails the caller's revalidation and re-peeks.
+///
+/// Bounded below for the reason [`drain_committable_prefix`] is, and reported
+/// rather than asserted for the same one. Holding is safe: a shard pump's
panic is
+/// swallowed by `compio::runtime::spawn`, while `tick_metadata` re-arms
repair on
+/// the level.
pub fn peek_committable_head<B, P>(consensus: &VsrConsensus<B, P>) ->
Option<PrepareHeader>
where
B: MessageBus,
P: Pipeline<Entry = PipelineEntry>,
{
let commit = consensus.commit_max();
- consensus
+ let commit_min = consensus.commit_min();
+ let head = consensus
.pipeline_head_header()
- .filter(|header| header.op <= commit)
+ .filter(|header| header.op <= commit)?;
+ if head.op != commit_min + 1 {
+ report_uncommittable_head(consensus.replica(), head.op, commit_min,
commit, 0);
+ return None;
+ }
+ Some(head)
+}
+
+/// Log a held pipeline head, with the remedy for the arm it is in.
+///
+/// `debug`, not `warn`, matching `tick_partitions` / `tick_metadata`: a hold
is the
+/// steady state for a whole rejoin, so `warn` is one line per group per tick.
A
+/// hold that never clears is caught by a simulator invariant, not by this
line.
+fn report_uncommittable_head(
+ replica: u8,
+ head_op: u64,
+ commit_min: u64,
+ commit_max: u64,
+ drained: usize,
+) {
+ if head_op > commit_min {
+ tracing::debug!(
+ replica,
+ head_op,
+ expected_op = commit_min + 1,
+ commit_min,
+ commit_max,
+ drained,
+ "committable head sits above a hole in the committed prefix;
holding the commit \
+ walk until the ops below it are journaled"
+ );
+ } else {
+ // Reported, not called a defect: a commit-floor jump reaches this
state
+ // legitimately. `IggyMetadata`'s state-transfer install and
+ // `IggyPartition::complete_repair` both raise `commit_min` past a live
+ // pipeline via `set_commit_floor` without the `clear_pipeline` the
+ // partition transfer path pairs with it. Nothing pops a head below the
+ // floor, so it holds until a view change clears the pipeline.
+ tracing::warn!(
+ replica,
+ head_op,
+ commit_min,
+ commit_max,
+ drained,
+ "committable head sits at or below the applied commit point;
nothing can pop \
+ or answer this entry until a view change clears the pipeline"
+ );
+ }
}
/// Build reply for a committed prepare.
@@ -1991,10 +2096,64 @@ mod tests {
assert_eq!(sent[0].0, 1);
}
+ /// `advance_commit_min` is strictly sequential, so draining op 7 with 6
never
+ /// applied panics the shard pump. Both gates must hold instead.
+ #[test]
+ fn given_a_hole_below_the_head_when_committing_should_hold_both_gates() {
+ let consensus = VsrConsensus::new(1, 0, 3, 0, NoopBus,
LocalPipeline::new());
+ consensus.init();
+ consensus.restore_commit_state(5, 5);
+
+ // Op 6 never arrived; 7 and 8 did, and the cluster committed through
8.
+ consensus.pipeline_message(PlaneKind::Metadata, &prepare_message(7, 0,
70));
+ consensus.pipeline_message(PlaneKind::Metadata, &prepare_message(8,
70, 80));
+ consensus.advance_commit_max(8);
+
+ assert!(
+ peek_committable_head(&consensus).is_none(),
+ "the head is covered by the frontier but op 6 is not applied"
+ );
+ assert!(
+ drain_committable_prefix(&consensus).is_empty(),
+ "the drain must hold on the same hole the peek does"
+ );
+ assert_eq!(
+ consensus.pipeline_head_header().map(|header| header.op),
+ Some(7),
+ "holding must not consume the entry"
+ );
+ }
+
+ /// The shape the journal-walk caps prevent. Both gates must still refuse
it:
+ /// re-applying an applied op panics `advance_commit_min`.
+ #[test]
+ fn given_an_applied_head_when_committing_should_refuse_it() {
+ let consensus = VsrConsensus::new(1, 0, 3, 0, NoopBus,
LocalPipeline::new());
+ consensus.init();
+ consensus.restore_commit_state(4, 4);
+
+ consensus.pipeline_message(PlaneKind::Metadata, &prepare_message(5, 0,
50));
+ consensus.advance_commit_max(6);
+ // The journal walk got there first and applied 5 and 6 out of the WAL.
+ consensus.advance_commit_min(5);
+ consensus.advance_commit_min(6);
+
+ assert!(
+ peek_committable_head(&consensus).is_none(),
+ "op 5 is already applied; re-applying it panics advance_commit_min"
+ );
+ assert!(
+ drain_committable_prefix(&consensus).is_empty(),
+ "the drain must refuse an applied head too"
+ );
+ }
+
#[test]
fn drains_only_up_to_commit_frontier_even_without_quorum_flags() {
let consensus = VsrConsensus::new(1, 0, 3, 0, NoopBus,
LocalPipeline::new());
consensus.init();
+ // Pipeline opens at op 5, so the state machine must already be
through 4.
+ consensus.restore_commit_state(4, 4);
consensus.pipeline_message(PlaneKind::Metadata, &prepare_message(5, 0,
50));
consensus.pipeline_message(PlaneKind::Metadata, &prepare_message(6,
50, 60));
diff --git a/core/metadata/src/impls/metadata.rs
b/core/metadata/src/impls/metadata.rs
index 3c4d19f47..1522cfbb5 100644
--- a/core/metadata/src/impls/metadata.rs
+++ b/core/metadata/src/impls/metadata.rs
@@ -3653,6 +3653,24 @@ where
applied += 1;
let op = consensus.commit_min() + 1;
+ // Never apply the op the pipeline holds: `on_ack` pops that entry
+ // and advances past it, so applying it here strands it below
+ // `peek_committable_head`'s floor. Nothing pops it after that (the
+ // only `pop_committed_prepare` sits inside that loop), so its wire
+ // reply is never built and its `reply_sender` neither fires nor
drops.
+ //
+ // Compared per op, not hoisted: the body read below awaits and a
+ // sibling driver can move the head. Comparing against `op` also
gets
+ // the two edge cases right for free -- an absent head is a
backup's
+ // empty pipeline and caps nothing, and a head at or below
`commit_min`
+ // is already stranded and must not freeze the walk on top of that.
+ if consensus
+ .pipeline_head_header()
+ .is_some_and(|head| head.op == op)
+ {
+ break;
+ }
+
let Some(header) = journal.handle().header(op as usize) else {
// Gap-stop: the walk halts at the first missing prepare and
// resumes once it is refilled -- by the primary's retransmit
diff --git a/core/partitions/src/iggy_partition.rs
b/core/partitions/src/iggy_partition.rs
index 5d363ef24..e6a221971 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -43,9 +43,9 @@ use consensus::{
PlaneKind, Project, ReplicaLogContext, RequestLogEvent, Sequencer,
SimEventKind, VsrConsensus,
ack_preflight, ack_quorum_reached, build_deny_reply_from_request,
build_reply_from_request,
build_reply_message, drain_committable_prefix,
emit_namespace_progress_event,
- emit_partition_diag, emit_sim_event, fence_old_prepare_by_commit,
repaired_frontier_update,
- replicate_frozen_to_next_in_chain, replicate_preflight,
restamp_prepare_view,
- send_prepare_ok as send_prepare_ok_common, verify_prepare_integrity,
+ emit_partition_diag, emit_sim_event, fence_old_prepare_by_commit,
repair_session_live,
+ repaired_frontier_update, replicate_frozen_to_next_in_chain,
replicate_preflight,
+ restamp_prepare_view, send_prepare_ok as send_prepare_ok_common,
verify_prepare_integrity,
};
use iggy_binary_protocol::requests::consumer_offsets::{
DeleteConsumerOffsetRequest, StoreConsumerOffsetRequest,
@@ -4304,13 +4304,34 @@ where
/// Committable entries (ops `commit_min+1 ..= commit_max`) read from the
/// journal, for a backup whose pipeline is empty. Stops at the first
missing
/// op: a replication gap must not be skipped, or `advance_commit_min`'s
- /// sequential contract breaks. Like the metadata plane's `commit_journal`,
- /// the journal keeps its committed entries until they are flushed
- /// (`commit_messages` drains only the committed prefix), so this read
finds
- /// every committed op while the uncommitted tail stays resident.
+ /// sequential contract breaks.
+ ///
+ /// KNOWN GAP: resident headers only. `commit_messages` evicts up to
+ /// `commit_max` (the cluster frontier, not this replica's commit point)
while
+ /// `committed_headers_from` never reads the evicted ring, so a backlog
past
+ /// [`COMMIT_WALK_OPS_MAX`] can have its un-reached ops flushed out from
under
+ /// it and stop. Repair refetches them and the simulator's contiguity
invariant
+ /// catches a walk that never recovers. Reading the ring here would close
it
+ /// directly, but not as a one-line swap: the apply path needs batch bytes
and
+ /// the ring is capacity-bounded, so headers it cannot back with bytes
would
+ /// fence the partition instead of stalling it.
fn collect_committable_from_journal(&self, max_ops: usize) ->
Vec<PipelineEntry> {
let from_op = self.consensus.commit_min() + 1;
+ // Stop below the pipeline head. The drain above holds rather than pops
+ // when the head is not the op owed next; walking that op out of the
+ // journal instead advances `commit_min` past a still-resident entry,
and
+ // `on_ack` then finds the drain empty on every later ack -- no reply
is
+ // ever shipped and each stranded entry leaves its awaiter parked.
+ //
+ // Only a head at or above `from_op` lowers the ceiling: an absent
head is
+ // a backup's empty pipeline, and a lower head is already stranded and
must
+ // not freeze the walk on top of that.
let commit_max = self.consensus.commit_max();
+ let commit_max = self
+ .consensus
+ .pipeline_head_header()
+ .filter(|head| head.op >= from_op)
+ .map_or(commit_max, |head| commit_max.min(head.op - 1));
self.log
.journal()
.inner
@@ -6708,7 +6729,11 @@ where
return;
};
let consensus = self.consensus();
- if !consensus.is_normal() || consensus.view() != session.view {
+ // NOT `is_normal` alone: a primary-elect repairing toward its parked
+ // merged log runs this in `ViewChange`, and dropping the session on
its
+ // first inbound frame leaves the coverage scan re-arming every tick
over
+ // a stream it can never keep.
+ if !repair_session_live(consensus) || consensus.view() != session.view
{
self.repair = None;
return;
}
@@ -8806,28 +8831,40 @@ mod tests {
assert!(!dir.path().join("2").exists());
}
+ /// `AckLevel::NoAck` stores apply on the primary only and never
replicate, so
+ /// which replicas hold an offset is not agreed and a committed delete can
+ /// legitimately find nothing. Erroring on that fails the committed apply,
+ /// fences the partition, then crash-loops on every replay of the op.
+ ///
+ /// Both kinds, because they are separate maps with separate directories.
#[compio::test]
async fn
given_absent_offset_file_when_delete_commits_should_skip_directory_sync() {
- let dir = tempfile::tempdir().unwrap();
- let (mut partition, sent) = recording_partition_at(0, 3);
- partition.consumer_offsets_path =
- Some(dir.path().join("missing").to_string_lossy().into_owned());
- partition.stage_consumer_offset_delete(1, ConsumerKind::Consumer, 7);
- partition.consensus.restore_commit_state(0, 1);
- let header = PrepareHeader {
- op: 1,
- operation: Operation::DeleteConsumerOffset,
- client: 42,
- request: 1,
- ..Default::default()
- };
- partition
- .handle_committed_entries(vec![PipelineEntry::new(header)],
&repair_config(), true)
- .await;
- assert!(partition.fatal.is_none());
- assert_eq!(partition.consensus.commit_min(), 1);
- assert_eq!(partition.offset_dir_sync_count.get(), 0);
- assert_eq!(sent.borrow().len(), 1);
+ for (op, kind) in [
+ (1, ConsumerKind::Consumer),
+ (2, ConsumerKind::ConsumerGroup),
+ ] {
+ let dir = tempfile::tempdir().unwrap();
+ let (mut partition, sent) = recording_partition_at(0, 3);
+ let missing =
Some(dir.path().join("missing").to_string_lossy().into_owned());
+ partition.consumer_offsets_path.clone_from(&missing);
+ partition.consumer_group_offsets_path = missing;
+ partition.stage_consumer_offset_delete(op, kind, 7);
+ partition.consensus.restore_commit_state(op - 1, op);
+ let header = PrepareHeader {
+ op,
+ operation: Operation::DeleteConsumerOffset,
+ client: 42,
+ request: 1,
+ ..Default::default()
+ };
+ partition
+ .handle_committed_entries(vec![PipelineEntry::new(header)],
&repair_config(), true)
+ .await;
+ assert!(partition.fatal.is_none(), "{kind:?} delete must not
fence");
+ assert_eq!(partition.consensus.commit_min(), op);
+ assert_eq!(partition.offset_dir_sync_count.get(), 0);
+ assert_eq!(sent.borrow().len(), 1);
+ }
}
#[compio::test]
@@ -11073,6 +11110,63 @@ mod tests {
.expect("journal append");
}
+ /// Walking through the head advances `commit_min` past a resident entry
only
+ /// `on_ack` can pop and answer, after which every later ack finds the
drain
+ /// empty and no reply is ever shipped.
+ #[compio::test]
+ async fn
given_a_pipeline_head_when_walking_the_journal_should_stop_below_it() {
+ let partition = test_partition();
+ for op in 1..=4 {
+ journal_prepare(&partition, op, Operation::CreateStream).await;
+ }
+ partition.consensus.restore_commit_state(0, 4);
+ partition.consensus.pipeline_message(
+ PlaneKind::Partitions,
+ &pipeline_prepare(3, Operation::CreateStream),
+ );
+
+ let ops: Vec<u64> = partition
+ .collect_committable_from_journal(COMMIT_WALK_OPS_MAX)
+ .into_iter()
+ .map(|entry| entry.header.op)
+ .collect();
+ assert_eq!(
+ ops,
+ vec![1, 2],
+ "the walk stops at the op the pipeline holds, leaving 3 to on_ack"
+ );
+ }
+
+ /// A backup journals replicated prepares and never populates a pipeline,
so an
+ /// absent head must mean NO ceiling. Read as a ceiling of zero it would
stop
+ /// every backup's commit walk.
+ #[compio::test]
+ async fn given_an_empty_pipeline_when_walking_the_journal_should_not_cap()
{
+ let partition = test_partition();
+ for op in 1..=3 {
+ journal_prepare(&partition, op, Operation::CreateStream).await;
+ }
+ partition.consensus.restore_commit_state(0, 3);
+ assert!(partition.consensus.pipeline_head_header().is_none());
+
+ let ops: Vec<u64> = partition
+ .collect_committable_from_journal(COMMIT_WALK_OPS_MAX)
+ .into_iter()
+ .map(|entry| entry.header.op)
+ .collect();
+ assert_eq!(ops, vec![1, 2, 3], "a backup walks its whole committed
run");
+ }
+
+ fn pipeline_prepare(op: u64, operation: Operation) ->
Message<PrepareHeader> {
+ let size = std::mem::size_of::<PrepareHeader>();
+ Message::<PrepareHeader>::new(size).transmute_header(|_, header: &mut
PrepareHeader| {
+ header.command = Command::Prepare;
+ header.op = op;
+ header.operation = operation;
+ header.size = u32::try_from(size).expect("prepare header size fits
in u32");
+ })
+ }
+
/// A repaired `SendMessages` prepare with an explicit chain identity, as a
/// serving peer ships it.
pub(super) fn repaired_send_prepare(
diff --git a/core/server/src/http/reads.rs b/core/server/src/http/reads.rs
index dc3262ff3..bcb387aa1 100644
--- a/core/server/src/http/reads.rs
+++ b/core/server/src/http/reads.rs
@@ -276,17 +276,23 @@ pub(in crate::http) async fn await_recovery_barrier(
let Some(consensus) = shard.plane.metadata().consensus.as_ref() else {
return Ok(());
};
- let barrier = consensus.recovery_barrier();
// Gate on commit_MIN (locally applied), not commit_max (known committed):
// a StartView adoption advances commit_max first and only then walks the
// journal applying ops, and this task interleaves with that walk at its
// await points -- a commit_max gate would serve state from before the
// suffix applied (e.g. a pre-restart password change not yet visible).
- if barrier_state(barrier, consensus.commit_min(), false) ==
BarrierWait::Ready {
+ if barrier_state(consensus.recovery_barrier(), consensus.commit_min(),
false)
+ == BarrierWait::Ready
+ {
return Ok(());
}
let deadline = std::time::Instant::now() + consensus.recovery_deadline();
loop {
+ // Re-read per poll, like `commit_min`. `redecide_recovery_barrier`
lowers
+ // the barrier when a view change settles the recovered suffix, so a
reader
+ // holding the value it captured on entry waits out a barrier that no
longer
+ // exists and then 503s on a replica that is serving.
+ let barrier = consensus.recovery_barrier();
let expired = std::time::Instant::now() >= deadline;
match barrier_state(barrier, consensus.commit_min(), expired) {
BarrierWait::Ready => return Ok(()),
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index 6f6f9d546..ae32c2261 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -906,6 +906,14 @@ pub const REPAIR_CHUNK_MAX: u64 = 128;
#[derive(Debug, Clone, Copy)]
struct MetadataRepairSession {
nonce: u128,
+ /// Lowest op this session must fetch, and the floor its stall retry
reopens at.
+ ///
+ /// Not re-derivable from `commit_min + 1`: the merged-log scan opens at
+ /// [`merged_log_scan_floor`], above the snapshot floor, while its
+ /// `committed_elsewhere` fallback reports ops below even that. A
recomputed
+ /// retry asks for a different window than the one reported missing, and
never
+ /// re-asks for the op that was.
+ from_op: u64,
to_op: u64,
/// Consensus view this session was armed in. A later view decides the log
/// again, so the window this names may no longer be the one to fetch;
@@ -1585,11 +1593,28 @@ where
/// [`Self::metadata_transfer_decode_failures`].
metadata_transfer_attempts: Cell<u32>,
- /// Consecutive stalled re-requests on the live metadata repair session,
- /// against [`partitions::REPAIR_MAX_STALL_RETRIES`]. Survives the session,
- /// so rotating the peer cannot reset it; cleared by an accepted repaired
- /// prepare.
- metadata_repair_attempts: Cell<u32>,
+ /// Consecutive stall rounds burned by the metadata repair session, against
+ /// [`partitions::REPAIR_MAX_STALL_RETRIES`]. Bounds how long one quiet
peer
+ /// pins the commit walk.
+ ///
+ /// On the shard, not the session: rotation mints a fresh session, so a
+ /// per-session counter would reset itself. Nothing on the rotation path
may
+ /// clear it either.
+ ///
+ /// Cleared only by [`Self::note_metadata_repair_walked`], which takes
evidence
+ /// attributable to the targeted peer. A repair prepare carries no sender
and
+ /// no nonce, so it restarts the stall clock only. Net effect: this bounds
peers
+ /// that go silent, serve unusable bytes, or terminate without closing the
gap.
+ /// It does not bound merely slow peers, whose chunks keep the clock from
+ /// firing.
+ ///
+ /// Paired with the view the rounds were charged in, and spent per view.
The
+ /// merged-log arm refuses to re-arm once the budget is out, and the
exhausting
+ /// path leaves no session behind, so nothing would ever be superseded or
walk:
+ /// unfenced, one spent budget would refuse merged-log repair for every
later
+ /// view for the life of the process, and a replica that keeps winning
+ /// elections would never repair again.
+ metadata_repair_attempts: Cell<(u32, u32)>,
/// Decode failures charged against one snapshot generation, as
/// `(snapshot_seq, failures)`. `None` until a pulled artifact set first
@@ -1744,7 +1769,7 @@ where
superblock_wedged_fatal_failures: Cell::new(0),
bus_max_message_size: Cell::new(DEFAULT_BUS_MAX_MESSAGE_SIZE),
metadata_transfer_attempts: Cell::new(0),
- metadata_repair_attempts: Cell::new(0),
+ metadata_repair_attempts: Cell::new((0, 0)),
metadata_transfer_decode_failures: Cell::new(None),
})
}
@@ -2231,7 +2256,7 @@ where
superblock_wedged_fatal_failures: Cell::new(0),
bus_max_message_size: Cell::new(DEFAULT_BUS_MAX_MESSAGE_SIZE),
metadata_transfer_attempts: Cell::new(0),
- metadata_repair_attempts: Cell::new(0),
+ metadata_repair_attempts: Cell::new((0, 0)),
metadata_transfer_decode_failures: Cell::new(None),
}
}
@@ -5037,6 +5062,12 @@ where
// Applies to both planes, and is why a backup parks a log at all.
The
// view already decided which prepare belongs at this op; a
different
// one forks the log. An op the parked log omits is unconstrained.
+ //
+ // Which covers most of the range under `pending.commit_max`: the
merged
+ // log names headers only from the DVC suffixes, and those span
+ // `commit..=op` per sender. Identity below that rests on
crash-stop --
+ // a committed op is the quorum's op. `verify_prepare_integrity`
below
+ // guards corruption; neither guards Byzantine faults.
let disagrees = consensus
.with_pending_view_log(|pending| {
pending
@@ -5069,15 +5100,19 @@ where
let Some(journal) = planes.0.journal.as_ref() else {
return;
};
- // Above the two returns below, not after them: only SILENCE should
- // age the stream, and an in-scope frame proves the peer is serving
- // whether or not this replica still needs the op it carries. The
- // ops a re-request re-serves are exactly the ones already held, so
- // counting accepted frames alone rotates away from a live peer.
- if let Some(session) = self.metadata_repair.borrow_mut().as_mut() {
- session.idle_ticks = 0;
- }
- self.note_metadata_repair_progress();
+ // Below the divergence and integrity returns, above the two under
it.
+ //
+ // Only silence should age the stream, and only a frame this
replica
+ // would have accepted proves anything is being served. A forked or
+ // corrupted frame is neither: the peer re-serves the same stored
bytes
+ // every re-request, so crediting those holds off the retry
forever.
+ //
+ // Still above the dedup return: a re-request re-serves ops already
+ // held, and a stream re-covering ground is still a stream.
+ //
+ // Clock only -- the frame has no sender and no nonce, so it
cannot be
+ // attributed. The budget is cleared from the terminator and the
walk.
+ self.note_metadata_repair_clock();
let journal = journal.handle();
#[allow(clippy::cast_possible_truncation)]
if journal.header(header.op as usize).is_some() {
@@ -5190,6 +5225,14 @@ where
done,
"metadata journal repair walked"
);
+ // The one attributable credit: fenced on `session.nonce`
+ // above, so it came from the targeted peer, and the walk
moved.
+ // Gating on the walk and not the frame keeps a peer that
+ // terminates every round while serving nothing useful from
+ // clearing its own budget and becoming un-rotatable.
+ if done || commit_min > before {
+ self.note_metadata_repair_walked();
+ }
if done {
*self.metadata_repair.borrow_mut() = None;
} else if repair_chunk_walked(before, commit_min,
header.op) {
@@ -5225,6 +5268,95 @@ where
// `RangeEvicted` again if the primary checkpointed mid
// transfer -- that reraises through the same path, and
each
// round lifts the local floor, so it converges.
+ //
+ // Never as primary-elect. A transfer replaces
snapshot-shaped
+ // state wholesale, and this replica has a merged log
parked
+ // against that state naming ops it was just told it cannot
+ // serve; installing under it starts the view over a log
the new
+ // state no longer matches. Re-target instead, and let the
+ // view-change timeout escalate if nobody can serve it.
+ //
+ // DROP the session before returning. The serving peer
follows
+ // `RangeEvicted` with `RepairDone(from_op - 1)` on the
same
+ // nonce, and a `RepairDone` at or below `commit_min` walks
+ // nothing, so `repair_chunk_walked` is trivially true and
that
+ // arm re-requests at once: no tick gate, no debounce, no
attempt
+ // burned -- an unthrottled request/reply loop across two
pumps.
+ // Dropping first lands the trailing frame on the `is_none`
+ // guard, as it did before this arm existed.
+ if consensus.view_log_is_pending()
+ && consensus.is_primary_for_view(consensus.view())
+ {
+ tracing::warn!(
+ shard = self.id,
+ peer = header.replica,
+ retained_from = header.op,
+ local_commit = consensus.commit_min(),
+ "merged-log repair peer evicted the requested
range; \
+ re-targeting rather than transferring state mid
view change"
+ );
+ // Definitive, not a stall: this sender has said it
cannot
+ // serve the window, so rotate now rather than spend a
retry
+ // interval on a stream that will not come. Still
charge a
+ // round, so a quorum that all answer this way stops
asking
+ // instead of cycling the ring until the timeout.
+ if self.burn_metadata_repair_attempt(consensus.view())
{
+ *self.metadata_repair.borrow_mut() = None;
+ tracing::warn!(
+ shard = self.id,
+ from_op = session.from_op,
+ to_op = session.to_op,
+ "merged-log repair exhausted its senders;
leaving the view \
+ change to its timeout"
+ );
+ return;
+ }
+ self.rotate_stalled_metadata_repair(
+ consensus,
+ header.replica,
+ session.from_op,
+ session.to_op,
+ )
+ .await;
+ return;
+ }
+
+ // The floor must also be ABOVE the op this replica needs.
A
+ // peer behind the requested window walks its serve range
off
+ // the end and answers `RangeEvicted` at the requested
floor
+ // itself, having retained nothing and evicted nothing;
+ // converting on that arms a transfer against a replica
with
+ // less state than this one and fences repair for a full
+ // transfer backoff. Drop the session and let the level
trigger
+ // re-request from the primary instead.
+ if header.op <= consensus.commit_min() + 1 {
+ tracing::warn!(
+ shard = self.id,
+ peer = header.replica,
+ retained_from = header.op,
+ local_commit = consensus.commit_min(),
+ "metadata repair peer retained nothing in the
requested range; \
+ re-requesting rather than converting to state
transfer"
+ );
+ // Charge the round, and ACT on exhaustion.
`gap_repair_peer`
+ // re-picks the primary deterministically, so dropping
the
+ // session on its own re-arms the same peer at the
debounce
+ // interval forever: the stall path never runs, so
rotation
+ // is never reached and the state-transfer escalation
this
+ // guard replaced stays out of reach.
+ if self.burn_metadata_repair_attempt(consensus.view())
{
+ self.rotate_stalled_metadata_repair(
+ consensus,
+ header.replica,
+ session.from_op,
+ session.to_op,
+ )
+ .await;
+ return;
+ }
+ *self.metadata_repair.borrow_mut() = None;
+ return;
+ }
if consensus.state_transfer_stage() ==
consensus::StateTransferStage::Idle {
*self.metadata_repair.borrow_mut() = None;
consensus.begin_state_transfer_await();
@@ -5269,7 +5401,12 @@ where
if header.nonce != session.nonce {
return;
}
- if !partition.consensus().is_normal() || partition.consensus().view()
!= session.view {
+ // Twin of the `apply_repaired_prepare` gate: a primary-elect's
merged-log
+ // session legitimately runs outside `Normal`, and dropping it here
would
+ // discard the terminator that closes the window it is repairing.
+ if !consensus::repair_session_live(partition.consensus())
+ || partition.consensus().view() != session.view
+ {
partition.repair = None;
return;
}
@@ -5559,11 +5696,23 @@ where
// cannot serve the state transfer it itself needs. A committed op
// cannot diverge from the merged log, and its bytes stay serveable
// from the evicted ring or the flushed segments.
+ let floor = ScanFloor {
+ repair_floor: consensus.commit_min(),
+ commit_min: consensus.commit_min(),
+ };
+ // Ring AND resident, one pass. `header_by_op` reads the resident
vec
+ // only, while `commit_messages` evicts up to `commit_max` (the
cluster
+ // frontier), so a primary-elect with an apply backlog reads
`None` for
+ // ops it holds in the repair ring and parks on a hole that is not
one.
+ // One pass also because `header_by_op` is a linear scan and this
window
+ // is the apply backlog, not the `prepare_queue_max` span the merge
+ // bounds -- probing per op is quadratic.
let missing = {
let journal = partition.log.journal();
- first_op_not_covered(&pending, consensus.commit_min(), |op| {
- journal.inner.header_by_op(op)
- })
+ let window = journal
+ .inner
+
.repair_headers_in(floor.opens_at(&pending)..=pending.op_head);
+ first_op_not_covered(&pending, floor, |op|
window.get(&op).copied())
};
if let Some(missing_op) = missing {
tracing::debug!(
@@ -5573,6 +5722,12 @@ where
op_head = pending.op_head,
"partition view change waiting on op {missing_op} before
starting the view"
);
+ // And a way out of the wait: nothing else fetches this op.
+ // `maybe_request_partition_repair` refuses outside `Normal`
and
+ // the sweep's gap detector needs `probe.normal`. Partition
twin of
+ // the metadata plane's view repair.
+ self.request_partition_view_repair(partition, missing_op,
pending.op_head, None)
+ .await;
return;
}
@@ -5606,6 +5761,11 @@ where
///
/// Repair frames are fire-and-forget, so a lost one leaves the session
armed
/// forever with the commit walk pinned below the frontier.
+ ///
+ /// Re-requests from the SAME peer while its stall budget holds. A peer
that
+ /// never answers is a different problem, and
+ /// [`Self::rotate_stalled_metadata_repair`] owns it: the target lives on
the
+ /// session, so it must be replaced there rather than shadowed for one
send.
#[allow(clippy::future_not_send)]
async fn retry_stalled_metadata_repair<P>(&self, consensus:
&VsrConsensus<B, P>)
where
@@ -5640,7 +5800,7 @@ where
"metadata repair session walked or superseded; closing it"
);
*self.metadata_repair.borrow_mut() = None;
- self.note_metadata_repair_progress();
+ self.note_metadata_repair_walked();
return;
}
@@ -5654,55 +5814,32 @@ where
return None;
}
session.idle_ticks = 0;
- Some((session.peer, session.nonce, session.to_op))
+ Some((session.peer, session.nonce, session.from_op,
session.to_op))
})
};
- if let Some((peer, nonce, to_op)) = stalled {
- // A session pins its peer and fences every arming site while it
- // stands, so a peer that cannot answer wedges the plane harder
than
- // having no session at all -- and the gap-stopped-primary rotation
- // can pick a peer that is simply down. Past the budget the session
- // is dropped and re-armed one step around the ring; an ordinary
lost
- // frame is re-requested long before that.
- if self.burn_metadata_repair_attempt() {
- let next_peer = next_transfer_peer(
- consensus.replica(),
- peer,
- consensus.replica_count(),
- consensus.primary_index(consensus.view()),
- );
- tracing::warn!(
+ if let Some((peer, nonce, session_from_op, to_op)) = stalled {
+ let from_op =
+ stalled_repair_from_op(session_from_op,
consensus.commit_min(), repairing_view);
+ if from_op > to_op {
+ // `from_op` past `to_op` without `commit_min` reaching it: the
+ // primary-elect window above starts at the merged log's commit
+ // point, which can sit above what this replica has walked. The
+ // top-of-tick check closes the ordinary case; this closes the
+ // one it cannot see. Leaving it armed wedges the replica: no
+ // `RepairDone` clears a window the walk is already past, and
the
+ // `is_none` gate then blocks the session the ops above it
need.
+ tracing::info!(
shard = self.id,
- peer,
- next_peer,
to_op,
- "metadata repair stalled past its retry budget; re-arming
from another \
- replica"
+ peer,
+ "metadata repair window fully requested; closing the
stalled session"
);
*self.metadata_repair.borrow_mut() = None;
- self.note_metadata_repair_progress();
- if next_peer != peer {
- self.maybe_request_metadata_repair(consensus, next_peer)
- .await;
- }
- // Nobody else to name (a solo group, or a two-replica group
- // whose only peer went quiet): dropping the session is still
- // right, since it unfences the detector, which re-arms after
its
- // debounce and logs the state each interval.
- return;
- }
- // Primary-elect only. Its window starts at the merged log's commit
- // point, which can sit below local `commit_min` (the headers
inherited
- // from senders behind the canonical log_view live there), so
- // `commit_min + 1` would skip them. A backup's parked `StartView`
- // suffix is only a verification reference; resuming from its
commit
- // point would restart at the view's opening head, not at the gap.
- let from_op = consensus
- .is_primary_for_view(consensus.view())
- .then(|| consensus.with_pending_view_log(|pending|
pending.commit_max.max(1)))
- .flatten()
- .unwrap_or_else(|| consensus.commit_min() + 1);
- if from_op <= to_op {
+ self.note_metadata_repair_walked();
+ } else if self.burn_metadata_repair_attempt(consensus.view()) {
+ self.rotate_stalled_metadata_repair(consensus, peer, from_op,
to_op)
+ .await;
+ } else {
tracing::info!(
shard = self.id,
from_op,
@@ -5720,22 +5857,95 @@ where
consensus.group(),
)
.await;
- } else {
- // `from_op` past `to_op` without `commit_min` reaching it: the
- // primary-elect window above starts at the merged log's commit
- // point, which can sit above what this replica has walked. The
- // top-of-tick check closes the ordinary case; this closes the
- // one it cannot see.
- tracing::info!(
+ }
+ }
+ }
+
+ /// Re-arm a repair session that spent its stall budget against another
replica.
+ ///
+ /// A session pins its peer and fences every arming site while it stands,
so a
+ /// peer that cannot answer wedges the walk harder than having no session
at
+ /// all. Past the budget the session is dropped and re-armed one step on;
an
+ /// ordinary lost frame is re-requested long before that. Mirrors the
partition
+ /// rotation in [`Self::tick_partitions`].
+ ///
+ /// Two rings, because two things decide who can serve. A `Normal` backup
is
+ /// repairing its committed tail and any replica ahead of it will do, so it
+ /// walks the cluster preferring the primary. A primary-elect is repairing
+ /// toward a merged log, and only the `DoViewChange` senders that named
the op
+ /// can serve it: walking the whole ring lands on a replica that answers
+ /// `RangeEvicted` for a range it never held.
+ ///
+ /// Does NOT spend the budget. It lives on the shard so rotation cannot
reset it
+ /// (see [`Self::metadata_repair_attempts`]); clearing it here would bound
one
+ /// round and re-target forever.
+ #[allow(clippy::future_not_send)]
+ async fn rotate_stalled_metadata_repair<P>(
+ &self,
+ consensus: &VsrConsensus<B, P>,
+ peer: u8,
+ from_op: u64,
+ to_op: u64,
+ ) where
+ B: MessageBus,
+ P: Pipeline<Entry = consensus::PipelineEntry>,
+ {
+ *self.metadata_repair.borrow_mut() = None;
+
+ if consensus.view_log_is_pending() &&
consensus.is_primary_for_view(consensus.view()) {
+ let sources = view_repair_sources(consensus, from_op);
+ let Some(next_peer) = next_view_repair_peer(&sources, Some(peer))
else {
+ // Only the quiet peer named this op. The session is dropped
either
+ // way: `advance_pending_metadata_view` re-scans on the next
tick and
+ // re-requests it, and a peer that never comes back leaves the
+ // view-change timeout to escalate.
+ tracing::warn!(
shard = self.id,
- to_op,
peer,
- "metadata repair window fully requested; closing the
stalled session"
+ from_op,
+ "no other replica offers op {from_op} for the merged log; \
+ view change is stalled"
);
- *self.metadata_repair.borrow_mut() = None;
- self.note_metadata_repair_progress();
- }
+ return;
+ };
+ tracing::warn!(
+ shard = self.id,
+ peer,
+ next_peer,
+ from_op,
+ to_op,
+ "merged-log repair stalled past its retry budget; re-arming
from \
+ another do_view_change sender"
+ );
+ self.arm_metadata_repair_session(consensus, next_peer, from_op,
to_op)
+ .await;
+ return;
+ }
+
+ let primary = consensus.primary_index(consensus.view());
+ let next_peer = next_transfer_peer(
+ consensus.replica(),
+ peer,
+ consensus.replica_count(),
+ primary,
+ );
+ if next_peer == peer {
+ // The ring had nobody else to offer (a solo group, or a
two-replica
+ // cluster whose only peer went quiet). Dropping the session is
still
+ // right: it unfences the level trigger below, which re-requests
the
+ // window on the next tick.
+ return;
}
+ tracing::warn!(
+ shard = self.id,
+ peer,
+ next_peer,
+ from_op,
+ to_op,
+ "metadata repair stalled past its retry budget; re-arming from
another replica"
+ );
+ self.maybe_request_metadata_repair(consensus, next_peer)
+ .await;
}
/// Compare this replica's log against the headers the view decided, and
drop or
@@ -5926,7 +6136,11 @@ where
// one back: demanding one parks the view change forever on an op
already
// applied and durable in the snapshot.
let repair_floor = journal.handle().snapshot_op();
- let missing = first_op_not_covered(&pending, repair_floor, |op| {
+ let floor = ScanFloor {
+ repair_floor,
+ commit_min: consensus.commit_min(),
+ };
+ let missing = first_op_not_covered(&pending, floor, |op| {
usize::try_from(op)
.ok()
.and_then(|slot| journal.handle().header(slot))
@@ -5967,7 +6181,23 @@ where
// Stream already running; the stall retry covers it drying up.
return;
}
- let sources = consensus.pending_view_body_sources(missing_op);
+ // Level-triggered, so it would re-arm against the head of the same
source
+ // list next tick -- including the sender that just answered
`RangeEvicted`,
+ // which is how the budget got spent. Once every sender has been asked
and
+ // charged, asking again is not progress; the view-change timeout is.
+ //
+ // Per view. A later view is a new merged log from a new quorum, so the
+ // senders that refused this one say nothing about it.
+ if self.metadata_repair_exhausted(consensus.view()) {
+ tracing::debug!(
+ shard = self.id,
+ missing_op,
+ view = consensus.view(),
+ "merged-log repair is out of retries for this view; not
re-arming"
+ );
+ return;
+ }
+ let sources = view_repair_sources(consensus, missing_op);
let Some(peer) = sources.first().copied() else {
// The merge only returns a startable log when some replica
offered each
// body, so an empty source list means that offer was withdrawn
(peer
@@ -5980,14 +6210,6 @@ where
return;
};
- let nonce = iggy_common::random_id::get_uuid();
- *self.metadata_repair.borrow_mut() = Some(MetadataRepairSession {
- nonce,
- to_op: pending.op_head,
- view: consensus.view(),
- peer,
- idle_ticks: 0,
- });
tracing::info!(
shard = self.id,
missing_op,
@@ -5995,13 +6217,45 @@ where
to_op = pending.op_head,
"repairing toward the merged log before starting the view"
);
+ self.arm_metadata_repair_session(consensus, peer, missing_op,
pending.op_head)
+ .await;
+ }
+
+ /// Mint a metadata repair session and send its first request.
+ ///
+ /// Three sites arm one (the view-change scan, the tail-repair funnel, the
+ /// stall rotation). Keeping the invariant here -- fresh nonce, arming
view,
+ /// clock from zero, a `from_op` the retry can reopen at -- stops a re-arm
from
+ /// shipping a window its own retry cannot reproduce.
+ ///
+ /// Callers decide whether to arm; this decides what an armed session is.
+ #[allow(clippy::future_not_send)]
+ async fn arm_metadata_repair_session<P>(
+ &self,
+ consensus: &VsrConsensus<B, P>,
+ peer: u8,
+ from_op: u64,
+ to_op: u64,
+ ) where
+ B: MessageBus,
+ P: Pipeline<Entry = consensus::PipelineEntry>,
+ {
+ let nonce = iggy_common::random_id::get_uuid();
+ *self.metadata_repair.borrow_mut() = Some(MetadataRepairSession {
+ nonce,
+ from_op,
+ to_op,
+ view: consensus.view(),
+ peer,
+ idle_ticks: 0,
+ });
self.send_request_prepares(
consensus.cluster(),
consensus.replica(),
peer,
nonce,
- missing_op,
- pending.op_head,
+ from_op,
+ to_op,
consensus.group(),
)
.await;
@@ -6013,11 +6267,14 @@ where
/// Every TAIL arming site funnels through here -- `StartView` adoption,
the
/// commit-heartbeat backstop, the state-transfer fallbacks, and
/// `tick_metadata`'s gap detector -- so the guards below are what make the
- /// level-triggered one idempotent. The one session this does not mint is
- /// the view-change repair `advance_pending_metadata_view` builds inline:
it
- /// repairs toward a merged log rather than the commit frontier, from a
peer
- /// that offered the body rather than from the primary, so none of the
- /// guards below describe it.
+ /// level-triggered one idempotent.
+ ///
+ /// It does not decide the merged-log sessions:
+ /// [`Self::advance_pending_metadata_view`] arms them and
+ /// [`Self::rotate_stalled_metadata_repair`] re-targets them. Those repair
+ /// toward a parked merged log rather than the commit frontier, from a
sender
+ /// that named the op rather than the primary, so none of the guards below
fit.
+ /// All three share [`Self::arm_metadata_repair_session`].
#[allow(clippy::future_not_send)]
async fn maybe_request_metadata_repair<P>(&self, consensus:
&VsrConsensus<B, P>, peer: u8)
where
@@ -6038,7 +6295,6 @@ where
&& consensus.commit_min() < consensus.commit_max()
&& self.metadata_repair.borrow().is_none()
{
- let nonce = iggy_common::random_id::get_uuid();
let to_op = consensus.commit_max();
let from_op = consensus.commit_min() + 1;
// Spent here rather than at the detector, so the edge-triggered
@@ -6046,13 +6302,6 @@ where
// the next tick would otherwise leave the count saturated and hand
// the next real gap an arm on its first tick.
self.metadata_gap_ticks.set(0);
- *self.metadata_repair.borrow_mut() = Some(MetadataRepairSession {
- nonce,
- to_op,
- view: consensus.view(),
- peer,
- idle_ticks: 0,
- });
tracing::info!(
shard = self.id,
from_op,
@@ -6060,16 +6309,8 @@ where
peer,
"metadata behind the group frontier; requesting repair"
);
- self.send_request_prepares(
- consensus.cluster(),
- consensus.replica(),
- peer,
- nonce,
- from_op,
- to_op,
- consensus.group(),
- )
- .await;
+ self.arm_metadata_repair_session(consensus, peer, from_op, to_op)
+ .await;
}
}
@@ -6600,21 +6841,23 @@ where
/// on the shard here for the same reason `metadata_transfer_attempts`
does:
/// one metadata group per node. It has to outlive the SESSION either way,
/// or the rotation that mints a new one would reset the count and
re-target
- /// forever without ever giving up on a peer.
- fn burn_metadata_repair_attempt(&self) -> bool {
- let attempts = self.metadata_repair_attempts.get() + 1;
- self.metadata_repair_attempts.set(attempts);
+ /// forever without giving up on a peer. Only
+ /// [`Self::note_metadata_repair_walked`] clears it.
+ fn burn_metadata_repair_attempt(&self, view: u32) -> bool {
+ let (charged_view, attempts) = self.metadata_repair_attempts.get();
+ let attempts = if charged_view == view {
+ attempts + 1
+ } else {
+ 1
+ };
+ self.metadata_repair_attempts.set((view, attempts));
attempts > partitions::REPAIR_MAX_STALL_RETRIES
}
- /// The serving peer answered: reset the budget, so it bounds CONSECUTIVE
- /// silence rather than the stalls a long healthy stream accumulates.
- ///
- /// Any in-scope repair frame, not only an accepted one. A re-request
- /// re-serves ops this replica already holds, so charging those as silence
- /// rotates away from a peer that is answering.
- fn note_metadata_repair_progress(&self) {
- self.metadata_repair_attempts.set(0);
+ /// Whether this view has already spent its merged-log repair budget.
+ const fn metadata_repair_exhausted(&self, view: u32) -> bool {
+ let (charged_view, attempts) = self.metadata_repair_attempts.get();
+ charged_view == view && attempts > partitions::REPAIR_MAX_STALL_RETRIES
}
/// Burn one retry round; `true` once the budget is exhausted.
@@ -6633,6 +6876,41 @@ where
self.metadata_transfer_attempts.set(0);
}
+ /// A usable repair frame landed in the window: restart the stall clock.
+ ///
+ /// A window is served in `REPAIR_CHUNK_MAX` slices and nothing else resets
+ /// `idle_ticks`, so a healthy multi-chunk stream would cross the retry
interval
+ /// on its own and rotate off a peer that is answering.
+ ///
+ /// Clock only. A repair prepare carries no sender and no session nonce
(the
+ /// frame IS the stored prepare, and its identity checksum covers every
byte
+ /// that could hold one), so a peer this session already rotated away from
can
+ /// land in-flight frames here and be credited to its successor. On the
clock
+ /// that costs one retry interval and is bounded, since nothing
re-requests from
+ /// that peer. On the budget it would cost rotation itself. See
+ /// [`Self::note_metadata_repair_walked`].
+ fn note_metadata_repair_clock(&self) {
+ if let Some(session) = self.metadata_repair.borrow_mut().as_mut() {
+ session.idle_ticks = 0;
+ }
+ }
+
+ /// The repair this session asked for is landing: restart the clock and the
+ /// budget.
+ ///
+ /// Takes only attributable signals, which a bare frame is not.
Terminators are
+ /// fenced on `session.nonce` before reaching here, so they came from the
+ /// targeted peer; an advanced `commit_min` is the gap actually closing,
+ /// whoever supplied the bytes.
+ ///
+ /// Stricter than the "any frame" rule it replaced: a re-request re-serves
ops
+ /// already held, so a peer answering with nothing new used to clear its
own
+ /// budget and could never be rotated away from.
+ fn note_metadata_repair_walked(&self) {
+ self.metadata_repair_attempts.set((0, 0));
+ self.note_metadata_repair_clock();
+ }
+
/// Charge one decode failure against `snapshot_seq`'s generation; `true`
/// once that generation's budget is spent. A different generation restarts
/// the count: the peer checkpointed since, so the artifacts are new bytes
@@ -7275,13 +7553,17 @@ where
walk_cursor.get_or_insert(namespace);
}
}
- let consensus_normal = partition.consensus().is_normal();
let consensus_view = partition.consensus().view();
let commit_min = partition.consensus().commit_min();
let cluster = partition.consensus().cluster();
let self_id = partition.consensus().replica();
+ // A primary-elect's merged-log session legitimately runs
outside
+ // `Normal` (`request_partition_view_repair`). Same predicate
as the
+ // two ingest sites, so the arming side and the ingest side
cannot
+ // drift apart.
+ let session_live =
consensus::repair_session_live(partition.consensus());
let repair_finished = partition.repair.is_some_and(|session| {
- if !consensus_normal || consensus_view != session.view {
+ if !session_live || consensus_view != session.view {
return true;
}
// Floored at the LIVE commit point, like
`complete_repair`:
@@ -7313,7 +7595,7 @@ where
continue;
}
let due = partition.repair.as_mut().and_then(|session| {
- if !consensus_normal {
+ if !session_live {
return None;
}
session.idle_ticks += 1;
@@ -7362,6 +7644,20 @@ where
);
partition.repair = None;
partition.note_repair_progress();
+ // A parked view change re-arms from the merged log's
senders,
+ // not around the cluster ring:
+ // `maybe_request_partition_repair` refuses outside
`Normal`,
+ // and a replica that never named the op answers
`RangeEvicted`
+ // for a range it never held.
+ if partition.consensus().view_log_is_pending()
+ && partition
+ .consensus()
+ .is_primary_for_view(partition.consensus().view())
+ {
+ self.request_partition_view_repair(partition, from_op,
to_op, Some(peer))
+ .await;
+ continue;
+ }
if next_peer == peer {
// The ring had nobody else to offer (a solo group, or
a
// two-replica group whose only peer is the one that
@@ -8582,6 +8878,82 @@ where
true
}
+ /// Repair a primary-elect's merged log before it starts the view.
+ ///
+ /// Sibling of [`Self::maybe_request_partition_repair`], which refuses
outside
+ /// `Normal` because its window comes from the live commit frontier. This
window
+ /// comes from the parked merged log, so it runs in `ViewChange` for the
replica
+ /// that parked it. Without it the coverage scan in
+ /// [`Self::start_pending_partition_view`] reports an op nothing ever
fetches --
+ /// the sweep's gap detector needs `probe.normal` too -- and only the
+ /// view-change timeout moves the replica.
+ ///
+ /// `avoid` is the peer a stall just gave up on, so rotation lands on a
+ /// different sender instead of the head of the same list.
+ #[allow(clippy::future_not_send)]
+ async fn request_partition_view_repair(
+ &self,
+ partition: &mut IggyPartition<B, SB>,
+ from_op: u64,
+ to_op: u64,
+ avoid: Option<u8>,
+ ) where
+ B: MessageBus,
+ {
+ if partition.repair.is_some() || from_op > to_op {
+ return;
+ }
+ if self.partition_repairs_inflight.get() >=
PARTITION_REPAIRS_INFLIGHT_MAX {
+ return;
+ }
+ let consensus = partition.consensus();
+ let sources = view_repair_sources(consensus, from_op);
+ let Some(peer) = next_view_repair_peer(&sources, avoid) else {
+ // Nobody else named this op. The view-change timeout escalates;
+ // re-arming the same silent sender would pin the scan behind a
+ // session for nothing.
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = consensus.group(),
+ from_op,
+ to_op,
+ "no replica offers op {from_op} for the merged partition log;
view change is \
+ stalled"
+ );
+ return;
+ };
+ let nonce = iggy_common::random_id::get_uuid();
+ let cluster = consensus.cluster();
+ let self_id = consensus.replica();
+ let namespace = consensus.group();
+ let view = consensus.view();
+ self.partition_repairs_inflight
+ .set(self.partition_repairs_inflight.get() + 1);
+ partition.repair = Some(partitions::RepairSession {
+ nonce,
+ view,
+ // Merged-log numbers, not the live frontier: `commit_to_op` is
what
+ // the walk must reach to finish the session, `fetch_to_op` the
head
+ // the view will announce.
+ commit_to_op: pending_commit_max(consensus),
+ fetch_to_op: to_op,
+ floor: None,
+ peer,
+ first_batch_offset: None,
+ idle_ticks: 0,
+ });
+ tracing::info!(
+ shard = self.id,
+ namespace_raw = namespace,
+ from_op,
+ to_op,
+ peer,
+ "repairing toward the merged partition log before starting the
view"
+ );
+ self.send_request_prepares(cluster, self_id, peer, nonce, from_op,
to_op, namespace)
+ .await;
+ }
+
/// Receiver side of a partition descriptor: accept the manifest, adopt
/// any reusable staged segments from an earlier attempt, and start
/// pulling, or fall back to journal repair when the peer cannot serve.
@@ -10004,6 +10376,111 @@ where
}
}
+/// Lowest op of a primary-elect's merged log this replica can be held to.
+///
+/// The merged commit point is what the cluster committed, `commit_min` what
this
+/// replica applied. They diverge whenever this replica has not applied the
merged
+/// commit point: a hole in the local prefix, or plain apply lag. Taking the
lower
+/// keeps coverage, repair scope and the stall retry asking about the same ops.
+fn merged_log_scan_floor(pending: &MergedLog, commit_min: u64) -> u64 {
+ pending.commit_max.min(commit_min + 1).max(1)
+}
+
+/// The two floors on a merged-log coverage scan.
+///
+/// Named, not positional: both are `u64`, they sit next to each other,
swapping
+/// them compiles, and the partition site passes the same value for both.
+#[derive(Debug, Clone, Copy)]
+struct ScanFloor {
+ /// Ops at or below this are gone AND already settled: compacted under a
+ /// snapshot (metadata) or at or below the local commit point (partitions).
+ /// Neither can diverge from the merged log and no repair puts the entry
back,
+ /// so demanding one parks the view change forever.
+ repair_floor: u64,
+ /// Highest op this replica has applied. See [`merged_log_scan_floor`].
+ commit_min: u64,
+}
+
+impl ScanFloor {
+ /// Lowest op the scan probes.
+ fn opens_at(self, pending: &MergedLog) -> u64 {
+ merged_log_scan_floor(pending, self.commit_min).max(self.repair_floor
+ 1)
+ }
+}
+
+/// Replicas a primary-elect can ask for `op` while its merged log is parked.
+///
+/// Offered bodies first: those senders proved they hold the entry. A gap below
+/// every sender's commit point has none, since a DVC suffix spans
`commit..=op`
+/// and says nothing underneath, so fall back to senders that committed the op.
+/// They hold it or compacted it, and `RangeEvicted` says which.
+///
+/// Both planes: a partition primary-elect parks a merged log the same way.
+fn view_repair_sources<B, P>(consensus: &VsrConsensus<B, P>, op: u64) ->
Vec<u8>
+where
+ B: MessageBus,
+ P: Pipeline<Entry = consensus::PipelineEntry>,
+{
+ let offered = consensus.pending_view_body_sources(op);
+ if offered.is_empty() {
+ consensus.pending_view_commit_sources(op)
+ } else {
+ offered
+ }
+}
+
+/// Where a stalled repair session reopens its window.
+///
+/// A merged-log session reopens exactly where it was armed. Its floor is
COVERAGE,
+/// not the walk: `first_op_not_covered` reports ops whose journal entry is
absent
+/// or diverging, and an op can be applied (`commit_min` past it) while its
entry is
+/// gone. Raising the floor to `commit_min + 1` there skips the very op the
scan
+/// reported, and the view change parks on it forever.
+///
+/// A tail-repair session is the other way round. Its window IS the commit
gap, so
+/// ops the walk has since consumed must not be asked for again.
`session.from_op`
+/// still floors it, carrying the initial arm's snapshot clamp so no retry
asks for
+/// compacted ops.
+fn stalled_repair_from_op(session_from_op: u64, commit_min: u64,
repairing_view: bool) -> u64 {
+ if repairing_view {
+ session_from_op
+ } else {
+ session_from_op.max(commit_min + 1)
+ }
+}
+
+/// Walk a merged-log source list one step past `avoid`, wrapping.
+///
+/// A ring, not a filter. The list is `log_view`-ordered and identical on every
+/// call, so `find(|c| *c != avoid)` yields the head for every peer but the
head
+/// itself and a third sender is never reached.
+///
+/// `None` when the list is empty or `avoid` is its only entry.
+fn next_view_repair_peer(sources: &[u8], avoid: Option<u8>) -> Option<u8> {
+ let Some(avoid) = avoid else {
+ return sources.first().copied();
+ };
+ let Some(index) = sources.iter().position(|candidate| *candidate == avoid)
else {
+ // The peer that stalled is not in this list at all (the DVC quorum
moved
+ // under it), so nothing has been tried yet from where we now stand.
+ return sources.first().copied();
+ };
+ let next = sources[(index + 1) % sources.len()];
+ if next == avoid { None } else { Some(next) }
+}
+
+/// The merged log's commit point while a view change is parked; the live
frontier
+/// otherwise.
+fn pending_commit_max<B, P>(consensus: &VsrConsensus<B, P>) -> u64
+where
+ B: MessageBus,
+ P: Pipeline<Entry = consensus::PipelineEntry>,
+{
+ consensus
+ .with_pending_view_log(|pending| pending.commit_max)
+ .unwrap_or_else(|| consensus.commit_max())
+}
+
/// Whether a repaired prepare at `op` falls inside the range this replica is
/// currently repairing.
///
@@ -10024,7 +10501,7 @@ fn repair_op_in_scope(
pending
.filter(|_| is_primary_elect)
.map_or(op > commit_min, |pending| {
- (op >= pending.commit_max.max(1) && op <= pending.op_head)
+ (op >= merged_log_scan_floor(pending, commit_min) && op <=
pending.op_head)
|| pending
.committed_elsewhere
.iter()
@@ -10723,13 +11200,29 @@ const fn header_is_view_entry(local: &PrepareHeader,
canonical: &PrepareHeader)
/// repair cannot walk back to them. `repair_floor` drops the ops whose
journal entry
/// is legitimately gone AND whose identity is already settled: on the
metadata plane
/// ops compacted under a snapshot, on the partition plane ops at or below the
local
-/// commit point, whose flushed entries `evict_prefix` moves out of the header
vec.
-/// Neither can diverge from the merged log (a committed or compacted op is the
-/// quorum's op), and no repair puts the journal entry back, so demanding one
parks
-/// the view change forever.
+/// commit point. Neither can diverge from the merged log (a committed or
compacted
+/// op is the quorum's op), and no repair puts the journal entry back, so
demanding
+/// one parks the view change forever.
+///
+/// Not a residency bound. `evict_prefix` clears the header vec up to
`commit_max`
+/// (the cluster frontier), so ops above `repair_floor` can be non-resident and
+/// still serveable, from the evicted ring or the flushed segments. Callers
pass a
+/// `header_at` that reads both.
+///
+/// Opens at [`merged_log_scan_floor`]: the merged commit point alone would
declare
+/// the log serveable over a local gap, promoting a replica whose
`CommitJournal`
+/// gap-stops below where `RebuildPipeline` seeds.
+///
+/// Below the merged commit point, identity rests on the fault model rather
than on
+/// this scan. The merged log names headers only from the DVC suffixes, which
span
+/// `commit..=op` per sender, so an op the widened floor admits under
+/// `pending.commit_max` usually has no canonical header and `held` degrades to
+/// bare residency. Sound under crash-stop, where a committed op is the
quorum's
+/// op. Not a Byzantine or bit-rot guard: corruption is
`verify_prepare_integrity`'s
+/// job on the ingest side.
fn first_op_not_covered(
pending: &MergedLog,
- repair_floor: u64,
+ floor: ScanFloor,
header_at: impl Fn(u64) -> Option<PrepareHeader>,
) -> Option<u64> {
let held = |op: u64| {
@@ -10743,14 +11236,19 @@ fn first_op_not_covered(
.find(|header| header.op == op)
.is_none_or(|canonical| header_is_view_entry(&local, canonical))
};
- (pending.commit_max.max(1).max(repair_floor + 1)..=pending.op_head)
+ (floor.opens_at(pending)..=pending.op_head)
.find(|op| !held(*op))
.or_else(|| {
+ // NOT raised to `opens_at`: these ops sit outside the merged
window
+ // by construction, and dropping the ones below it would start the
+ // view over a committed op this replica cannot serve. The repair
+ // window is floored to match instead, via
+ // `MetadataRepairSession::from_op`.
pending
.committed_elsewhere
.iter()
.map(|header| header.op)
- .filter(|op| *op > repair_floor)
+ .filter(|op| *op > floor.repair_floor)
.find(|op| !held(*op))
})
}
@@ -11346,9 +11844,17 @@ mod repair_scope_tests {
mod view_coverage_tests {
//! Holding an op is not holding the view's op.
- use super::{MergedLog, first_op_not_covered};
+ use super::{MergedLog, ScanFloor, first_op_not_covered};
use iggy_binary_protocol::{Command, Operation, PrepareHeader};
+ /// What a caught-up replica passes: nothing compacted, nothing lagging.
+ fn caught_up(pending: &MergedLog) -> ScanFloor {
+ ScanFloor {
+ repair_floor: 0,
+ commit_min: pending.commit_max,
+ }
+ }
+
fn sealed(op: u64, request: u64) -> PrepareHeader {
let mut header = PrepareHeader {
command: Command::Prepare,
@@ -11373,7 +11879,7 @@ mod view_coverage_tests {
committed_elsewhere: Vec::new(),
};
let held = [sealed(100, 1), sealed(99, 7), sealed(98, 1)];
- let missing = first_op_not_covered(&pending, 0, |op| {
+ let missing = first_op_not_covered(&pending, caught_up(&pending), |op|
{
held.iter().find(|header| header.op == op).copied()
});
assert_eq!(missing, Some(99));
@@ -11397,16 +11903,187 @@ mod view_coverage_tests {
};
let nothing_resident = |_: u64| None;
assert_eq!(
- first_op_not_covered(&pending, 0, nothing_resident),
+ first_op_not_covered(&pending, caught_up(&pending),
nothing_resident),
Some(256),
"unfloored, the evicted committed op reads as an unfillable hole"
);
assert_eq!(
- first_op_not_covered(&pending, 256, nothing_resident),
+ first_op_not_covered(
+ &pending,
+ ScanFloor {
+ repair_floor: 256,
+ commit_min: 256,
+ },
+ nothing_resident
+ ),
None,
"floored at the local commit point, the view starts"
);
}
+
+ #[test]
+ fn
given_a_hole_below_the_merged_commit_point_when_scanning_should_report_it() {
+ // Missed op 7 and kept taking prepares above it: the cluster committed
+ // through 10 while this state machine stopped at 6. From the merged
commit
+ // point the view would start over the gap and the first quorum ack
would
+ // apply an op with 7..=10 never executed locally.
+ let pending = MergedLog {
+ op_head: 12,
+ commit_max: 10,
+ headers: (7..=12).rev().map(|op| sealed(op, 1)).collect(),
+ committed_elsewhere: Vec::new(),
+ };
+ let held: Vec<_> = (8..=12).map(|op| sealed(op, 1)).collect();
+ let missing = first_op_not_covered(
+ &pending,
+ ScanFloor {
+ repair_floor: 0,
+ commit_min: 6,
+ },
+ |op| held.iter().find(|header| header.op == op).copied(),
+ );
+ assert_eq!(
+ missing,
+ Some(7),
+ "a hole below the merged commit point must park the view change"
+ );
+ }
+
+ #[test]
+ fn
given_a_contiguous_prefix_when_scanning_should_open_at_the_merged_commit_point()
{
+ // Nothing missing below, so both bounds coincide. Op 9 is held but is
not
+ // the view's op 9, so the commit point itself is still
identity-checked.
+ let pending = MergedLog {
+ op_head: 12,
+ commit_max: 9,
+ headers: (9..=12).rev().map(|op| sealed(op, 1)).collect(),
+ committed_elsewhere: Vec::new(),
+ };
+ let held: Vec<_> = (9..=12)
+ .map(|op| sealed(op, if op == 9 { 7 } else { 1 }))
+ .collect();
+ let missing = first_op_not_covered(&pending, caught_up(&pending), |op|
{
+ held.iter().find(|header| header.op == op).copied()
+ });
+ assert_eq!(
+ missing,
+ Some(9),
+ "the merged commit point stays in scope when the prefix is
contiguous"
+ );
+ }
+
+ #[test]
+ fn given_a_held_run_below_the_hole_when_scanning_should_walk_to_the_hole()
{
+ // The span the widened floor buys, and the one that costs: the open
sits
+ // well below the merged commit point, the ops between are all held,
and the
+ // scan must walk them to reach 15. A scan that stopped on its first
probe
+ // would find op 4 covered and never look further.
+ let pending = MergedLog {
+ op_head: 22,
+ commit_max: 20,
+ headers: (4..=22).rev().map(|op| sealed(op, 1)).collect(),
+ committed_elsewhere: Vec::new(),
+ };
+ let held: Vec<_> = (4..=22)
+ .filter(|op| *op != 15)
+ .map(|op| sealed(op, 1))
+ .collect();
+ let missing = first_op_not_covered(
+ &pending,
+ ScanFloor {
+ repair_floor: 0,
+ commit_min: 3,
+ },
+ |op| held.iter().find(|header| header.op == op).copied(),
+ );
+ assert_eq!(
+ missing,
+ Some(15),
+ "the scan must walk the held run below the merged commit point,
not stop at its first covered probe"
+ );
+ }
+
+ #[test]
+ fn
given_a_committed_elsewhere_op_below_the_open_when_scanning_should_still_report_it()
{
+ // `committed_elsewhere` sits outside the merged window, so the
fallback is
+ // floored at `repair_floor` and not at the scan's open. Which is why
the
+ // repair window floors at the op reported here: a retry reopening at
+ // `opens_at` would skip op 5 forever.
+ let pending = MergedLog {
+ op_head: 12,
+ commit_max: 10,
+ headers: (7..=12).rev().map(|op| sealed(op, 1)).collect(),
+ committed_elsewhere: vec![sealed(5, 1)],
+ };
+ let held: Vec<_> = (7..=12).map(|op| sealed(op, 1)).collect();
+ let floor = ScanFloor {
+ repair_floor: 0,
+ commit_min: 6,
+ };
+ assert_eq!(floor.opens_at(&pending), 7);
+ let missing = first_op_not_covered(&pending, floor, |op| {
+ held.iter().find(|header| header.op == op).copied()
+ });
+ assert_eq!(
+ missing,
+ Some(5),
+ "an op committed elsewhere and below the open is still uncovered"
+ );
+ }
+
+ #[test]
+ fn given_a_hole_when_scoping_repair_should_admit_the_missing_op() {
+ // Coverage and scope must agree: the scan parks on op 7, so op 7's
repaired
+ // prepare must be ingested. From the merged commit point it would be
+ // requested and then refused.
+ let pending = MergedLog {
+ op_head: 12,
+ commit_max: 10,
+ headers: (7..=12).rev().map(|op| sealed(op, 1)).collect(),
+ committed_elsewhere: Vec::new(),
+ };
+ assert!(
+ super::repair_op_in_scope(Some(&pending), true, 6, 7),
+ "the op the coverage scan parked on must be in repair scope"
+ );
+ }
+
+ #[test]
+ fn given_a_source_list_when_rotating_should_walk_it_as_a_ring() {
+ use super::next_view_repair_peer;
+
+ let sources = [1u8, 2, 3];
+ assert_eq!(next_view_repair_peer(&sources, None), Some(1));
+ assert_eq!(next_view_repair_peer(&sources, Some(1)), Some(2));
+ assert_eq!(
+ next_view_repair_peer(&sources, Some(2)),
+ Some(3),
+ "a filter answers 1 here and never reaches the third sender"
+ );
+ assert_eq!(
+ next_view_repair_peer(&sources, Some(3)),
+ Some(1),
+ "the walk wraps"
+ );
+ }
+
+ #[test]
+ fn given_a_sole_or_absent_source_when_rotating_should_report_nobody_left()
{
+ use super::next_view_repair_peer;
+
+ assert_eq!(next_view_repair_peer(&[], None), None);
+ assert_eq!(next_view_repair_peer(&[], Some(1)), None);
+ assert_eq!(
+ next_view_repair_peer(&[1], Some(1)),
+ None,
+ "the only sender is the one that went quiet"
+ );
+ assert_eq!(
+ next_view_repair_peer(&[2, 3], Some(9)),
+ Some(2),
+ "a peer no longer in the list means nothing here has been tried
yet"
+ );
+ }
}
#[cfg(test)]
@@ -12286,13 +12963,14 @@ mod metadata_repair_session_tests {
use super::{
MetadataRepairSession, gap_repair_peer, metadata_repair_superseded,
next_transfer_peer,
- repair_chunk_walked,
+ repair_chunk_walked, stalled_repair_from_op,
};
- /// Armed at view 3, against a window ending at op 20.
+ /// Armed at view 3, against the window `11..=20`.
const fn session() -> MetadataRepairSession {
MetadataRepairSession {
nonce: 7,
+ from_op: 11,
to_op: 20,
view: 3,
peer: 0,
@@ -12300,6 +12978,31 @@ mod metadata_repair_session_tests {
}
}
+ /// A merged-log session must re-ask for the op the coverage scan
reported, even
+ /// once the walk has passed it. Coverage is about the journal ENTRY; an
op can
+ /// be applied and still have no entry to serve, which is exactly what
+ /// `committed_elsewhere` reports.
+ #[test]
+ fn
given_a_dropped_response_below_commit_min_when_retrying_should_still_ask_for_it()
{
+ assert_eq!(
+ stalled_repair_from_op(5, 6, true),
+ 5,
+ "clamping to commit_min + 1 would retry from 7 and skip the
reported hole"
+ );
+ }
+
+ /// The tail-repair session is the other way round: its window is the
commit gap,
+ /// so ops the walk consumed must not be re-requested.
+ #[test]
+ fn
given_a_walked_window_when_retrying_a_tail_session_should_open_above_it() {
+ assert_eq!(stalled_repair_from_op(5, 6, false), 7);
+ assert_eq!(
+ stalled_repair_from_op(11, 3, false),
+ 11,
+ "the arm floor still holds, so no retry asks for compacted ops"
+ );
+ }
+
#[test]
fn given_a_gap_stopped_backup_when_picking_a_peer_should_ask_the_primary()
{
assert_eq!(gap_repair_peer(2, 3, 0), Some(0));
diff --git a/core/shard/src/router.rs b/core/shard/src/router.rs
index b7465d2b7..1e46ec9c9 100644
--- a/core/shard/src/router.rs
+++ b/core/shard/src/router.rs
@@ -532,6 +532,15 @@ where
})
}
+ /// `first_partition_commit_fault` for the simulator's lost-wakeup
+ /// tripwire: a fenced pump and a missed wake both leave frames undrained,
and
+ /// only the second is a channel bug. Test/simulator only, like
`inbox_len`.
+ #[cfg(any(test, feature = "simulator"))]
+ #[must_use]
+ pub fn fenced_partition_fault(&self) -> Option<FatalCommit> {
+ self.first_partition_commit_fault()
+ }
+
/// Sanity check at pump entry: every Consensus frame routed through
/// [`Self::dispatch`] must land on the shard whose `id` matches the
/// `target_shard` the sender stamped on the frame. The ctor
diff --git a/core/simulator/src/bin/workload-fuzz.rs
b/core/simulator/src/bin/workload-fuzz.rs
index 8143016a7..fb6ab966a 100644
--- a/core/simulator/src/bin/workload-fuzz.rs
+++ b/core/simulator/src/bin/workload-fuzz.rs
@@ -132,11 +132,21 @@ struct Args {
/// empty shadow against empty committed state and agrees. `0` opts out.
#[arg(long, default_value_t = 1)]
min_commits: u64,
- /// Committed metadata ops 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.
+ /// 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.
#[arg(long, default_value_t = 1)]
min_ops_compared: usize,
+ /// As `--min-ops-compared`, but METADATA ops only.
+ ///
+ /// Separate because a partition-only run satisfies the combined floor
while the
+ /// metadata oracle compares an empty chain against an empty chain and
agrees.
+ /// Defaults to `1`, which is the floor `--min-ops-compared` carried
before it
+ /// counted both planes; a campaign that wants no metadata coverage opts
out
+ /// 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.
/// 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.
@@ -562,9 +572,11 @@ fn run_quiesce_phase(
};
println!(
"quiesced and converged (leader-relative; entity oracle:
{entity_oracle}; \
- evictions={}; ops_compared={} replicas_compared={}
namespaces_checked={})",
+ evictions={}; ops_compared={} partition_ops_compared={}
replicas_compared={} \
+ namespaces_checked={})",
workload.evictions(),
convergence.ops_compared,
+ convergence.partition_ops_compared,
convergence.replicas_compared,
convergence.namespaces_checked,
);
@@ -574,13 +586,29 @@ fn run_quiesce_phase(
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 || convergence.ops_compared >=
args.min_ops_compared,
- "--min-ops-compared {}: {live} replicas live but only {} op(s)
witnessed \
- by more than one, so cross-replica agreement went untested \
- (seed={seed:#x})",
+ 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,
+ );
+ // 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,
);
// 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.
diff --git a/core/simulator/src/lib.rs b/core/simulator/src/lib.rs
index d5c554023..51a2d3559 100644
--- a/core/simulator/src/lib.rs
+++ b/core/simulator/src/lib.rs
@@ -33,7 +33,7 @@ use deps::SimClock;
use deps::SimSuperblock;
use deps::{MemStorage, SimJournal};
use executor::{DetExecutor, RunOutcome, TaskId};
-use iggy_binary_protocol::{Command, GenericHeader, ReplyHeader};
+use iggy_binary_protocol::{Command, GenericHeader, PrepareHeader, ReplyHeader};
use iggy_common::IggyError;
use message_bus::installer::conn_info::{ClientConnMeta, ClientTransportKind};
use metadata::impls::metadata::StreamsFrontend;
@@ -53,7 +53,7 @@ use server_common::sharding::{IggyNamespace,
PartitionLocation, ShardId};
use shard::shards_table::{ShardsTable, calculate_shard_assignment};
use shard::{CONSENSUS_TICK_INTERVAL, PartitionMaterialisation};
use std::cell::RefCell;
-use std::collections::{HashMap, HashSet};
+use std::collections::{BTreeMap, HashMap, HashSet};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::rc::Rc;
use std::sync::Arc;
@@ -135,6 +135,54 @@ pub(crate) struct PartitionConsensusState {
pub commit_min: u64,
}
+/// A pipeline head the commit walk is holding on: covered by the commit
frontier,
+/// but not the op the state machine is next owed.
+///
+/// What `drain_committable_prefix` / `peek_committable_head` refuse to drain.
Two
+/// different faults share that refusal and need different thresholds, so
+/// [`CommitPrefixHole::kind`] keeps them apart.
+#[derive(Debug, Clone, Copy)]
+pub(crate) struct CommitPrefixHole {
+ pub head_op: u64,
+ pub commit_min: u64,
+ pub commit_max: u64,
+ pub kind: CommitHoldKind,
+}
+
+/// Why a commit walk is holding below its pipeline head.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub(crate) enum CommitHoldKind {
+ /// `head_op > commit_min + 1`: the ops between never arrived. Legitimate
and
+ /// transient right after a promotion, while the bounded journal walk
clears the
+ /// apply backlog `RebuildPipeline` seeded above. Repair clears it, and
+ /// `commit_min` climbing is the proof repair is working.
+ MissingOps,
+ /// `head_op <= commit_min`: the walk applied this op and advanced past a
+ /// still-resident entry. Nothing missing, no repair owed, only a pop that
can
+ /// no longer happen -- so it never self-clears, and `commit_min` keeps
climbing
+ /// while the head stays frozen.
+ AppliedHead,
+}
+
+impl CommitPrefixHole {
+ fn read(head: Option<PrepareHeader>, commit_min: u64, commit_max: u64) ->
Option<Self> {
+ let head = head?;
+ if head.op > commit_max || head.op == commit_min + 1 {
+ return None;
+ }
+ Some(Self {
+ head_op: head.op,
+ commit_min,
+ commit_max,
+ kind: if head.op > commit_min {
+ CommitHoldKind::MissingOps
+ } else {
+ CommitHoldKind::AppliedHead
+ },
+ })
+ }
+}
+
pub struct Simulator {
/// All replicas, indexed by replica id. Always fully populated; crashed
replicas
/// stay alive but are skipped during dispatch.
@@ -966,6 +1014,21 @@ impl Simulator {
continue;
}
for shard in &replica.shards {
+ // First and unconditional. A fenced pump has exited, so its
frames
+ // pile up exactly as a missed wake does and every lane assert
below
+ // would misreport the cause -- and the pump's `FatalCommit`
return
+ // is dropped at the spawn, so nothing else sees it. Gated on a
+ // non-empty inbox, a fenced pump that happened to drain would
pass
+ // quiescence outright.
+ assert!(
+ shard.fenced_partition_fault().is_none(),
+ "fenced pump: replica {replica_id} shard {} exited on a
fatal commit ({:?}). \
+ Not a lost wakeup; fix the commit failure (seed {:#x},
schedule hash {:#x})",
+ shard.id,
+ shard.fenced_partition_fault(),
+ self.seed,
+ self.executor.schedule_hash(),
+ );
let pending = shard.inbox_len();
assert_eq!(
pending,
@@ -1364,6 +1427,30 @@ impl Simulator {
Some(partition.offsets())
}
+ /// A replica's journaled partition-plane prepare headers over `ops`, or
`None`
+ /// when it does not host the namespace.
+ ///
+ /// Repair headers, not resident ones. `evict_prefix` clears the resident
vec as
+ /// the committed prefix flushes to segments and moves those entries to the
+ /// repair ring, so a resident-only read compares nothing at all once a
run has
+ /// flushed. The ring is capacity-bounded, which makes this the recently
+ /// committed tail rather than the whole prefix -- and that tail is where
a bad
+ /// repair or a mis-decided view change lands.
+ ///
+ /// One pass per replica per namespace, not one per op: both lookups behind
+ /// `repair_headers_in` are linear.
+ #[must_use]
+ pub(crate) fn partition_journaled_headers(
+ &self,
+ replica_idx: usize,
+ namespace: IggyNamespace,
+ ops: std::ops::RangeInclusive<u64>,
+ ) -> Option<BTreeMap<u64, PrepareHeader>> {
+ let shard = self.replicas[replica_idx].partition_shard(namespace);
+ let partition = shard.plane.partitions().get_by_ns(&namespace)?;
+ Some(partition.log.journal().inner.repair_headers_in(ops))
+ }
+
/// Consensus view for a replica's partition-plane group, or `None` if that
/// replica does not host the namespace.
#[must_use]
@@ -1398,6 +1485,53 @@ impl Simulator {
})
}
+ /// A replica's metadata consensus handle, or `None` when it hosts no
metadata
+ /// plane. The one way to reach it; do not hand-walk the shard-0 / plane /
+ /// metadata chain.
+ #[must_use]
+ pub(crate) fn metadata_consensus(
+ &self,
+ replica_idx: usize,
+ ) -> Option<&consensus::VsrConsensus<crate::bus::SharedSimOutbox>> {
+ self.replicas[replica_idx].shards[0]
+ .plane
+ .metadata()
+ .consensus
+ .as_ref()
+ }
+
+ /// The metadata pipeline head the commit walk is holding on, if any. See
+ /// [`CommitPrefixHole`].
+ #[must_use]
+ pub(crate) fn metadata_commit_prefix_hole(
+ &self,
+ replica_idx: usize,
+ ) -> Option<CommitPrefixHole> {
+ let consensus = self.metadata_consensus(replica_idx)?;
+ CommitPrefixHole::read(
+ consensus.pipeline_head_header(),
+ consensus.commit_min(),
+ consensus.commit_max(),
+ )
+ }
+
+ /// Partition-plane twin of [`Self::metadata_commit_prefix_hole`].
+ #[must_use]
+ pub(crate) fn partition_commit_prefix_hole(
+ &self,
+ replica_idx: usize,
+ namespace: IggyNamespace,
+ ) -> Option<CommitPrefixHole> {
+ let shard = self.replicas[replica_idx].partition_shard(namespace);
+ let partition = shard.plane.partitions().get_by_ns(&namespace)?;
+ let consensus = partition.consensus();
+ CommitPrefixHole::read(
+ consensus.pipeline_head_header(),
+ consensus.commit_min(),
+ consensus.commit_max(),
+ )
+ }
+
/// Index of the current primary for `namespace`, as seen by the first live
/// replica hosting it, or `None` if no live replica hosts it.
///
diff --git a/core/simulator/src/workload/invariants.rs
b/core/simulator/src/workload/invariants.rs
index 4f6dd3a1d..d2011bcb5 100644
--- a/core/simulator/src/workload/invariants.rs
+++ b/core/simulator/src/workload/invariants.rs
@@ -23,18 +23,49 @@
//! trace and the determinism baseline (`workload_replay_is_deterministic`)
//! unchanged.
-use crate::Simulator;
use crate::workload::state_checker::StateChecker;
use crate::workload::{CLIENT_REQUEST_QUEUE_MAX, Workload};
+use crate::{CommitHoldKind, CommitPrefixHole, Simulator};
+use consensus::Consensus;
use server_common::sharding::IggyNamespace;
use std::collections::HashMap;
+/// Ticks a `Normal` metadata primary may sit behind its own recovery barrier
+/// before the run is called wedged.
+///
+/// A primary below the barrier admits nothing. Transient while a resumed
primary
+/// re-pipelines its suffix, a handful of round trips, so two orders of
magnitude
+/// of slack: only a barrier nothing will ever lower trips it.
+const RECOVERY_BARRIER_WEDGE_TICKS: u32 = 2_000;
+
+/// Ticks a replica may hold its commit walk below a MISSING op.
+///
+/// A promotion holds here legitimately while the journal walk clears its apply
+/// backlog 64 ops per sweep, so this sits past any backlog a run generates.
The
+/// count also resets when `commit_min` advances, which is repair working.
+const COMMIT_PREFIX_HOLE_WEDGE_TICKS: u32 = 2_000;
+
+/// Ticks a replica may hold its commit walk below an ALREADY APPLIED head.
+///
+/// Reachable without a bug: a `set_commit_floor` jump can raise `commit_min`
past
+/// a live pipeline. Nothing pops a head below the floor, so only a view change
+/// clears it, and this has to outlast the view-change escalation to avoid
failing
+/// a run on the window in between.
+const COMMIT_PREFIX_APPLIED_HEAD_TICKS: u32 = 2_000;
+
/// Per-(replica, namespace) high-water marks carried across ticks so each new
/// reading can be compared against the last.
#[derive(Debug, Default)]
pub struct Invariants {
commit_offset: HashMap<(u8, IggyNamespace), u64>,
view: HashMap<(u8, IggyNamespace), u64>,
+ /// Consecutive ticks a replica has been a `Normal` metadata primary still
+ /// gated by its recovery barrier. Reset as soon as any of that stops
holding.
+ barrier_gated_ticks: HashMap<u8, u32>,
+ /// Consecutive ticks a plane's commit walk has held below a committable
+ /// pipeline head, keyed by replica and namespace (`None` = metadata),
paired
+ /// with the `commit_min` the count started from.
+ commit_hole_ticks: HashMap<(u8, Option<IggyNamespace>), (u32, u64)>,
/// Cross-replica committed-log agreement. Runs every tick like the rest,
so a
/// divergence is reported where it appears rather than at the next
quiesce.
state_checker: StateChecker,
@@ -71,9 +102,25 @@ impl Invariants {
for replica_idx in 0..sim.replica_count {
if sim.is_crashed(replica_idx) {
+ self.barrier_gated_ticks.remove(&replica_idx);
+ self.commit_hole_ticks
+ .retain(|(replica, _), _| *replica != replica_idx);
continue;
}
+ self.check_recovery_barrier(sim, seed, replica_idx);
+ self.check_commit_prefix_contiguity(
+ seed,
+ replica_idx,
+ None,
+ sim.metadata_commit_prefix_hole(usize::from(replica_idx)),
+ );
for &ns in &workload.options.namespaces {
+ self.check_commit_prefix_contiguity(
+ seed,
+ replica_idx,
+ Some(ns),
+ sim.partition_commit_prefix_hole(usize::from(replica_idx),
ns),
+ );
if let Some(offsets) = sim.offsets(usize::from(replica_idx),
ns) {
let cur = offsets.commit_offset;
if let Some(&prev) = self.commit_offset.get(&(replica_idx,
ns)) {
@@ -102,6 +149,121 @@ impl Invariants {
self.state_checker.check(sim, seed);
}
+ /// Catch a metadata primary permanently shut behind its own recovery
barrier.
+ ///
+ /// The shape `VsrConsensus::redecide_recovery_barrier` fixes, caught from
the
+ /// outside: a primary that can never clear its barrier drops every
request as
+ /// `NotReady`, which otherwise surfaces only as an unexplained stall.
+ ///
+ /// # Panics
+ /// When a `Normal` metadata primary sits below its barrier for
+ /// [`RECOVERY_BARRIER_WEDGE_TICKS`] consecutive ticks.
+ fn check_recovery_barrier(&mut self, sim: &Simulator, seed: u64,
replica_idx: u8) {
+ let Some(consensus) = sim.metadata_consensus(usize::from(replica_idx))
else {
+ return;
+ };
+ // Mirrors `consensus::is_caught_up_primary`, `!is_transferring`
included:
+ // a primary in state transfer is shut by the transfer, not the
barrier, so
+ // blaming the barrier is a false panic.
+ //
+ // Both gates, because they read different counters: admission compares
+ // `commit_max`, the HTTP read gate (`server/src/http/reads.rs`)
compares
+ // `commit_min`. A barrier met by one and never the other 503s every
read
+ // while admission looks open, which is the half
+ // `redecide_recovery_barrier` exists for.
+ let gated = consensus.is_primary()
+ && !consensus.has_ceded_primaryship()
+ && consensus.is_normal()
+ && !consensus.is_transferring()
+ && consensus.recovery_barrier() > 0
+ && (consensus.commit_max() < consensus.recovery_barrier()
+ || consensus.commit_min() < consensus.recovery_barrier());
+ if !gated {
+ self.barrier_gated_ticks.remove(&replica_idx);
+ return;
+ }
+ let ticks = self
+ .barrier_gated_ticks
+ .entry(replica_idx)
+ .and_modify(|ticks| *ticks += 1)
+ .or_insert(1);
+ assert!(
+ *ticks < RECOVERY_BARRIER_WEDGE_TICKS,
+ "replica {replica_idx} has been a Normal metadata primary gated by
its recovery \
+ barrier for {ticks} ticks: barrier={} commit={}..{} view={}.
Nothing lowers the \
+ barrier, so this primary drops every client request (commit_max
gate) or 503s \
+ every local read (commit_min gate) from here on (seed={seed:#x})",
+ consensus.recovery_barrier(),
+ consensus.commit_min(),
+ consensus.commit_max(),
+ consensus.view(),
+ );
+ }
+
+ /// Catch a commit walk permanently held below a committable pipeline head.
+ ///
+ /// The state `drain_committable_prefix` and `peek_committable_head`
refuse to
+ /// drain. Two faults share that refusal and they are not the same wait,
so they
+ /// are counted apart (see [`CommitHoldKind`]):
+ ///
+ /// - [`CommitHoldKind::MissingOps`] is legitimate while repair runs, so
the
+ /// count restarts when `commit_min` advances and only a hole nothing
refills
+ /// trips it.
+ /// - [`CommitHoldKind::AppliedHead`] never self-clears; only a view change
+ /// does. Resetting it on `commit_min` would be exactly wrong, since the
head
+ /// is frozen while `commit_min` climbs.
+ ///
+ /// # Panics
+ /// When one plane holds past the threshold for its hold kind.
+ fn check_commit_prefix_contiguity(
+ &mut self,
+ seed: u64,
+ replica_idx: u8,
+ namespace: Option<IggyNamespace>,
+ hole: Option<CommitPrefixHole>,
+ ) {
+ let key = (replica_idx, namespace);
+ let Some(hole) = hole else {
+ self.commit_hole_ticks.remove(&key);
+ return;
+ };
+ let (limit, diagnosis) = match hole.kind {
+ CommitHoldKind::MissingOps => (
+ COMMIT_PREFIX_HOLE_WEDGE_TICKS,
+ "the ops between are missing and nothing is refilling them, so
every reply \
+ above the hole is owed forever",
+ ),
+ CommitHoldKind::AppliedHead => (
+ COMMIT_PREFIX_APPLIED_HEAD_TICKS,
+ "the commit point moved past this entry, so nothing is missing
and no repair \
+ is owed: only a view change can clear it, and none has",
+ ),
+ };
+ let entry = self
+ .commit_hole_ticks
+ .entry(key)
+ .or_insert((0, hole.commit_min));
+ // A missing-op hold whose `commit_min` moved is repair landing, so
the run
+ // starts over. An applied-head hold is measured while `commit_min`
climbs,
+ // so it must not.
+ if hole.kind == CommitHoldKind::MissingOps && entry.1 !=
hole.commit_min {
+ *entry = (0, hole.commit_min);
+ }
+ entry.0 += 1;
+ let ticks = entry.0;
+ assert!(
+ ticks < limit,
+ "replica {replica_idx} has held its commit walk below a
committable pipeline head \
+ for {ticks} ticks on {} ({:?}): head_op={} commit={}..{}.
{diagnosis} \
+ (seed={seed:#x})",
+ namespace.map_or_else(|| "the metadata plane".to_owned(), |ns|
format!("{ns:?}")),
+ hole.kind,
+ hole.head_op,
+ hole.commit_min,
+ hole.commit_max,
+ );
+ }
+
/// The canonical committed chain built so far. Tests read it to prove the
/// equality check compared replicas against each other rather than passing
/// over an empty chain.
diff --git a/core/simulator/src/workload/oracle.rs
b/core/simulator/src/workload/oracle.rs
index 4c84bdfe6..18b4b714d 100644
--- a/core/simulator/src/workload/oracle.rs
+++ b/core/simulator/src/workload/oracle.rs
@@ -435,10 +435,20 @@ pub fn assert_converged(sim: &Simulator, workload: &mut
Workload) -> Convergence
}
}
+ // Partition-plane twin of the walk above: that one bounds how much each
+ // replica committed, this compares what they committed.
+ let partition_ops_compared =
+ state_checker::assert_partition_prefixes_agree(sim,
&workload.options.namespaces, seed);
+ tracing::info!(
+ partition_ops_compared,
+ "committed partition entries agree across every live replica"
+ );
+
let replicas_compared = assert_committed_metadata_agrees(sim, &live, seed);
let report = ConvergenceReport {
ops_compared,
+ partition_ops_compared,
replicas_compared,
namespaces_checked,
};
@@ -491,6 +501,10 @@ pub fn assert_converged(sim: &Simulator, workload: &mut
Workload) -> Convergence
pub struct ConvergenceReport {
/// Committed metadata ops witnessed by more than one live replica.
pub ops_compared: usize,
+ /// Committed partition ops witnessed by more than one live replica,
summed over
+ /// every namespace. Separate from `ops_compared`: a partition-plane run
commits
+ /// almost no metadata and would otherwise read as having compared nothing.
+ pub partition_ops_compared: usize,
/// Live replicas whose committed metadata CONTENT was compared against a
peer
/// sharing its commit point. Zero on a solo cluster.
pub replicas_compared: usize,
diff --git a/core/simulator/src/workload/state_checker.rs
b/core/simulator/src/workload/state_checker.rs
index dd7e91729..53995a6ec 100644
--- a/core/simulator/src/workload/state_checker.rs
+++ b/core/simulator/src/workload/state_checker.rs
@@ -32,9 +32,9 @@
//! non-vacuous: a chain nothing was compared against passes silently.
use crate::Simulator;
-use consensus::MetadataHandle;
use iggy_binary_protocol::PrepareHeader;
use journal::Journal;
+use server_common::sharding::IggyNamespace;
use std::collections::{BTreeMap, BTreeSet};
/// One op of the canonical committed chain.
@@ -104,7 +104,7 @@ impl StateChecker {
continue;
}
let replica = &sim.replicas[usize::from(replica_idx)];
- let Some(consensus) =
replica.shards[0].plane.metadata().consensus.as_ref() else {
+ let Some(consensus) =
sim.metadata_consensus(usize::from(replica_idx)) else {
continue;
};
let committed = consensus.commit_min();
@@ -254,7 +254,7 @@ pub fn assert_committed_prefixes_agree(sim: &Simulator,
seed: u64) -> usize {
continue;
}
let replica = &sim.replicas[usize::from(replica_idx)];
- let Some(consensus) =
replica.shards[0].plane.metadata().consensus.as_ref() else {
+ let Some(consensus) = sim.metadata_consensus(usize::from(replica_idx))
else {
continue;
};
let committed = consensus.commit_min();
@@ -291,6 +291,71 @@ pub fn assert_committed_prefixes_agree(sim: &Simulator,
seed: u64) -> usize {
witnesses.values().filter(|&&count| count > 1).count()
}
+/// Assert every live replica's committed partition prefix agrees, op for op.
+///
+/// Partition-plane counterpart of [`assert_committed_prefixes_agree`], and
the only
+/// check comparing what replicas committed on this plane rather than how
much. The
+/// quiesce oracle otherwise bounds only that no backup committed past its
leader.
+///
+/// Two differences from the metadata twin, both from the partition journal
evicting
+/// its committed prefix as it flushes to segments:
+///
+/// * A missing header is ordinary, not a hole, so this compares only the ops
two
+/// replicas both still hold. `evict_prefix` moves a flushed entry to the
repair
+/// ring rather than dropping it, so that set is the ring plus whatever is
still
+/// resident: the recently committed tail, where a bad repair or a
mis-decided
+/// view change lands.
+/// * Identity, not the sealed checksum. `restamp_prepare_view` rewrites the
view on
+/// retransmit, so `identity_checksum` compares the entry, not the delivery.
+///
+/// Returns ops witnessed by more than one replica, summed over every
namespace.
+///
+/// # Panics
+/// If two live replicas hold different entries at the same committed
partition op.
+#[must_use]
+pub fn assert_partition_prefixes_agree(
+ sim: &Simulator,
+ namespaces: &[IggyNamespace],
+ seed: u64,
+) -> usize {
+ let mut compared = 0;
+ for &namespace in namespaces {
+ let mut canonical: BTreeMap<u64, (u128, u8)> = BTreeMap::new();
+ let mut witnesses: BTreeMap<u64, usize> = BTreeMap::new();
+ for replica_idx in 0..sim.replica_count {
+ if sim.is_crashed(replica_idx) {
+ continue;
+ }
+ let idx = usize::from(replica_idx);
+ let Some(state) = sim.partition_consensus_state(idx, namespace)
else {
+ continue;
+ };
+ let Some(headers) =
+ sim.partition_journaled_headers(idx, namespace,
1..=state.commit_min)
+ else {
+ continue;
+ };
+ for (op, header) in headers {
+ let identity = header.identity_checksum();
+ if let Some(&(expected, owner)) = canonical.get(&op) {
+ assert_eq!(
+ identity, expected,
+ "at quiesce replica {replica_idx} and replica {owner}
disagree on \
+ committed partition op {op} of ns {namespace:?}:
{identity:#x} vs \
+ {expected:#x} (seed={seed:#x})",
+ );
+ *witnesses.entry(op).or_insert(1) += 1;
+ } else {
+ canonical.insert(op, (identity, replica_idx));
+ witnesses.insert(op, 1);
+ }
+ }
+ }
+ compared += witnesses.values().filter(|&&count| count > 1).count();
+ }
+ compared
+}
+
/// Whether a committed op having no journal header is legitimate rather than a
/// hole.
///
@@ -320,6 +385,7 @@ mod tests {
use super::*;
use crate::client::SimClient;
use crate::packet::PacketSimulatorOptions;
+ use consensus::PartitionsHandle;
const SEED: u64 = 0x5C11;
@@ -359,11 +425,8 @@ mod tests {
#[should_panic(expected = "a hole in the committed log")]
fn a_missing_committed_head_above_the_snapshot_floor_is_a_hole() {
let sim = cluster_with_committed_ops();
- let committed = sim.replicas[1].shards[0]
- .plane
- .metadata()
- .consensus
- .as_ref()
+ let committed = sim
+ .metadata_consensus(1)
.expect("shard 0 owns metadata consensus")
.commit_min();
assert!(
@@ -406,6 +469,97 @@ mod tests {
checker.check(&sim, SEED);
}
+ /// A three-replica cluster with committed partition ops flushed to
segments.
+ ///
+ /// The state the partition comparison has to work in: `evict_prefix`
clears the
+ /// resident header vec on flush, so a resident-only read sees nothing.
+ fn cluster_with_flushed_partition_ops() -> (Simulator, IggyNamespace) {
+
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
+ enabled: false,
+ size: iggy_common::IggyByteSize::from(0u64),
+ bucket_capacity: 1,
+ });
+ let client_id: u128 = 1;
+ let mut sim = Simulator::new(
+ 3,
+ std::iter::once(client_id),
+ PacketSimulatorOptions {
+ node_count: 3,
+ client_count: 1,
+ seed: SEED,
+ ..PacketSimulatorOptions::default()
+ },
+ );
+ let client = SimClient::new(client_id);
+ let namespace = IggyNamespace::new(1, 1, 0);
+ sim.init_partition(namespace);
+ sim.register_client_with_primary(&client);
+ for sequence in 0..6u32 {
+ let msg = client.send_messages(
+ namespace,
+ &[bytes::Bytes::from(format!("wl-part-{sequence}"))],
+ );
+ sim.submit_request(client_id, 0, msg.into_generic());
+ for _ in 0..60 {
+ sim.step();
+ }
+ }
+
+ for replica_idx in 0..3usize {
+ let shard = sim.replicas[replica_idx].partition_shard(namespace);
+ let partitions = shard.plane.partitions();
+ let config = partitions.config();
+ let Some(partition) = partitions.get_mut_by_ns(&namespace) else {
+ continue;
+ };
+
futures::executor::block_on(partition.flush_committed_messages(config))
+ .expect("the in-memory flush must succeed");
+ }
+ (sim, namespace)
+ }
+
+ /// The partition comparison must survive a flush.
+ ///
+ /// `header_by_op` reads the resident header vec, which `evict_prefix`
clears as
+ /// the committed prefix flushes, so a resident-only read compares zero
ops at
+ /// quiescence and the check passes over nothing.
+ #[test]
+ fn a_flushed_partition_prefix_is_still_compared() {
+ let (sim, namespace) = cluster_with_flushed_partition_ops();
+
+ let committed = sim
+ .partition_consensus_state(1, namespace)
+ .expect("replica 1 hosts the namespace")
+ .commit_min;
+ assert!(
+ committed > 1,
+ "the cluster committed {committed} partition op(s), so there is
nothing \
+ for two replicas to agree on"
+ );
+
+ let resident = sim.replicas[1]
+ .partition_shard(namespace)
+ .plane
+ .partitions()
+ .get_by_ns(&namespace)
+ .expect("replica 1 hosts the namespace")
+ .log
+ .journal()
+ .inner
+ .header_by_op(1);
+ assert!(
+ resident.is_none(),
+ "op 1 is still resident, so this test does not exercise the
flushed path"
+ );
+
+ let compared = assert_partition_prefixes_agree(&sim, &[namespace],
SEED);
+ assert!(
+ compared > 0,
+ "no committed partition op was witnessed by more than one replica
after \
+ the flush, so the check passed over an empty set"
+ );
+ }
+
/// An undamaged prefix passes the recheck: the reset must turn a restart
into a
/// comparison, not into a failure.
#[test]