// 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.
///|
/// The set of servers that currently form the cluster. Raft changes membership
/// through the log so every server adopts each change at the same point in the
/// sequence (Raft §6). Two disciplines are supported: single-server changes,
/// where the old and new majorities always overlap; and joint consensus, where
/// the cluster passes through a transitional configuration C(old,new) that
/// needs a majority of *both* the old and the new voter sets to agree.
///
/// `members` is the incoming configuration C(new). `outgoing` holds the old
/// configuration C(old) and is non-empty only while `joint` is true.
pub(all) struct Membership {
members : Array[String]
outgoing : Array[String]
mut joint : Bool
// Non-voting members: they receive replicated entries and can be caught up,
// but are never counted toward a quorum and never vote (Raft §4.2.1 learners).
learners : Array[String]
// Voters demoted to learner *during* a joint change: they keep voting via the
// outgoing half (so a demotion can't strand the old majority) and only become
// real learners when the joint config is left (etcd's LearnersNext, §4.3).
learners_next : Array[String]
// Whether this joint configuration should leave automatically once committed
// (etcd's ConfState.AutoLeave). This is *durable* config state, set from the
// committed EnterJoint entry, so it survives even if a not-yet-committed
// LeaveJoint entry is truncated by a new leader.
mut auto_leave : Bool
}
///|
/// Create a simple (non-joint) cluster configuration from an initial voter set.
pub fn Membership::new(members : Array[String]) -> Membership {
{
members: members.copy(),
outgoing: [],
joint: false,
learners: [],
learners_next: [],
auto_leave: false,
}
}
///|
/// Add `id` as a learner (non-voting member). If it is currently a voter this
/// *demotes* it (etcd's `l`). A voter demoted while a joint config is active
/// and still present in the outgoing half is *staged* in `learners_next`: it
/// leaves the incoming voters but keeps voting through the outgoing half until
/// the config is left, so its removal from the quorum is atomic with the leave
/// (Raft §4.3). Otherwise it becomes a learner right away.
pub fn Membership::add_learner(self : Membership, id : String) -> Unit {
self.members.retain(fn(m) { m != id })
if self.joint && self.outgoing.contains(id) {
if !self.learners_next.contains(id) {
self.learners_next.push(id)
}
} else if !self.learners.contains(id) {
self.learners.push(id)
}
}
///|
/// Whether `id` is a learner (a non-voting member).
pub fn Membership::is_learner(self : Membership, id : String) -> Bool {
self.learners.contains(id)
}
///|
/// Every server that participates in replication: voters and learners alike.
pub fn Membership::nodes(self : Membership) -> Array[String] {
let out = self.voters()
for l in self.learners {
if !out.contains(l) {
out.push(l)
}
}
// NB: staged learners (learners_next) need no separate pass — by construction
// they are always outgoing voters, hence already returned by `voters()`.
out
}
///|
/// The majority size of the current (incoming) configuration. Meaningful for a
/// simple configuration; in a joint configuration use `has_majority`, which
/// accounts for both halves.
pub fn Membership::quorum(self : Membership) -> Int {
self.members.length() / 2 + 1
}
///|
/// Whether `id` is a voter in the current configuration — in the incoming set,
/// or, during a joint transition, in either half.
pub fn Membership::contains(self : Membership, id : String) -> Bool {
self.members.contains(id) || (self.joint && self.outgoing.contains(id))
}
///|
/// The number of servers in the incoming configuration.
pub fn Membership::size(self : Membership) -> Int {
self.members.length()
}
///|
/// Whether the configuration is currently in the joint (transitional) state.
pub fn Membership::is_joint(self : Membership) -> Bool {
self.joint
}
///|
/// The number of `granted` ids that are voters in `voters`.
fn count_in(voters : Array[String], granted : Array[String]) -> Int {
let mut n = 0
for id in voters {
if granted.contains(id) {
n = n + 1
}
}
n
}
///|
/// Whether `granted` (the ids that agreed, e.g. voted or acknowledged) forms a
/// majority. A simple configuration needs a majority of the incoming set; a
/// joint configuration needs a majority of the incoming set *and* a majority of
/// the outgoing set, which is what makes joint consensus safe against split
/// decisions during a change (Raft §6).
pub fn Membership::has_majority(
self : Membership,
granted : Array[String],
) -> Bool {
let inc = count_in(self.members, granted) > self.members.length() / 2
if !self.joint {
return inc
}
let out = count_in(self.outgoing, granted) > self.outgoing.length() / 2
inc && out
}
///|
/// Add a server as a voter, unless it already is one. A learner being added as
/// a voter is *promoted*: it leaves the learner set (Raft §4.2.1).
pub fn Membership::add(self : Membership, id : String) -> Unit {
self.learners.retain(fn(l) { l != id })
self.learners_next.retain(fn(l) { l != id })
if self.members.contains(id) {
return
}
self.members.push(id)
}
///|
/// Remove a server from the configuration entirely — voter or learner.
pub fn Membership::remove(self : Membership, id : String) -> Unit {
self.members.retain(fn(m) { m != id })
self.learners.retain(fn(l) { l != id })
self.learners_next.retain(fn(l) { l != id })
}
///|
/// Enter joint consensus, moving to the target voter set `new_members` while
/// keeping the current set as the outgoing half. Until the transition is
/// committed and `leave_joint` is called, decisions need both majorities.
pub fn Membership::enter_joint(
self : Membership,
new_members : Array[String],
) -> Unit {
self.outgoing.clear()
for m in self.members {
self.outgoing.push(m)
}
self.members.clear()
for m in new_members {
self.members.push(m)
}
self.joint = true
}
///|
/// Enter joint consensus keeping the already-updated incoming set, recording
/// `outgoing` as the old half. Used when the incoming voters have been mutated
/// in place by a batch of changes (etcd's ConfChangeV2 EnterJoint).
pub fn Membership::begin_joint(
self : Membership,
outgoing : Array[String],
) -> Unit {
self.outgoing.clear()
for m in outgoing {
self.outgoing.push(m)
}
self.joint = true
}
///|
/// Leave joint consensus once C(old,new) is committed: the outgoing half is
/// dropped and the cluster runs on the incoming configuration alone.
pub fn Membership::leave_joint(self : Membership) -> Unit {
// Staged voters now become real learners (their progress is untouched).
for l in self.learners_next {
if !self.learners.contains(l) {
self.learners.push(l)
}
}
self.learners_next.clear()
self.outgoing.clear()
self.joint = false
self.auto_leave = false
}
///|
/// The voters of the current configuration, de-duplicated across both halves.
pub fn Membership::voters(self : Membership) -> Array[String] {
let out : Array[String] = []
for m in self.members {
out.push(m)
}
if self.joint {
for m in self.outgoing {
if !out.contains(m) {
out.push(m)
}
}
}
out
}
///|
/// The committed index this configuration agrees on, given the acked indices,
/// accounting for the joint transition when one is in progress.
pub fn Membership::committed_index(
self : Membership,
acked : Map[String, UInt64],
) -> UInt64 {
let out = if self.joint { self.outgoing } else { [] }
@quorum.committed_index(self.members, out, acked)
}
///|
/// The vote outcome for this configuration, accounting for a joint transition.
pub fn Membership::vote_result(
self : Membership,
votes : Map[String, Bool],
) -> @quorum.VoteState {
let out = if self.joint { self.outgoing } else { [] }
@quorum.vote_result(self.members, out, votes)
}