This is an automated email from the ASF dual-hosted git repository.
krishvishal pushed a commit to branch vsr-dvc-headers
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/vsr-dvc-headers by this push:
new e2819a567 fix: address review comment
e2819a567 is described below
commit e2819a56776caaa084d7a24dc766f016c10f0c2c
Author: Krishna Vishal <[email protected]>
AuthorDate: Thu Aug 6 15:09:16 2026 +0530
fix: address review comment
---
core/consensus/src/dvc_merge.rs | 45 ++++++++++
.../tests/cluster/metadata_checkpoint_restart.rs | 6 +-
core/metadata/src/impls/metadata.rs | 99 +++++++++++++++++++++-
core/partitions/src/iggy_partitions.rs | 83 ++++++++++++++++++
core/partitions/src/journal.rs | 25 ++++++
core/shard/src/lib.rs | 44 +++++++++-
6 files changed, 296 insertions(+), 6 deletions(-)
diff --git a/core/consensus/src/dvc_merge.rs b/core/consensus/src/dvc_merge.rs
index 79331a80c..bbf265248 100644
--- a/core/consensus/src/dvc_merge.rs
+++ b/core/consensus/src/dvc_merge.rs
@@ -619,6 +619,51 @@ mod tests {
);
}
+ #[test]
+ fn given_blank_commit_point_from_every_sender_should_deadlock() {
+ // The commit point is scanned and may not be discarded, so a sender
that
+ // reports it blank is deferring to a peer. When every sender defers
there
+ // is no peer left and the view cannot start.
+ //
+ // Nothing in the merge can rescue this, which is why the senders must
not
+ // produce it: a replica keeps the header at its own commit point
through
+ // compaction (the metadata checkpoint drain stops one op short, a
+ // partition answers from its evicted ring).
+ let mut quorum = dvc_quorum_array_empty();
+ let blank_at_commit = DvcSuffix::new(vec![dvc_blank(5)], 0, 0);
+ for replica in 0..3 {
+ dvc_record(&mut quorum, dvc(replica, 1, 5, 5,
blank_at_commit.clone()));
+ }
+
+ assert_eq!(
+ merge_dvc_quorum(&quorum, quorums_r3()),
+ MergeOutcome::Deadlocked { undecided_op: 5 },
+ "a blank commit point is neither adoptable nor discardable"
+ );
+ }
+
+ #[test]
+ fn given_blank_commit_point_from_one_sender_should_adopt_the_peer_header()
{
+ // The same suffix stops being fatal the moment one sender still holds
the
+ // header: that one is canonical and serves the body, and the deferring
+ // sender neither nacks it nor conflicts with it.
+ let mut quorum = dvc_quorum_array_empty();
+ dvc_record(
+ &mut quorum,
+ dvc(0, 1, 5, 5, DvcSuffix::new(vec![dvc_blank(5)], 0, 0)),
+ );
+ dvc_record(
+ &mut quorum,
+ dvc(1, 1, 5, 5, suffix_all_present(suffix_headers(5, 5, 1))),
+ );
+
+ let MergeOutcome::Ready(log) = merge_dvc_quorum(&quorum, quorums_r3())
else {
+ panic!("one surviving copy of the commit point is enough to start
the view");
+ };
+ assert_eq!(log.op_head, 5);
+ assert_eq!(log.commit_max, 5);
+ }
+
#[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
diff --git a/core/integration/tests/cluster/metadata_checkpoint_restart.rs
b/core/integration/tests/cluster/metadata_checkpoint_restart.rs
index 75ae60f6e..d3d9a70e8 100644
--- a/core/integration/tests/cluster/metadata_checkpoint_restart.rs
+++ b/core/integration/tests/cluster/metadata_checkpoint_restart.rs
@@ -119,8 +119,10 @@ async fn await_checkpoint_on_all_nodes(harness:
&TestHarness, generation: usize)
// a checkpoint, so the transfer descriptor's `commit_op == snapshot_seq` and
// the post-install tail repair has nothing to fetch (`commit_min ==
// commit_max` skips it). The below-floor retry then proves the reply ring
-// rode the transferred table: request 191's reply was minted at op 192, which
-// every node drained out of its WAL at that same checkpoint.
+// rode the transferred table: request 191's reply was minted at op 192, and
+// replay starts at `snapshot_seq + 1`, so no node re-executes it. The
+// checkpoint drain keeps op 192's entry as the commit-point header a
+// `DoViewChange` needs, but never replays it.
#[iggy_harness(cluster_nodes = 3, server(metadata.journal_slots = "256"))]
async fn given_drained_journal_when_node_restarts_should_install_snapshot_only(
harness: &mut TestHarness,
diff --git a/core/metadata/src/impls/metadata.rs
b/core/metadata/src/impls/metadata.rs
index e523c99a9..4bf974cd1 100644
--- a/core/metadata/src/impls/metadata.rs
+++ b/core/metadata/src/impls/metadata.rs
@@ -423,17 +423,29 @@ impl<M> SnapshotCoordinator<M> {
Ok(checksum)
}
- /// Drain the snapshotted prefix `0..=last_op` to reclaim WAL space. Runs
only
- /// after the pairing is durable (see [`Self::persist_snapshot`]).
+ /// Drain the snapshotted prefix below `last_op` to reclaim WAL space. Runs
+ /// only after the pairing is durable (see [`Self::persist_snapshot`]).
+ ///
+ /// `last_op` itself is retained, one entry the snapshot has already
+ /// superseded. It is this replica's commit point, and a `DoViewChange`
+ /// carries a header for every op from there up. Draining it inclusively
+ /// leaves that entry blank, and blank at the commit point is the one slot
+ /// the merge can neither adopt nor discard: a quorum of senders that all
+ /// checkpointed at the same op deadlocks the view change
+ /// (`dvc_merge::merge_dvc_quorum`). Reclaiming one more entry is not worth
+ /// a group that cannot elect.
#[allow(clippy::future_not_send)]
async fn drain<J: JournalHandle>(
&self,
journal: &J,
last_op: u64,
) -> Result<(), SnapshotError> {
+ let Some(drain_to) = last_op.checked_sub(1) else {
+ return Ok(());
+ };
journal
.handle()
- .drain(0..=last_op)
+ .drain(0..=drain_to)
.await
.map_err(SnapshotError::Io)?;
Ok(())
@@ -4808,6 +4820,87 @@ mod tests {
);
}
+ /// A checkpoint reclaims the WAL prefix the snapshot supersedes, but must
+ /// stop one op short of the checkpoint op itself.
+ ///
+ /// That op is the replica's commit point, and its `DoViewChange` suffix is
+ /// floored there. The merge scans the commit point and may not discard it,
+ /// so a sender with no header to put there is deferring to a peer; when
+ /// every sender has checkpointed at the same op the view change deadlocks
+ /// (`dvc_merge::merge_dvc_quorum`). Checkpoints fire on local journal
+ /// occupancy, which is symmetric across replicas seeing the same ops, so
+ /// "every sender" is the ordinary case, not a coincidence.
+ #[compio::test]
+ async fn checkpoint_drain_retains_the_commit_point_header() {
+ const CLIENT: u128 = 1;
+ const SESSION: u64 = 1;
+ const ACTING_USER: u32 = 7;
+ const OPS: u64 = 5;
+ const CHECKPOINT_OP: u64 = 3;
+
+ let dir = tempfile::tempdir().unwrap();
+
std::fs::create_dir_all(dir.path().join(crate::impls::METADATA_DIR)).unwrap();
+ let journal =
+
journal::prepare_journal::PrepareJournal::open(&dir.path().join("journal.wal"),
0)
+ .await
+ .unwrap();
+ let consensus = VsrConsensus::new(
+ 1,
+ 0,
+ 1,
+ server_common::sharding::METADATA_CONSENSUS_NAMESPACE,
+ NoopBus,
+ LocalPipeline::new(),
+ );
+ consensus.init();
+ let md: IggyMetadata<_, journal::prepare_journal::PrepareJournal, (),
TestMux> =
+ IggyMetadata::new(
+ Some(consensus),
+ Some(journal),
+ None,
+ None,
+ TestMux::default(),
+ Some(dir.path().to_path_buf()),
+ );
+ let consensus = md.consensus.as_ref().unwrap();
+ md.client_table.borrow_mut().commit_register(
+ CLIENT,
+ ACTING_USER,
+ register_reply(CLIENT, SESSION),
+ );
+
+ for op in 1..=OPS {
+ let prepare = md
+ .prepare_request(create_stream_request(CLIENT, op,
&format!("s{op}")))
+ .expect("CreateStream is client-allowed");
+ consensus.pipeline_message(PlaneKind::Metadata, &prepare);
+ md.on_replicate(prepare).await;
+ }
+
+ let journal = md.journal.as_ref().unwrap();
+ md.coordinator
+ .as_ref()
+ .expect("data_dir present arms the coordinator")
+ .drain(journal, CHECKPOINT_OP)
+ .await
+ .expect("drain the snapshotted prefix");
+
+ let header_at = |op: u64|
journal.header(usize::try_from(op).expect("test ops fit usize"));
+ for op in 1..CHECKPOINT_OP {
+ assert!(
+ header_at(op).is_none(),
+ "op {op} is below the checkpoint and must be reclaimed"
+ );
+ }
+ assert!(
+ header_at(CHECKPOINT_OP).is_some(),
+ "the checkpoint op is the commit point and must stay describable
in a DVC"
+ );
+ for op in CHECKPOINT_OP + 1..=OPS {
+ assert!(header_at(op).is_some(), "op {op} was never snapshotted");
+ }
+ }
+
/// Reproduces the single-node "metadata prepare queue is full" wedge
///
/// `checkpoint_if_needed` runs inside `on_replicate`, once per submit.
diff --git a/core/partitions/src/iggy_partitions.rs
b/core/partitions/src/iggy_partitions.rs
index 5377701a1..48458f2f2 100644
--- a/core/partitions/src/iggy_partitions.rs
+++ b/core/partitions/src/iggy_partitions.rs
@@ -652,6 +652,28 @@ mod tests {
)
}
+ /// `build_partition` for a replicated group. The replica count is what
+ /// decides whether the journal retains evicted entries for repair, so a
+ /// single-replica partition cannot exercise anything that reads the ring.
+ fn build_replicated_partition() -> IggyPartition<IggyMessageBus> {
+ let namespace = IggyNamespace::new(1, 1, 0);
+ let consensus = VsrConsensus::new(
+ TEST_CLUSTER,
+ 0,
+ 3,
+ namespace.inner(),
+ IggyMessageBus::new(0),
+ LocalPipeline::new(),
+ );
+ consensus.init();
+ IggyPartition::with_in_memory_storage(
+ Arc::new(PartitionStats::default()),
+ consensus,
+ IggyByteSize::from(1024 * 1024),
+ false,
+ )
+ }
+
/// One-message `SendMessages` journal entry stamped at `op` /
`base_offset`.
/// Reuses the production blob builder + checksum stamping so the entry
/// decodes through `decode_prepare_slice` and indexes into `offset_to_op`,
@@ -794,6 +816,67 @@ mod tests {
);
}
+ /// A flush evicts the committed prefix up to and INCLUDING `commit_max`,
so
+ /// a caught-up replica keeps no resident header at its own commit point.
The
+ /// `DoViewChange` suffix is floored there and cannot nack it, so reading
the
+ /// resident headers alone sends the commit point out blank, which a
quorum of
+ /// senders turns into a view change that never starts.
+ ///
+ /// The entry is still servable (`repair_entry` answers from the evicted
+ /// ring), so the suffix reads through `repair_header`, over the same
range.
+ #[compio::test]
+ async fn evicted_commit_point_still_answers_for_the_view_change_suffix() {
+ let namespace = IggyNamespace::new(1, 1, 0);
+ let partition = build_replicated_partition();
+
+ for offset in 0..=2u64 {
+ partition
+ .log
+ .journal()
+ .inner
+ .append(build_send_messages_entry(namespace, offset + 1,
offset))
+ .await
+ .expect("append journal entry");
+ }
+
+ let commit_max = 3;
+ let prefix =
partition.log.journal().inner.committed_prefix(commit_max);
+ assert_eq!(prefix.len(), 3, "the whole log is committed and
flushable");
+ partition
+ .log
+ .journal()
+ .inner
+ .evict_prefix(prefix.len())
+ .await;
+
+ assert!(
+ partition
+ .log
+ .journal()
+ .inner
+ .header_by_op(commit_max)
+ .is_none(),
+ "the flush evicted the commit point from the resident headers",
+ );
+ assert!(
+ partition
+ .log
+ .journal()
+ .inner
+ .repair_entry(commit_max)
+ .is_some(),
+ "yet the entry is still servable from the evicted ring",
+ );
+
+ let header = partition
+ .log
+ .journal()
+ .inner
+ .repair_header(commit_max)
+ .expect("the commit point must stay describable for the DVC
suffix");
+ assert_eq!(header.op, commit_max);
+ }
+
/// The resident journal holds replicated-but-uncommitted prepares ahead of
/// the commit frontier. A poll must clamp at `ceiling` (the commit offset)
/// so it never returns a dirty read of view-change-rollbackable data, even
diff --git a/core/partitions/src/journal.rs b/core/partitions/src/journal.rs
index 0b2eaf8d0..cf3988777 100644
--- a/core/partitions/src/journal.rs
+++ b/core/partitions/src/journal.rs
@@ -325,6 +325,31 @@ impl PartitionJournal<PartitionJournalMemStorage> {
.map(|(_, entry)| entry.clone())
}
+ /// The header at `op`, over exactly the range [`Self::repair_entry`]
serves.
+ ///
+ /// NOT [`Self::header_by_op`], which reads the resident headers alone. The
+ /// committed prefix is evicted from those the moment its bytes reach a
+ /// segment, up to and including `commit_max`, so a `DoViewChange` built
off
+ /// the resident headers reports its own commit point blank. The merge
scans
+ /// the commit point and cannot discard it, so a quorum of such senders is
+ /// undecidable and the view never starts (`dvc_merge::merge_dvc_quorum`).
+ /// The entry is still servable from the evicted ring, which is what makes
+ /// the blank wrong rather than merely pessimistic.
+ ///
+ /// The ring drops from the front, so the highest evicted op -- the commit
+ /// point of the last flush -- is the last thing it forgets.
+ pub fn repair_header(&self, op: u64) -> Option<PrepareHeader> {
+ if let Some(header) = self.header_by_op(op) {
+ return Some(header);
+ }
+ let ring = unsafe { &*self.evicted_ring.get() };
+ let (_, entry) = ring.iter().find(|(ring_op, _)| *ring_op == op)?;
+ let header_bytes = entry.as_slice().get(..PREPARE_HEADER_SIZE)?;
+ bytemuck::checked::try_from_bytes::<PrepareHeader>(header_bytes)
+ .ok()
+ .copied()
+ }
+
/// Oldest op this journal can still serve for repair (ring front, else
/// resident head), or `None` when it holds nothing at all.
pub fn repair_retained_from(&self) -> Option<u64> {
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index f75d4dc81..9c0e010a8 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -5642,6 +5642,10 @@ where
/// is in-memory only, so after a restart it reads empty and this replica votes
/// all-nack: correct, since the ops really are lost and the merge needs a peer
/// that still holds them.
+///
+/// Read through `repair_header`, not the resident headers: the committed
prefix
+/// leaves those as soon as its bytes reach a segment, which on a caught-up
+/// replica includes the commit point itself.
fn refresh_partition_dvc_suffix<B>(partition: &partitions::IggyPartition<B>)
where
B: MessageBus,
@@ -5657,7 +5661,7 @@ where
let suffix = build_dvc_suffix(
commit,
op,
- |entry_op| journal.inner.header_by_op(entry_op),
+ |entry_op| journal.inner.repair_header(entry_op),
pending.as_ref().map(|pending| pending.headers.as_slice()),
);
consensus.set_local_dvc_suffix(suffix);
@@ -5881,6 +5885,25 @@ fn build_dvc_suffix(
headers.push(dvc_blank(entry_op));
if entry_op > commit {
nack_bitset |= 1u128 << index;
+ } else {
+ // The commit point, the one slot that goes out blank AND
+ // un-nacked. The merge scans it and may not discard it, so a
+ // sender is asking the new primary to take the header from
+ // someone else; if every sender in the quorum does that, the
+ // op is undecidable and the view never starts.
+ //
+ // Every compaction path is supposed to leave this header
behind
+ // (the metadata checkpoint drain stops one op short, a
+ // partition serves it from the evicted ring), so reaching here
+ // means a replica whose log genuinely starts above its own
+ // commit point: a state-transfer receiver that jumped its
+ // commit floor to a snapshot whose prepares it never held.
+ tracing::warn!(
+ op = entry_op,
+ commit,
+ "no header at this replica's commit point; the DVC reports
it blank and \
+ cannot nack it, so the view change stalls unless a peer
supplies it"
+ );
}
}
}
@@ -6497,6 +6520,25 @@ mod dvc_suffix_window_tests {
assert_eq!(suffix.nack_bitset(), 0b010, "only the blank op nacks");
}
+ #[test]
+ fn
given_no_header_at_the_commit_point_should_report_it_blank_and_undecidable() {
+ // The window's floor is the commit point, and a blank there is the one
+ // entry that goes out with neither a header nor a nack. The merge
scans
+ // that op and may not discard it, so a quorum of these deadlocks the
view
+ // change. Pinned here because both compaction paths are meant to keep
the
+ // header alive precisely so this shape never leaves a healthy replica.
+ let suffix = build_dvc_suffix(5, 5, |_| None, None);
+
+ assert_eq!(suffix.len(), 1);
+ assert_eq!(floor(&suffix), Some(5));
+ assert_eq!(
+ suffix.nack_bitset(),
+ 0,
+ "the commit point is never nacked, whatever the journal says"
+ );
+ assert_eq!(suffix.present_bitset(), 0);
+ }
+
#[test]
fn
given_a_window_at_the_depth_ceiling_when_building_should_floor_at_the_commit() {
// At the deepest legal prepare-queue depth the window still starts
exactly