// Copyright 2015 The etcd Authors
// Copyright 2026 Leo Cheng
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
///|
/// How the leader is currently replicating to one follower.
///
/// `Probe` sends one AppendEntries at a time until the follower's match point
/// is found; `Replicate` streams entries once the logs are known to agree; and
/// `Snapshot` means the follower is so far behind that the next thing it needs
/// has already been compacted, so a snapshot must be shipped first (etcd's
/// three progress states).
pub(all) enum ProgressState {
Probe
Replicate
Snapshot
} derive(Eq)
///|
/// The leader's view of one follower's replication progress. `next_index` is
/// the next log index to send; `match_index` is the highest index known to be
/// stored on the follower. `recent_active` records whether the follower has
/// answered since the last liveness sweep, which the read-index and lease paths
/// use to confirm the leader still commands a quorum.
pub(all) struct Progress {
mut next_index : UInt64
mut match_index : UInt64
mut state : ProgressState
mut recent_active : Bool
// Throttle flag: set when the MsgApp flow to this follower is paused (a probe
// was sent, or the in-flight window filled). A periodic message is still sent
// once it clears — on an ack or a heartbeat response — so progress resumes.
mut msg_app_flow_paused : Bool
// In `Snapshot`, the last index of the snapshot the leader shipped; replication
// is paused until the follower reconnects to the log past it.
mut pending_snapshot : UInt64
// Whether this follower is a learner (non-voting). Kept here too so a progress
// dump is self-describing; the authoritative set lives in `Membership`.
mut is_learner : Bool
// The highest commit index the leader has put in flight to this follower
// (etcd's `sentCommit`). Generally monotonic but may regress when converting
// to `Probe` or on a rejection. In `Snapshot`, sent_commit == pending_snapshot
// == next_index - 1.
//
// CONTRACT for the ③-path replication (raftnode.mbt / replication.mbt), which
// owns the send loop: before emitting a commit-bearing MsgApp, gate an eager
// commit-only send on `can_bump_commit(commit)`; after emitting, record it with
// `note_commit_sent(commit)`. This field is *staged* here per the 0.4.0 plan;
// until the ③-path wires it, it stays 0 and does not affect consensus (commit
// still propagates on every append), but the state-machine regressions below
// are already applied so wiring is a pure call-site change.
mut sent_commit : UInt64
inflights : Inflights
}
///|
/// A fresh progress that will start probing from `next`, with a replication
/// flow-control window of `max_inflight` outstanding AppendEntries and, when
/// `max_inflight_bytes` is non-zero, at most that many outstanding bytes
/// (etcd's `MaxInflightBytes`; 0 = no byte limit). The byte budget is threaded
/// straight into the `Inflights` window so the ③-path only has to pass the real
/// entry size to `sent_entries` once its `Config` carries the knob.
pub fn Progress::new(
next : UInt64,
max_inflight? : Int = 256,
max_inflight_bytes? : UInt64 = 0,
) -> Progress {
{
next_index: next,
match_index: 0,
state: Probe,
recent_active: false,
msg_app_flow_paused: false,
pending_snapshot: 0,
is_learner: false,
sent_commit: 0,
inflights: Inflights::new(max_inflight, max_inflight_bytes),
}
}
///|
fn progress_state_name(s : ProgressState) -> String {
match s {
Probe => "StateProbe"
Replicate => "StateReplicate"
Snapshot => "StateSnapshot"
}
}
///|
/// A one-line, self-describing summary of this progress (etcd's Progress.String).
pub fn Progress::to_string(self : Progress) -> String {
let mut s = progress_state_name(self.state) +
" match=" +
self.match_index.to_string() +
" next=" +
self.next_index.to_string()
if self.is_learner {
s = s + " learner"
}
if self.is_paused() {
s = s + " paused"
}
if self.pending_snapshot > 0 {
s = s + " pendingSnap=" + self.pending_snapshot.to_string()
}
if !self.recent_active {
s = s + " inactive"
}
let n = self.inflights.count()
if n > 0 {
s = s + " inflight=" + n.to_string()
if self.inflights.full() {
s = s + "[full]"
}
}
s
}
///|
/// Move into `state`, clearing the throttle, pending snapshot and in-flight
/// window (etcd's ResetState).
fn Progress::reset_state(self : Progress, state : ProgressState) -> Unit {
self.msg_app_flow_paused = false
self.pending_snapshot = 0
self.state = state
self.inflights.reset()
}
///|
/// Whether replication to this follower is currently throttled: while a snapshot
/// is pending, or whenever the MsgApp flow has been paused (a probe in flight, or
/// a full in-flight window).
pub fn Progress::is_paused(self : Progress) -> Bool {
match self.state {
Probe | Replicate => self.msg_app_flow_paused
Snapshot => true
}
}
///|
/// Clear the flow-control throttle (on an ack or a heartbeat response), so one
/// more message may be sent.
pub fn Progress::unpause(self : Progress) -> Unit {
self.msg_app_flow_paused = false
}
///|
/// A deep copy sharing no mutable state (used by the confchange Changer, which
/// preserves a demoted voter's progress across a joint transition).
pub fn Progress::copy(self : Progress) -> Progress {
{
next_index: self.next_index,
match_index: self.match_index,
state: self.state,
recent_active: self.recent_active,
msg_app_flow_paused: self.msg_app_flow_paused,
pending_snapshot: self.pending_snapshot,
is_learner: self.is_learner,
sent_commit: self.sent_commit,
inflights: self.inflights.clone(),
}
}
///|
/// Record that a replication message ending at `last`, carrying `has_entries`
/// entries totalling `bytes` bytes, was sent. In `Replicate` this consumes an
/// in-flight slot (against both the message-count and the byte budget) and
/// pauses once the window fills; in `Probe` any non-empty send pauses until
/// acked. `bytes` defaults to 0: while the ③-path send loop does not yet supply
/// the real encoded size (see `Progress::new`), byte accounting is inert because
/// the byte budget is disabled — the message-count limit still applies exactly
/// as before. Pass the real size to activate `MaxInflightBytes`.
pub fn Progress::sent_entries(
self : Progress,
last : UInt64,
has_entries : Bool,
bytes? : UInt64 = 0,
) -> Unit {
match self.state {
Replicate => {
if has_entries {
self.optimistic_advance(last)
self.inflights.add(last, bytes)
}
// Re-evaluate the throttle even for an empty probe (etcd SentEntries): a
// full window keeps the flow paused.
self.msg_app_flow_paused = self.inflights.full()
}
Probe => if has_entries { self.msg_app_flow_paused = true }
// etcd's SentEntries panics for any state other than Replicate/Probe; the
// leader never sends an append while a snapshot is pending (send_to returns
// the snapshot instead), so this is unreachable in the live path.
Snapshot => abort("sending append in unhandled state Snapshot")
}
}
///|
/// Free every in-flight slot up through the acknowledged `index`.
pub fn Progress::free_le(self : Progress, index : UInt64) -> Unit {
self.inflights.free_le(index)
}
///|
/// Record that the follower answered during the current liveness sweep.
pub fn Progress::mark_active(self : Progress) -> Unit {
self.recent_active = true
}
///|
/// Clear the liveness flag at the start of a new sweep.
pub fn Progress::reset_active(self : Progress) -> Unit {
self.recent_active = false
}
///|
/// Whether the follower has answered since the last sweep.
pub fn Progress::is_active(self : Progress) -> Bool {
self.recent_active
}
///|
/// Move to streaming replication, sending from just past the match point.
pub fn Progress::become_replicate(self : Progress) -> Unit {
self.reset_state(Replicate)
self.next_index = self.match_index + 1
}
///|
/// Move back to cautious probing, one entry at a time. Coming out of `Snapshot`,
/// resume just past the snapshot the follower was sent (etcd's BecomeProbe).
pub fn Progress::become_probe(self : Progress) -> Unit {
let from_snapshot = self.state == Snapshot
let pending = self.pending_snapshot
self.reset_state(Probe)
self.next_index = if from_snapshot {
let a = self.match_index + 1
let b = pending + 1
if a > b {
a
} else {
b
}
} else {
self.match_index + 1
}
// The in-flight commit cannot exceed the entry the follower is being probed
// for; regress it (etcd BecomeProbe).
let ceil = self.next_index - 1
if self.sent_commit > ceil {
self.sent_commit = ceil
}
}
///|
/// Mark the follower as needing a snapshot up to `snapshot_index`; probing will
/// resume just past it once the snapshot is acknowledged.
pub fn Progress::become_snapshot(
self : Progress,
snapshot_index : UInt64,
) -> Unit {
self.reset_state(Snapshot)
self.pending_snapshot = snapshot_index
self.next_index = snapshot_index + 1
// In Snapshot, sent_commit == pending_snapshot == next_index - 1 (etcd
// BecomeSnapshot).
self.sent_commit = snapshot_index
}
///|
/// Fold in a successful acknowledgement up through `index`. Advances the match
/// and next indices, never backwards, and returns whether the match point moved
/// forward (which is what can let the leader commit new entries).
pub fn Progress::maybe_update(self : Progress, index : UInt64) -> Bool {
let advanced = index > self.match_index
if advanced {
self.match_index = index
// A genuine advance means the follower is keeping up: resume the flow.
self.msg_app_flow_paused = false
}
if self.next_index < index + 1 {
self.next_index = index + 1
}
advanced
}
///|
/// Optimistically advance `next_index` past `last` while streaming, so the next
/// AppendEntries carries the following batch without waiting for the ack.
pub fn Progress::optimistic_advance(self : Progress, last : UInt64) -> Unit {
if self.next_index < last + 1 {
self.next_index = last + 1
}
}
///|
/// Adjust to a rejected AppendEntries (etcd `MaybeDecrTo`). `rejected` is the
/// prev-index the follower rejected; `match_hint` is where we want to retry
/// (the leader-side findConflictByTerm result). A rejection is stale — and
/// ignored — if it cannot pertain to an entry still in flight. Returns whether
/// `next_index` moved.
pub fn Progress::maybe_decr_to(
self : Progress,
rejected : UInt64,
match_hint : UInt64,
) -> Bool {
if self.state == Replicate {
// In flight streaming: a rejection at or below the match point is stale.
if rejected <= self.match_index {
return false
}
self.next_index = self.match_index + 1
// The rejected entry is unlikely to have been applied; regress the in-flight
// commit with it (etcd MaybeDecrTo).
let ceil = self.next_index - 1
if self.sent_commit > ceil {
self.sent_commit = ceil
}
return true
}
// Probing sends one entry at a time, so a rejection must be for the entry we
// last probed; otherwise it is a stale duplicate.
if self.next_index - 1 != rejected {
return false
}
let capped = if rejected < match_hint + 1 { rejected } else { match_hint + 1 }
let floor = self.match_index + 1
self.next_index = if capped > floor { capped } else { floor }
let ceil = self.next_index - 1
if self.sent_commit > ceil {
self.sent_commit = ceil
}
self.msg_app_flow_paused = false
true
}
///|
/// Whether sending `index` as the commit index could still advance this
/// follower's commit (etcd `CanBumpCommit`). True only when `index` is past what
/// we last put in flight *and* that in-flight commit has not already reached the
/// last acknowledged-in-flight entry (`next_index - 1`) — so the ③-path can skip
/// redundant commit-only MsgApps. Staged for the 0.4.0 replication wiring.
pub fn Progress::can_bump_commit(self : Progress, index : UInt64) -> Bool {
index > self.sent_commit && self.sent_commit < self.next_index - 1
}
///|
/// Record the highest commit index put in flight to this follower (etcd
/// `SentCommit`). The ③-path calls this after emitting an append/commit MsgApp.
pub fn Progress::note_commit_sent(self : Progress, commit : UInt64) -> Unit {
self.sent_commit = commit
}
///|
/// Back off after a rejected AppendEntries, using the follower's conflict hint
/// to jump rather than decrement by one. Never rewinds below the match point or
/// below index 1. Returns whether `next_index` actually moved.
pub fn Progress::maybe_decrease(self : Progress, hint : UInt64) -> Bool {
let floor = self.match_index + 1
let target = if hint > floor { hint } else { floor }
if target < self.next_index {
self.next_index = target
true
} else {
false
}
}