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:
| Role | Constant | Meaning |
|---|---|---|
member | NodusRoles.Member | Full cluster participant — elects, is elected, sends and receives messages. |
observer | NodusRoles.Observer | Watches lifecycle and leader changes only. |
metrics-collector | NodusRoles.MetricsCollector | Actively polls metrics from members. |
client | NodusRoles.Client | Sends requests, with no cluster awareness. |
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 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",
});
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}");
MemberJoinedfires when a node is admitted into active membership.MemberLeftfires 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.
| Member | Returns |
|---|---|
Self | This node as a ClusterNode. |
Members | The 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). |
ReachableNodeIds | Ids currently confirmed reachable, independent of liveness. |
LastHeartbeatUtc(nodeId) | When we last heard from a peer, or null. |
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
- Leader election — how the current membership picks a single coordinator.
- Health and gossip — how membership shrinks when a peer goes silent.
- Querying health and membership — the task-focused how-to.