This is an automated email from the ASF dual-hosted git repository.
krishvishal pushed a commit to branch consensus-prefix-contiguity
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/consensus-prefix-contiguity by
this push:
new 0100a561a fix: address review comments round 4
0100a561a is described below
commit 0100a561a864896be356884dcb37cfd50f539fe4
Author: Krishna Vishal <[email protected]>
AuthorDate: Tue Sep 8 22:22:52 2026 +0530
fix: address review comments round 4
---
core/consensus/src/plane_helpers.rs | 38 +++++++++++++----
core/partitions/src/iggy_partition.rs | 12 ++++--
core/shard/src/lib.rs | 69 ++++++++++++++++++++-----------
core/simulator/src/workload/invariants.rs | 16 +++----
4 files changed, 93 insertions(+), 42 deletions(-)
diff --git a/core/consensus/src/plane_helpers.rs
b/core/consensus/src/plane_helpers.rs
index 45951379e..a72995cf0 100644
--- a/core/consensus/src/plane_helpers.rs
+++ b/core/consensus/src/plane_helpers.rs
@@ -433,6 +433,25 @@ 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 from the head, while covered by the commit frontier,
and
@@ -453,8 +472,9 @@ where
/// `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. Both journal walks stop below
the
-/// head to keep it unreachable, so it is logged apart.
+/// 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).
@@ -560,16 +580,20 @@ fn report_uncommittable_head(
walk until the ops below it are journaled"
);
} else {
- // Unreachable while both journal walks stop below the head, so this
is a
- // defect and not a state to wait out: nothing will pop or answer the
entry.
- tracing::error!(
+ // 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; the
commit walk \
- advanced past a resident pipeline entry and its reply can no
longer be sent"
+ "committable head sits at or below the applied commit point;
nothing can pop \
+ or answer this entry until a view change clears the pipeline"
);
}
}
diff --git a/core/partitions/src/iggy_partition.rs
b/core/partitions/src/iggy_partition.rs
index e7d1f9599..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,
@@ -6729,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;
}
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index e408541c8..fb544cf90 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -1607,7 +1607,14 @@ where
/// 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.
- metadata_repair_attempts: Cell<u32>,
+ ///
+ /// 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
@@ -1762,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),
})
}
@@ -2249,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),
}
}
@@ -5293,7 +5300,7 @@ where
// 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() {
+ if self.burn_metadata_repair_attempt(consensus.view())
{
*self.metadata_repair.borrow_mut() = None;
tracing::warn!(
shard = self.id,
@@ -5337,7 +5344,7 @@ where
// 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() {
+ if self.burn_metadata_repair_attempt(consensus.view())
{
self.rotate_stalled_metadata_repair(
consensus,
header.replica,
@@ -5394,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;
}
@@ -5824,7 +5836,7 @@ where
);
*self.metadata_repair.borrow_mut() = None;
self.note_metadata_repair_walked();
- } else if self.burn_metadata_repair_attempt() {
+ } else if self.burn_metadata_repair_attempt(consensus.view()) {
self.rotate_stalled_metadata_repair(consensus, peer, from_op,
to_op)
.await;
} else {
@@ -6173,12 +6185,15 @@ where
// 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.
- if self.metadata_repair_attempts.get() >
partitions::REPAIR_MAX_STALL_RETRIES {
+ //
+ // 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,
- attempts = self.metadata_repair_attempts.get(),
- "merged-log repair is out of retries; not re-arming"
+ view = consensus.view(),
+ "merged-log repair is out of retries for this view; not
re-arming"
);
return;
}
@@ -6828,12 +6843,23 @@ where
/// or the rotation that mints a new one would reset the count and
re-target
/// forever without giving up on a peer. Only
/// [`Self::note_metadata_repair_walked`] clears it.
- fn burn_metadata_repair_attempt(&self) -> bool {
- let attempts = self.metadata_repair_attempts.get() + 1;
- self.metadata_repair_attempts.set(attempts);
+ 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
}
+ /// Whether this view has already spent its merged-log repair budget.
+ 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.
fn burn_metadata_transfer_attempt(&self) -> bool {
let attempts = self.metadata_transfer_attempts.get() + 1;
@@ -6881,7 +6907,7 @@ where
/// 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);
+ self.metadata_repair_attempts.set((0, 0));
self.note_metadata_repair_clock();
}
@@ -7527,20 +7553,15 @@ 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();
- // The one session that legitimately runs outside `Normal`
- // (`request_partition_view_repair`). Treated as superseded it
would
- // be dropped on the tick after it was armed, leaving the
coverage
- // scan with no fetcher; refused a stall retry it would pin
the view
- // change behind one lost frame. Mirrors
- // `retry_stalled_metadata_repair`'s `repairing_view`.
- let repairing_view =
partition.consensus().view_log_is_pending()
- &&
partition.consensus().is_primary_for_view(consensus_view);
- let session_live = consensus_normal || repairing_view;
+ // 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 !session_live || consensus_view != session.view {
return true;
diff --git a/core/simulator/src/workload/invariants.rs
b/core/simulator/src/workload/invariants.rs
index c9912b5a3..d2011bcb5 100644
--- a/core/simulator/src/workload/invariants.rs
+++ b/core/simulator/src/workload/invariants.rs
@@ -47,9 +47,11 @@ const COMMIT_PREFIX_HOLE_WEDGE_TICKS: u32 = 2_000;
/// Ticks a replica may hold its commit walk below an ALREADY APPLIED head.
///
-/// Near zero: nothing clears this one, so there is no legitimate window to
wait
-/// out. The slack only covers a reading taken between an apply and its pop.
-const COMMIT_PREFIX_APPLIED_HEAD_TICKS: u32 = 5;
+/// 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.
@@ -207,8 +209,8 @@ impl Invariants {
/// - [`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, so it gets a
near-zero
- /// threshold. Resetting it on `commit_min` would be exactly wrong: the
head
+ /// - [`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
@@ -233,8 +235,8 @@ impl Invariants {
),
CommitHoldKind::AppliedHead => (
COMMIT_PREFIX_APPLIED_HEAD_TICKS,
- "the commit walk advanced past this entry, so nothing is
missing and nothing \
- will ever pop it: its reply can no longer be built and its
awaiter never wakes",
+ "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