DriverInstanceActor.DisconnectObserved had no production sender. The only paths into Reconnecting were a failed *initial* connect and an operator's Force Reconnect, so a driver that connected and then lost its device stayed Connected indefinitely: publishing Bad for every tag, never re-initialising, and NOT recovering when the device returned. PublishHealthSnapshot read GetHealth() for reporting only and never acted on a driver reporting itself unhealthy. Both comms-loss quality paths -- #477's native-condition annotation and #481's scripted-alarm input fan-out -- hang off ConnectivityChanged, so both were correctly wired to a signal that never fired. The Connected health tick now evaluates a watchdog and self-sends DisconnectObserved when the driver reports a non-Healthy state AND its LastSuccessfulRead has been frozen for DefaultCommsLossTimeout (90s). The conjunction is the design, and the issue's own proposal -- "non-Healthy for N consecutive polls" -- is wrong. Degraded means *connected but some data is bad*; several drivers write health per tag inside the read loop (ModbusDriver.ReadAsync), so one unreadable address latches Degraded forever against a perfectly reachable device, and a state-only watchdog would reconnect that driver every 90s indefinitely -- worse than the bug. LastSuccessfulRead is the honest evidence: preserved across Degraded/Faulted writes, advanced by any success. The state check remains the second conjunct so an idle Healthy driver with nothing subscribed is not declared dead for not reading. Verified across all 12 drivers that a sustained failure does reach a non-Healthy state -- nine poll drivers via Degraded, MQTT via live connection state, Galaxy via its reconnect supervisor. CalculationDriver reports Healthy while running and so can never trip, which is correct: it has no transport. Also: the Reconnecting retry now calls ShutdownAsync before InitializeAsync. ModbusDriver (_transport) and MqttDriver (_connection) overwrite an IAsyncDisposable field inside InitializeAsync without disposing the previous one, so re-initialising without a teardown leaks a socket per attempt -- latent while only a manual Force Reconnect reached this path, but the watchdog makes it automatic and unbounded (~360 leaks for an hour-long outage at the 10s retry). Tests: 8 new in DriverInstanceActorCommsLossWatchdogTests, each absence assertion carrying a positive control (a proven count of processed health-poll ticks). Falsified five ways -- dropping either conjunct reds exactly the test written for it, and removing the teardown, the null baseline, or the watchdog call each reds its own guard. Live gate PASSED on docker-dev (Modbus + otopcua-pymodbus-standard), run twice: - fixture stopped -> watchdog fires at ~108s, "declaring comms lost" - fan-out observed at Debug: "pushed BadNoCommunication for 2 mux ref(s)" (#481 Layer 4) and the #477 native-condition annotation on a driver owning conditions - fixture restarted -> reconnected in ~8s with NO node restart; both the raw (ns=2) and UNS (ns=3) NodeIds read Good with fresh timestamps Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
22 KiB
Driver Lifecycle & Server Infrastructure Contracts
Reference for the server-side infrastructure interfaces that surround a
driver but are not driver capabilities (read/write/subscribe/etc.,
documented in ReadWriteOperations.md and the
per-driver pages). These contracts live in
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/
so they carry no behavior — concrete implementations live in the driver
projects, the Runtime, and the ControlPlane. Each subsection below gives the
purpose, the key members, and where it is implemented/used.
The capability interfaces a driver opts into (IReadable, IWritable,
ITagDiscovery, ISubscribable, IAlarmSource, IHistoryProvider,
IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable) are
covered elsewhere and discovered by the server via is-checks on the
IDriver instance. The interfaces here are the plumbing the server uses to
create, probe, supervise, report on, and configure those
drivers, plus the server-side historian read surface.
IDriverFactory — creating drivers from config rows
Core.Abstractions/IDriverFactory.cs
Abstraction over the process-wide driver registry. The Runtime consumes this
instead of the concrete registry so the Runtime project does not pull in
ZB.MOM.WW.OtOpcUa.Core (which would drag in Polly + driver hosting).
Members:
IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)— returns a new driver for the given type, ornullwhen no factory is registered for that type (missing assembly, typo). TheDriverHostActorlogs and skips the row rather than failing the whole apply.IReadOnlyCollection<string> SupportedTypes— driver-type names this factory can materialise; mostly for diagnostics and logs.
Implementations:
NullDriverFactory(same file) returnsnullfrom everyTryCreateand exposes zero supported types. Bound when no concrete driver assemblies have been registered (Mac dev path, smoke tests); the deployment becomes a no-op.DriverFactoryRegistry(Core/Hosting/DriverFactoryRegistry.cs) is the real process-singleton registry keyed byDriverInstance.DriverType(case-insensitive). Each driver project ships aRegister(...)extension;Registerrecords the factory and the driver's stabilityDriverTier(defaults to Tier A). Registering the same type twice throws.DriverFactoryRegistryAdapter(Core/Hosting/DriverFactoryRegistryAdapter.cs) bridges the registry to theIDriverFactoryabstraction.
Wiring: DriverFactoryBootstrap.AddOtOpcUaDriverFactories
(Host/Drivers/DriverFactoryBootstrap.cs)
registers the singleton registry, runs every driver assembly's Register
extension, then binds IDriverFactory to the adapter. It must run before
AddAkka so the Runtime can resolve IDriverFactory when spawning the
DriverHostActor
(Runtime/Drivers/DriverHostActor.cs).
The registry is skipped on admin-only nodes (they never run drivers); the
probe set is the exception — see IDriverProbe.
IDriverProbe — Test Connect
Core.Abstractions/IDriverProbe.cs
A cheap test-connect probe for one driver type, backing the AdminUI Test
Connect button. An implementation deserializes a driver-config JSON, attempts
a cheap connection (TCP open, OPC UA session, gRPC ping — whatever the driver's
native protocol supports), and reports success/failure with latency. Probes
must not mutate persistent state: the AdminUI invokes them against the
transient config in the typed form, not against the persisted DriverInstance
row.
Members:
string DriverType { get; }— theDriverInstance.DriverTypestring this probe handles; used for DI lookup.Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)— never throws on connection failure; returns a result withOk = falseand a message instead.DriverProbeResult(bool Ok, string? Message, TimeSpan? Latency)— outcome record (Messageisnullon success;Latencyisnullon failure).
Implementations: every driver ships a *DriverProbe in its driver project
(e.g.
Driver.Modbus/ModbusDriverProbe.cs
does a bare socket open/close). The historian backend is the external
HistorianGateway (consumed as a gRPC client package, not an IDriver), so it
has no driver probe.
Flow: the AdminUI's AdminProbeService
(AdminUI/Clients/AdminProbeService.cs)
dispatches a TestDriverConnect message through IAdminOperationsClient to the
cluster-singleton AdminOperationsActor
(ControlPlane/AdminOperations/AdminOperationsActor.cs),
which holds the probes keyed by DriverType and invokes the matching one
(timeout clamped to [1, 60] seconds). Because the admin singleton is
admin-pinned, the probe set must be registered on admin nodes too — Program.cs
calls AddOtOpcUaDriverProbes in the hasAdmin block, and
AddOtOpcUaDriverFactories registers it for fused admin+driver nodes.
IDriverSupervisor / process recycle — REMOVED (Gitea #522)
IDriverSupervisor, MemoryRecycle, MemoryTracking and
ScheduledRecycleScheduler no longer exist. This section used to describe
them as the Tier C protection layer.
They were removed because the thing they acted on is gone. "Tier C" meant a
driver running out-of-process behind a supervisor that could restart its Host
without tearing down the OPC UA session or co-hosted drivers. No such process
remains anywhere: Galaxy reaches MXAccess over gRPC to the external
mxaccessgw sidecar (PR 7.2 retired the in-process Galaxy.Host / Proxy /
Shared projects), and FOCAS has run in-process since its managed wire client
landed 2026-04-24. Consistent with that, IDriverSupervisor had zero
implementations, and MemoryTracking / MemoryRecycle /
ScheduledRecycleScheduler were constructed only in their own unit tests —
so this was not dormant-but-ready code, it was code with no path to running.
The operator-facing half went too: RecycleIntervalSeconds was authorable
through the AdminUI's driver resilience section and validated by the parser
while configuring nothing. A stored ResilienceConfig still carrying the key
parses cleanly (unknown keys are ignored) and the AdminUI preserves it rather
than silently rewriting an operator's config.
DriverTier itself survives and is load-bearing — do not delete it while
following this note. DriverResilienceOptions.GetTierDefaults(tier) supplies
the real per-capability timeout / retry / breaker policies, resolved via
DriverFactoryRegistry.GetTier and applied by DriverCapabilityInvokerFactory.
Every driver runs Tier A because no factory passes a tier, so today that means
one uniform policy set.
IDriver no longer carries GetMemoryFootprint() or
FlushOptionalCachesAsync() (Gitea #525). They existed to feed
MemoryTracking, deleted with the rest of the driver-tier recycle machinery in
#522; after that nothing called them, and their doc-comments still claimed
Core polled the footprint every 30 s, which had not been true since v3. The
sweep removed them from the interface, all 12 drivers, and every test stub,
along with the support code that existed only to feed them — MTConnect's
per-entry byte weights and its cached data-item count, OpcUaClient's
_discoveredNodeCount, and MQTT's per-tag byte estimate.
Two consequences worth knowing:
AbCipTemplateCachenow has no production reset path. Its only caller was AB CIP's flush — which itself had no caller, so nothing has actually changed in behaviour, only in how visible the fact is. A UDT redefined on a live controller keeps its stale shape until the driver instance is rebuilt.- MTConnect's
/probecache is still dropped, byInvalidateProbeModel()on an Agent restart. The tests that used to reach it through the flush now drive that path instead, so they cover a case that actually occurs.
If per-driver memory protection is wanted again, build it for the architecture as it is now: a process-wide watchdog that can ask drivers to flush, not a per-driver tier whose only remedy requires a supervisor that cannot exist in-process. The deleted per-driver implementations are in git history and were mostly reusable — what was always missing is the trigger.
IDriverHealthPublisher — health pub/sub sink
Core.Abstractions/IDriverHealthPublisher.cs
A sink for driver-health state-change notifications. Implementations must be non-blocking and safe to call from any thread.
Member:
void Publish(string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min)
Implementations:
NullDriverHealthPublisher(same file) is the drop-in no-op for tests and dev-stub paths. ADriverInstanceActordefaults to it when no publisher is supplied.AkkaDriverHealthPublisher(Runtime/Drivers/AkkaDriverHealthPublisher.cs) is the production binding: it forwards each transition as aDriverHealthChangedmessage onto the cluster-widedriver-healthAkka DistributedPubSub topic.
Producer: DriverInstanceActor
(Runtime/Drivers/DriverInstanceActor.cs)
calls Publish when a driver's health transitions. The published snapshot is
consumed AdminUI-side and surfaced through the driver-status panel (read
in-process by the AdminUI bridge rather than dialing its own hub).
Writing health around a teardown (Gitea #517)
Two rules, and the second is the counter-intuitive one:
-
GetHealth()must report actual state, never a constant. A hard-codedHealthyreports a healthy driver before initialize and after shutdown alike.CalculationDriverdid exactly this — it was the fleet's one driver reporting Healthy with its timers stopped, deterministically rather than as a race. A driver with no transport still has a running / not-running distinction. -
Land the terminal health value after the teardown — and, where the window is observable, write it before as well. #517's original suggestion was to move the write earlier so the race window leans conservative. Moving it is wrong: teardown drains in-flight poll/probe loops, and those loops write health as they unwind, so a driver that only wrote first could have a late loop overwrite the shutdown state with
Healthyand leave that as the resting value — with every connection closed. The safe shape is a bracket: conservative value before, terminal value after.
Where the window is actually observable is worth knowing before adding brackets everywhere:
| Path | Caller | Can GetHealth() land mid-teardown? |
|---|---|---|
ShutdownAsync |
DriverInstanceActor.PostStop |
No — actor thread, after the mailbox drains, so the health poll is serialised against it. |
InitializeAsync failure |
DriverInstanceActor.InitializeAsync |
Yes — invoked on a fire-and-forget Task.Run, so the health-poll tick keeps calling GetHealth() throughout. |
So a driver whose InitializeAsync disposes anything on its failure path must bracket; only
MTConnectDriver does today, because it is the only one that tears down inside a catch. The
shutdown paths deliberately keep the single trailing write.
The comms-loss watchdog (Gitea #527) — GetHealth() is now load-bearing
GetHealth() used to be reporting-only: DriverInstanceActor.PublishHealthSnapshot read it every 30 s
and forwarded it to the AdminUI, and nothing ever acted on a driver that reported itself unhealthy. That
left DisconnectObserved with no production sender at all — the only paths into Reconnecting were a
failed initial connect and an operator's Force Reconnect. A driver that connected and then lost its
device stayed Connected forever: publishing Bad for every tag, never re-initialising, and not
recovering when the device came back (live-confirmed against a stopped Modbus fixture, which needed a
node restart). Both comms-loss quality paths — #477's native-condition annotation and #481's
scripted-alarm input fan-out — hang off ConnectivityChanged, so both were wired to a signal that never
fired.
The Connected-state health tick now also evaluates a watchdog, and self-sends DisconnectObserved when
both of these hold:
- the driver reports a state other than
Healthy, and DriverHealth.LastSuccessfulReadhas not advanced forDefaultCommsLossTimeout(90 s = three ticks), measured from the Connected entry when the driver has never reported a successful read.
The conjunction is the whole design, and "non-Healthy for N polls" is the wrong rule. Degraded means
connected but some data is bad — the enum says so. Several drivers publish health per tag inside their
read loop (ModbusDriver.ReadAsync writes Healthy or Degraded once per reference), so a single
unreadable address leaves the driver latched Degraded whenever it happens to be read last, indefinitely,
against a device that is perfectly reachable. A state-only watchdog would tear that driver down every 90 s
forever — strictly worse than the bug it fixes. LastSuccessfulRead is the honest liveness evidence: every
driver advances it on any successful read and preserves it across Degraded/Faulted writes, so one bad
tag among good ones keeps it moving while a device that is genuinely gone freezes it. The state check is
still needed as the second conjunct, because a Healthy driver with nothing subscribed legitimately never
reads and must not be declared dead for being idle. Both halves are pinned by their own test in
DriverInstanceActorCommsLossWatchdogTests, each of which goes red when the other conjunct is deleted.
Two consequences worth knowing:
CalculationDrivercan never trip it, correctly. It reportsHealthywhenever it is running and has no transport to be degraded about. Every other driver reaches a non-Healthy state on sustained failure: the nine poll drivers viaDegraded, MQTT via its live connection state, Galaxy via its reconnect supervisor. This was surveyed across all 12 before relying on it — it is the assumption the whole mechanism rests on.- A device that accepts a connection but serves no data will cycle, reconnecting once per threshold. That is the correct reading of a broken device, it is rate-limited, and it is visible in the health snapshot's error count — but do not mistake it for a bug when reading logs.
The reconnect retry now tears down before re-initialising. Reconnecting's RetryConnect calls
ShutdownAsync before InitializeAsync — the sequence every driver's own ReinitializeAsync already
uses — because several drivers overwrite an IAsyncDisposable transport field inside InitializeAsync
without disposing the previous one (ModbusDriver._transport, MqttDriver._connection). Re-initialising
without a teardown leaks one socket per attempt. That was latent while only a manual Force Reconnect
re-entered this path; the watchdog makes it automatic and unbounded, so a device down for an hour would
have leaked ~360 transports at the 10 s retry interval. The shutdown is best-effort: a driver that throws
on it still gets its initialise attempt.
IDriverConfigEditor — custom AdminUI config editor (plug-point)
Core.Abstractions/IDriverConfigEditor.cs
An optional plug-point a driver can implement to provide a custom AdminUI
editor for its DriverConfig JSON. Drivers that don't implement it fall back to
the generic JSON editor with schema-driven validation. This is the contract
between the driver and the Admin Blazor app; the Admin app discovers
implementations and slots them into the Driver Detail screen.
Members:
string DriverType { get; }— the driver type this editor handles.Type EditorComponentType { get; }— the Razor component type that renders the editor (returned asTypesoCore.Abstractionsneeds no Blazor reference).
Status: this is a forward-looking plug-point. No driver ships a concrete
IDriverConfigEditor today — every driver uses the generic JSON editor — so
the interface currently has the contract defined but no implementations.
IHistorianDataSource — server-side historian read surface
Core.Abstractions/Historian/IHistorianDataSource.cs
The server-side historian read surface. Registered with the server's history
router and resolved per OPC UA namespace, independent of any driver's
lifecycle. This is distinct from the driver capability IHistoryProvider:
IHistoryProvideris a driver capability — the server dispatches to it via the driver instance.IHistorianDataSourceis a server registration — the server resolves it by namespace and calls it directly, so one historian (the HistorianGateway) can serve many drivers' nodes, and drivers can restart without dropping history availability.
The interface is : IDisposable and declares the full read surface as
required members (unlike IHistoryProvider, where at-time/event reads are
optional default-impl methods so legacy drivers can stay raw-only):
ReadRawAsync(fullReference, startUtc, endUtc, maxValuesPerNode, ct)— raw historical samples over a time range.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, ct)— interval-bucketed aggregates (average/min/max/count); an empty bucket returns aBadNoDatasample.ReadAtTimeAsync(fullReference, timestampsUtc, ct)— one sample per requested timestamp (OPC UA HistoryReadAtTime); the returned list matches the requested length and order, gaps as Bad-quality snapshots.ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, ct)— historical alarm/event records (OPC UA HistoryReadEvents);sourceNameisnullto return all sources.maxEventsis a signedintso a non-positive value is a "use the backend's default cap" sentinel.GetHealthSnapshot()— point-in-time health snapshot for diagnostics and dashboards; pure observation, never blocks on backend I/O.
All values use the shared DataValueSnapshot / HistoricalEvent shapes;
backend-specific quality/type encodings are translated to OPC UA StatusCode
uints inside the data source.
Implementation:
GatewayHistorianDataSource(Driver.Historian.Gateway/GatewayHistorianDataSource.cs) — the read backend that talks gRPC to the externalZB.MOM.WW.HistorianGateway(via theZB.MOM.WW.HistorianGateway.Clientpackage, behind theIHistorianGatewayClientseam). The alarm-event drain target is the separateGatewayAlarmHistorianWriter(the gatewaySendEventpath; see AlarmHistorian.md).
The HistorianGateway is the sole historian backend; its config keys and deployment prerequisites are in Historian.md.
Commons — shared cross-cutting primitives
src/Core/ZB.MOM.WW.OtOpcUa.Commons/
ZB.MOM.WW.OtOpcUa.Commons is the low-level shared library that the Runtime,
ControlPlane, AdminUI, and OPC UA server projects all reference. It holds
cross-cutting primitives with no driver- or host-specific behavior, so the
heavier projects can share message contracts and value types without taking a
dependency on each other. It references only Akka and the internal
ZB.MOM.WW.Audit package.
Folders:
Messages/— Akka message contracts grouped by concern (Admin,Alerts,Deploy,Drivers,Fleet,Logging,Redundancy). These are the wire/inter-actor messages — e.g.Messages/Admin/TestDriverConnect.cs(Test Connect request, see IDriverProbe) andMessages/Drivers/DriverHealthChanged.cs(the driver-health pub/sub payload, see IDriverHealthPublisher).Interfaces/— cluster-facing client contracts such asIAdminOperationsClient,IClusterRoleInfo, andIFleetDiagnosticsClient.Types/— strongly-typed identifier value types:CorrelationId,DeploymentId,ExecutionId,NodeId,RevisionHash.Browsing/— live-browse abstractions (BrowseNode,IBrowseSession,IDriverBrowser) backing the AdminUI address pickers.Engines/— evaluator seams (IScriptedAlarmEvaluator,IVirtualTagEvaluator,IAlarmActorStateStore) consumed by the VirtualTags / ScriptedAlarms engines.OpcUa/— deferred-publish seams (IOpcUaAddressSpaceSink,IServiceLevelPublisherand theirDeferred*no-op stand-ins) so address-space and ServiceLevel writes can be wired late.Observability/—OtOpcUaTelemetry(the shared ActivitySource/metrics surface).
See also
- ReadWriteOperations.md — the driver capability interfaces (read/write/subscribe) and resilience pipeline.
- ServiceHosting.md — role gating, the Akka cluster, and the external HistorianGateway backend.
- AlarmHistorian.md — the store-and-forward SQLite alarm
sink that drains to
IAlarmHistorianWriter. - Redundancy.md — driver stability tiers in the redundancy context.