7.3 KiB
OtOpcUa v2 Architecture
Single-page tour of the v2 layout. For decision history + tradeoffs, see 2026-05-26-akka-hosting-alignment-design.md.
Big picture
┌─────────────────────────────────────────────┐
│ OtOpcUa.Host │ (fused binary)
│ │
│ reads OTOPCUA_ROLES env, mounts: │
│ ┌─────────────────────────────────────┐ │
│ │ admin → Blazor + auth + control- │ │
│ │ plane singletons │ │
│ │ driver → OPC UA endpoint + │ │
│ │ per-node actors │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
│
│ joins
▼
┌─────────────────────────────────────────────┐
│ Akka.NET cluster │
│ (split-brain resolver: keep-oldest, 15s) │
└─────────────────────────────────────────────┘
shared by every node: ┌─────────────────┐
│ ConfigDb (SQL) │ live-edit + Deployment artifacts + audit
└─────────────────┘
The v1 setup was two separate Windows services (OtOpcUa.Server + OtOpcUa.Admin) talking through the DB. v2 collapses them into one binary with role gating, and adds an Akka cluster so admin singletons can drive deploys and the redundancy story is automatic.
Project layout
src/Core/ shared abstractions, no Server deps
ZB.MOM.WW.OtOpcUa.Commons types + Akka message contracts + interfaces
ZB.MOM.WW.OtOpcUa.Cluster HOCON, AkkaClusterOptions, IClusterRoleInfo
ZB.MOM.WW.OtOpcUa.Configuration EF Core DbContext + entities
src/Server/ server-side projects
ZB.MOM.WW.OtOpcUa.Security cookie+JWT auth, LDAP, JwtTokenService
ZB.MOM.WW.OtOpcUa.ControlPlane admin-role cluster singletons
ZB.MOM.WW.OtOpcUa.Runtime driver-role per-node actors
ZB.MOM.WW.OtOpcUa.OpcUaServer OPC UA endpoint facade + Phase7Composer
ZB.MOM.WW.OtOpcUa.AdminUI Blazor Razor class library
ZB.MOM.WW.OtOpcUa.Host fused binary (Program.cs)
| Project | Role | Doc |
|---|---|---|
| Cluster | Bootstrap + cluster topology view | Cluster.md |
| ControlPlane | Admin singletons (deploy, audit, fleet, redundancy) | ControlPlane.md |
| Runtime | Driver-role actor tree | Runtime.md |
| Security | Cookie+JWT auth, LDAP, /auth/* endpoints | ../security.md |
| OpcUaServer | OPC UA endpoint host + composer | ../OpcUaServer.md |
| Host | Role-gated DI graph + Program.cs | ../ServiceHosting.md |
Role gating
Program.cs reads OTOPCUA_ROLES once (per process) and decides what to wire:
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
var hasAdmin = roles.Contains("admin");
var hasDriver = roles.Contains("driver");
builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
builder.Services.AddOtOpcUaCluster(builder.Configuration);
builder.Services.AddAkka("otopcua", (ab, sp) =>
{
ab.WithOtOpcUaClusterBootstrap(sp); // HOCON + remote + cluster options
if (hasAdmin) ab.WithOtOpcUaControlPlaneSingletons();
if (hasDriver) ab.WithOtOpcUaRuntimeActors();
});
if (hasAdmin)
{
builder.Services.AddOtOpcUaAuth(builder.Configuration);
builder.Services.AddAdminUI();
// SignalR, AdminOpsClient, etc.
}
builder.Services.AddOtOpcUaHealth();
There is a single ActorSystem. Cluster singletons + per-node actors share it via the Akka.Hosting registry. This was a v2 fix (the initial Phase 9 wiring ran two ActorSystems by mistake; see commit d6fac2d).
Live-edit vs draft/publish
v1 had ConfigGeneration(Draft|Published) with every live-edit entity FK'd to a generation. Edits accumulated in a Draft until Publish promoted them.
v2 removes that entirely:
- No
ConfigGenerationtable, noGenerationIdcolumns. - Every live-edit entity has a
RowVersion(IsRowVersion()) for last-write-wins. - Audit goes to
ConfigEdit(per-row delta) andConfigAuditLog(event-level). - Deploys snapshot the current DB state into an immutable
Deployment.ArtifactBlob+ itsRevisionHash. That artifact is what driver nodes apply.
See ControlPlane.md § Deploy flow for the end-to-end dispatch + ACK + seal sequence.
NodeId
Each cluster member has a NodeId derived as {PublicHostname}:{Port} of the Akka remote endpoint. ClusterRoleInfo.LocalNode + ConfigPublishCoordinator.DiscoverDriverNodes() use the same formula so they always agree. The port suffix makes loopback test deployments distinguishable (commit 5cfbe8b); in production the hostname alone is already unique.
Health endpoints
| Path | Returns 200 when… |
|---|---|
/healthz |
Process is alive (no checks). |
/health/ready |
DB reachable + this node is Up in the cluster. |
/health/active |
This node is the admin role-leader (used by Traefik/HA-LB to pin traffic). |
What lives where (quick map)
| Concern | Project | Entry point |
|---|---|---|
| Read OTOPCUA_ROLES | Cluster.RoleParser |
static Parse(string?) |
| Cluster lifecycle | Cluster.WithOtOpcUaClusterBootstrap |
extension on AkkaConfigurationBuilder |
| Local node identity | Cluster.IClusterRoleInfo.LocalNode |
DI singleton |
| Admin singletons | ControlPlane.WithOtOpcUaControlPlaneSingletons |
extension on AkkaConfigurationBuilder |
| Driver actors | Runtime.WithOtOpcUaRuntimeActors |
extension on AkkaConfigurationBuilder |
| Auth pipeline | Security.AddOtOpcUaAuth + MapOtOpcUaAuth |
extensions on IServiceCollection / IEndpointRouteBuilder |
| OPC UA facade | OpcUaServer.OpcUaApplicationHost |
runtime host, started by driver-role startup |
| Partner-URI advertising | OpcUaServer.OpcUaApplicationHost.PopulateServerArray |
runs after _application.Start, appends PeerApplicationUris to the SDK ServerUris StringTable so Server.ServerArray (i=2254) returns self + peers |
| Health endpoints | Host.Health.AddOtOpcUaHealth + MapOtOpcUaHealth |
extensions on IServiceCollection / IEndpointRouteBuilder |