March Docs

Membership

Membership: SWIM-style cluster membership with a CRDT member view (P1 L3).

Each node is tracked as { status, incarnation }. A node refutes a false suspicion/death by incrementing its own incarnation and re-announcing Alive, so the merge rule is: higher incarnation wins; at the same incarnation the more-severe status wins (Dead > Suspect > Alive). That order makes merge a lexicographic max over (incarnation, severity) — associative, commutative, and idempotent — so gossiped member views converge regardless of order (the anti-entropy CRDT join for the small member set; no Merkle needed).

This is the pure membership state + merge + transitions; the SWIM probe loop (direct/indirect ping, suspicion timers) drives it from the runtime layer.

Origin fields (the cluster node service, specs/progress/2026-09-18-cluster-node-service.md): a member also carries its human name, its advertised listen address addr ("host:port", "" when unknown) and its creation (bumped each time the node process restarts). The merge order is lexicographic over (creation, incarnation, severity): a restarted node outranks every verdict about its previous life, whatever that verdict's incarnation, and name/addr travel with the winning observation. Still a max, so still a join. The two-argument constructors (alive(id, inc) ...) build creation 0 / name = id / addr "" -- the shape every pre-service caller and the legacy tag-3 gossip frame produce.

Types

typeMemberStatusMemberStatus = Alive | Suspect | Dead#
typeMemberMember = { node_id : String, status : MemberStatus, incarnation : Int,#
typeMembersMembers = Members(Map(String, Member))#

Functions

fnalivealive(node_id : String, incarnation : Int) : Member#

An Alive observation of [node_id] at [incarnation], origin-less: creation 0, name = [node_id], addr "".

fnalive_membersalive_members(ms : Members) : List(Member)#

The members whose status is Alive, in unspecified (hash) order.

fndeaddead(node_id : String, incarnation : Int) : Member#

A Dead observation of [node_id] at [incarnation], origin-less: creation 0, name = [node_id], addr "".

fnemptyempty() : Members do Members(Map.empty()) end#

An empty member view.

fngetget(ms : Members, node_id : String) : Option(Member)#

The current winning observation for [node_id], or None if the view has never seen it.

fnmembermember(node_id : String, name : String, addr : String, creation : Int,#

A member observation with every field given: [name] is the human node name, [addr] its advertised "host:port" ("" when unknown), [creation] its restart counter.

fnmembersmembers(ms : Members) : List(Member)#

Every member in the view, of every status (Dead and Suspect included), in unspecified (hash) order.

fnmergemerge(a : Members, b : Members) : Members#

CRDT join of two member views: every member of [b] is observed into [a]. The result keeps, per node id, the winner under the observe order, so merging is associative and idempotent and, up to the name/addr of exact ties, commutative.

fnobserveobserve(ms : Members, obs : Member) : Members#

Fold one observation into the view, keeping the winner for its node id. The winner is the lexicographic max over (creation, incarnation, severity) with Dead > Suspect > Alive; on a full tie [obs] replaces the stored entry (so its name/addr win). An unknown node id is simply added.

Examples

let v = Membership.observe(Membership.empty(), Membership.alive("n1", 1))
let v = Membership.observe(v, Membership.suspect("n1", 1))   -- same inc: Suspect wins
let v = Membership.observe(v, Membership.alive("n1", 0))     -- older inc: ignored
-- Membership.get(v, "n1") is Some(suspect("n1", 1))
fnsizesize(ms : Members) : Int do match ms do Members(m) -> Map.size(m) end end#

The number of distinct node ids in the view (members of every status, Dead included).

fnsuspectsuspect(node_id : String, incarnation : Int) : Member#

A Suspect observation of [node_id] at [incarnation], origin-less: creation 0, name = [node_id], addr "".

fnverdictverdict(ms : Members, node_id : String, status : MemberStatus, incarnation : Int) : Member#

A verdict about [node_id] at [incarnation], carrying the origin fields (name, addr, creation) this view already holds for it, so a Suspect/Dead about creation 5 is about creation 5 rather than a creation-0 observation the known member outranks. An unknown node gets the origin-less shape (creation 0, name = [node_id], addr ""). The view is not modified; pass the result to observe to record it.

fnwith_statuswith_status(m : Member, status : MemberStatus, incarnation : Int) : Member#

[m] with a new [status] and [incarnation], keeping its node_id, name, addr and creation.