# 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<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

```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. **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](../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](../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<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).
