feat(drivers): every driver reports the authored tags it skipped (Gitea #541)

A driver builds its RawPath -> definition table at init and tolerates a
per-tag miss: an unmappable TagConfig, an unparseable address, a
capability-matrix rejection. That tolerance is right -- one bad tag must not
take down the other few thousand. What was missing is any statement of the
fact afterwards. A skip is not an error and happens before any read, so the
driver stays Healthy, errors/5 min stays 0, Last error stays empty, and Last
read advances live off the tags that did map. On the wonder Makino in #540
that reading held while focas-z-34184 ignored 20 of its 22 tags.

New optional capability ISkippedTagReporter, backed by a shared
SkippedTagLedger so the reset/commit/cap rules are one implementation rather
than one per driver. The report -- authored count, exact skipped count, and a
capped sample of RawPath + reason -- rides DriverHealthChanged to a new Tags
column on /hosts, over the Phase 5 telemetry stream. No table: a driver-only
node has no ConfigDb to write to, the lesson from #521.

The issue named eight drivers. It is twelve -- every driver in
DriverTypeNames.All has an authored-tag skip path. Calculation (no scriptId),
MTConnect (no readable DataItem id), Galaxy (no attributeRef) and OpcUaClient
(no nodeId) were unlisted, and OpcUaClient's was the quietest of the twelve:
NamespaceMap drops such an entry with a bare `continue` and not even a log
line. SkippedTagReporterFleetTests pins the invariant reflectively over every
IDriver in src/Drivers/, so a thirteenth cannot re-introduce the silence.

Sql drops a tag at TWO points and its report is the union of both: a blob
that does not map, AND an identifier the 8.1 catalog gate rejects against the
live catalog. The second is what schema drift produces -- a renamed column, a
dropped view -- months after a deployment sealed green, and reporting only
the first would have that driver claim "0 skipped" while polling half its
tags, which is worse than reporting nothing because the column would then be
actively reassuring.

Deliberately additive to driver state, never a change of it. Latching
non-Healthy for a config problem would satisfy both halves of the #527
comms-loss conjunction -- 90 s without a successful read AND non-Healthy --
and a driver whose tags were ALL skipped has no successful reads by
construction, so it would reconnect-loop forever against a healthy device.

Three details that are load-bearing rather than incidental:

- The skip digest is part of the publish fingerprint. A skipped tag leaves
  every other fingerprint component identical to a clean driver's, so without
  it PublishHealthSnapshot's dedup swallows exactly the publish carrying the
  signal -- the trap that already bit the rediscovery and host-status
  channels. The digest covers the sample, not just the counts: a redeploy
  that fixes one tag and breaks another leaves both counts unchanged.
- The count is exact; only the sample is capped, and the tooltip says so when
  it is showing fewer than it counted. A truncated list that looked complete
  would have an operator fix twenty tags and believe the job done.
- The ledger stages and commits, so an init that throws part-way leaves the
  previous report standing. A partial snapshot would read as "we now skip
  fewer tags", the opposite of the truth.

The proto field needs no companion presence flag, unlike host_statuses: a
singular message field carries real presence in proto3, and a has_ flag would
collide with protoc's generated HasSkippedTags.

Verified red-first three ways: dropping SkippedTagDigest from the fingerprint
fails the two dedup tests, removing the seam from one driver fails the fleet
guard, and dropping the catalog-gate leg fails the Sql union test. Full
solution builds with zero new warnings against 4e22f024.
This commit is contained in:
Joseph Doherty
2026-08-05 19:23:40 -04:00
parent 4e22f02434
commit 39db75083d
34 changed files with 1572 additions and 30 deletions
+37
View File
@@ -11,6 +11,7 @@ OtOpcUa is a multi-driver OPC UA server. The Core (`ZB.MOM.WW.OtOpcUa.Core` + `C
- `IAlarmSource` — driver-emitted OPC UA A&C events
- `IHistoryProvider` — driver-side raw / processed / at-time / events HistoryRead (see [HistoricalDataAccess.md](../v1/HistoricalDataAccess.md))
- `IRediscoverable` — driver-initiated address-space rebuild notifications
- `ISkippedTagReporter` — authored tags the driver could not build into its poll table (see [Skipped tags](#skipped-tags) below)
- `IHistorianDataSource` — server-side historian read backend registration (the HistorianGateway backend), distinct from the driver-side `IHistoryProvider` HistoryRead path
Each driver opts into only the capabilities it supports. Every async capability call at the Server dispatch layer goes through `CapabilityInvoker` (`Core/Resilience/CapabilityInvoker.cs`), which wraps it in a Polly pipeline keyed on `(DriverInstanceId, HostName, DriverCapability)`. The `OTOPCUA0001` analyzer enforces the wrap at build time. Drivers themselves never depend on Polly; they just implement the capability interface and let the Core wrap it.
@@ -21,6 +22,42 @@ Driver **factories** are registered at startup in `DriverFactoryRegistry` (`src/
>
> **Stability tier** never came from that registry anyway: it is the `DriverTier tier = DriverTier.A` parameter on `DriverFactoryRegistry.cs:46`, and **no factory in `src/Drivers/` passes a tier**, so every shipped driver runs Tier A. The Tier-C recycle protections that [docs/v2/driver-stability.md](../v2/driver-stability.md) describes — `MemoryTracking`, `MemoryRecycle`, `ScheduledRecycleScheduler`, `IDriverSupervisor` — are **gone**, not merely dormant: they required an out-of-process driver Host that no longer exists anywhere (Galaxy is gRPC-to-`mxaccessgw`, FOCAS is in-process), `IDriverSupervisor` had no implementations, and the trackers were only ever constructed in tests. The `Tier` column below is therefore uniformly A, and the tier's remaining effect is `DriverResilienceOptions.GetTierDefaults` — the per-capability timeout / retry / breaker policy set, which **is** live.
## Skipped tags
Every driver builds its `RawPath → definition` table at `InitializeAsync` and **tolerates a per-tag miss** — an unmappable `TagConfig`, an unparseable address, a capability-matrix rejection, a `TagConfig` naming no readable id. The tag is logged, skipped, never enters the poll table, and every read of it returns `BadNodeIdUnknown`. That tolerance is deliberate: one bad tag must not take down the other few thousand on the same device.
**What that costs, and what closes it (Gitea [#541](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/541)).** A skip is not an error, and it happens before any read. So a driver ignoring part or all of its configuration reads, on `/hosts`:
| Column | What it shows | Why |
|---|---|---|
| Status | `Healthy` | nothing faulted — a config miss is not a fault |
| Errors/5 min | `0` | the counter counts transitions into Faulted |
| Last error | `—` | never set |
| Last read | advancing live | the tags that *did* map are polling fine |
On the wonder Makino in [#540](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/540) that reading held for weeks while `focas-z-34184` ignored **20 of its 22 tags**.
Each driver therefore implements `ISkippedTagReporter`, accumulating into the shared `SkippedTagLedger` during its tag-table build. The report — authored count, exact skipped count, and a capped sample of `RawPath` + reason — rides `DriverHealthChanged` to the **Tags** column on `/hosts`, alongside the per-host connectivity of [#521](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/521) and by the same transport (the Phase 5 telemetry stream, **not** a table — a driver-only node has no ConfigDb to write to).
Reading the Tags column:
| Cell | Meaning |
|---|---|
| `—` | the driver cannot report skips. **Not** the same as reporting none. |
| `12 tags` | the driver reports, and skipped nothing. |
| `20 / 22 skipped` (amber) | partly working — some tags poll, some do not. |
| `22 / 22 skipped` (red) | polling **nothing**. The state most likely to look healthy, because it has no failing reads to report. |
Three properties worth knowing:
- **A skip never changes driver state.** Latching non-Healthy for a config problem would satisfy both halves of the [#527](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/527) comms-loss conjunction — *90 s without a successful read AND non-Healthy* — and a driver whose tags were all skipped has no successful reads by construction, so it would reconnect-loop forever against a device that is perfectly fine. The signal is additive to the state, not a change of it.
- **The count is exact; only the sample is capped** (`SkippedTagLedger.MaxSample`). The tooltip says explicitly when it is showing fewer than it counted — a truncated list that looked complete would have an operator fix twenty tags and believe the job done.
- **The report is replaced per build, never accumulated**, and an init that throws part-way leaves the previous report standing rather than publishing the partial one it reached. A truncated snapshot would read as *"we now skip fewer tags"*, the opposite of the truth.
**Sql is the one driver that drops a tag at two points**, and its report is the union: a `TagConfig` that does not map (`BuildTagTable`) *and* an identifier the §8.1 catalog gate rejects against the live catalog (`ApplyCatalogGateAsync`). The second is what schema drift produces — a renamed column, a dropped view — months after a deployment sealed green, and reporting only the first would have the driver claim `0 skipped` while polling half its tags. In both cases the tag keeps its node and publishes `BadNodeIdUnknown`; it is only absent from the *polled* table.
`SkippedTagReporterFleetTests` pins the invariant reflectively over every `IDriver` in `src/Drivers/`, so a thirteenth driver cannot re-introduce the silence.
## Ground-truth driver list
| Driver | Project path | Tier | Wire / library | Capabilities | Notable quirk |