From dc75b206db471883a8fce2bb4508af8de0d70751 Mon Sep 17 00:00:00 2001 From: Tamir Suliman Date: Tue, 11 Aug 2026 12:53:02 +0000 Subject: [PATCH 1/5] =?UTF-8?q?node:=20promotion=20enforces=20Raft's=20ele?= =?UTF-8?q?ction=20restriction=20=E2=80=94=20a=20majority=20must=20vouch?= =?UTF-8?q?=20for=20the=20candidate=20(#240)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #265 postmortem left one safety question open, stated rather than closed: whether the fence plus the acknowledged-records bound fully substitute for Raft §5.4.1, given that the lease is granted with no log-completeness condition and LeaderBehind only refuses a candidate behind the proven floor — which can itself sit below an acknowledged record. They do not substitute, and the postmortem's own replay shows it: A acknowledges on {A,B} and dies; B answers 101, candidate C answers 100; the k-th largest floor computes to 100, C holds it, every existing gate passes, and B's acknowledged record is reconciled away under C's boundary. establish now applies the restriction in Raft's PER-VOTER form: only replicas at or below the candidate's own offset would have granted it the vote, and a majority of grants is what makes promotion safe — any acknowledged record's quorum intersects every vote quorum, so a candidate a majority can vouch for holds every acknowledged record. Deliberately not candidate-holds-the-maximum: at RF 5 a candidate with a majority at or below it may lead even though one fenced replica is ahead, because the record making it ahead was never acknowledged, and the stricter form would trade availability for nothing. The refusal names the most complete replica — the one the lease should go to — and the lease agent suspends rather than demotes, exactly as LeaderBehind does, so the epoch stays grantable to that replica. The hazard replay is pinned as a test, alongside its remedy (the named replica promotes over the identical probes) and the availability guarantee. Scenario 09 passes live — its promotion of the most caught-up follower is precisely the candidate the new gate blesses. Item 5 of #240 — the signed transition record — remains open by design: it is evidence, not safety, and the ROADMAP frames it as its own design conversation. --- crates/vtop-node/src/lease_agent.rs | 22 +++++ crates/vtop-node/src/promotion.rs | 134 +++++++++++++++++++++++++++- 2 files changed, 154 insertions(+), 2 deletions(-) diff --git a/crates/vtop-node/src/lease_agent.rs b/crates/vtop-node/src/lease_agent.rs index 2e72173..10c0d4d 100644 --- a/crates/vtop-node/src/lease_agent.rs +++ b/crates/vtop-node/src/lease_agent.rs @@ -364,6 +364,28 @@ impl Promoter { self.suspended(fencing_epoch); return false; } + crate::promotion::Promotion::CandidateBehindVoters { + candidate_offset, + votes, + required, + most_complete, + } => { + tracing::warn!( + range = %self.range_uuid, + fencing_epoch, + candidate_offset, + votes, + required, + most_complete_node = %most_complete.0, + most_complete_offset = most_complete.1, + "refusing promotion: fewer than a majority of the fenced replicas are at or below this node's offset (Raft §5.4.1); letting the lease lapse so the more complete replica can win the range" + ); + // Retryable for the same reason as LeaderBehind: the + // right fix is a different candidate, and suspending + // leaves the epoch grantable to it. + self.suspended(fencing_epoch); + return false; + } } } }; diff --git a/crates/vtop-node/src/promotion.rs b/crates/vtop-node/src/promotion.rs index 977b564..8ed13f0 100644 --- a/crates/vtop-node/src/promotion.rs +++ b/crates/vtop-node/src/promotion.rs @@ -65,11 +65,17 @@ //! //! This is the same arithmetic the replication path already uses to advance the //! watermark during steady-state produce; promotion applies it once, from a -//! standing start, to state written by someone else. One further gate applies: +//! standing start, to state written by someone else. Two further gates apply: //! the candidate must itself hold the boundary the quorum proved //! ([`Promotion::LeaderBehind`]) — a leader behind the boundary would publish //! a high-water mark covering offsets its own log does not contain, and the -//! produce fast path would then acknowledge fresh writes into them. +//! produce fast path would then acknowledge fresh writes into them — and a +//! majority of the fenced replicas must be at or below the candidate's own +//! offset ([`Promotion::CandidateBehindVoters`]), which is Raft's election +//! restriction (§5.4.1): the floor alone can sit below a record acknowledged +//! on a quorum whose survivors straddle the candidate, and a candidate that +//! majority would refuse the vote used to promote anyway and let +//! reconciliation truncate the acknowledged record away (#240). //! //! # Why an inherited watermark is never lowered //! @@ -129,6 +135,33 @@ pub enum Promotion { /// which is refused for the same reason. leader_committed_offset: Option, }, + /// The candidate holds the proven floor, but fewer than a majority of the + /// fenced replicas are at or below its own offset — Raft's election + /// restriction (§5.4.1), in its per-voter form. + /// + /// The floor alone is not enough: the k-th largest can sit BELOW a record + /// that was acknowledged on a quorum whose survivors now straddle the + /// candidate — a candidate at 100 counting a fenced replica at 101 passes + /// the floor check (the floor computes to 100) and would then publish a + /// boundary under which reconciliation truncates the acknowledged record + /// away. In Raft terms, a replica whose log is ahead of the candidate + /// would refuse it the vote; counting it toward the quorum anyway is how + /// promotion used to conclude an entry was uncommitted merely because + /// the candidate had not seen it. Deliberately per-voter rather than + /// candidate-must-hold-the-maximum: a majority at or below the candidate + /// is exactly §5.4.1's guarantee (any acknowledged record's quorum + /// intersects every vote quorum), and the stricter form would refuse a + /// legitimate leader over a record that was never acknowledged. + CandidateBehindVoters { + /// The candidate's own offset. + candidate_offset: u64, + /// How many fenced replicas are at or below the candidate. + votes: usize, + required: usize, + /// The most complete replica observed — the one an operator or the + /// lease agent should let win the range instead. + most_complete: (Uuid, u64), + }, } /// Majority of a replica set, including the leader itself. @@ -199,6 +232,33 @@ pub fn establish(probes: &[ReplicaProbe], replication_factor: usize, leader_id: leader_committed_offset, }; } + let candidate_offset = + leader_committed_offset.expect("a candidate below the floor returned above"); + // Raft's election restriction (§5.4.1), per voter: only replicas at or + // below the candidate's own offset would have granted it the vote, and a + // majority of grants is what makes the promotion safe — any record + // acknowledged on a quorum lives on at least one member of every + // majority, so a candidate a majority can vouch for holds every + // acknowledged record. The floor check above cannot substitute: the + // k-th largest can sit below an acknowledged record whose surviving + // holders straddle the candidate. + let votes = answered + .values() + .filter(|offset| **offset <= candidate_offset) + .count(); + if votes < required { + let most_complete = answered + .iter() + .max_by_key(|(_, offset)| **offset) + .map(|(node, offset)| (*node, *offset)) + .expect("a quorum answered"); + return Promotion::CandidateBehindVoters { + candidate_offset, + votes, + required, + most_complete, + }; + } Promotion::Established { committed_offset, answered, @@ -216,6 +276,76 @@ mod tests { } } + /// REGRESSION shape, from #240's #265 postmortem: A acknowledges a + /// record on {A, B} and dies; B answers 101, candidate C answers 100. + /// The floor computes to 100 and C holds it, so every pre-§5.4.1 check + /// passed — and B's acknowledged record was then reconciled away under + /// C's boundary. The election restriction refuses C: only one fenced + /// replica is at or below C's offset, and one is not a majority. + #[test] + fn a_candidate_a_fenced_replica_would_refuse_the_vote_is_not_promoted() { + let outcome = establish( + &[probe(2, Some(101)), probe(3, Some(100))], + 3, + Uuid::from_u128(3), + ); + assert_eq!( + outcome, + Promotion::CandidateBehindVoters { + candidate_offset: 100, + votes: 1, + required: 2, + most_complete: (Uuid::from_u128(2), 101), + }, + "a candidate counting a fenced replica ahead of its own log used to promote at a floor below an acknowledged record; the replica ahead is the one that must win" + ); + } + + /// The remedy the refusal names: the more complete replica promotes over + /// the identical probe set. + #[test] + fn the_replica_the_refusal_names_promotes_over_the_same_probes() { + let outcome = establish( + &[probe(2, Some(101)), probe(3, Some(100))], + 3, + Uuid::from_u128(2), + ); + match outcome { + Promotion::Established { + committed_offset, .. + } => assert_eq!( + committed_offset, 100, + "the floor is still what the quorum can vouch for; the record above it is protected by the candidate holding it, not by the floor" + ), + other => panic!("the most complete replica must promote: {other:?}"), + } + } + + /// Deliberately Raft's PER-VOTER form, not candidate-holds-the-maximum: + /// at RF 5 a candidate with a majority at or below it may lead even + /// though one fenced replica is ahead — the record making that replica + /// ahead was never acknowledged (its quorum would have needed three), + /// and refusing here would trade availability for nothing. + #[test] + fn a_majority_at_or_below_the_candidate_promotes_despite_a_more_complete_minority() { + let outcome = establish( + &[ + probe(2, Some(101)), + probe(3, Some(100)), + probe(4, Some(100)), + probe(5, Some(100)), + ], + 5, + Uuid::from_u128(3), + ); + match outcome { + Promotion::Established { + committed_offset, .. + } => assert_eq!(committed_offset, 100), + other => panic!("a candidate with majority votes must promote: {other:?}"), + } + } + #[test] fn a_majority_needs_more_than_half_even_at_even_sizes() { assert_eq!(majority(1), 1); From 12a7a4a346c43e0b096d84554f0f36cb6e8b475d Mon Sep 17 00:00:00 2001 From: Tamir Suliman Date: Tue, 11 Aug 2026 13:08:39 +0000 Subject: [PATCH 2/5] node: the vote inputs are post-reconciliation offsets, and that is the point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round one (Codex + cubic, same P1): the concern was that the fence reconciles before it answers, so the replica ahead of the candidate could be truncated down before its vote is counted — passing the restriction in the exact scenario it exists to refuse. Verified against the code, the premise does not hold for that scenario: a same-epoch prefix relationship is not divergence — compare_lineage answers Agreed and reconciliation touches nothing — so the replica ahead answers with its full offset and the candidate is refused. And where truncation before answering DOES happen, genuine lineage divergence, a pre-truncation offset would be the wrong vote input: offsets are only comparable within an agreed lineage, and counting bytes from a contradicted leadership line as log-completeness is the mistake epoch qualification exists to prevent. The rationale now lives in the restriction's comment. The warn line's embedded indentation is fixed alongside (cubic P3). --- crates/vtop-node/src/lease_agent.rs | 5 ++++- crates/vtop-node/src/promotion.rs | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/vtop-node/src/lease_agent.rs b/crates/vtop-node/src/lease_agent.rs index 10c0d4d..db149aa 100644 --- a/crates/vtop-node/src/lease_agent.rs +++ b/crates/vtop-node/src/lease_agent.rs @@ -378,7 +378,10 @@ impl Promoter { required, most_complete_node = %most_complete.0, most_complete_offset = most_complete.1, - "refusing promotion: fewer than a majority of the fenced replicas are at or below this node's offset (Raft §5.4.1); letting the lease lapse so the more complete replica can win the range" + "refusing promotion: fewer than a majority of the fenced \ + replicas are at or below this node's offset (Raft §5.4.1); \ + letting the lease lapse so the more complete replica can \ + win the range" ); // Retryable for the same reason as LeaderBehind: the // right fix is a different candidate, and suspending diff --git a/crates/vtop-node/src/promotion.rs b/crates/vtop-node/src/promotion.rs index 8ed13f0..1445862 100644 --- a/crates/vtop-node/src/promotion.rs +++ b/crates/vtop-node/src/promotion.rs @@ -242,6 +242,18 @@ pub fn establish(probes: &[ReplicaProbe], replication_factor: usize, leader_id: // acknowledged record. The floor check above cannot substitute: the // k-th largest can sit below an acknowledged record whose surviving // holders straddle the candidate. + // + // These are POST-RECONCILIATION offsets — the fence reconciles before it + // answers (#263) — and that is correct, not a leak in the restriction. + // A same-epoch prefix relationship is not divergence (`compare_lineage` + // answers Agreed and reconciliation touches nothing), so the replica + // ahead of the candidate still answers with its full offset and refuses + // the candidate here. Truncation before answering happens only on + // GENUINE lineage divergence, and there a pre-truncation offset would be + // the wrong vote input: offsets are only comparable within an agreed + // lineage, and counting bytes from a contradicted leadership line as + // log-completeness is the exact mistake epoch qualification (#258) + // exists to prevent. let votes = answered .values() .filter(|offset| **offset <= candidate_offset) From 3fc7d07efb320f2107dbc10f1f73b1653ee100ff Mon Sep 17 00:00:00 2001 From: Tamir Suliman Date: Tue, 11 Aug 2026 13:17:39 +0000 Subject: [PATCH 3/5] =?UTF-8?q?node:=20why=20a=20divergent=20reconciliatio?= =?UTF-8?q?n=20cannot=20delete=20an=20acknowledged=20record=20=E2=80=94=20?= =?UTF-8?q?by=20induction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round two (Codex): the DivergesAt path does truncate before the vote is read, and its guard only knows a possibly-stale high-water mark. What makes that safe is not the guard — it is that the hazard state is unreachable through restricted promotions. A record acknowledged on a quorum intersects every later epoch's fence-majority, so with this gate at every promotion the intersecting voter refuses any candidate whose log lacks the record; every granted epoch's leader therefore holds every previously acknowledged record, every subsequent leadership line contains them, and any suffix a voter loses to DivergesAt was written under a superseded line and never acknowledged. Divergence below an acknowledged record requires a promotion that already violated the restriction — the pre-#263 bugs, or a history predating this gate — and the below-HWM guard remains the last resort for exactly those. The local (non-inductive) form — a fence that votes before it reconciles — is a protocol reordering for #240's remaining design conversation, stated in the doc rather than smuggled into this slice. --- crates/vtop-node/src/promotion.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/vtop-node/src/promotion.rs b/crates/vtop-node/src/promotion.rs index 1445862..d8ca04d 100644 --- a/crates/vtop-node/src/promotion.rs +++ b/crates/vtop-node/src/promotion.rs @@ -254,6 +254,21 @@ pub fn establish(probes: &[ReplicaProbe], replication_factor: usize, leader_id: // lineage, and counting bytes from a contradicted leadership line as // log-completeness is the exact mistake epoch qualification (#258) // exists to prevent. + // + // Can a divergent reconciliation delete an ACKNOWLEDGED record before + // the vote is read? Only if some past promotion already violated this + // restriction. The argument is inductive: a record acknowledged on a + // quorum intersects every later epoch's fence-majority, so with this + // gate in force at every promotion, the intersecting voter refuses any + // candidate whose log lacks the record — meaning every granted epoch's + // leader holds every previously acknowledged record, every subsequent + // leadership line contains them, and any suffix a voter loses to + // `DivergesAt` was written under a superseded line and never + // acknowledged. The truncation-below-HWM guard stays as the last + // resort for histories that predate the restriction; making the + // property local rather than inductive — a fence that votes before it + // reconciles — is a protocol reordering that belongs to #240's + // remaining design conversation, not to this gate. let votes = answered .values() .filter(|offset| **offset <= candidate_offset) From cbd1c6c500c87c7c127fa60df8b0a7f61058c30a Mon Sep 17 00:00:00 2001 From: Tamir Suliman Date: Tue, 11 Aug 2026 13:28:15 +0000 Subject: [PATCH 4/5] node: a refused candidate stands aside instead of winning races it cannot serve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round three (Codex P2), verified real — and pre-existing for LeaderBehind, which the new variant merely made visible: suspend only stops renewal, so a refused candidate re-enters the acquisition race, can keep beating the replica its own refusal named to the CAS, holds a lease it cannot serve, lapses it, and wins again — while receiving no replication with which to become eligible. Consistent poll timing can starve an eligible, online replica indefinitely. An eligibility refusal — LeaderBehind or the §5.4.1 vote check, NOT a quorum miss, which is a verdict nobody reached — now requests a stand-aside: the agent skips the campaign for two lease lifetimes of poll rounds, giving the named replica uncontested acquisitions however the two agents' polls interleave. BOUNDED, deliberately, not until-another-holder-is-observed: if the eligible replica is down, someone must keep probing, and the refusal repeating on a duty cycle is the honest unavailability signal — a suppression that waits for a holder that never comes would wedge the range on a second failure. Pinned: the verdict requests the stand-aside and a quorum miss does not; one verdict funds one hold-off. --- crates/vtop-node/src/lease_agent.rs | 91 +++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/crates/vtop-node/src/lease_agent.rs b/crates/vtop-node/src/lease_agent.rs index db149aa..57c57af 100644 --- a/crates/vtop-node/src/lease_agent.rs +++ b/crates/vtop-node/src/lease_agent.rs @@ -286,6 +286,13 @@ struct Promoter { /// the boundary it is about to publish. node_uuid: Uuid, range_uuid: Uuid, + /// Set when a refusal was an ELIGIBILITY verdict — the quorum answered + /// and this node's log is the problem — rather than a transient quorum + /// miss. The agent turns it into a campaign hold-off: a refused + /// candidate that keeps winning the acquisition race starves the very + /// replica its own refusal named, because a suspended non-leader + /// receives no replication with which to become eligible. + stand_aside: bool, } impl Promoter { @@ -361,6 +368,7 @@ impl Promoter { // Also retryable in principle: probes are a snapshot, // and a stale follower answer can transiently place // the boundary above this node's own disk. + self.stand_aside = true; self.suspended(fencing_epoch); return false; } @@ -386,6 +394,7 @@ impl Promoter { // Retryable for the same reason as LeaderBehind: the // right fix is a different candidate, and suspending // leaves the epoch grantable to it. + self.stand_aside = true; self.suspended(fencing_epoch); return false; } @@ -411,6 +420,12 @@ impl Promoter { self.publisher.suspend(fencing_epoch); self.verified_epoch = None; } + + /// Whether the last refusal was an eligibility verdict; reading clears + /// it, because one verdict funds one hold-off. + fn take_stand_aside(&mut self) -> bool { + std::mem::take(&mut self.stand_aside) + } } /// How the agent paces itself. @@ -570,6 +585,17 @@ pub struct LeaseAgent { range_uuid: Uuid, promoter: Promoter, state: LeaseState, + /// Rounds of the run loop during which this node will NOT campaign for + /// the lease, set when a promotion was refused on eligibility grounds + /// (LeaderBehind or the §5.4.1 vote check). A refused candidate that + /// keeps winning the acquisition race starves the replica its own + /// refusal named — it holds the lease it cannot serve, lets it lapse, + /// and wins again — while receiving no replication with which to become + /// eligible. Standing aside for a bounded window gives the eligible + /// replica uncontested acquisitions; BOUNDED, not until-another-holder, + /// because if the eligible replica is down someone must keep probing, + /// and the refusal repeating is the honest unavailability signal. + campaign_hold_off_rounds: u32, /// Local upper bound on how long the current hold may be trusted without /// hearing from metadata, in the same wall-clock the envelope carries. /// @@ -616,9 +642,11 @@ impl LeaseAgent { verified_epoch: None, node_uuid, range_uuid, + stand_aside: false, }, state: LeaseState::NotHeld, held_until_ms: None, + campaign_hold_off_rounds: 0, }) } @@ -812,6 +840,18 @@ impl LeaseAgent { if let LeaseState::Held { fencing_epoch } = self.state { self.publish_lost(fencing_epoch); } + // Standing aside after an eligibility refusal: campaigning + // now would only take the lease away from the replica the + // refusal named, hold it unserved, and lapse it again. + if self.campaign_hold_off_rounds > 0 { + self.campaign_hold_off_rounds -= 1; + tracing::debug!( + range = %self.range_uuid, + rounds_remaining = self.campaign_hold_off_rounds, + "standing aside from the lease race after an eligibility refusal" + ); + return Ok(self.config.poll_interval); + } match self.acquire(expected_range_generation).await? { Some(fencing_epoch) => { let granted_until = Some( @@ -906,6 +946,13 @@ impl LeaseAgent { async fn publish_held(&mut self, fencing_epoch: u64, held_until_ms: Option) -> bool { if !self.promoter.ensure(fencing_epoch).await { + if self.promoter.take_stand_aside() { + // Two lease lifetimes of poll rounds: enough for the replica + // the refusal named to see the lapse and win at least one + // uncontested acquisition, however the two agents' polls + // interleave. + self.campaign_hold_off_rounds = self.stand_aside_rounds(); + } self.state = LeaseState::NotHeld; self.held_until_ms = None; return false; @@ -915,6 +962,14 @@ impl LeaseAgent { true } + /// How many poll rounds an eligibility refusal sits out: two lease + /// lifetimes, expressed in this agent's own polling cadence. + fn stand_aside_rounds(&self) -> u32 { + let lease_ms = self.config.lease_duration.as_millis().max(1); + let poll_ms = self.config.poll_interval.as_millis().max(1); + (lease_ms.saturating_mul(2).div_ceil(poll_ms)).min(u128::from(u32::MAX)) as u32 + } + fn publish_lost(&mut self, fencing_epoch: u64) { tracing::warn!( range = %self.range_uuid, @@ -1178,6 +1233,7 @@ mod tests { publisher, probe, verified_epoch: None, + stand_aside: false, // Matches `at(1, ..)`: the tests' candidate is node 1. node_uuid: Uuid::from_u128(1), range_uuid: Uuid::from_u128(21), @@ -1280,6 +1336,41 @@ mod tests { ); } + /// An eligibility refusal — the quorum answered and this node's log is + /// the problem — requests a stand-aside, so the agent stops winning + /// acquisition races away from the replica the refusal named. A quorum + /// MISS does not: standing aside there would delay recovery for a + /// verdict nobody reached. + #[tokio::test] + async fn an_eligibility_refusal_requests_a_stand_aside_and_a_quorum_miss_does_not() { + let recorder = Arc::new(Recorder::default()); + // Node 1 answers 100 and holds the floor, but node 2 is ahead at + // 101: one vote of a required two — the §5.4.1 refusal. + let mut behind = promoter( + Arc::clone(&recorder) as Arc, + Some(fixed(vec![at(1, Some(100)), at(2, Some(101))], 3)), + ); + assert!(!behind.ensure(7).await); + assert!( + behind.take_stand_aside(), + "an eligibility verdict must request a stand-aside, or the refused candidate keeps winning the race away from the replica it named" + ); + assert!( + !behind.take_stand_aside(), + "one verdict funds one hold-off; reading clears it" + ); + + let mut miss = promoter( + Arc::clone(&recorder) as Arc, + Some(fixed(vec![at(1, Some(10)), at(2, None), at(3, None)], 3)), + ); + assert!(!miss.ensure(8).await); + assert!( + !miss.take_stand_aside(), + "a quorum miss is not an eligibility verdict; standing aside would delay recovery for nothing" + ); + } + /// The recovery half of the transient-refusal story, end to end against a /// real fencing view: quorum miss, then quorum back, and the broker must /// actually serve again at the SAME epoch. From a29ec1cee7ff57caa2720b0736f51c6238ddfe7a Mon Sep 17 00:00:00 2001 From: Tamir Suliman Date: Tue, 11 Aug 2026 13:37:23 +0000 Subject: [PATCH 5/5] =?UTF-8?q?node:=20a=20rival's=20acquisition=20clears?= =?UTF-8?q?=20the=20stand-aside=20=E2=80=94=20its=20purpose=20is=20achieve?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round four (Codex P2): the hold-off decremented only in Acquire rounds, so a node that stood aside successfully — the eligible replica took the lease — kept the residual counter through every Wait round, and a failure of that holder even long afterward was answered by serving out up to two lease lifetimes of leftover wait before campaigning. The Wait arm, which is exactly where this node observes a rival holding the range, now clears the hold-off: the stand-aside exists to let an eligible replica win, and once one has, an immediate campaign on the next vacancy is the correct posture — if this node is still behind, the refusal and a fresh hold-off will say so. --- crates/vtop-node/src/lease_agent.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/vtop-node/src/lease_agent.rs b/crates/vtop-node/src/lease_agent.rs index 57c57af..45ae5a1 100644 --- a/crates/vtop-node/src/lease_agent.rs +++ b/crates/vtop-node/src/lease_agent.rs @@ -889,6 +889,14 @@ impl LeaseAgent { // monotonic and idempotent, so repeating it every poll is // free. self.promoter.lost(fencing_epoch); + // A rival holding the range is the stand-aside's purpose + // ACHIEVED: the replica this node's refusal made way for (or + // any other eligible one) has the lease. Clearing the + // hold-off here means a much later failure of that holder is + // answered by an immediate campaign, not by serving out the + // residue of a wait that already did its job (review round + // four). + self.campaign_hold_off_rounds = 0; Ok(self.config.poll_interval) } LeaseDecision::RangeMissing => {