Skip to content

Commit b8ae2f3

Browse files
committed
adress code review
1 parent 6142dd8 commit b8ae2f3

12 files changed

Lines changed: 666 additions & 71 deletions

File tree

core/binary_protocol/src/consensus/header.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ pub fn read_size_field(header: &[u8]) -> Option<u32> {
7070
pub trait ConsensusHeader: Sized + CheckedBitPattern + NoUninit {
7171
const COMMAND: Command2;
7272

73+
/// Whether a frame carrying `command` may be typed as this header.
74+
/// Defaults to an exact match; a header that serves several commands
75+
/// with one layout (e.g. `RepairDone` / `RangeEvicted`) widens it.
76+
#[must_use]
77+
fn accepts(command: Command2) -> bool {
78+
command == Self::COMMAND
79+
}
80+
7381
/// # Errors
7482
/// Returns `ConsensusError` if the header fields are inconsistent.
7583
fn validate(&self) -> Result<(), ConsensusError>;
@@ -1215,6 +1223,12 @@ const _: () = {
12151223

12161224
impl ConsensusHeader for RepairRangeReplyHeader {
12171225
const COMMAND: Command2 = Command2::RepairDone;
1226+
// One layout, two commands: `RepairDone` terminates a stream,
1227+
// `RangeEvicted` prefixes it. Without this widening, `try_into_typed`
1228+
// rejects `RangeEvicted` frames before `validate` ever sees them.
1229+
fn accepts(command: Command2) -> bool {
1230+
command == Command2::RepairDone || command == Command2::RangeEvicted
1231+
}
12181232
fn operation(&self) -> Operation {
12191233
Operation::Reserved
12201234
}

core/consensus/src/impls.rs

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,9 @@ pub enum VsrAction {
580580
},
581581
/// Broadcast a `RequestStartView` probe (recovering replica asking for
582582
/// the current view's `StartView`; only that view's primary answers).
583-
SendRequestStartView { namespace: u64 },
583+
/// Stamped with the prober's view so peers can fence stale duplicates
584+
/// out of the probed-primary election path.
585+
SendRequestStartView { view: u32, namespace: u64 },
584586
/// Send `StartView` to all backups (as new primary).
585587
SendStartView {
586588
view: u32,
@@ -1236,6 +1238,7 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
12361238
.borrow_mut()
12371239
.reset(TimeoutKind::RequestStartViewMessage);
12381240
actions.push(VsrAction::SendRequestStartView {
1241+
view: self.view.get(),
12391242
namespace: self.namespace,
12401243
});
12411244
}
@@ -1245,6 +1248,7 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
12451248
.borrow_mut()
12461249
.reset(TimeoutKind::RequestStartViewMessage);
12471250
actions.push(VsrAction::SendRequestStartView {
1251+
view: self.view.get(),
12481252
namespace: self.namespace,
12491253
});
12501254
}
@@ -1845,24 +1849,24 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
18451849
/// election's `StartView` adopts this replica first). The replica sits
18461850
/// in `Status::Recovering` meanwhile: it acks nothing, votes in no
18471851
/// election, and initiates nothing.
1848-
pub fn begin_view_probe(&self) -> Vec<VsrAction> {
1852+
/// Returns nothing: the first probe rides the
1853+
/// `RequestStartViewMessage` timeout (~1s after boot), by which point
1854+
/// the replica mesh -- absent entirely at the boot-time call sites --
1855+
/// has formed. Emitting an action here implied a send that never
1856+
/// happened.
1857+
pub fn begin_view_probe(&self) {
18491858
tracing::info!(
18501859
replica = self.replica,
18511860
namespace_raw = self.namespace,
18521861
"beginning view probe"
18531862
);
18541863
self.status.set(Status::Recovering);
18551864
self.probe_attempts.set(0);
1856-
{
1857-
let mut timeouts = self.timeouts.borrow_mut();
1858-
timeouts.stop(TimeoutKind::Prepare);
1859-
timeouts.stop(TimeoutKind::CommitMessage);
1860-
timeouts.stop(TimeoutKind::NormalHeartbeat);
1861-
timeouts.start(TimeoutKind::RequestStartViewMessage);
1862-
}
1863-
vec![VsrAction::SendRequestStartView {
1864-
namespace: self.namespace,
1865-
}]
1865+
let mut timeouts = self.timeouts.borrow_mut();
1866+
timeouts.stop(TimeoutKind::Prepare);
1867+
timeouts.stop(TimeoutKind::CommitMessage);
1868+
timeouts.stop(TimeoutKind::NormalHeartbeat);
1869+
timeouts.start(TimeoutKind::RequestStartViewMessage);
18661870
}
18671871

18681872
/// Peer side of the probe (sent by a Recovering replica at boot, or by
@@ -1893,7 +1897,17 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
18931897
return Vec::new();
18941898
}
18951899
if self.primary_index(self.view.get()) == header.replica {
1896-
return self.start_election(plane, ViewChangeReason::PrimaryProbedView);
1900+
// Probes are re-broadcast on a timer, so delayed duplicates are
1901+
// the normal case, and `primary_index` is view % replica_count:
1902+
// a stale probe from replica R re-matches every replica_count
1903+
// views. Only a probe stamped with the CURRENT view proves the
1904+
// current primary is the one probing; anything else falls
1905+
// through (a backup answers nothing, and the true primary of a
1906+
// newer view answers with its StartView).
1907+
if header.view == self.view.get() {
1908+
return self.start_election(plane, ViewChangeReason::PrimaryProbedView);
1909+
}
1910+
return Vec::new();
18971911
}
18981912
if !self.is_primary() || self.ceded_primaryship.get() {
18991913
return Vec::new();

core/integration/tests/server/scenarios/reconnect_after_restart_scenario.rs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,3 +608,107 @@ async fn poll_from_zero_until(
608608
sleep(Duration::from_millis(250)).await;
609609
}
610610
}
611+
612+
/// Rejoin with a window larger than the peers' evicted ring
613+
/// (`EVICTED_RING_CAPACITY` = 4096 entries): the serving peer answers
614+
/// `RangeEvicted` for the front of the range and the commit floor must
615+
/// settle at its retention point, with the rejoiner's recovered segments
616+
/// standing in below it. This is the only scenario that exercises
617+
/// `RangeEvicted` delivery and the floor end to end -- everything else
618+
/// fits inside the ring. The node-0 disk-growth assert is the proof the
619+
/// floor engaged: without it the commit walk gap-stops below the frontier
620+
/// and no post-restart batch ever flushes on the rejoiner.
621+
pub async fn run_ring_overflow_rejoin(harness: &mut TestHarness) {
622+
const RING_OVERFLOW_OPS: u32 = 4300;
623+
const POST_RESTART_OPS: u32 = 50;
624+
625+
let setup_client = harness
626+
.root_client()
627+
.await
628+
.expect("Failed to create setup client");
629+
setup_client
630+
.create_stream(STREAM_NAME)
631+
.await
632+
.expect("Failed to create stream");
633+
setup_client
634+
.create_topic(
635+
&Identifier::named(STREAM_NAME).unwrap(),
636+
TOPIC_NAME,
637+
1,
638+
Default::default(),
639+
None,
640+
IggyExpiry::NeverExpire,
641+
MaxTopicSize::ServerDefault,
642+
)
643+
.await
644+
.expect("Failed to create topic");
645+
send_messages(&setup_client, "ring", RING_OVERFLOW_OPS).await;
646+
drop(setup_client);
647+
// Let the backups' commit walks flush; the rejoin window must start
648+
// from durable segments, not from a racing in-memory tail.
649+
sleep(Duration::from_secs(2)).await;
650+
651+
let partition_path = format!(
652+
"{}/streams/0/topics/0/partitions/0",
653+
harness.server().data_path().display()
654+
);
655+
let durable_before = partition_log_bytes(&partition_path);
656+
assert!(
657+
durable_before > 0,
658+
"pre-restart flush must have landed on node 0"
659+
);
660+
661+
harness
662+
.restart_server()
663+
.await
664+
.expect("Failed to restart node 0");
665+
666+
let client = harness
667+
.root_client()
668+
.await
669+
.expect("Failed to reconnect after restart");
670+
let polled = poll_from_zero_until(&client, RING_OVERFLOW_OPS, Duration::from_secs(60)).await;
671+
assert_eq!(
672+
polled.len(),
673+
RING_OVERFLOW_OPS as usize,
674+
"all pre-restart messages must survive the over-ring rejoin"
675+
);
676+
677+
send_messages(&client, "post", POST_RESTART_OPS).await;
678+
let all = poll_from_zero_until(
679+
&client,
680+
RING_OVERFLOW_OPS + POST_RESTART_OPS,
681+
Duration::from_secs(60),
682+
)
683+
.await;
684+
assert_eq!(all.len(), (RING_OVERFLOW_OPS + POST_RESTART_OPS) as usize);
685+
686+
// The rejoiner's commit walk must cross the floor: post-restart batches
687+
// flush on node 0 only if commit_min advanced through the repaired
688+
// window, so local disk growth is the floor-path proof.
689+
let deadline = std::time::Instant::now() + Duration::from_secs(30);
690+
loop {
691+
if partition_log_bytes(&partition_path) > durable_before {
692+
break;
693+
}
694+
assert!(
695+
std::time::Instant::now() < deadline,
696+
"node 0 never flushed past its recovered durable end: the \
697+
commit walk is gap-stopped (floor path did not engage)"
698+
);
699+
sleep(Duration::from_millis(250)).await;
700+
}
701+
}
702+
703+
/// Sum of the `.log` segment file sizes under a partition directory.
704+
fn partition_log_bytes(partition_path: &str) -> u64 {
705+
let Ok(entries) = std::fs::read_dir(partition_path) else {
706+
return 0;
707+
};
708+
entries
709+
.filter_map(Result::ok)
710+
.filter(|e| e.path().extension().is_some_and(|ext| ext == "log"))
711+
.filter_map(|e| e.metadata().ok())
712+
.map(|m| m.len())
713+
.sum()
714+
}

core/integration/tests/server/specific.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,18 @@ async fn full_cluster_restart_recovers_and_serves(harness: &mut TestHarness) {
119119
reconnect_after_restart_scenario::run_full_cluster_restart(harness).await;
120120
}
121121

122+
// vsr-only: exercises `RangeEvicted` + the commit floor, which only exist
123+
// on the replicated plane (the rejoin window exceeds the peers' evicted
124+
// ring, so journal repair alone cannot cover it).
125+
#[cfg(feature = "vsr")]
126+
#[iggy_harness(server(
127+
partition.messages_required_to_save = "1",
128+
partition.enforce_fsync = true
129+
))]
130+
async fn rejoin_window_exceeding_evicted_ring(harness: &mut TestHarness) {
131+
reconnect_after_restart_scenario::run_ring_overflow_rejoin(harness).await;
132+
}
133+
122134
#[iggy_harness(server(
123135
partition.messages_required_to_save = "1",
124136
partition.enforce_fsync = true

0 commit comments

Comments
 (0)