Skip to main content

Getting started with Nodus

This quickstart takes you from an empty console project to a running two-node cluster on localhost in a few minutes. By the end you have started nodes that discover each other, elect a leader, and log membership and leadership changes as they happen — the whole Nodus core exercised end to end.

Everything here is runnable code against the public Clustron.Nodus.Client API.

Prerequisites

Before you start, make sure you have the following:

  • .NET 8 SDK or later.
  • Two free TCP ports on localhost (this guide uses 7601 and 7602).
  • Access to the Clustron package feed (Clustron.Nodus.Client 1.1.0).

1. Create the project and add the package

Create a console project and add the client package:

dotnet new console -n NodusQuickstart
cd NodusQuickstart
dotnet add package Clustron.Nodus.Client

Clustron.Nodus.Client bundles the internal engine, so it is the only Nodus package you need to reference.

2. Understand the two inputs a node needs

A node needs exactly two things: the cluster definition and this process's identity within it.

InputTypeAnswers
Cluster definitionNodusConfigWhat cluster is this, and who are its members?
This node's identityNodeInfoWhich of those members is this process?

The same NodusConfig is used by every process — it lists the full peer set. Each process then picks its own NodeInfo out of that list, typically from a command-line argument or environment variable. That is the whole trick to running a static cluster: one shared topology, a per-process identity.

Core needs a container

Nodus core services log through ILogger<T> and resolve an IHttpClientFactory (the metrics collector uses it). Register logging and HTTP in the service collection you hand the node, or startup fails to resolve those dependencies.

3. Write the program

The program reads the node id from the first command-line argument, builds a shared two-node topology, starts the node, and wires up membership and leadership event handlers. Replace Program.cs with the following:

using Clustron.Nodus.Abstractions;
using Clustron.Nodus.Client;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

// ── which node is this process? ──────────────────────────────────────────────
var nodeId = args.Length > 0
? args[0]
: throw new InvalidOperationException("Pass the node id as an argument: node1 or node2.");

// ── a shared, static two-node topology on localhost ──────────────────────────
const string ClusterId = "quickstart";
var topology = new Dictionary<string, int>
{
["node1"] = 7601,
["node2"] = 7602,
};

var config = new NodusConfig
{
ClusterId = ClusterId,
Roles = { "member" },
Cluster = new ClusterInfo
{
Nodes = topology.Select(kv => new NodeInfo
{
NodeId = kv.Key,
Host = "localhost",
Port = kv.Value,
ClusterId = ClusterId,
Roles = { "member" },
}).ToList()
}
};

// which member in the topology am I?
var self = config.Cluster.Nodes.Single(n => n.NodeId == nodeId);

// ── a container with the dependencies core needs ─────────────────────────────
var services = new ServiceCollection();
services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Warning));
services.AddHttpClient();

Console.WriteLine($"[{nodeId}] starting on localhost:{self.Port} ...");

// ── the one entry point: build, join, and get the running handle ─────────────
await using INodusNode node = await NodusNode
.Configure(config)
.WithNodeIdentity(self)
.WithServices(services)
.StartAsync();

// ── observe the cluster forming and leadership settling ──────────────────────
node.Cluster.MemberJoined += m =>
Console.WriteLine($"[{nodeId}] MEMBER JOINED {m.NodeId} (members now: {Members()})");

node.Cluster.MemberLeft += m =>
Console.WriteLine($"[{nodeId}] MEMBER LEFT {m.NodeId} (members now: {Members()})");

node.Leadership.Changed += (leader, epoch) =>
Console.WriteLine($"[{nodeId}] LEADER CHANGED -> {leader.NodeId} (epoch {epoch})"
+ (node.Leadership.IsSelf ? " ** that's me **" : ""));

Console.WriteLine($"[{nodeId}] up. leader: {node.Leadership.Current?.NodeId ?? "(none yet)"}");
Console.WriteLine($"[{nodeId}] press Ctrl+C to leave the cluster.");

// ── graceful shutdown on Ctrl+C ──────────────────────────────────────────────
var done = new TaskCompletionSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; done.TrySetResult(); };
await done.Task;

