# 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 ```csharp // 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` 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 ```json { "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. First node bootstraps the cluster from its own address. - `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](../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`: ```csharp public sealed class MyService(IClusterRoleInfo cluster) { public NodeId Self => cluster.LocalNode; public IReadOnlyList 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).