Skip to main content

Health and gossip

The hardest question in a distributed system is not "is that node alive?" but "how long do I wait before deciding it is dead?" Wait too little and a momentary hiccup evicts a healthy node, triggering a needless election and a membership flap. Wait too long and the cluster keeps routing work to a node that is already gone.

Nodus answers this with a heartbeat-based failure detector and a suspicion window, surfaced through node.Health and node.Cluster. On top of the same heartbeat traffic, it offers a gossip plane (node.Gossip) so a product can piggyback its own state summary without running a second protocol.

Heartbeats and failure detection

Every member sends a periodic heartbeat to its peers. Receiving one refreshes that peer's liveness; missing enough of them makes the peer suspect, and continued silence eventually evicts it.

The detector runs on three fixed intervals:

SettingValueWhat it governs
Heartbeat interval5 sHow often a node emits its own heartbeat.
Heartbeat timeout12 sHow long without a heartbeat before a peer is suspected.
Suspicion window20 sHow long a peer stays suspected before it is evicted from membership.

The suspicion window is the key to avoiding flap: a peer that misses a beat is not immediately removed. It is marked suspected, and only if it stays silent through the window is it declared failed and evicted — at which point MemberLeft fires and a re-election may run.

Failure detector state machine: a peer goes from Alive to Suspected after the 12-second heartbeat timeout, returns to Alive if heartbeats resume, or moves to Failed and is evicted (firing MemberLeft) if it stays silent through the 20-second suspicion window.

A node that leaves gracefully short-circuits this: on shutdown it stops heartbeating and broadcasts its departure, so peers evict it promptly instead of waiting out the window. This is why StopAsync produces a fast, clean MemberLeft, whereas a killed node takes up to the suspicion window to disappear.

Alive vs. reachable

Nodus tracks two distinct liveness facts about each peer, and the distinction is deliberate.

MeaningQuery
AliveWe have received traffic from the peer — it is actively live.node.Cluster.IsAlive(nodeId)
ReachableWe can dial the peer — an outbound connection succeeded within the reachability window.node.Cluster.IsReachable(nodeId)

A peer can be reachable before it is alive: you can open a socket to it (reachable) before it has sent you anything (alive). Reachability is backed by a maintained set, so IsReachable is O(1) and never opens a socket on the calling path. Use ReachableNodeIds to enumerate everything you can currently dial, independent of liveness.

You can also inspect when you last heard from a peer:

DateTime? last = node.Cluster.LastHeartbeatUtc("node-2");
if (last is { } t && DateTime.UtcNow - t > TimeSpan.FromSeconds(15))
Console.WriteLine("node-2 is going quiet");

Health surface

node.Health (IClusterHealth) rolls the above into a small snapshot of this node, its peers, and cluster-level counters:

NodeHealthStatus                 me    = node.Health.Local;    // this node
IReadOnlyList<NodeHealthStatus> peers = node.Health.Peers; // each known peer
ClusterMetrics m = node.Health.Metrics; // cluster-level counters

Console.WriteLine($"active peers: {m.ActivePeers}");
foreach (var p in peers)
Console.WriteLine($"{p.NodeId}: {(p.IsAlive ? "alive" : "down")}, last seen {p.LastSeenUtc:HH:mm:ss}");
  • NodeHealthStatus carries NodeId, IsAlive, LastSeenUtc, and Roles.
  • ClusterMetrics carries ActivePeers, TotalMessagesSent, and AvgLatencyMs.

The gossip digest rider

Heartbeats are already flowing between every pair of nodes on a steady cadence. The gossip plane lets a product piggyback its own compact state digest on that existing traffic — so peers can passively detect state drift without any additional RPC or a second protocol.

You supply a digest provider, and you observe peers' digests as they arrive:

// attach your digest to every outbound heartbeat
node.Gossip.SetDigestProvider(new MyDigestProvider());

// observe the digests peers attach to theirs
node.Gossip.DigestReceived += (fromNodeId, digest) =>
{
if (digest.Epoch < node.Leadership.Epoch)
Console.WriteLine($"{fromNodeId} is behind: epoch {digest.Epoch} vs {node.Leadership.Epoch}");
};

A provider implements IClusterStateDigestProvider, returning a ClusterStateDigest snapshot:

public sealed class MyDigestProvider : IClusterStateDigestProvider
{
public ClusterStateDigest GetCurrentDigest() => new()
{
Epoch = /* my current election epoch */,
LeaderId = /* who I think leads */,
MapVersion = /* my view version */,
// ... other compact scalars ...
};
}

ClusterStateDigest is intentionally small — a handful of scalars such as Epoch, LeaderId, MapVersion, and MapNodeCount, plus optional per-partition generation maps. It is a summary that lets a peer notice "your view and mine disagree," not a full state transfer. Keep the field count small; it rides on every heartbeat.

Gossip digest exchange: node-1 attaches a compact state digest to its outbound heartbeat; node-2 receives it via DigestReceived, compares it against its own view, and detects drift when the epoch or map version differ, with no extra RPC.

Why ride the heartbeat instead of a separate protocol

The heartbeat is the one message guaranteed to flow between every pair of nodes, continuously, whether or not there is application traffic. Attaching a digest to it gives you cluster-wide, always-on drift detection with no new connection, no new schedule, and no extra failure mode.

This is how Zaris keeps its partition map convergent across nodes: it rides its map version and per-partition ownership generations on the Nodus heartbeat and reacts when a peer's digest reveals a stale view.

Next steps