Console.WriteLine($"[{nodeId}] leaving ...");
await node.StopAsync(); // disposing (await using) would also leave the cluster

// Members() lists the active peers plus self — node.Cluster.Members excludes self.
string Members() =>
string.Join(", ", node.Cluster.Members.Select(m => m.NodeId).Append(nodeId).Distinct().OrderBy(x => x));
Why Members excludes self

node.Cluster.Members is the set of other active members — it never contains this node. When you want the full cluster including yourself, append node.NodeId, as Members() does above. This is a deliberate, consistent convention across the API.

4. Run a two-node cluster

Open two terminals in the project directory and start one node in each:

# terminal 1
dotnet run -- node1
# terminal 2
dotnet run -- node2

5. What you will see

As the second node comes up, the two processes discover each other over TCP, complete a handshake, and settle on a leader. Expect output like this:

[node1] starting on localhost:7601 ...
[node1] up. leader: (none yet)
[node1] press Ctrl+C to leave the cluster.
[node1] MEMBER JOINED node2 (members now: node1, node2)
[node1] LEADER CHANGED -> node1 (epoch 1) ** that's me **
[node2] starting on localhost:7602 ...
[node2] MEMBER JOINED node1 (members now: node1, node2)
[node2] LEADER CHANGED -> node1 (epoch 1)
[node2] up. leader: node1

Both nodes agree on the same leader and the same epoch (1) — a monotonically increasing election counter. Which node wins depends on the election strategy; the default, oldest-node, elects the member that has been running longest.

6. Watch a failover

With both nodes running, press Ctrl+C on the leader's terminal. The survivor detects the loss after the suspicion window elapses, runs a new election, and logs a leadership change at a higher epoch:

[node2] MEMBER LEFT    node1   (members now: node2)
[node2] LEADER CHANGED -> node2 (epoch 2) ** that's me **

The epoch advancing from 1 to 2 is how every node distinguishes the new leadership term from the old one. Restart node1 and it rejoins as a member under node2's leadership.

How long until eviction?

A departing node that leaves gracefully (via Ctrl+CStopAsync) is removed promptly because it broadcasts its departure. A node that is killed is detected by missed heartbeats: peers heartbeat every 5 seconds and evict a silent peer after a suspicion window of 20 seconds. See Health and gossip for the exact timings.

The node surface you just used

Every capability hangs off the single INodusNode handle, grouped into six areas:

AreaInterfaceWhat it gives you
node.ClusterIClusterMembershipSelf, Members, Find, IsAlive / IsReachable, MembersInRole, MemberJoined / MemberLeft
node.LeadershipILeadershipCurrent, IsSelf, Epoch, SetTiebreaker(...), Changed
node.MessagingIMessagingSendAsync<T>, BroadcastAsync<T>, On<T>, RequestAsync<TReply>, raw Message control
node.EventsIClusterEventsPublishAsync<T>, Subscribe<T> — cluster-wide typed pub/sub
node.HealthIClusterHealthlocal and per-peer health, cluster metrics
node.GossipIClusterGossipSetDigestProvider(...), DigestReceived — piggyback state on heartbeats

The complete sample: ClusterCoordination

The repository ships a fuller sample, samples/ClusterCoordination, that builds on exactly this foundation. It is a single console program you run several times; the cluster elects a leader, and the leader distributes CPU work (counting primes in 100,000-number chunks) round-robin across the live members, aggregating a cluster-wide total. Kill the leader and a survivor takes over the coordination.

# three terminals, in samples/ClusterCoordination
dotnet run -- node1
dotnet run -- node2
dotnet run -- node3

It uses only the public surface — NodusNode.Configure(...).StartAsync(), node.Cluster.MemberJoined / MemberLeft, node.Leadership.Changed / IsSelf, and node.Messaging.On<T>() / SendAsync<T>() — and is a good starting point to copy from.

Next steps

  • Concepts — the mental model behind what you just ran.
  • Building a node — the entry point, container build, and graceful shutdown in depth.
  • Sending messages — put the messaging plane to work.