This is an automated email from the ASF dual-hosted git repository.
spetz 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 73a0bed8b fix(consensus): count an unservable header as a nack (#4109)
73a0bed8b is described below
commit 73a0bed8be2cf4ed247f5f735106dbc3f0ac85ef
Author: Krishna Vishal <[email protected]>
AuthorDate: Fri Sep 11 18:26:58 2026 +0530
fix(consensus): count an unservable header as a nack (#4109)
A view change can reach a state where an op is neither recoverable from
the `DoViewChange` messages in hand nor provably uncommitted.
`merge_dvc_quorum` answers `AwaitingRepair` and waits for every replica
to report, so a permanently crashed replica leaves two healthy survivors
unable to elect a primary. The view number keeps climbing while
`log_view` stays put, and client requests retry forever.
The op was decidable from the quorum already present. A sender that
holds the header but cannot serve the body counted as neither a copy nor
a nack. A prepare is journaled before it is acked and nothing compacts
an op above the commit point, so a missing body proves that sender never
acked it, which is what a nack asserts.
`tally_op` now derives a nack from that sender. The quorum is unchanged
at `replica_count - quorum_replication + 1`, so replicas provably
outside the ack set still leave fewer than a replication quorum inside
it, and a sender that can serve the body is untouched. The derived nack
stops at the sender's own commit point, where a missing body means
compaction rather than absence.
Two unit tests now assert truncation where they asserted a stall, one
pins the boundary at a single servable copy, and the undecidable case
moves to abstention. A simulator test replays the seed that found this:
without the change the cluster reaches view 103 against `log_view` 3
with both survivors caught up, and one request retries 266 times
unanswered.
---
core/consensus/src/dvc_merge.rs | 86 +++++++++++++------
core/consensus/src/impls.rs | 82 +++++++++++++------
core/consensus/src/plane_helpers.rs | 150 +++++++++++++++++++++++++++++++++-
core/metadata/src/impls/metadata.rs | 19 ++++-
core/partitions/src/iggy_partition.rs | 49 ++++++-----
core/shard/src/lib.rs | 31 ++++---
core/simulator/src/lib.rs | 78 ++++++++++++++++++
7 files changed, 401 insertions(+), 94 deletions(-)
diff --git a/core/consensus/src/dvc_merge.rs b/core/consensus/src/dvc_merge.rs
index 516ef8ff8..0f8dfca4d 100644
--- a/core/consensus/src/dvc_merge.rs
+++ b/core/consensus/src/dvc_merge.rs
@@ -210,8 +210,9 @@ fn tally_op<'a>(
};
let held = dvc.suffix.valid_header_at(index);
+ let offers_body = dvc.suffix.offers_body(index);
if let (Some(held), Some(canonical)) = (held, canonical)
- && dvc.suffix.offers_body(index)
+ && offers_body
&& held.checksum == canonical.checksum
{
copies += 1;
@@ -221,21 +222,31 @@ fn tally_op<'a>(
// Explicit: the sender proves it never prepared this op.
nacks += 1;
} else if let Some(held) = held {
- // Only a sender BEHIND the canonical log_view can implicitly nack.
- // Without this, corrupting one canonical header in transit turns
every
- // honest sender's correct header into an implicit nack against the
- // garbage: a nack quorum on three replicas. A same-log_view
- // disagreement is evidence, not a vote, and goes through
`conflict`.
- let may_nack_implicitly = dvc.log_view < canonical_log_view;
- match canonical {
- // Implicit: the sender holds a DIFFERENT prepare, so not this
one.
- Some(canonical) if may_nack_implicitly && held.checksum !=
canonical.checksum => {
- nacks += 1;
+ if !offers_body && op > dvc.commit {
+ // A prepare is journaled before it is acked, so a header this
+ // sender cannot serve, above its own commit, proves it is
outside
+ // the ack set. Below that commit a missing body is compaction.
+ nacks += 1;
+ } else {
+ // Only a sender BEHIND the canonical log_view can implicitly
+ // nack. Without this, corrupting one canonical header in
transit
+ // turns every honest sender's correct header into an implicit
+ // nack against the garbage: a nack quorum on three replicas. A
+ // same-log_view disagreement is evidence, not a vote, and goes
+ // through `conflict`.
+ let may_nack_implicitly = dvc.log_view < canonical_log_view;
+ match canonical {
+ // Implicit: the sender holds a DIFFERENT prepare, so not
this one.
+ Some(canonical)
+ if may_nack_implicitly && held.checksum !=
canonical.checksum =>
+ {
+ nacks += 1;
+ }
+ // Implicit: no canonical sender holds anything here, so a
newer
+ // view already truncated this op and the sender holds a
corpse.
+ None if may_nack_implicitly => nacks += 1,
+ _ => {}
}
- // Implicit: no canonical sender holds anything here, so a
newer
- // view already truncated this op and the sender holds a
corpse.
- None if may_nack_implicitly => nacks += 1,
- _ => {}
}
}
}
@@ -707,23 +718,24 @@ mod tests {
}
#[test]
- fn
given_header_without_a_servable_body_when_replicas_outstanding_should_await_repair()
{
- // Both senders have op 4's header, neither can serve its body, and
replica 2
- // has not reported. A head whose body nobody holds would wedge the
view.
+ fn
given_header_without_a_servable_body_when_replicas_outstanding_should_truncate()
{
+ // Neither sender journaled op 4, so the ack set was at most replica 2:
+ // short of a replication quorum, so it never committed.
let mut quorum = dvc_quorum_array_empty();
let headers = suffix_headers(2, 4, 1);
let header_only = DvcSuffix::new(headers, 0, 0b110);
dvc_record(&mut quorum, dvc(0, 1, 4, 2, header_only.clone()));
dvc_record(&mut quorum, dvc(1, 1, 4, 2, header_only));
- assert_eq!(
- merge_dvc_quorum(&quorum, quorums_r3()),
- MergeOutcome::AwaitingRepair { undecided_op: 4 }
- );
+ let MergeOutcome::Ready(log) = merge_dvc_quorum(&quorum, quorums_r3())
else {
+ panic!("two senders provably outside the ack set decide op 4");
+ };
+ assert_eq!(log.op_head, 3, "op 4 is truncated, not awaited");
+ assert_eq!(log.commit_max, 2);
}
#[test]
- fn given_all_replicas_reported_and_op_undecidable_should_deadlock() {
+ fn
given_all_replicas_reported_and_no_body_should_truncate_rather_than_deadlock() {
let mut quorum = dvc_quorum_array_empty();
let headers = suffix_headers(2, 4, 1);
let header_only = DvcSuffix::new(headers, 0, 0b110);
@@ -731,10 +743,32 @@ mod tests {
dvc_record(&mut quorum, dvc(1, 1, 4, 2, header_only.clone()));
dvc_record(&mut quorum, dvc(2, 1, 4, 2, header_only));
+ let MergeOutcome::Ready(log) = merge_dvc_quorum(&quorum, quorums_r3())
else {
+ panic!("no replica can serve op 4 and all three say so");
+ };
+ assert_eq!(log.op_head, 3);
+ }
+
+ #[test]
+ fn
given_one_servable_copy_against_a_header_only_sender_should_keep_the_op() {
+ // Boundary: one servable copy outranks one derived nack.
+ let mut quorum = dvc_quorum_array_empty();
+ let headers = suffix_headers(2, 4, 1);
+ dvc_record(
+ &mut quorum,
+ dvc(0, 1, 4, 2, DvcSuffix::new(headers.clone(), 0, 0b111)),
+ );
+ dvc_record(
+ &mut quorum,
+ dvc(1, 1, 4, 2, DvcSuffix::new(headers, 0, 0b110)),
+ );
+
+ let MergeOutcome::Ready(log) = merge_dvc_quorum(&quorum, quorums_r3())
else {
+ panic!("op 4 is recoverable from replica 0, so the view must
start");
+ };
assert_eq!(
- merge_dvc_quorum(&quorum, quorums_r3()),
- MergeOutcome::Deadlocked { undecided_op: 4 },
- "with every replica in, an unrecoverable op stalls the view
forever"
+ log.op_head, 4,
+ "a servable copy keeps the op against a single derived nack"
);
}
diff --git a/core/consensus/src/impls.rs b/core/consensus/src/impls.rs
index 4a2ae9785..649843209 100644
--- a/core/consensus/src/impls.rs
+++ b/core/consensus/src/impls.rs
@@ -1106,8 +1106,8 @@ where
/// built-in default.
probe_attempts_max: Cell<u32>,
- /// This replica's own uncommitted suffix, with the `(op, commit)` the
journal
- /// was at when it was read.
+ /// This replica's own uncommitted suffix, with the head, commit point and
+ /// mutation count the journal was at when it was read.
///
/// Installed by the shard via [`Self::set_local_dvc_suffix`] before any
handler
/// that could enter a view change. Snapshotted rather than recomputed per
send
@@ -1115,11 +1115,24 @@ where
/// replica's log, and silently retracting one lets the new primary
assemble a
/// quorum that never simultaneously existed.
///
- /// Tagged by `(op, commit)`, not by view, because that is what the suffix
- /// describes: a view advance leaves the log alone so the snapshot
survives,
- /// while anything moving the head or commit point makes the tag mismatch,
- /// which reads as no snapshot at all.
- local_dvc_suffix: RefCell<Option<(u64, u64, DvcSuffix)>>,
+ /// Tagged by `(op, commit, journal_mutations)`, not by view, because that
is
+ /// what the suffix describes: a view advance leaves the log alone so the
+ /// snapshot survives, while anything moving the head, the commit point or
the
+ /// journal's contents makes the tag mismatch, which reads as no snapshot
at
+ /// all.
+ local_dvc_suffix: RefCell<Option<(u64, u64, u64, DvcSuffix)>>,
+
+ /// Counts mutations of the journal this replica's suffix is read from.
+ ///
+ /// `(op, commit)` alone says how far the log reaches, not what it holds. A
+ /// backup that adopted a `StartView` sits at the announced head with the
+ /// bodies still missing, so repair filling one moves neither number, and
the
+ /// header-only snapshot taken before it would keep going out. The merge
reads
+ /// a header no sender can serve as proof that sender never journaled the
op,
+ /// which is now a nack: a stale snapshot would nack an op this replica
holds
+ /// and acked. Bumped by every append, truncation, drain and eviction on
both
+ /// planes.
+ journal_mutations: Cell<u64>,
/// The log a DVC quorum settled on, parked until this replica's journal
can
/// serve all of it.
@@ -1425,6 +1438,7 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>>
VsrConsensus<B, P> {
probe_attempts: Cell::new(0),
probe_attempts_max: Cell::new(PROBE_ATTEMPTS_MAX),
local_dvc_suffix: RefCell::new(None),
+ journal_mutations: Cell::new(0),
pending_view_log: RefCell::new(None),
do_view_change_from_all_replicas:
RefCell::new(dvc_quorum_array_empty()),
do_view_change_quorum: Cell::new(false),
@@ -2191,28 +2205,44 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>>
VsrConsensus<B, P> {
/// Installing twice for one view overwrites: the shard refreshes before
each
/// handler, and a later suffix is at least as complete (repair only adds).
pub fn set_local_dvc_suffix(&self, suffix: DvcSuffix) {
- let (op, commit) = self.local_dvc_suffix_tag();
- *self.local_dvc_suffix.borrow_mut() = Some((op, commit, suffix));
+ let (op, commit, mutations) = self.local_dvc_suffix_tag();
+ *self.local_dvc_suffix.borrow_mut() = Some((op, commit, mutations,
suffix));
}
/// Drop the cached suffix snapshot.
///
- /// The `(op, commit)` tag tracks how far the log reaches, not what it
still
- /// contains, so a mutation that removes entries without moving either
- /// (truncating a diverging uncommitted range) leaves a snapshot reading as
- /// current while offering bodies this replica can no longer serve. A peer
that
- /// picks it as a body source then waits out the whole view change.
- ///
- /// Call from the mutation site. The next refresh re-reads the journal.
+ /// For a change the journal counter cannot see: `start_pending_view`
takes the
+ /// parked log the snapshot was stitched over, which alters the suffix
while
+ /// every journal entry stays put.
pub fn invalidate_local_dvc_suffix(&self) {
self.local_dvc_suffix.borrow_mut().take();
}
- /// The `(op, commit)` a snapshot must match to still describe this log.
- /// `commit` is clamped to `op` exactly as the outgoing DVC clamps it.
- fn local_dvc_suffix_tag(&self) -> (u64, u64) {
+ /// Record that the journal backing this replica's suffix changed: an
append, a
+ /// truncation, a drain, an eviction.
+ ///
+ /// Call from the mutation site. The next refresh re-reads the journal, and
+ /// until it does the snapshot reads as absent rather than as a
description of
+ /// a log that has moved on. Both directions matter: a removal leaves a
+ /// snapshot offering bodies this replica can no longer serve, stranding a
peer
+ /// that picks it as a repair source, and an append leaves one nacking an
op
+ /// this replica has since journaled and acked, which is a licence to
truncate
+ /// committed data.
+ pub fn note_journal_mutation(&self) {
+ self.journal_mutations
+ .set(self.journal_mutations.get().wrapping_add(1));
+ }
+
+ /// The `(op, commit, journal_mutations)` a snapshot must match to still
+ /// describe this log. `commit` is clamped to `op` exactly as the outgoing
DVC
+ /// clamps it.
+ fn local_dvc_suffix_tag(&self) -> (u64, u64, u64) {
let op = self.sequencer.current_sequence();
- (op, self.commit_max.get().min(op))
+ (
+ op,
+ self.commit_max.get().min(op),
+ self.journal_mutations.get(),
+ )
}
/// This replica's suffix snapshot, or an empty one when none matches the
log's
@@ -2223,7 +2253,9 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>>
VsrConsensus<B, P> {
pub fn local_dvc_suffix(&self) -> DvcSuffix {
let tag = self.local_dvc_suffix_tag();
match &*self.local_dvc_suffix.borrow() {
- Some((op, commit, suffix)) if (*op, *commit) == tag =>
suffix.clone(),
+ Some((op, commit, mutations, suffix)) if (*op, *commit,
*mutations) == tag => {
+ suffix.clone()
+ }
_ => DvcSuffix::empty(),
}
}
@@ -2235,7 +2267,7 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>>
VsrConsensus<B, P> {
let tag = self.local_dvc_suffix_tag();
!matches!(
&*self.local_dvc_suffix.borrow(),
- Some((op, commit, _)) if (*op, *commit) == tag
+ Some((op, commit, mutations, _)) if (*op, *commit, *mutations) ==
tag
)
}
@@ -2617,9 +2649,9 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>>
VsrConsensus<B, P> {
.reset(TimeoutKind::DoViewChangeMessage);
// NOT the snapshot the first send used: `build_do_view_change`
re-reads
- // `local_dvc_suffix()`, whose `(op, commit)` tag can have moved
since, in
- // which case it answers EMPTY, retracting every nack and body offer
already
- // sent. Survivable only because `dvc_record` drops a duplicate
sender, so the
+ // `local_dvc_suffix()`, whose tag can have moved since, in which case
it
+ // answers EMPTY, retracting every nack and body offer already sent.
+ // Survivable only because `dvc_record` drops a duplicate sender, so
the
// candidate keeps the first vote. Allow a retransmit to replace a
seated vote
// and this must pin the snapshot instead.
let action =
self.build_do_view_change(self.primary_index(self.view.get()));
diff --git a/core/consensus/src/plane_helpers.rs
b/core/consensus/src/plane_helpers.rs
index cb9605c35..437d2376d 100644
--- a/core/consensus/src/plane_helpers.rs
+++ b/core/consensus/src/plane_helpers.rs
@@ -1584,6 +1584,41 @@ mod tests {
(header, body)
}
+ /// A DVC carrying `op` and `commit` only. Abstention: counts toward the
+ /// view-change quorum, says nothing about any op.
+ fn dvc_numbers_only(
+ replica: u8,
+ view: u32,
+ log_view: u32,
+ op: u64,
+ commit: u64,
+ ) -> (iggy_binary_protocol::DoViewChangeHeader, Body) {
+ use iggy_binary_protocol::DoViewChangeHeader;
+
+ let headers: Vec<PrepareHeader> = Vec::new();
+ let body = encode_body(&headers);
+ let header = DoViewChangeHeader {
+ checksum: 0,
+ checksum_body: 0,
+ cluster: 0,
+ size: u32::try_from(std::mem::size_of::<DoViewChangeHeader>() +
body.len())
+ .expect("synthetic DVC frame fits u32"),
+ view,
+ release: 0,
+ command: Command::DoViewChange,
+ replica,
+ reserved_frame: [0; 66],
+ op,
+ commit,
+ group: 0,
+ log_view,
+ reserved: [0; 68],
+ nack_bitset: 0,
+ present_bitset: 0,
+ };
+ (header, body)
+ }
+
/// Headers for `low..=high`, high-to-low as a suffix requires, sealed and
/// chained the way a real producer writes them.
///
@@ -1647,6 +1682,116 @@ mod tests {
consensus.set_local_dvc_suffix(crate::dvc_merge::suffix_all_present(headers));
}
+ /// The replica that prepared the head is gone for good, one survivor
holds its
+ /// header without the body and the other never had it. Both survivors are
+ /// outside the ack set, so the op is truncated and the view starts.
Counting the
+ /// header-only sender as neither copy nor nack instead waits for the
crashed
+ /// replica forever.
+ #[test]
+ fn
given_a_crashed_body_holder_when_merging_should_truncate_and_start_the_view() {
+ // View 3 of 3 replicas elects this one.
+ let consensus = VsrConsensus::new(1, 0, 3, 0, NoopBus,
LocalPipeline::new());
+ consensus.init();
+ consensus.restore_commit_state(2, 2);
+ consensus.sequencer().set_sequence(4);
+ // Bit 0 is op 4: its header, never journaled.
+ let local = suffix_headers(2, 4, 0);
+
consensus.set_local_dvc_suffix(crate::view_change_quorum::DvcSuffix::new(local,
0, 0b110));
+
+ let _ = consensus.handle_start_view_change(PlaneKind::Metadata,
&svc_header(1, 3));
+
+ // Replica 1 stops at op 3. Replica 2 held the only body and never
reports.
+ let (dvc, body) = dvc_with_suffix(1, 3, 0, 3, 2, None);
+ let _ = consensus.handle_do_view_change(PlaneKind::Metadata, &dvc,
&body);
+
+ let pending = consensus
+ .pending_view_log()
+ .expect("two senders outside op 4's ack set decide it without
replica 2");
+ assert_eq!(
+ pending.op_head, 3,
+ "op 4 never committed, so it is truncated"
+ );
+ assert_eq!(pending.commit_max, 2);
+ }
+
+ /// The shard's refresh, gate included: it re-reads the journal only when
the
+ /// snapshot no longer describes it (`refresh_metadata_dvc_suffix`).
+ fn refresh_local_suffix_if_stale(
+ consensus: &VsrConsensus<NoopBus, LocalPipeline>,
+ journal: crate::view_change_quorum::DvcSuffix,
+ ) {
+ if consensus.local_dvc_suffix_stale() {
+ consensus.set_local_dvc_suffix(journal);
+ }
+ }
+
+ /// A backup that adopted a `StartView` sits at the announced head with the
+ /// bodies still missing, so repair filling one moves neither the head nor
the
+ /// commit point. Tag the snapshot by those two alone and it keeps reading
as
+ /// current, which the merge takes as proof this replica never journaled
the op.
+ #[test]
+ fn
given_a_repaired_body_under_an_unmoved_head_when_tagging_should_read_the_snapshot_stale()
{
+ let consensus = VsrConsensus::new(1, 0, 3, 0, NoopBus,
LocalPipeline::new());
+ consensus.init();
+ consensus.restore_commit_state(2, 2);
+ consensus.sequencer().set_sequence(4);
+ let headers = suffix_headers(2, 4, 0);
+ consensus
+
.set_local_dvc_suffix(crate::view_change_quorum::DvcSuffix::new(headers, 0,
0b110));
+ assert!(!consensus.local_dvc_suffix_stale());
+
+ consensus.note_journal_mutation();
+
+ assert!(
+ consensus.local_dvc_suffix_stale(),
+ "the head and the commit point did not move, so only the journal
counter \
+ can report the body landing"
+ );
+ assert!(
+ consensus.local_dvc_suffix().is_empty(),
+ "a snapshot that no longer describes the log must nack nothing"
+ );
+ }
+
+ /// The same op, carried through the merge: once the refresh is allowed to
run,
+ /// this replica offers op 4's body and the peer that never prepared it is
one
+ /// nack short of the quorum, so the view starts keeping the op it acked.
+ #[test]
+ fn
given_a_repaired_body_under_an_unmoved_head_when_merging_should_keep_the_acked_op()
{
+ // View 3 of 3 replicas elects this one.
+ let consensus = VsrConsensus::new(1, 0, 3, 0, NoopBus,
LocalPipeline::new());
+ consensus.init();
+ consensus.restore_commit_state(2, 2);
+ consensus.sequencer().set_sequence(4);
+ // Adopted the view's header for op 4, body still being repaired.
+ let headers = suffix_headers(2, 4, 0);
+
consensus.set_local_dvc_suffix(crate::view_change_quorum::DvcSuffix::new(
+ headers.clone(),
+ 0,
+ 0b110,
+ ));
+ // Repair journals the body and this replica acks it: a replication
quorum
+ // with the primary, so op 4 is committed even though nobody's commit
point
+ // has caught up yet.
+ consensus.note_journal_mutation();
+ refresh_local_suffix_if_stale(&consensus,
crate::dvc_merge::suffix_all_present(headers));
+
+ let _ = consensus.handle_start_view_change(PlaneKind::Metadata,
&svc_header(1, 3));
+
+ // Replica 1's log stops at op 3, an explicit nack for op 4. Replica 2
is the
+ // crashed primary and never reports.
+ let (dvc, body) = dvc_with_suffix(1, 3, 0, 3, 2, None);
+ let _ = consensus.handle_do_view_change(PlaneKind::Metadata, &dvc,
&body);
+
+ let pending = consensus
+ .pending_view_log()
+ .expect("op 4 is servable from this replica, so the view can
start");
+ assert_eq!(
+ pending.op_head, 4,
+ "one nack cannot discard an op this replica journaled and acked"
+ );
+ }
+
#[test]
fn
given_an_undecidable_quorum_when_a_later_dvc_decides_it_should_start_the_view()
{
// Reaching a view-change quorum is not the same as deciding a log.
Latching
@@ -1666,10 +1811,9 @@ mod tests {
let _ = consensus.handle_start_view_change(PlaneKind::Metadata,
&svc_header(1, 5));
- // Two peers report, reaching the quorum of 3. All three hold op 4's
header,
- // none can serve its body, and two replicas have yet to report.
+ // Two peers abstain, reaching the quorum of 3 and leaving op 4 one
nack short.
for replica in [1u8, 2] {
- let (dvc, body) = dvc_with_suffix(replica, 5, 0, 4, 2, Some(4));
+ let (dvc, body) = dvc_numbers_only(replica, 5, 0, 4, 2);
let actions = consensus.handle_do_view_change(PlaneKind::Metadata,
&dvc, &body);
assert!(actions.is_empty());
}
diff --git a/core/metadata/src/impls/metadata.rs
b/core/metadata/src/impls/metadata.rs
index 347209493..6a74224fd 100644
--- a/core/metadata/src/impls/metadata.rs
+++ b/core/metadata/src/impls/metadata.rs
@@ -1365,6 +1365,12 @@ where
return;
}
+ // Paired with the append, not with the sequencer advance below: a
backup
+ // repairing under a `StartView` it already adopted is at the
announced head
+ // already, so the entry arriving moves neither number the suffix
snapshot
+ // is otherwise tagged by.
+ consensus.note_journal_mutation();
+
// Journal mutation done; wire traffic below must not hold the gate.
drop(journal_gate);
@@ -1853,10 +1859,10 @@ where
.await
.map_err(SnapshotError::Io)?;
if removed > 0 {
- // The DVC snapshot's `(op, commit)` tag does not move when
- // entries are removed under it; left stale it would advertise
+ // The DVC snapshot's head and commit point do not move when
+ // entries are removed under them; left stale it would
advertise
// headers this replica can no longer serve.
- consensus.invalidate_local_dvc_suffix();
+ consensus.note_journal_mutation();
tracing::warn!(
snapshot_seq,
removed,
@@ -3354,7 +3360,12 @@ where
}
}
- if let Err(e) = coordinator.drain(journal, snap_op).await {
+ let drained = coordinator.drain(journal, snap_op).await;
+ // On the error path too: a drain that fails part-way still removed
+ // whatever it reached, and a snapshot left offering those bodies
strands
+ // the peer that picks this replica as a repair source.
+ consensus.note_journal_mutation();
+ if let Err(e) = drained {
error!(
target: "iggy.metadata.diag",
plane = "metadata",
diff --git a/core/partitions/src/iggy_partition.rs
b/core/partitions/src/iggy_partition.rs
index 52da8e519..630189f9e 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -5128,12 +5128,7 @@ where
// batches for segment-commit thresholds, which do not
// apply to offset ops.
let frozen = message.into_frozen();
- self.log
- .journal()
- .inner
- .append(frozen.clone())
- .await
- .map_err(|_| IggyError::CannotAppendMessage)?;
+ self.journal_append(frozen.clone()).await?;
match header.operation {
Operation::StoreConsumerOffset => {
@@ -5180,6 +5175,21 @@ where
}
}
+ /// Journal one prepare and record the mutation.
+ ///
+ /// Every partition-plane append goes through here. The DVC suffix
snapshot is
+ /// tagged by the head and the commit point, and a repair filling a body
under
+ /// a `StartView` this replica already adopted moves neither: the head is
the
+ /// announced one and the op is not committed yet. An append that skipped
the
+ /// counter would leave the header-only snapshot reading as current, and
the
+ /// merge takes a header no sender can serve as proof that sender never
+ /// journaled the op.
+ async fn journal_append(&self, entry: Frozen<4096>) -> Result<(),
IggyError> {
+ let appended = self.log.journal().inner.append(entry).await;
+ self.consensus.note_journal_mutation();
+ appended.map_err(|_| IggyError::CannotAppendMessage)
+ }
+
async fn append_send_messages_to_journal(
&mut self,
message: Message<PrepareHeader>,
@@ -5278,12 +5288,7 @@ where
journal_info.max_timestamp =
journal_info.max_timestamp.max(batch.base_timestamp);
let frozen = message.into_frozen();
- self.log
- .journal()
- .inner
- .append(frozen.clone())
- .await
- .map_err(|_| IggyError::CannotAppendMessage)?;
+ self.journal_append(frozen.clone()).await?;
self.note_append_live();
self.dirty_offset
@@ -5394,7 +5399,7 @@ where
self.offset_space.committed_seeded = false;
}
}
- self.consensus.invalidate_local_dvc_suffix();
+ self.consensus.note_journal_mutation();
let commit_max = self.consensus.commit_max();
self.pending_consumer_offset_commits
.retain(|op, _| *op < from_op || *op <= commit_max);
@@ -5785,6 +5790,10 @@ where
return;
}
let retained = self.log.journal().inner.evict_prefix(count).await;
+ // Eviction drains the ring and re-appends the retained tail, so the
slots
+ // a suffix snapshot describes move under it without the head or the
+ // commit point changing.
+ self.consensus.note_journal_mutation();
let mut retained_info = JournalInfo::default();
for (entry, meta) in &retained {
// Purge floor: a retained pre-purge batch must not fold its
@@ -7984,12 +7993,7 @@ where
// `first_batch_offset`: that anchors the floor-connect check, and
// purged bytes cannot stand in for durable state.
if op <= self.purge_floor_op {
- self.log
- .journal()
- .inner
- .append(message.into_frozen())
- .await
- .map_err(|_| IggyError::CannotAppendMessage)?;
+ self.journal_append(message.into_frozen()).await?;
return Ok(None);
}
@@ -8024,12 +8028,7 @@ where
journal_info.max_timestamp =
journal_info.max_timestamp.max(base_timestamp);
let frozen = message.into_frozen();
- self.log
- .journal()
- .inner
- .append(frozen)
- .await
- .map_err(|_| IggyError::CannotAppendMessage)?;
+ self.journal_append(frozen).await?;
self.note_append_live();
self.dirty_offset
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index e492a8fb3..ede938135 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -5157,6 +5157,14 @@ where
);
return;
}
+ // The body landing is invisible to the head and the commit point:
a
+ // backup repairing under a `StartView` it already adopted sits at
the
+ // announced head with its commit point unmoved. Leave the suffix
+ // snapshot tagged as current and the next `DoViewChange` reports
this
+ // op header-only, which the merge reads as proof this replica
never
+ // journaled it, one nack away from truncating an op it is about to
+ // acknowledge.
+ consensus.note_journal_mutation();
// Contiguous-frontier advance, mirroring
// `apply_repaired_prepare`: DVC advertises the sequencer, so a
// hole below a repaired op must stall the advance rather than
@@ -6091,10 +6099,11 @@ where
// mutations through gate-taking metadata methods would make it
structural.
match journal.handle().truncate_from(from_op).await {
Ok(removed) => {
- // The snapshot's `(op, commit)` tag does not move when
entries are
- // removed under it, so the next `DoViewChange` would
advertise the
- // dropped headers and offer bodies this replica cannot serve.
- consensus.invalidate_local_dvc_suffix();
+ // The snapshot's head and commit point do not move when
entries
+ // are removed under them, so without this the next
`DoViewChange`
+ // would advertise the dropped headers and offer bodies this
replica
+ // cannot serve.
+ consensus.note_journal_mutation();
tracing::warn!(
shard = self.id,
from_op,
@@ -9899,10 +9908,10 @@ where
{
match journal.handle().truncate_from(stuck_op).await {
Ok(removed) => {
- // The snapshot's `(op, commit)` tag does not move when entries
- // are removed under it, so the next `DoViewChange` would
- // otherwise advertise headers this replica can no longer
serve.
- consensus.invalidate_local_dvc_suffix();
+ // The snapshot's head and commit point do not move when
entries
+ // are removed under them, so without this the next
`DoViewChange`
+ // would advertise headers this replica can no longer serve.
+ consensus.note_journal_mutation();
tracing::warn!(
shard = self.id,
stuck_op,
@@ -10309,9 +10318,9 @@ fn rebuild_pipeline_entries<B, P>(
///
/// Called before every handler that could start or join a view change:
consensus
/// records its own `DoViewChange` there and has no journal to read. A stale
-/// snapshot is never reused; consensus tags it with its `(op, commit)` and
falls
-/// back to an empty suffix, stalling the view change rather than nacking an op
-/// since acquired.
+/// snapshot is never reused; consensus tags it with the journal's head, commit
+/// point and mutation count, and falls back to an empty suffix, stalling the
view
+/// change rather than nacking an op since acquired.
fn refresh_metadata_dvc_suffix<B, P, MJ>(consensus: &VsrConsensus<B, P>,
journal: Option<&MJ>)
where
B: MessageBus,
diff --git a/core/simulator/src/lib.rs b/core/simulator/src/lib.rs
index 0b25090dc..747e5b70b 100644
--- a/core/simulator/src/lib.rs
+++ b/core/simulator/src/lib.rs
@@ -4536,6 +4536,84 @@ mod tests {
/// finds two replicas at the same op passes in silence, so `ops_compared`
counts
/// only ops witnessed on more than one replica, the subset that exercised
the
/// property.
+ /// A replica prepares an op and stays down. One survivor holds its header
+ /// without the body, the other never had it, and the merge must read that
as
+ /// proof both are outside the ack set. Otherwise it waits for the crashed
+ /// replica: on this seed the cluster reached view 103 against `log_view`
3 with
+ /// both survivors caught up and one request retried 266 times unanswered.
+ ///
+ /// Asserts the drain, so it fails as the fuzzer does with the
outstanding-request
+ /// report attached.
+ #[test]
+ fn view_change_completes_without_the_replica_that_prepared_the_head() {
+ use crate::workload::{
+ self, FaultInjector, Workload,
+ options::{ActionWeights, WorkloadOptions},
+ oracle,
+ };
+
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
+ enabled: false,
+ size: iggy_common::IggyByteSize::from(0u64),
+ bucket_capacity: 1,
+ });
+
+ let replica_count: u8 = 3;
+ let client_id: u128 = 1;
+ // `workload-fuzz --seed 211 --replicas 3 --ticks 4000 --plane metadata
+ // --journal-slots 80 --crash-prob 0.02 --restart-prob 0.08
--crash-primary`.
+ let seed = 211u64;
+ let root = tempfile::tempdir().expect("temp dir for the simulator's
snapshots");
+ let network_opts = packet::PacketSimulatorOptions {
+ node_count: replica_count,
+ client_count: 1,
+ seed,
+ ..packet::PacketSimulatorOptions::default()
+ };
+ // A bounded journal is what drives the WAL drain behind the
header-only sender.
+ let mut sim = Simulator::with_checkpoints(
+ usize::from(replica_count),
+ std::iter::once(client_id),
+ network_opts,
+ false,
+ root.path(),
+ );
+ sim.set_metadata_journal_slots(80);
+
+ let ns = IggyNamespace::new(1, 1, 0);
+ sim.init_partition(ns);
+ let client = SimClient::new(client_id);
+ sim.register_client_with_primary(&client);
+
+ let mut options = WorkloadOptions::new(seed, replica_count, vec![ns]);
+ options.client_count = 1;
+ options.crash_per_tick_ratio = 0.02;
+ options.restart_per_tick_ratio = 0.08;
+ options.spare_primary = false;
+ options.weights = ActionWeights::metadata_only();
+ let mut workload = Workload::new(options);
+
+ let clients = [client];
+ let mut injector = FaultInjector::new(seed, replica_count);
+ let _ = workload::run_with_faults(
+ &mut sim,
+ &mut workload,
+ &clients,
+ 4_000,
+ u64::MAX,
+ &mut injector,
+ );
+
+ assert!(
+ injector.crashes() > 0,
+ "no replica crashed, so no view change ran and this proves nothing"
+ );
+ assert!(
+ oracle::drive_to_quiesce(&mut sim, &mut workload, 50_000),
+ "{}",
+ oracle::quiesce_failure_report(&sim, &workload),
+ );
+ }
+
#[test]
fn committed_metadata_agrees_across_replicas() {
use crate::workload::{