Skip to main content

Membership

Membership is the answer to "who is in the cluster right now?" — and, just as importantly, how that answer changes over time as nodes join and leave. It is the ground every other Nodus concept stands on: leader election runs over the current membership, and messaging addresses the current members.

You reach membership through node.Cluster, which implements IClusterMembership.

Nodes, peers, and roles

A cluster is described by a peer set: a list of NodeInfo entries, one per node, each carrying the identity a node advertises.

var self = new NodeInfo
{
NodeId = "node-1", // stable, unique id — the join key
Host = "localhost", // where peers dial this node
Port = 7601, // the cluster (inter-node) TCP port
ClusterId = "orders", // nodes only cluster with matching ClusterId
Roles = { "member" }, // what this node participates as
};

A node's role decides how it participates. The built-in roles are defined in NodusRoles:

RoleConstantMeaning
memberNodusRoles.MemberFull cluster participant — elects, is elected, sends and receives messages.
observerNodusRoles.ObserverWatches lifecycle and leader changes only.
metrics-collectorNodusRoles.MetricsCollectorActively polls metrics from members.
clientNodusRoles.ClientSends requests, with no cluster awareness.
Only members send and elect

Leader election considers only nodes advertising the member role, and the messaging plane refuses to send from a non-member — SendAsync, BroadcastAsync, and PublishAsync throw NotSupportedException. Give a node the member role unless you specifically want a passive observer or a metrics collector.

Discovery and admission

Membership forms in two stages. A node must first be discovered — Nodus learns it exists and where to dial it — then admitted, when a connection and handshake succeed and the node becomes an active member.

Discovery and admission: a joining node-1 reads the peer set, connects to the existing node-2 and exchanges a handshake; both then admit each other into active membership, raise MemberJoined, and heartbeats begin flowing both ways.

Discovery is pluggable behind IDiscoveryProvider, which has two operations: DiscoverNodesAsync() (who are the candidates?) and RegisterSelfAsync(self) (advertise this node). The two shipped providers correspond to the two ways a cluster's shape is decided — static and dynamic.

Static vs. dynamic peer sets

Nodus supports two ways to decide which nodes belong to the cluster: a fixed list known up front, or a fixed list that runtime-discovered peers can extend.

Static peer sets

The common case — and what the quickstart uses — is a static peer set: every process is handed the same NodusConfig listing every node. The topology is known up front and identical on every process.

var config = new NodusConfig
{
ClusterId = "orders",
Cluster = new ClusterInfo
{
Nodes =
{
new NodeInfo { NodeId = "node-1", Host = "localhost", Port = 7601, ClusterId = "orders", Roles = { "member" } },
new NodeInfo { NodeId = "node-2", Host = "localhost", Port = 7602, ClusterId = "orders", Roles = { "member" } },
new NodeInfo { NodeId = "node-3", Host = "localhost", Port = 7603, ClusterId = "orders", Roles = { "member" } },
}
}
};

Backed by StaticDiscoveryProvider, the configured list is the cluster. This is deterministic, needs no external registry, and is ideal for a fixed set of nodes.

Dynamic peer sets

When nodes can appear that were not in the original list, an adaptive provider (AdaptiveDiscoveryProvider, an IRuntimeUpdatableDiscovery) treats the configured static nodes as the source of truth and adds runtime-discovered peers additively:

  • static nodes always win — a runtime peer with the same id never overrides a configured one;
  • new peers are registered at runtime via RegisterPeerAsync(peer), and are kept only if their id is not already a static node.

You can also seed a peer directly through membership before it is formally admitted, so it can be dialed early:

node.Cluster.RegisterKnownPeer(new ClusterNode
{
NodeId = "node-4", Host = "10.0.0.4", Port = 7604, ClusterId = "orders",
});
Choosing between them

Use a static peer set when the cluster has a fixed, known membership — most services, and every containerized deployment with a stable replica set. Reach for a dynamic peer set only when nodes genuinely arrive that could not be listed up front.

Join and leave are events

Membership is event-driven. Rather than polling "who's here?", you subscribe once and react to changes:

node.Cluster.MemberJoined += m => Console.WriteLine($"joined: {m.NodeId}");
node.Cluster.MemberLeft += m => Console.WriteLine($"left: {m.NodeId}");
  • MemberJoined fires when a node is admitted into active membership.
  • MemberLeft fires when a node leaves — whether it announced a graceful departure or was evicted after its heartbeats stopped.

Both hand you a ClusterNode describing the affected peer.

Querying membership

Alongside the events, node.Cluster exposes the current view synchronously.

MemberReturns
SelfThis node as a ClusterNode.
MembersThe current active members — excluding self.
Find(nodeId)A member by id, or null.
MembersInRole(role)Members advertising a given role.
IsAlive(nodeId)True once we have received from a peer (it is live).
IsReachable(nodeId)True when we can dial a peer (outbound connect succeeded).
ReachableNodeIdsIds currently confirmed reachable, independent of liveness.
LastHeartbeatUtc(nodeId)When we last heard from a peer, or null.
Members never includes self

node.Cluster.Members is the set of other members. To enumerate the whole cluster, append node.NodeId:

var all = node.Cluster.Members.Select(m => m.NodeId)
.Append(node.NodeId)
.Distinct();

The distinction between alive and reachable matters: a peer can be reachable — you can open a connection to it — before it is alive, meaning it has actually sent you traffic. The Health and gossip page explains how these are maintained.

Next steps