Skip to main content

Building a node

Every task with Nodus starts from one entry point that builds the container, joins the cluster, and returns a running handle. This page covers that lifecycle: the two ways to start, what the builder does, the startup contract, and how to shut down cleanly.

The entry point

NodusNode is the single entry point. There are two ways in.

The simplest start

When a default container is enough, StartAsync takes the cluster config and this node's identity and returns a running node.

using Clustron.Nodus.Abstractions;
using Clustron.Nodus.Client;

await using INodusNode node = await NodusNode.StartAsync(config, self);

This is shorthand for NodusNode.Configure(config).WithNodeIdentity(self).StartAsync().

The fluent builder

When you supply your own container, drive config overrides, or compose Nodus into an existing host, use Configure. It returns a NodusNodeBuilder.

await using INodusNode node = await NodusNode
.Configure(config)
.WithNodeIdentity(self) // required: which node this process is
.WithServices(services) // optional: compose into your container
.WithConfiguration(fullConfig) // optional: IConfiguration for env/CLI overrides
.WithOverrides(c => c.Election.Strategy = ElectionStrategyKind.OldestNode) // optional
.StartAsync(cancellationToken);

The builder methods:

Builder methodSignatureRequired
WithNodeIdentityNodusNodeBuilder WithNodeIdentity(NodeInfo self)Yes
WithServicesNodusNodeBuilder WithServices(IServiceCollection services)No
WithConfigurationNodusNodeBuilder WithConfiguration(IConfiguration fullConfig)No
WithOverridesNodusNodeBuilder WithOverrides(Action<NodusConfig> overrides)No
StartAsyncTask<INodusNode> StartAsync(CancellationToken ct = default)Yes

Node identity is mandatory. A product resolves "which node am I" from its own launch inputs — a command-line argument, the CLUSTRON_NODE_ID environment variable, or an orchestrator-assigned ordinal — and passes the resulting NodeInfo. Calling StartAsync without WithNodeIdentity throws InvalidOperationException.

The two inputs

A node is defined by a NodusConfig (the whole cluster) and a NodeInfo (this process's slice of it). Every process in a cluster shares the same NodusConfig and selects its own NodeInfo from the peer list.

const string clusterId = "orders";

var config = new NodusConfig
{
ClusterId = clusterId,
Cluster = new ClusterInfo
{
Nodes =
{
new NodeInfo { NodeId = "node-1", Host = "localhost", Port = 7601, ClusterId = clusterId, Roles = { NodusRoles.Member } },
new NodeInfo { NodeId = "node-2", Host = "localhost", Port = 7602, ClusterId = clusterId, Roles = { NodusRoles.Member } },
}
},
Election = new ElectionOptions { Strategy = ElectionStrategyKind.OldestNode },
};

// resolve "which node am I" from a launch input
var myId = Environment.GetEnvironmentVariable("CLUSTRON_NODE_ID") ?? "node-1";
var self = config.Cluster.Nodes.Single(n => n.NodeId == myId);

NodusConfig also exposes cluster-wide options. The values below are the code defaults; see Configuration and transport for the full table.

PropertyTypeDefault
ClusterIdstring"default-cluster"
ClusterClusterInfoempty peer set
RolesList<string>empty
ElectionElectionOptionsOldestNode
MetricsMetricsOptions60 s window
RetryOptionsRetryOptions3 attempts / 500 ms
UseDuplexConnectionsbooltrue

The container Nodus needs

Nodus core services log through ILogger<T> and resolve an IHttpClientFactory. Register both in the container you hand it.

var services = new ServiceCollection();
services.AddLogging(b => b.AddConsole());
services.AddHttpClient();
Register logging and HTTP

If logging or HTTP is missing from the container, startup fails resolving core dependencies. This is the most common first-run mistake.

When you call WithServices(services), Nodus registers its own services into your collection and builds the provider for you. You can register your own product services alongside it and resolve them from node.Services.

Startup order

StartAsync runs a fixed, ordered sequence and returns only once the node is serving.

Node startup flow: product code configures the builder, which builds the DI container and resolves the node; the host then binds the transport, begins discovery and handshakes, and wires the handler, returning a running node with IsRunning true.

When the returned Task completes, the node is started and joining. You get back an INodusNode with IsRunning == true, and you can immediately subscribe to events and send messages. StartAsync on the node is idempotent: calling it again on an already-running node is a no-op, so a product that builds the node in its own container and drives the process lifecycle can call it itself.

The running handle

The returned INodusNode is the object a product holds. It carries the node's identity and lifecycle plus the six capability areas.

string id      = node.NodeId;       // this node's stable id
bool running = node.IsRunning; // true once started, false once stopped

// the six capability areas
node.Cluster; // membership and topology
node.Leadership; // who leads this epoch
node.Messaging; // point-to-point, broadcast, request/reply
node.Events; // typed cluster-wide pub/sub
node.Health; // liveness and metrics
node.Gossip; // state digest on heartbeats

node.Services; // the underlying container, for advanced composition

node.Services exposes the DI container for advanced cases, such as resolving your own registered services. Prefer the named areas — they are the supported surface.

Graceful shutdown and disposal

INodusNode is IAsyncDisposable. There are two ways to leave the cluster cleanly, and they do the same thing.

// explicit
await node.StopAsync();

// or via disposal (await using, or an explicit call)
await node.DisposeAsync();

StopAsync leaves the cluster gracefully: it stops heartbeating, so peers stop seeing this node as alive; it broadcasts its departure so peers evict it promptly rather than waiting out the suspicion window; and it tears down the transport. Disposing an already-stopped node is a no-op. Disposing a running one stops it first.

The idiomatic pattern is await using, which guarantees a clean leave even on an exception.

await using INodusNode node = await NodusNode.StartAsync(config, self);

// ... run until a shutdown signal ...
var done = new TaskCompletionSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; done.TrySetResult(); };
await done.Task;
// leaving the 'await using' scope disposes the node → graceful leave
A graceful leave means fast failover

Because a graceful leave broadcasts the departure, survivors run their re-election immediately instead of waiting out the suspicion window (about 20 seconds). Prefer StopAsync or disposal over killing the process when you can.

Next steps