Skip to main content

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.

KeyTypeDefaultDescription
ClusterIdstring"default-cluster"Cluster identity. Nodes cluster only with a matching ClusterId.
Versionstring"1.0.0"Cluster/protocol version tag carried in identity.
ClusterClusterInfoemptyThe peer set; Cluster.Nodes is a List<NodeInfo>.
RolesList<string>emptyThis node's roles. Also settable per NodeInfo; use NodusRoles constants.
ElectionElectionOptionsOldestNodeLeader-election strategy. See Election options.
MetricsMetricsOptions60 s windowRolling metrics window and optional push target.
RetryOptionsRetryOptions3 × 500 msTransient-failure retry policy for messaging.
UseDuplexConnectionsbooltrueSelects the connection model. See Transport.
LogClusterViewIntervalSecondsint0If greater than 0, logs the current cluster view every N seconds. 0 disables it.
MetadataDictionary<string,string>emptyFree-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.

KeyTypeDefaultDescription
NodeIdstring—Stable, unique id — the join key. Also the final election tiebreak.
Hoststring"localhost"The address peers dial to reach this node.
Portint4000The cluster (inter-node) TCP port this node binds and listens on.
ClusterIdstring"default-cluster"Must match the cluster's ClusterId.
Versionstring"1.0.0"Node version tag.
RolesList<string>emptyRoles this node advertises (member, observer, and so on).
MetadataDictionary<string,string>emptyFree-form per-node metadata.
MachineIdstring?nullPhysical-machine identifier; co-located processes share one. Used by higher layers for rack-aware placement.
IsPreferredPrimaryboolfalsePlacement hint consumed by higher layers; not enforced by Nodus itself.
StartedAtTickslong0Process-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.Nodes list, 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.

KeyTypeDefaultDescription
StrategyElectionStrategyKindOldestNodeWhich election strategy to run.

ElectionStrategyKind has two values:

ValueNumericWinner
HighestId0The reachable member with the highest NodeId (ordinal). Deterministic and clock-independent.
OldestNode1The member running longest — smallest non-zero StartedAtTicks, with NodeId as tiebreak.
The default is OldestNode, despite the XML comment

The 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.

KeyTypeDefaultDescription
RollingWindowSecondsint60Width of the rolling metrics window.
MetricsTimeoutMillisecondsint60000Timeout for metrics-collection requests.
PushTargetUrlstring?nullIf set, metrics are pushed to this endpoint.

RetryOptions: transient-failure retries​

RetryOptions sets the retry policy for retriable messaging operations.

KeyTypeDefaultDescription
MaxAttemptsint3Maximum attempts for a retriable operation.
DelayMillisecondsint500Delay 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.

UseDuplexConnectionsTransportModel
true (default)PipelinedTcpTransportA duplex, pipelined connection per peer, built on System.IO.Pipelines.
falseUnidirectionalTcpTransportA 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(...) and RequestAsync(...) 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 calls AuthenticateAsServerAsync with your certificate, your RequireClientCertificate setting, and your MinProtocol; 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);
}
MemberWhat it controls
EnabledWhether TLS is active for this transport. false (or a null implementation) means plaintext.
MinProtocolThe permitted TLS protocol versions, expressed as a floor (for example Tls12 | Tls13).
RequireClientCertificateWhether 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.
Certificate rotation is built into the seam

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.

TimingValueFieldGoverns
Heartbeat interval5 s_intervalHow often a node emits its own heartbeat.
Heartbeat timeout12 s_timeoutHow long without a heartbeat before a peer is unresponsive.
Suspicion window20 s_suspicionWindowHow 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);
These timings are currently fixed

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​