Keeps the root CLAUDE.md lean by lazy-loading per-area detail (drivers, topology, data-plane auth, alarms, historian) only when matching files are touched.
42 KiB
paths
| paths | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
Cluster topology: redundancy, role authority, per-cluster meshes, MeshTransport, ConfigSource/ConfigServe, telemetry transport, LocalDb, DB-less driver nodes
Moved verbatim from the root
CLAUDE.md(2026-10-13) so it loads only when working in this area. RootCLAUDE.md§ "Subsystem guides" indexes it. Edit it here, not in the root file.
Redundancy
The server supports non-transparent warm/hot redundancy via the Redundancy section in appsettings.json. Two instances share the same Galaxy DB and the same mxaccessgw (under distinct MxAccess.ClientName values) but have unique ApplicationUri values. Each exposes RedundancySupport, ServerUriArray, and a dynamic ServiceLevel based on role and runtime health. The primary advertises a higher ServiceLevel than the secondary. See docs/Redundancy.md for the full guide.
Two corrections landed 2026-07-21 — both were total-outage or wrong-node defects, and both are worth knowing before touching this area.
- Downing is
auto-down, notkeep-oldest.Cluster:SplitBrainResolverStrategy(defaultauto-down) selects the provider viaServiceCollectionExtensions.BuildDowningHocon; an unknown value fails host start. A two-node pair running the previouskeep-oldest+down-if-alonecould not survive a crash of the oldest node: Akka'sKeepOldest.OldestDecisiononly letsdown-if-alonerescue a side holding ≥ 2 members, so the 1-vs-1 survivor downs itself and exits.down-if-aloneis a 3+-node feature. The accepted trade forauto-downis dual-active during a genuine network partition. Its live gate is CLOSED — Phase 7 drill D4 (2026-07-24) ran the 1-vs-1 crash-the-oldest survival drill on the three-mesh docker-dev rig and every survivor stayed Up, never self-downing (c50ebcf7;docs/plans/2026-07-24-mesh-phase7-failover-drills.md). - The Primary is the oldest Up
drivermember, not the role leader.RedundancyStateActorpreviously usedClusterState.RoleLeader("driver")(lowest address), whileClusterSingletonManagerplaces singletons on the oldest. They agree only on a freshly-formed cluster and diverge after any restart, so the Primary-gated data plane (writes, alarm acks, alerts emit, alarm-history drain) could enable on a node not hosting the singletons.NodeRedundancyState.IsRoleLeaderForDriverwas renamedIsDriverPrimary.
A third bootstrap gap closed 2026-07-22 — downing was never the whole story, and the fix changed twice. Even under auto-down, a node cold-starting while its peer was dead never came Up: Akka runs FirstSeedNodeProcess — the only bootstrap path that can form a new cluster when no peer answers InitJoin — exclusively when seed-nodes[0] is the node’s own address; every other node runs JoinSeedNodeProcess and retries InitJoin forever. Cluster:SeedNodes is therefore ORDERED: every node that is one of its own seeds lists ITSELF first, partner second (docker-dev central-2 was swapped; site nodes list only central-1 and are legitimately not seeds). AkkaClusterOptionsValidator (AddValidatedOptions/ValidateOnStart in AddOtOpcUaCluster) enforces it at boot — conditionally (self must be entry 0 only if self is in the list, or every site node would refuse to start), matching on PublicHostname+Port, never the 0.0.0.0 bind address. The earlier fix — a Cluster:SelfFormAfter timer calling Cluster.Join(SelfAddress) — is deleted: it sat outside Akka’s join handshake, so it could not tell "no seed answered" from "a seed answered and the join is in flight", and it islanded a manually-failed-over node live before a TCP reachability guard patched that one shape. Pinned by SelfFirstSeedBootstrapTests (real in-process clusters, incl. a falsifiability control proving peer-first ordering never forms) + AkkaClusterOptionsValidatorTests. See docs/Redundancy.md §"Bootstrap: self-first seed ordering".
Simultaneous cold-start splits — the bootstrap guard closes it (opt-in, Cluster:BootstrapGuard:Enabled, default OFF). Self-first on BOTH nodes means when both cold-start at the same instant, each runs FirstSeedNodeProcess, times out, and forms its OWN 1-node cluster → two Primaries in one pair (the Phase 6 live gate reproduced this on docker; two separate clusters do NOT auto-merge). The guard prevents it without losing cold-start-alone: the lexicographically lower host:port node is the preferred founder (self-first, forms immediately); the higher node TCP-probes the partner's Akka port up to PartnerProbeSeconds (25s) — reachable ⇒ peer-first (join the founder), unreachable ⇒ self-first (partner dead, form alone). The order is chosen BEFORE the single JoinSeedNodes, from an explicit reachability signal — it never re-forms mid-handshake (the SelfFormAfter failure mode). When on, Akka gets NO config seeds (BuildClusterOptions empties ClusterOptions.SeedNodes) and ClusterBootstrapCoordinator drives the join. Residual trade (accepted, operator-visible via a warning + restart-recovers): the higher node hangs if the founder dies in the probe→join window. docker-dev: site-a = guard demo (guard on, serialization removed); site-b = A/B control (guard off, depends_on: service_healthy serialization). The guard is the production-faithful fix (compose depends_on doesn't exist on real VMs). Pinned by ClusterBootstrapGuardTests + ClusterBootstrapCoordinatorTests (real 2-node clusters, incl. the higher-node-cold-start-alone case). See docs/Redundancy.md §"Bootstrap guard".
Assert the effective cluster config off a running ActorSystem (as SplitBrainResolverActivationTests does), never the typed options or the akka.conf text — several HOCON fragments compete and the losing one still reads correctly in the file.
RESOLVED by per-cluster mesh Phase 6 (2026-07-24) — no longer per-Akka-cluster. The "one Primary across the whole Akka mesh" limitation described above is gone; see the Phase 6 paragraph immediately below.
Per-cluster mesh Phase 6 (2026-07-24) — the fleet is now three independent 2-node meshes, not one. The single fleet-wide Akka mesh was split into one mesh per application Cluster: the central pair (admin,driver,cluster-MAIN), site-a (driver,cluster-SITE-A), site-b (driver,cluster-SITE-B) — same ActorSystem name, separated purely by per-pair self-first seeds (each node's Cluster:SeedNodes lists itself first, its own pair partner second, never a node from another cluster's pair). Consequences:
- The redundancy singleton is cluster-scoped and spawned per driver node.
RedundancyStateActoris now acluster-{ClusterId}-scoped singleton (falls back to plaindriverfor a legacy node), so each pair elects and publishes its own Primary on its own mesh's DPS. Two or more Primaries exist fleet-wide, one per pair — by design, not a bug. ⚠️ Wiring landmine (fixed3a4ed8dd, caught by the live gate): the singleton's cluster-role scope is computed atAddAkka-configurator time, so it must come fromAkkaClusterOptions(config) — never by resolvingIClusterRoleInfofrom the SP there.ClusterRoleInfodepends on theActorSystem, and that lambda runs while the ActorSystem is being built, so resolving it recurses into ActorSystem construction and stack-overflows every node at boot (compiles clean; the rehome tests passed a hand-built fake straight into the extension, mocking around the composition-root line). KeepWithOtOpcUaClusterRedundancySingleton/BuildClusterRedundancySingletonOptionstakingAkkaClusterOptions. ClusterNodeAddressReconcileris own-cluster-scoped — an admin node can only reconcileClusterNoderows in its own cluster; it has no gossip visibility into another mesh.CentralCommunicationActordials each node directly over gRPC — oneNodeCommandService.Executecall per enabledClusterNoderow carrying aGrpcPort— rather than fanning through a client/receptionist object. Superseded by the ClusterClient→gRPC migration: the AkkaClusterClientthis bullet originally described (one per applicationCluster, closing theTODO(Phase 6)Phase 2 left behind) is deleted; the per-node gRPC dial is its replacement, not an evolution of it.SplitTopologyTransportValidatorfails host start on a node carrying acluster-{ClusterId}role unless it also runs the split-safe transports:MeshTransport:Mode=Grpc,Telemetry:Mode=Grpc(driver),TelemetryDial:Mode=Grpc(admin). A DPS transport on a cluster-role node would try to gossip across a partition that no longer exists.- The compiled transport-mode defaults were deliberately NOT flipped —
MeshTransport:Mode/Telemetry:Mode/TelemetryDial:Modeall still default toDps; a blast-radius assessment showed flipping the default breaks every integration test and appsettings profile that leaves the key unset. The validator + explicit per-deployment config is the guardrail, not a default flip. - Secrets replication is pair-local — each pair replicates its own Akka DPS secrets within itself; there is no fleet-wide/cross-cluster sync after the split (sites have no SQL, so no SQL-hub, consistent with Phase 4).
See docs/Redundancy.md §"Per-cluster meshes (Phase 6)", docs/Configuration.md §"Per-cluster mesh split (Phase 6)", and docs/plans/2026-07-24-mesh-phase6-partition-and-colocation.md.
Role authority (EffectiveRoles)
A node's roles are resolved in exactly one place (archreview R3-04, closing 01/S-12 = 03/C5 and
01/C-9): EffectiveRoles.Resolve (ZB.MOM.WW.OtOpcUa.Cluster), with one precedence —
Cluster:Roles when non-empty, else OTOPCUA_ROLES, else empty — and two-layer normalization: a
plain role (admin/driver/dev) is trimmed and lower-cased, but a cluster role's ClusterId suffix
(cluster-SITE-A) is kept verbatim, because lower-casing it would rename the Akka role of a running
deployment. An unknown role from either source fails host start. Program.cs resolves one
EffectiveRoles instance at the top of the composition root and registers it as a singleton; that
SAME instance drives both the conditional host wiring (hasAdmin/hasDriver) and
AkkaClusterOptions.Roles (what Akka gossips) — there is no second derivation for the two to
disagree about, which is why no runtime "do the views agree" assert exists. Before this, Program.cs
read OTOPCUA_ROLES only, so a node configured solely through Cluster:Roles gossiped the right
Akka roles while Program.cs's own wiring mounted none of them — the inverse of the pre-#532 field
incident (docs/deployments/wonder-app-vd03-makino-z-34184.md).
- A role overlay may not itself set
Cluster:Roles. Roles are resolved before the per-role appsettings overlay loads (the overlay's filename depends on the answer), so a later layer changingCluster:Roleswould leave the bound options disagreeing with the registered instance.AkkaClusterOptionsValidatorfails host start if this ever happens, naming both views — a no-op today since none of the four shipped overlays carries aClustersection. - Both sources set to different normalized role sets logs a Warning at boot (
Cluster:Rolesstill wins outright) — the boot banner also names which source won (source=Configured/Environment/None). RoleParser.GossipedRolesHave(case-insensitive) is defence in depth at the handful of production sites that read LIVE GOSSIP directly (Member.Roles) rather than throughEffectiveRoles— a peer running an older, pre-R3-04 build can still gossip a miscased plain role during a rolling upgrade. ⚠️ Accepted residual (#620), not closed by R3-04:RedundancyStateActor's election delegates toZB.MOM.WW.Health.Akka'sClusterActiveNode.OldestUpMember, which matches via Akka's OWN ordinalMember.HasRole. Deliberately not patched — neither in the library nor with a local case-insensitive pre-filter, which would split the Primary election fromClusterSingletonManagerplacement (also ordinal). It only bites a mixed fixed/pre-fix rolling-upgrade window; finish the upgrade. Full rationale:docs/Redundancy.md("Accepted residual (#620)").Program.cswraps the composition root (#618): a start-up failure logs Fatal, writesFATAL:to stderr (the bootstrap logger is still silent beforeAddZbSerilog), flushes, andEnvironment.Exit(1)s — leftover Akka/Kestrel threads would otherwise keep the process alive.HostAbortedException(design-time tooling) passes through untouched.RoleAuthorityGuardTests(tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/) is the enforcement: a source scan failing on any production file outsideEffectiveRoles.cs/RoleParser.csthat readsOTOPCUA_ROLES,Cluster:Rolesas code, or declares a bare"admin"/"driver"/"dev"literal — with a small, reasoned allow-list for the handful of genuinely unrelated domain uses of those same English words (a raw-tree node-kind label, a Sql driver's own JSON marker, an LDAP-derived AdminUI RBAC role).
See docs/Configuration.md §Roles, docs/ServiceHosting.md §"Role gating", and
archreview/plans/R3-04-one-role-authority-plan.md.
Mesh command transport (MeshTransport)
The Akka ClusterClient transport described in this section's earlier history is DELETED
(branch feat/grpc-phase3-deletion, ClusterClient→gRPC migration,
Phase 3) — code, the akka.cluster.client HOCON block, the receptionist extension,
MeshTransport:CentralContactPoints, and the ClusterClient mode value itself, which now fails
validation loudly at boot (MeshTransportOptionsValidator names the key and the allowed values)
rather than silently falling back to Dps. What remains is exactly two transports, selected by
MeshTransport:Mode:
Dps(default) — DistributedPubSub, the path this repo has always used. Pair-local only: it works only when central and the node share a gossip ring, which after Phase 6 means within one 2-node mesh.Grpc— unary gRPC calls on themesh_transport.v1contract. Required on any node carrying acluster-{ClusterId}role;SplitTopologyTransportValidatorrefuses to boot such a node on anything else.
Central→node commands (deployments, driver-control, alarm-commands) ride NodeCommandService.Execute,
served on each node's driver-role telemetry h2c listener (Telemetry:GrpcListenPort, the docker-dev
rig's :4056 — it shares that listener with TelemetryStreamGrpcService rather than claiming a port
of its own). CentralCommunicationActor dials it directly, one call per node — the target set is
every enabled, non-maintenance ClusterNode row carrying a GrpcPort, dialled at Host+GrpcPort
and refreshed every MeshTransport:ContactRefreshSeconds. There is no client/receptionist object and
no rotation: each command goes to the specific node dialled, which is what makes per-node delivery
provable — the old ClusterClient.SendToAll/Send distinction this section used to explain no longer
applies to either direction.
Node→central deploy acks ride CentralCommandService.ReportApply, served on central's admin-role
config-serve h2c listener (ConfigServe:GrpcListenPort, the rig's :4055 — again sharing rather than
claiming a new port). GrpcApplyAckSender walks MeshTransport:CentralGrpcEndpoints in order and
stops at the first success.
Auth is a per-cluster PSK bearer, both directions: MeshTransport:GrpcPsk (node-side, one key per
application Cluster) and MeshTransport:ClusterPsks (central-side map, consulted before the Secrets
store). MeshCommandAuthInterceptor (node) and CentralCommandAuthInterceptor (central) fail closed
on every unresolvable case — constant-time compare, no key material logged. Production keys are
${secret:OT-MESH-PSK-<clusterId>}; the docker-dev rig supplies three literal dev keys via env
(dev-mesh-psk-MAIN/-SITE-A/-SITE-B).
Things that carried over unchanged from the ClusterClient era, because they were never
ClusterClient-specific to begin with:
- Fire-and-forget, no retry, on both transports. A failed gRPC dispatch is logged (naming the node
and the gRPC status) and dropped in the dispatch second. No queue, no ordering guarantee across
nodes — the deploy coordinator's apply deadline and a node's boot-time replay are the only recovery.
The old
buffer-size = 0decision this section used to describe is gone with the HOCON block that set it; the gRPC transport has nothing to buffer in the first place — a dial either lands or doesn't. - Inbound commands re-emit on the node's
EventStream, not back onto DPS. DPS is mesh-wide, so a DPS re-publish would deliver N copies to every subscriber. Node-side subscribers (DriverHostActor,ScriptedAlarmHostActor) accept a command from either transport identically. - Do not over-claim what fire-and-forget buys. The Phase 2 live gate observed a node applying a
deployment 44 s after it was sealed
TimedOutanyway, viaDriverHostActor.Bootstrap()replaying the orphanApplyingrow on restart. That backstop is unaffected by the ClusterClient deletion — it was always about the deploy path, not the transport.
redundancy-state stays on DPS in both modes (bidirectional, and built from Cluster.State, which
makes it genuinely mesh-bound). See docs/Configuration.md §MeshTransport, docs/Redundancy.md
§"Command transport", and the migration's design doc /
plan / tracking doc /
live gate for the phase-by-phase history —
those are historical records, not updated further by this sweep. docs/plans/2026-07-22-mesh-phase2-clusterclient-transport.md
is the original Phase 2 design doc and is likewise left as a historical record of the transport this
section now describes as deleted.
Phase 1 landed 2026-07-22 and changed a deploy-path behaviour. ConfigPublishCoordinator sources its expected-ack set from enabled ClusterNode rows, not from Akka.Cluster.State.Members filtered by the driver role — central must be able to name a deployment's nodes without sharing a gossip ring with them. Consequences worth knowing before touching deploys:
- A configured node that is switched off now FAILS the deployment at the apply deadline instead of letting it seal green without that node. The maintenance hatch is the new
ClusterNode.MaintenanceModecolumn, NOTEnabled = 0: the live gate showedEnabled = 0is rejected outright byDraftValidator.ValidateClusterTopology(ClusterEnabledNodeCountMismatch— enabled count must equalServerCluster.NodeCount), so on a 2-node pair — every cluster in the target topology — it can never be the hatch.Enabled= part of the declared topology;MaintenanceMode= do not expect it now. The deadline log names the silent nodes and points at the right flag. - Every
ClusterNoderow is now defined to be a driver node. The DB has no per-node role column and deliberately did not gain one (it would drift fromCluster:Roles). Giving an admin-only node aClusterNoderow makes every deploy time out. - There is no such thing as a cluster-scoped deployment.
Deploymenthas noClusterId,ConfigComposeralways snapshots the whole DB, andDeploymentArtifact.ResolveClusterScopeis node-side self-scoping of a fleet-wide artifact. The phase-1 plan asked for cluster-scope filtering of the ack set; it was dropped as describing a feature that does not exist. ClusterNodegainedAkkaPort(NOT NULL, 4053) +GrpcPort(nullable) as central's dial targets for Phases 2/5. They duplicate the node's ownCluster:Port/Cluster:PublicHostnamewith no schema-level enforcement, soClusterNodeAddressReconcilerActor(admin singleton) logs an Error on drift. Phase 2 must revisit it — once the meshes split, an admin node cannot see site members and every site row would reportEnabledRowNotInClusterforever. Resolved, and not the way this bullet originally expected: the ClusterClient→gRPC migration deleted the AkkaClusterClienttransport that would have dialledHost+AkkaPort; the Phase 2 command dial now usesHost+GrpcPort, the same column Phase 5 telemetry already dialled —AkkaPortis Akka remoting only again.
Config source (ConfigSource / ConfigServe)
Per-cluster mesh Phase 3 (config fetch-and-cache) added a second way a driver node obtains its
deployed configuration, selected by ConfigSource:Mode: Direct (read Deployment.ArtifactBlob from
central SQL — the default, byte-for-byte today's behaviour) or FetchAndCache (fetch the artifact
bytes from central over a dedicated-h2c gRPC stream, verify SHA-256 == RevisionHash, cache in
LocalDb, and read only the cache — no central-SQL config read). Central (admin role) serves via
DeploymentArtifactService behind ConfigServeAuthInterceptor on ConfigServe:GrpcListenPort, gated by
a shared node bearer key (ConfigServe:ApiKey == ConfigSource:ApiKey, FixedTimeEquals,
fail-closed). Things worth knowing before touching it:
- The serve side is wired in both modes (mapped whenever
ConfigServe:GrpcListenPort > 0on an admin node), so flipping a node toFetchAndCacheis a config change, not a redeploy — the same dark-switch discipline as MeshTransport. RevisionHash == SHA-256(artifact bytes)is load-bearing.ConfigComposercomputes the revision hash as the lowercase-hex SHA-256 of the exact blob, so the node verifies a fetch against theRevisionHashthe dispatch already carries — no hash rides in the proto, andDispatchDeploymentstays payload-free. A future change to how the revision hash is derived silently breaks every fetch. The blob is gzip-compressed since 2026-08-15, and the hash covers the COMPRESSED bytes —ConfigComposer.Packgzips (CompressionLevel.Fastest) immediately afterSerializeToUtf8Bytesand hashes the result, so the invariant holds unchanged and nothing between the composer andDeploymentArtifact.Open(SQLArtifactBlob, the LocalDb cache chunks, the gRPC fetch stream) ever looks inside.Opensniffs the gzip magic1F 8B— valid JSON can never start with0x1F— so pre-upgrade uncompressed rows keep parsing; inflation runs under a 512 MiB guard and every decompression failure surfaces asJsonException, the same family the malformed-JSON paths already handle. Two operational consequences indocs/Configuration.md§ConfigSource: the first deploy after upgrade re-hashes every revision (a one-time full re-dispatch, not a no-op), and nodes must be upgraded BEFORE central starts emitting compressed blobs.- A null fetch is "no answer," never an empty config (#485). All endpoints down /
NotFound/ SHA mismatch / zero-length ⇒ the fetcher returns null ⇒ the apply FAILS, the revision does not advance, and the node keeps serving last-known-good. It never tears down to an empty address space. At boot aFetchAndCachenode restores served state from the LocalDb pointer with no central-SQL read; an empty/unreadable cache lands Steady-with-no-revision (the first dispatch fetches), never Stale. Malformed non-empty bytes are "no answer" too — on cache boot, theFetchAndCacheboot reconcile and the subscription push alike (the lenient swallow leg is closed). A cache boot from malformed bytes does not adopt the revision (Direct⇒ Stale;FetchAndCache⇒ no revision, the re-dispatch fetches). - Both pair nodes fetch independently — no Primary gating of the fetch (same shape as both reading
SQL today;
StoreAsyncis idempotent and the cache replicates). - The dedicated h2c listener coexists with the LocalDb-sync listener. A fused admin+driver node with
both ports set re-binds its existing HTTP surface exactly once and adds both dedicated HTTP/2-only
ports — getting this wrong either double-binds (crash) or silently unbinds the AdminUI (the Kestrel
Listen*-discards-ASPNETCORE_URLStrap the LocalDb block already warned about). - Phase boundary: config reads only. A
FetchAndCachenode still writes itsNodeDeploymentStateack row to central SQL;DbHealthProbeandEfAlarmConditionStateStorestill touch SQL. Cutting those is Phase 4 — resist folding it in, or the "no SQL" scope becomes unprovable-in-parts.
On the docker-dev rig the central pair carries ConfigServe (:4055, committed dev key) and stays
hardcoded Direct via its admin role; the four site nodes carry ConfigSource (endpoints + matching
key) hardcoded FetchAndCache — mandatory for driver-only nodes since Phase 4, not a flip. There is
no OTOPCUA_CONFIG_MODE env var in the compose file. See
docs/Configuration.md §ConfigSource / ConfigServe, docs/Redundancy.md §"Command transport", and
docs/plans/2026-07-22-mesh-phase3-config-fetch-and-cache.md.
Live telemetry transport (Telemetry / TelemetryDial)
Per-cluster mesh Phase 5 (merged 35552a96; live gate PASSED f134935f) added a second
transport for the four live-observability channels the AdminUI's /alerts, /script-log, and
/hosts panels feed on, selected by Telemetry:Mode (node/serve side) and TelemetryDial:Mode
(central/dial side): Dps — the four existing DPS SignalR bridges, the default, today's
behaviour — or Grpc, one gRPC server-streaming contract carrying all four channels as oneof
event kinds. The direction is inverted from ConfigSource/MeshTransport: the driver node hosts
the gRPC server (dedicated Kestrel h2c port), and central is the client that dials in — mirroring
ScadaBridge's SiteStreamService shape, and required so telemetry survives once the meshes split
(Phase 6) and central no longer shares a gossip ring with a site node. Things worth knowing before
touching it:
- Migrated (4 channels):
alerts(AlarmTransitionEvent),script-logs(ScriptLogEntry),driver-health(DriverHealthChanged),driver-resilience-status(DriverResilienceStatusChanged) — feeding the exact same central sinks (IInProcessBroadcaster<...>,IDriverStatusSnapshotStore,IDriverResilienceStatusStore) in either mode. - Deferred, with reasons (do not read "seven observability topics" from the program sketch as the
delivered scope):
redundancy-statestays on DPS in every mode (pair-local, built fromCluster.State, drives ServiceLevel + the Primary gate — genuinely mesh-bound by design, not an observability panel);deployment-acksalready rides the Phase 2 gRPC command transport (MeshTransport:Mode=Grpc,CentralCommandService.ReportApply) as a command-plane reply, not telemetry. A third,fleet-status, was DELETED rather than deferred (2026-08-06, Gitea #542):FleetStatusBroadcaster→ DPS → SignalR bridge →FleetStatusHubhad zero clients end to end (no browser JS, noHubConnection—/fleetpollsNodeDeploymentStatein the Config DB), itsDriverHostStatusHeartbeatinput had no production sender, and Phase 6 left its "fleet" as just central's own pair. Fleet visibility is/fleet+ the four per-node telemetry dials above. - The node ALWAYS hosts and ALWAYS double-publishes, in both modes. Every publish seam emits
into a node-local
ITelemetryLocalHuband still publishes DPS, unconditionally; the gRPC server binds wheneverTelemetry:GrpcListenPort > 0, independent ofTelemetry:Mode. OnlyTelemetryDial:Modeon central actually switches anything: which of the two always-available sources (DPS bridges vs. theTelemetryDialSupervisordialer) feeds the sinks. This is what makes the flip a config change, not a coordinated redeploy — same dark-switch discipline as MeshTransport and ConfigSource. - Authenticated from day one, fail-closed — a shared node bearer key (
Telemetry:ApiKey==TelemetryDial:ApiKey), gated byTelemetryStreamAuthInterceptor(CryptographicOperations.FixedTimeEquals,PermissionDeniedon mismatch, an unset key rejects every call). This supersedes design doc §6.3's "match ScadaBridge's unauthenticated posture for now" — ScadaBridge itself has since closed that gap with this identical interceptor pattern. - Reconnect story: central runs one reconnecting dialer per driver node (discovered from
ClusterNode.Host/GrpcPort, refreshed everyTelemetryDial:ContactRefreshSeconds), immediate first retry then ~5s fixed backoff, generation-stamped so a superseded stream's late error/event is ignored, and it never permanently gives up (observability, not data plane).TelemetryLocalHub.Evictdrops a removed driver's snapshots (StopChild, viaplan.ToStop; central-side stores are not evicted). The node-local hub replays last-value snapshots fordriver-health/driver-resilience-statuson every (re)subscribe so those stores re-prime immediately after a reconnect;alerts/script-logsare append logs and tolerate the gap. The/alerts//script-logpill reflects aggregate stream health (live when ≥1 node stream is up) — the same semantics the DPS pill has always had.
See docs/Telemetry.md for the full architecture, docs/Configuration.md
§Telemetry/TelemetryDial for the config keys, and
docs/plans/2026-07-23-mesh-phase5-grpc-telemetry-stream.md for the implementation plan and live
gate procedure.
LocalDb pair-local store (Phases 1 + 2)
Every driver-role node keeps a consolidated ZB.MOM.WW.LocalDb SQLite database (the retired
LiteDB LocalCache subsystem was deleted as superseded). It holds three replicated tables:
deployment_artifacts + deployment_pointer (Phase 1) and alarm_sf_events (Phase 2).
Phase 1 caches the
deployed-configuration artifact (chunked, SHA-256-verified, newest-2 retention per cluster) so a
driver node can boot from cache when central SQL Server is unreachable — DriverHostActor writes
the cache after each successful apply (IDeploymentArtifactCache → LocalDbDeploymentArtifactCache,
in Runtime/Deployment/) and reads it as a boot fallback, flagging running-from-cache. Wired by
AddOtOpcUaLocalDb in the hasDriver branch only (Host/Configuration/LocalDbRegistration.cs +
LocalDbSetup.OnReady, whose DDL→RegisterReplicated order is load-bearing); admin-only nodes
never register it. The cache can optionally replicate to the node's redundant pair peer over the
library's gRPC sync — default-OFF, fail-closed bearer auth (LocalDbSyncAuthInterceptor), gated
on LocalDb:SyncListenPort (a dedicated Kestrel h2c listener) + LocalDb:Replication:*. On the
docker-dev rig the site-a pair has replication on; central + site-b stay default-OFF (site-b is
the pin). ApiKey via ${secret:}/env, never committed (the rig's dev key is the committed-dev-secret
exception).
Phase 2 moved the alarm store-and-forward buffer off its standalone alarm-historian.db
and into the same database as alarm_sf_events, so a node that dies holding undelivered alarm
history no longer takes it to the grave. SqliteStoreAndForwardSink became
LocalDbStoreAndForwardSink (Core.AlarmHistorian), and the breaking config key
AlarmHistorian:DatabasePath was removed — it is still read, by AlarmSfLegacyMigrator, purely
to find a pre-consolidation file to copy across on first boot (renamed .migrated after the copy
commits). Because the buffer replicates, only the node holding the Primary role drains it: the
sink takes a drainGate delegate, Runtime supplies it from the IRedundancyRoleView singleton that
DriverHostActor publishes its PrimaryGatePolicy verdict to, and a gated-off node reports the new
HistorianDrainState.NotPrimary. Delivery is therefore at-least-once across a failover, by
design (no dedup layer). Row ids are a SHA-256 of the event payload rather than a GUID, so the
same event accepted on both nodes — which happens in every boot window, since
HistorianAdapterActor default-writes while its role is unknown — converges to one row. On the
docker-dev rig the site-a pair and site-b both run AlarmHistorian__Enabled=true against a
deliberately unresolvable gateway endpoint, so the buffer is always a live, non-draining queue.
The library pin is 0.3.4 (Directory.Packages.props, both ZB.MOM.WW.LocalDb and
.Replication), bumped from 0.2.1 on 2026-09-03 (was itself bumped from 0.1.3 on 2026-08-15).
The 0.2.1→0.3.4 bump is wire-compatible with 0.2.x, and the on-disk bookkeeping schema v2 → v3
upgrades in place on open, so no rig or volume action was needed. RegisterReplicated(string) is
unchanged and everything 0.2.x/0.3.x added is additive, except that RegisterReplicated (0.3.3)
now throws LocalDbRegistrationException for a table that is the parent of a row-mutating ON
DELETE FK — none of this repo's four replicated tables have such an FK, so this did not fire.
0.3.0 made the snapshot-resync flag per-table (see the ⚠️ below, now corrected); 0.3.2 added
validated config Replication:KeepAlivePingDelay/KeepAlivePingTimeout (defaults 60s/20s, left
unset here) and a public LocalDbOplogGapException, neither yet wired to a call site. Its two
new APIs — DeregisterReplicated and RegisterReplicated(table, baselineExistingRows:) — are now
both used (2026-08-18); the bullets below are the shape that landed, not the bump itself. Two
things are worth knowing:
MaxBatchSizeno longer means what the rig's16was chosen to mean. 0.2.0 made batching byte-budgeted —LocalDb:Replication:MaxBatchBytes(2 MB default, left unset here, sized under the 4 MB gRPC cap) bounds the wire message.MaxBatchSizeis demoted to a secondary row cap and the DB read-page size inOplogStore.ReadBatchAboveAsync/SnapshotStreamer, which materialise a whole page in memory before the byte split runs. So the site-a pair keeps16— it now bounds that transient allocation (≈2.7 MB at ≈171 KB artifact chunk rows), not the message size. Every comment and doc row that called it "row-count-only against gRPC's 4 MB cap" was corrected with the bump; do not restore that rationale.- Registration FOLLOWS the replication config — it is no longer unconditional (2026-08-18).
LocalDbSetup.OnReadyreads one predicate,LocalDbReplicationPolicy.CapturesChanges(viaLocalDbSetup.ReplicationIsConfigured):LocalDb:Replication:PeerAddressORLocalDb:Replication:ApiKeyis non-blank. The OR is not belt-and-braces — only the dialling half of a pair setsPeerAddress, so keying on it alone would strip capture from every passive node (site-a-2 carriesApiKey+LocalDb:SyncListenPortand nothing else) and the pair would converge in one direction only, silently. ⚠️SyncListenPortdeliberately does NOT join the OR: the passive endpoint is fail-closed on the key, so a port without anApiKeyrejects every inbound stream and has nothing to capture for.- ⚠️ The alarm DRAIN gate uses a different predicate —
LocalDbReplicationPolicy.SharesRowsWithPeer:ApiKeyand (PeerAddressorLocalDb:SyncListenPort > 0), i.e. "a peer actually holds these rows". Capture must be wider than the drain gate (a dialling-only node still captures); a node with no peer must still drain, so the two predicates are deliberately not the same. The passive half (noPeerAddress) cannot name its peer, so it stands down for its mesh partner (the other member of itscluster-{ClusterId}role); a legacy passive half with no mesh role still drains alongside its partner (duplicate historian rows). ⚠️ Before a node has joined its mesh the published Primary verdict (and the live data-plane gate, which must stay the same decision) allows — for seconds, up to ~25 s under the bootstrap guard — so a cold-starting node can briefly co-hold the Sparkplug primary-host claim with its Primary peer. - Configured ⇒
RegisterReplicated(table, baselineExistingRows: true), per table, in the same load-bearing DDL→register order. Baselining is what makes enabling replication on a node that has been running without it actually converge: capture is CDC and the snapshot streamer pages from__localdb_row_version, so pre-registration rows were in neither and a late opt-in used to replicate only writes made after the restart. It seeds the ledger at the LWW floor (HLC0, this node's id) and flags a resync — a baselined row loses to any genuine remote write of the same key and wins only where the peer has no version at all. It is idempotent, so it is free on every boot after the first. As of0.3.0(this repo pins0.3.4) the snapshot flag is per-table, not per-database — baselining one table no longer streams every registered table in both directions. (Prior to0.3.0, on the0.2.1pin this repo carried until 2026-09-03, the flag was per-database and baselining anything streamed every registered table in full; the old advice to trimalarm_sf_eventsbefore enabling replication no longer applies at the current pin.) - Not configured ⇒
DeregisterReplicated(table)per table, an idempotent boot-time self-heal: it drops the capture triggers an earlier (unconditional) build left in the file and prunes that table's oplog + row-version rows. Merely declining to register would leave every pre-2026-08-18 file capturing forever — two INSERTs plus ajson_objectof the whole row inside every write transaction — on precisely the node that has no peer to capture for. The call returnstruewhen it removed something, so the Information line fires once, and only when it actually cleaned; a default-OFF node's ordinary boot stays silent. The logger is Serilog's staticLog.Logger(this runs inside theAddZbLocalDbsingleton factory, where resolving one from DI would nest a resolution inside a singleton construction;Program.csassigns it before anything resolvesILocalDb), with an optional parameter so tests observe the line rather than infer it. - ⚠️ PAIR SYMMETRY: flipping registration is a BOTH-NODES-TOGETHER change. The sync handshake compares registered-table digests fail-closed, so a node that deregisters a table its peer still replicates stops syncing with a schema mismatch. Enabling replication on an established pair means setting the config and restarting both nodes — do it on both, or on neither.
- Rollout note: the only replicating pair in the fleet is docker-dev site-a, and it was
enabled from a fresh pair, so the impact of the old gap was low — central and site-b are
default-OFF and simply shed their unused triggers on their next boot.
LocalDbSetupTestspins both branches (registered-table set on a configured node, empty set + zero triggers + the HLC-0 baseline ledger entry on the other) and the DDL→register ordering, unchanged.
- ⚠️ The alarm DRAIN gate uses a different predicate —
See docs/Configuration.md (LocalDb section), docs/Redundancy.md (pair-local config
cache), docs/AlarmHistorian.md, and the runbook
docs/operations/2026-07-20-localdb-pair-replication.md.
Phase 4 — cut the driver-side ConfigDb connection
Per-cluster mesh Phase 4 finished the job Phase 3 started: a driver-only node (Cluster:Roles
has driver, not admin) now boots with no ConfigDb connection string at all. Program.cs
gates AddOtOpcUaConfigDb (and its health check) on hasAdmin; a fused admin,driver node is
unaffected (it keeps ConfigDb via its admin role). Consequences:
ConfigSource:Modemust beFetchAndCacheon a driver-only node —ConfigSourceOptionsValidatorfails host start if such a node is left onDirect(there is nothing to read Direct from). A fused node may stayDirect.- ServiceLevel semantics change — client-visible. With no ConfigDb,
DbHealthProbeActoris not spawned (ServiceCollectionExtensions.WithOtOpcUaRuntimeActorsskips it when the resolved db factory is null), andOpcUaPublishActorrunsdbLess:DbReachableis fed constanttrueandStalecomes from the redundancy-snapshot age alone. A healthy driver-only node therefore publishes 240 (or 250 as driver Primary) even with central SQL unreachable — the survive-alone posture — where aDirect/DB-backed node in the same state drops to 100/0.ServiceLevelCalculatoritself is unchanged; only the inputs a DB-less node feeds it changed. Seedocs/Redundancy.md§"DB-less (driver-only) nodes". - Scripted-alarm Part 9 condition state moved to LocalDb.
LocalDbAlarmConditionStateStore(replicatedalarm_condition_statetable, pair-local like the Phase-2 alarm S&F buffer) replacesEfAlarmConditionStateStoreon every driver-role node, fused central included. The DB-backedScriptedAlarmStatetable was dropped (Task 9,3a590a0c, migration20260723183050_DropScriptedAlarmStateTable);EfAlarmConditionStateStoreis deleted. - Central persists deploy acks.
DriverHostActorno longer writesNodeDeploymentStateto SQL on a driver-only node —ConfigPublishCoordinator.PersistNodeAck(already fed by every ApplyAck) is the system of record. - LDAP group→role DB grants don't reach driver-only nodes. They register the empty
NullLdapGroupRoleMappingService(no ConfigDb to readLdapGroupRoleMappingfrom), soOtOpcUaGroupRoleMapperfalls back to theSecurity:Ldap:GroupToRoleappsettings baseline only — the same rows a driver node never received via config either, so this is not a regression.
Merged to master (26fad75c); live gate PASSED (1281aebf — driver nodes run ConfigDb-free) and
Task 9 landed (3a590a0c). See docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md and
docs/plans/2026-07-22-per-cluster-mesh-program.md §Phase 4.