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.
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.0listens 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 letsseed-nodes[0]form a new cluster (FirstSeedNodeProcess); every other node retriesInitJoinforever. 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 byAkkaClusterOptionsValidator, 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 usesadmin+driver; per-role wiring inProgram.csreadsOTOPCUA_ROLESenv 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).