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
7601and7602). - Access to the Clustron package feed (
Clustron.Nodus.Client1.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.
| Input | Type | Answers |
|---|---|---|
| Cluster definition | NodusConfig | What cluster is this, and who are its members? |
| This node's identity | NodeInfo | Which 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.
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));
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.
A departing node that leaves gracefully (via Ctrl+C → StopAsync) 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:
| Area | Interface | What it gives you |
|---|---|---|
node.Cluster | IClusterMembership | Self, Members, Find, IsAlive / IsReachable, MembersInRole, MemberJoined / MemberLeft |
node.Leadership | ILeadership | Current, IsSelf, Epoch, SetTiebreaker(...), Changed |
node.Messaging | IMessaging | SendAsync<T>, BroadcastAsync<T>, On<T>, RequestAsync<TReply>, raw Message control |
node.Events | IClusterEvents | PublishAsync<T>, Subscribe<T> — cluster-wide typed pub/sub |
node.Health | IClusterHealth | local and per-peer health, cluster metrics |
node.Gossip | IClusterGossip | SetDigestProvider(...), 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.