Files
lmxopcua/docs/v2/Cluster.md
T
Joseph Doherty 9af935f237
v2-ci / build (push) Successful in 3m44s
v2-ci / unit-tests (push) Failing after 10m7s
docs(cluster): self-first seed ordering replaces the self-form fallback
docs/Redundancy.md's bootstrap section is rewritten around the mechanism that is
actually in force: which of Akka's two bootstrap processes runs is decided by
whether seed-nodes[0] is this node's own address, so the ORDER of Cluster:SeedNodes
is the fix, not a timer. Adds the per-process table, the shipped self-first
example, why the validator is conditional (site nodes are not seeds) and why it
matches PublicHostname rather than the 0.0.0.0 bind address.

The retirement is documented rather than erased: a new subsection explains that a
watchdog outside the join handshake cannot tell "no seed answered" from "a seed
answered and the join is in flight", and the ea45ace1 live-gate finding is kept
verbatim as the evidence that produced that conclusion (InitJoinAck at 10:49:05,
old incarnation retired 10:49:06, watchdog fired 10:49:15, second cluster). The
watchdog's own earlier PASS is kept too — it did pass what it was gated on.

Also: a live gate for the NEW mechanism is registered as outstanding (not
re-drilled on the docker-dev rig since the swap), and the plan doc that shipped
the watchdog gets a superseded banner rather than an edit, so the execution record
and its Task 8 finding stay readable.

Ripple: CLAUDE.md cluster section, docs/Configuration.md + docs/v2/Cluster.md +
docs/ServiceHosting.md SeedNodes entries, the per-cluster-mesh design doc's two
"CLOSED by SelfFormAfter" notes, and mesh-program Phase 6 (which had this
convergence queued — now marked done early) + Phase 7's live-gate name.
2026-07-22 07:46:02 -04:00

5.5 KiB

OtOpcUa.Cluster

Akka.NET cluster bootstrap + topology view. Used by every other server-side project to talk to the live cluster.

Path: src/Core/ZB.MOM.WW.OtOpcUa.Cluster/

Public surface

Type Role
AkkaClusterOptions DI-bound options from appsettings.json::Cluster. Hostname/Port/PublicHostname/SeedNodes/Roles.
IClusterRoleInfo (interface in Commons) Live view of cluster membership + role-leader topology. Thread-safe + event-raising.
ClusterRoleInfo Implementation. Subscribes to ClusterEvent.IMemberEvent + RoleLeaderChanged + LeaderChanged.
HoconLoader.LoadBaseConfig() Reads the embedded Resources/akka.conf.
RoleParser.Parse(string?) Parses OTOPCUA_ROLES env var into a deduped string[].
ServiceCollectionExtensions.AddOtOpcUaCluster(configuration) Binds options + registers IClusterRoleInfo singleton. Does not start an ActorSystem.
WithOtOpcUaClusterBootstrap(serviceProvider) Extension on AkkaConfigurationBuilder. Loads embedded HOCON + applies WithRemoting(...) + WithClustering(...) from options.

Bootstrap flow

// Program.cs
builder.Services.AddOtOpcUaCluster(builder.Configuration);

builder.Services.AddAkka("otopcua", (ab, sp) =>
{
    ab.WithOtOpcUaClusterBootstrap(sp);          // HOCON + remote + cluster
    // …singletons + node actors layered on
});

Order matters: AddOtOpcUaCluster must come before AddAkka so the options binding has run by the time the AddAkka lambda fires. Inside the lambda, WithOtOpcUaClusterBootstrap resolves IOptions<AkkaClusterOptions> from sp and writes them into the Akka builder.

The single ActorSystem this produces is what every other v2 piece runs on. There is no second Akka instance — that was a Phase 9 bug (commit d6fac2d consolidated).

Embedded HOCON

src/Core/ZB.MOM.WW.OtOpcUa.Cluster/Resources/akka.conf contains:

Setting Value Why
akka.actor.provider cluster Required for Cluster.Get(system) to work.
akka.cluster.split-brain-resolver.active-strategy keep-oldest Smaller/younger side downs itself on partition.
akka.cluster.split-brain-resolver.stable-after 15s Time before SBR acts.
akka.cluster.failure-detector.threshold 10.0 Higher than default (8.0) for GC-pause tolerance.
opcua-synchronized-dispatcher.type PinnedDispatcher Dedicated thread for OpcUaPublishActor so SDK calls stay marshalled.

The Cluster.Tests project verifies these key values stay correct (HoconLoaderTests).

Configuration

{
  "Cluster": {
    "Hostname": "0.0.0.0",
    "Port": 4053,
    "PublicHostname": "node-a.lan",
    "SeedNodes": ["akka.tcp://otopcua@node-a.lan:4053"],
    "Roles": ["admin", "driver"]
  }
}
  • Hostname: interface to bind. 0.0.0.0 listens on every interface.
  • Port: TCP port for cluster gossip. Default 4053.
  • PublicHostname: address advertised in cluster gossip. Must be reachable by every other node.
  • SeedNodes: where new nodes go to join. List one (or two) stable nodes. Order is load-bearing: Akka only lets seed-nodes[0] form a new cluster (FirstSeedNodeProcess); every other node retries InitJoin forever. A node that is one of its own seeds must therefore list ITSELF first, or it can never cold-start while its peer is down — enforced at boot by AkkaClusterOptionsValidator, explained in Redundancy.md § Bootstrap: self-first seed ordering. Nodes seeded only by someone else (driver-only site nodes) are exempt.
  • Roles: free-form tags Akka gossip propagates. v2 uses admin + driver; per-role wiring in Program.cs reads OTOPCUA_ROLES env var, not this list — these two should stay in sync.

Per-role overlay files (appsettings.admin.json, appsettings.driver.json, appsettings.admin-driver.json) layer on top of base appsettings.json based on the parsed OTOPCUA_ROLES (alphabetical, joined by -). See ServiceHosting.md § Per-role configuration overlays.

IClusterRoleInfo

Anywhere in the host that needs the local node's identity or a view of who-else-is-in-the-cluster, inject IClusterRoleInfo:

public sealed class MyService(IClusterRoleInfo cluster)
{
    public NodeId Self => cluster.LocalNode;
    public IReadOnlyList<NodeId> Drivers => cluster.MembersWithRole("driver");
    public NodeId? AdminLeader => cluster.RoleLeader("admin");

    public MyService(IClusterRoleInfo cluster)
    {
        cluster.RoleLeaderChanged += (_, e) =>
            Console.WriteLine($"role={e.Role}: {e.PreviousLeader} → {e.NewLeader}");
    }
}

LocalNode is {PublicHostname}:{Port} (the port suffix lets loopback test deployments stay distinct; production hostnames are already unique). ConfigPublishCoordinator uses the same {host}:{port} formula so the expected-ack set and the driver self-identification agree (commit 5cfbe8b).

Lifecycle

Akka.Hosting owns the lifecycle: IHostedService starts the ActorSystem at host start, runs CoordinatedShutdown.ClusterLeavingReason on host stop. The Cluster project does not register its own IHostedService (the v1 AkkaHostedService was deleted in commit d6fac2d).

Tests

tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ covers:

  • HoconLoaderTests — embedded resource loads + key settings parse correctly.
  • RoleParserTests — comma-split + dedup + trim semantics.

Cross-project integration is in tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ (cluster formation, deploy round-trip).