Files
lmxopcua/docs/DriverLifecycle.md
T
Joseph Doherty b815446940 refactor(drivers): delete GetMemoryFootprint / FlushOptionalCachesAsync (#525)
Both `IDriver` members had no production call site. Their only consumer was
`MemoryTracking`, deleted with the driver-tier recycle machinery in #522, and
`GetMemoryFootprint`'s doc-comment claimed "polled every 30s by Core" — which
had not been true since v3, even before #522 removed the poller.

Owner decision on #525 was (a) delete rather than (b) build the process-wide
watchdog that would consume them. The driver-side implementations are in git
history and were mostly reusable; what was always missing is the trigger, and a
member nobody calls is how the misleading doc-comment survived this long.

Removed from `IDriver`, all 12 driver implementations, and every test stub —
plus the support code that existed only to feed them, which is the part a
member-only deletion would have left behind as dead weight:

- MTConnect's three per-entry byte-weight constants, its cached
  `ProbeModelDataItemCount`, and `CountDataItems` — a full device-tree walk run
  on every cache fill to populate a number with no remaining reader.
- OpcUaClient's `_discoveredNodeCount` field and the discovery-pass write to it.
- MQTT's `ApproxBytesPerAuthoredTag`.

Two behavioural notes, neither a change in what the software does:

- `AbCipTemplateCache` now has no production reset path. Its only caller was
  AB CIP's flush, which itself had no caller — so nothing changes except how
  visible the fact is. Recorded on the type, along with the correction that its
  comment's claim about a device `ReinitializeAsync` also flushing it was never
  true. A UDT redefined on a live controller keeps its stale shape until the
  driver instance is rebuilt.
- MTConnect's `/probe` cache is still dropped, by `InvalidateProbeModel()` on an
  Agent restart. That is now `internal` rather than `private` so the tests which
  used the flush purely to empty the cache drive the real path instead — they
  now cover a case that actually occurs in production. Several unrelated doc
  comments cited `FlushOptionalCachesAsync` as the canonical CAS example and are
  repointed to it.

Tests: the ones that existed only to assert on the removed members are deleted
(3 OpcUaClient, 3 Galaxy, 2 MQTT, 1 MTConnect, 1 AbCip). Worth noting one was
vacuous — `FlushOptionalCachesAsync_resets_footprint_counter` asserted a
footprint of 0 before the flush and 0 after with no discovery in between, so it
would have passed against a flush that did nothing at all.

Full suite green except 7 failures verified identical on master before and
after (4 AbLegacy integration with the compose profile down, 3
Host.IntegrationTests), checked by name rather than assumed.

Docs corrected where they described this as live: `DriverLifecycle.md` (which
carried the #525 placeholder), `drivers/AbCip.md`, `drivers/AbLegacy.md`, and
`v2/driver-stability.md` — whose "polls this every 30 s" + per-driver
`DriverConfig` memory budget were never built as written and are now banner-ed
as design intent rather than description.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 09:07:11 -04:00

18 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, or null when no factory is registered for that type (missing assembly, typo). The DriverHostActor logs 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) returns null from every TryCreate and 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 by DriverInstance.DriverType (case-insensitive). Each driver project ships a Register(...) extension; Register records the factory and the driver's stability DriverTier (defaults to Tier A). Registering the same type twice throws.
  • DriverFactoryRegistryAdapter (Core/Hosting/DriverFactoryRegistryAdapter.cs) bridges the registry to the IDriverFactory abstraction.

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; } — the DriverInstance.DriverType string this probe handles; used for DI lookup.
  • Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) — never throws on connection failure; returns a result with Ok = false and a message instead.
  • DriverProbeResult(bool Ok, string? Message, TimeSpan? Latency) — outcome record (Message is null on success; Latency is null on 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:

  • AbCipTemplateCache now 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 /probe cache is still dropped, by InvalidateProbeModel() 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. A DriverInstanceActor defaults to it when no publisher is supplied.
  • AkkaDriverHealthPublisher (Runtime/Drivers/AkkaDriverHealthPublisher.cs) is the production binding: it forwards each transition as a DriverHealthChanged message onto the cluster-wide driver-health Akka 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:

  1. GetHealth() must report actual state, never a constant. A hard-coded Healthy reports a healthy driver before initialize and after shutdown alike. CalculationDriver did 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.

  2. 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 Healthy and 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.


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 as Type so Core.Abstractions needs 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:

  • IHistoryProvider is a driver capability — the server dispatches to it via the driver instance.
  • IHistorianDataSource is 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 a BadNoData sample.
  • 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); sourceName is null to return all sources. maxEvents is a signed int so 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 external ZB.MOM.WW.HistorianGateway (via the ZB.MOM.WW.HistorianGateway.Client package, behind the IHistorianGatewayClient seam). The alarm-event drain target is the separate GatewayAlarmHistorianWriter (the gateway SendEvent path; 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) and Messages/Drivers/DriverHealthChanged.cs (the driver-health pub/sub payload, see IDriverHealthPublisher).
  • Interfaces/ — cluster-facing client contracts such as IAdminOperationsClient, IClusterRoleInfo, and IFleetDiagnosticsClient.
  • 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, IServiceLevelPublisher and their Deferred* 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.