Configuration and transport
A Nodus node is configured by two objects: a NodusConfig that describes the cluster, and one NodeInfo per node that describes an individual member. This page documents every operator-facing property on both, with its default and what it controls, and explains the transport underneath and which timings are not configurable.
Everything here is a plain object model. You build it in code (as the samples do) or bind it from IConfiguration or appsettings.json and pass it through the builder.
NodusConfig: the cluster definition
The same NodusConfig is shared by every process in a cluster. It lists the full peer set and the cluster-wide options. A node forms a cluster only with peers whose ClusterId matches, so keep that value consistent across every process and every NodeInfo.
| Key | Type | Default | Description |
|---|---|---|---|
ClusterId | string | "default-cluster" | Cluster identity. Nodes cluster only with a matching ClusterId. |
Version | string | "1.0.0" | Cluster/protocol version tag carried in identity. |
Cluster | ClusterInfo | empty | The peer set; Cluster.Nodes is a List<NodeInfo>. |
Roles | List<string> | empty | This node's roles. Also settable per NodeInfo; use NodusRoles constants. |
Election | ElectionOptions | OldestNode | Leader-election strategy. See Election options. |
Metrics | MetricsOptions | 60 s window | Rolling metrics window and optional push target. |
RetryOptions | RetryOptions | 3 × 500 ms | Transient-failure retry policy for messaging. |
UseDuplexConnections | bool | true | Selects the connection model. See Transport. |
LogClusterViewIntervalSeconds | int | 0 | If greater than 0, logs the current cluster view every N seconds. 0 disables it. |
Metadata | Dictionary<string,string> | empty | Free-form cluster-level metadata. |
var config = new NodusConfig
{
ClusterId = "orders",
Election = new ElectionOptions { Strategy = ElectionStrategyKind.OldestNode },
Cluster = new ClusterInfo { Nodes = { /* NodeInfo entries */ } },
};
NodeInfo: a node's identity
Each entry in Cluster.Nodes is a NodeInfo. Each process also selects its own NodeInfo from that list to pass as its identity, via WithNodeIdentity. The transport listens on NodeInfo.Port, and peers connect to Host:Port — so in a containerized deployment set Host to an address other nodes can route to, not localhost.
| Key | Type | Default | Description |
|---|---|---|---|
NodeId | string | — | Stable, unique id — the join key. Also the final election tiebreak. |
Host | string | "localhost" | The address peers dial to reach this node. |
Port | int | 4000 | The cluster (inter-node) TCP port this node binds and listens on. |
ClusterId | string | "default-cluster" | Must match the cluster's ClusterId. |
Version | string | "1.0.0" | Node version tag. |
Roles | List<string> | empty | Roles this node advertises (member, observer, and so on). |
Metadata | Dictionary<string,string> | empty | Free-form per-node metadata. |
MachineId | string? | null | Physical-machine identifier; co-located processes share one. Used by higher layers for rack-aware placement. |
IsPreferredPrimary | bool | false | Placement hint consumed by higher layers; not enforced by Nodus itself. |
StartedAtTicks | long | 0 | Process-start marker; drives the oldest-node election strategy. Stamped at startup; a restart yields a larger value. |
new NodeInfo
{
NodeId = "node-1",
Host = "10.0.0.1", // how peers dial this node
Port = 7601, // the port this node binds
ClusterId = "orders",
Roles = { NodusRoles.Member },
}
StartedAtTicks is stamped by Nodus at startup — leave it at 0 and let the runtime populate it.
Static versus dynamic peer sets
The peer set is either fixed at configuration time or grown at runtime.
- Static (default): every process is handed the same
Cluster.Nodeslist, and the configured list is the cluster (StaticDiscoveryProvider). Deterministic, with no external registry. - Dynamic: an adaptive provider treats the configured nodes as the source of truth and adds runtime-discovered peers additively. Peers can also be seeded at runtime with
node.Cluster.RegisterKnownPeer(...).
See Membership for the full treatment.
ElectionOptions: leader election
ElectionOptions has a single property, Strategy, which selects the pluggable election strategy to run.
| Key | Type | Default | Description |
|---|---|---|---|
Strategy | ElectionStrategyKind | OldestNode | Which election strategy to run. |
ElectionStrategyKind has two values:
| Value | Numeric | Winner |
|---|---|---|
HighestId | 0 | The reachable member with the highest NodeId (ordinal). Deterministic and clock-independent. |
OldestNode | 1 | The member running longest — smallest non-zero StartedAtTicks, with NodeId as tiebreak. |
OldestNode, despite the XML commentThe XML-doc comment on ElectionOptions.Strategy reads "Defaults to HighestId", but the code initializer sets OldestNode (public ElectionStrategyKind Strategy { get; set; } = ElectionStrategyKind.OldestNode;). Unless you set it explicitly, a node runs the oldest-node strategy. Set the value explicitly to be unambiguous:
Election = new ElectionOptions { Strategy = ElectionStrategyKind.HighestId }
Epoch and the tiebreaker are not config values. The epoch is a runtime counter you read via node.Leadership.Epoch, and the equal-epoch tiebreaker is supplied in code via node.Leadership.SetTiebreaker(...). Both are covered in Leader election.
MetricsOptions: metrics
MetricsOptions controls the rolling metrics window and an optional push target.
| Key | Type | Default | Description |
|---|---|---|---|
RollingWindowSeconds | int | 60 | Width of the rolling metrics window. |
MetricsTimeoutMilliseconds | int | 60000 | Timeout for metrics-collection requests. |
PushTargetUrl | string? | null | If set, metrics are pushed to this endpoint. |
RetryOptions: transient-failure retries
RetryOptions sets the retry policy for retriable messaging operations.
| Key | Type | Default | Description |
|---|---|---|---|
MaxAttempts | int | 3 | Maximum attempts for a retriable operation. |
DelayMilliseconds | int | 500 | Delay between attempts. |
Transport: pipelined TCP and MessagePack
All Nodus traffic — messaging, events, and heartbeats — rides one transport, chosen by UseDuplexConnections. The pipelined duplex transport is the intended production path; the unidirectional transport exists as a fallback, so change this only for a specific reason.
UseDuplexConnections | Transport | Model |
|---|---|---|
true (default) | PipelinedTcpTransport | A duplex, pipelined connection per peer, built on System.IO.Pipelines. |
false | UnidirectionalTcpTransport | A simpler one-directional TCP transport. |
Either way, the transport is wrapped in a PrioritizedTransport. This separates urgent control traffic (heartbeats, immediate sends) from bulk queued traffic, so a flood of application messages cannot starve the liveness signal.
- Wire framing and serialization use MessagePack; the transport binds and listens on
NodeInfo.Port. node.Messaging.SendImmediateAsync(...)andRequestAsync(...)use the priority plane;SendAsync(...)uses the queued path.
Transport security
The default is PLAINTEXT. Node-to-node traffic is unencrypted unless you supply a transport-security implementation — there is no TLS knob on NodusConfig. Concretely, Core's TCP transport holds an optional ITransportSecurity; when it is absent (or Enabled is false) the connection is used byte-for-byte with no SslStream, exactly as the tables above describe.
Encryption is opt-in through a single public seam, ITransportSecurity, in Clustron.Nodus.Abstractions (Clustron.Nodus.Abstractions/ITransportSecurity.cs). It turns on node-to-node TLS with mutual client-certificate authentication, cluster-CA chain validation, a minimum-protocol floor, and per-handshake certificate reload.
The division of responsibility
Core owns the mechanism and the higher layer owns the material:
- Core wraps each accepted and dialed connection in an
SslStream. On the listening side it callsAuthenticateAsServerAsyncwith your certificate, yourRequireClientCertificatesetting, and yourMinProtocol; the dialing side authenticates as a client against the peer it intended to reach. Core never learns where certificates come from. - The host supplies the certificate material — which certificate this node presents, and how a peer certificate is judged — by implementing
ITransportSecurity. This keeps Core a standalone framework with no dependency on any particular certificate machinery. Zaris supplies its own implementation of this interface for its clusters.
The implementation is consumed as an optional dependency: register one ITransportSecurity with the node's service provider and Core's transport picks it up; register none and the transport stays plaintext.
The interface
namespace Clustron.Nodus.Abstractions;
public interface ITransportSecurity
{
bool Enabled { get; } // false ⇒ plaintext for this transport
SslProtocols MinProtocol { get; } // the permitted-version floor, e.g. Tls12 | Tls13
bool RequireClientCertificate { get; } // mutual TLS: the peer must present a cert
// Read per handshake, never cached — a renewed/hot-reloaded cert is
// picked up by new connections without a node restart.
X509Certificate2 GetLocalCertificate();
// Validate a presented peer certificate. `expectedPeerId` is the identity
// the transport intended to reach (a nodeId for node-to-node). The OS chain
// and errors are advisory only — trust is decided against the cluster-CA
// anchors, not the machine store.
bool ValidateRemoteCertificate(
X509Certificate2? peer, X509Chain? chain, SslPolicyErrors errors, string? expectedPeerId);
}
| Member | What it controls |
|---|---|
Enabled | Whether TLS is active for this transport. false (or a null implementation) means plaintext. |
MinProtocol | The permitted TLS protocol versions, expressed as a floor (for example Tls12 | Tls13). |
RequireClientCertificate | Whether the peer must also present a certificate — mutual TLS for node-to-node links. |
GetLocalCertificate() | Returns the certificate this node presents. Called per handshake and never cached by the transport, so a renewed certificate is used by new connections without a restart. |
ValidateRemoteCertificate(peer, chain, errors, expectedPeerId) | Decides whether a presented peer certificate is trusted. The implementation checks the certificate chains to a trusted cluster CA and carries the expected identity; the OS-provided chain/errors are advisory only. |
Because GetLocalCertificate() is invoked on every handshake rather than cached, rolling a renewed certificate into place is picked up by subsequent connections on its own — no NodusConfig change and no process restart.
Heartbeat and suspicion timings (not configurable)
Failure detection runs on three timers. These are currently hardcoded readonly fields in TcpHeartbeatMonitor and are not bound to NodusConfig — there is no supported knob to change them.
| Timing | Value | Field | Governs |
|---|---|---|---|
| Heartbeat interval | 5 s | _interval | How often a node emits its own heartbeat. |
| Heartbeat timeout | 12 s | _timeout | How long without a heartbeat before a peer is unresponsive. |
| Suspicion window | 20 s | _suspicionWindow | How long a peer stays suspected before eviction. |
// Clustron.Nodus.Core/Health/TcpHeartbeatMonitor.cs — hardcoded, not config-bound
private readonly TimeSpan _interval = TimeSpan.FromSeconds(5);
private readonly TimeSpan _timeout = TimeSpan.FromSeconds(12);
private readonly TimeSpan _suspicionWindow = TimeSpan.FromSeconds(20);
Plan around 5 s, 12 s, and 20 s. A gracefully leaving node (via StopAsync or disposal) is evicted promptly because it broadcasts its departure; only a killed node waits out the suspicion window. See Health and gossip.
Complete example
A three-node static cluster on localhost, with oldest-node election and the pipelined transport.
using Clustron.Nodus.Abstractions;
const string clusterId = "orders";
var config = new NodusConfig
{
ClusterId = clusterId,
Version = "1.0.0",
Roles = { NodusRoles.Member },
UseDuplexConnections = true, // pipelined TCP (default)
LogClusterViewIntervalSeconds = 0, // no periodic cluster-view logging
Election = new ElectionOptions
{
Strategy = ElectionStrategyKind.OldestNode, // explicit; matches the code default
},
Metrics = new MetricsOptions
{
RollingWindowSeconds = 60,
MetricsTimeoutMilliseconds = 60000,
// PushTargetUrl = "http://collector:9000", // optional
},
RetryOptions = new RetryOptions
{
MaxAttempts = 3,
DelayMilliseconds = 500,
},
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 } },
new NodeInfo { NodeId = "node-3", Host = "localhost", Port = 7603, ClusterId = clusterId, Roles = { NodusRoles.Member } },
}
}
};
// each process picks its own identity from the shared list
var myId = Environment.GetEnvironmentVariable("CLUSTRON_NODE_ID") ?? "node-1";
var self = config.Cluster.Nodes.Single(n => n.NodeId == myId);
Pass config and self to the entry point to start the node.
Next steps
- Building a node — turning this config into a running node.
- API reference — the full public surface, area by area.
- Leader election — what the
Strategyyou chose does.