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
@@ -44,6 +44,21 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
/// Nothing dedups on record equality — <c>DriverInstanceActor</c> keeps its own flattened fingerprint
/// precisely because of this — but do not introduce such a comparison without fixing it here first.</para>
/// </param>
/// <param name="SkippedTags">
/// What this driver skipped when it last built its tag table, from
/// <c>ISkippedTagReporter.GetSkippedTags()</c>; null when the driver does not implement that
/// capability. All twelve production drivers do.
/// <para><b>Why this is on the health snapshot (Gitea #541).</b> A skip happens at init, is not an
/// error, and does not stop the driver's OTHER tags from reading — so <c>State</c> stays Healthy,
/// <c>ErrorCount5Min</c> stays 0, <c>LastError</c> stays null and <c>LastSuccessfulReadUtc</c> keeps
/// advancing while the driver ignores part or all of its configuration. On the Makino in Gitea #540
/// that combination held while 20 of 22 tags were ignored. This field is what contradicts it.</para>
/// <para><b>Additive, never a state change.</b> See <c>ISkippedTagReporter</c> for why a skipped tag
/// must not latch the driver non-Healthy — it would satisfy both halves of the #527 comms-loss
/// conjunction and reconnect-loop a healthy device.</para>
/// <para><b>Value-equality caveat</b> — the same one <paramref name="HostStatuses"/> carries: the
/// record's generated <c>Equals</c> compares the sample list by REFERENCE.</para>
/// </param>
public sealed record DriverHealthChanged(
string ClusterId,
string DriverInstanceId,
@@ -54,7 +69,8 @@ public sealed record DriverHealthChanged(
DateTime PublishedUtc,
DateTime? RediscoveryNeededUtc = null,
string? RediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? HostStatuses = null)
IReadOnlyList<HostConnectivityStatus>? HostStatuses = null,
SkippedTagReport? SkippedTags = null)
{
/// <summary>
/// DPS topic name. Both the runtime <c>AkkaDriverHealthPublisher</c> and the AdminUI
@@ -76,6 +76,27 @@ message DriverHealth {
// than letting an empty list silently claim the driver has no probe.
bool has_host_statuses = 10;
repeated HostConnectivity host_statuses = 11;
// Authored tags the driver could not build into its poll table (Gitea #541). The same two facts
// host_statuses needs a flag for — "the driver cannot report skips" vs "it reports none" — are
// distinguished here by the field's own presence: a singular MESSAGE field has real presence in
// proto3, which a repeated field does not. So no companion has_ flag (and one would collide: protoc
// already generates a HasSkippedTags property for this field).
SkippedTags skipped_tags = 12;
}
// Mirrors ZB.MOM.WW.OtOpcUa.Core.Abstractions.SkippedTagReport.
message SkippedTags {
int32 authored_count = 1;
// The TRUE total, which may exceed sample.length — the detail list is capped so a deployment that
// misconfigures thousands of tags cannot inflate a message published on every state change.
int32 skipped_count = 2;
repeated SkippedTag sample = 3;
}
// Mirrors ZB.MOM.WW.OtOpcUa.Core.Abstractions.SkippedTag.
message SkippedTag {
string raw_path = 1;
string reason = 2;
}
// Mirrors ZB.MOM.WW.OtOpcUa.Core.Abstractions.HostConnectivityStatus.
@@ -28,6 +28,12 @@ public interface IDriverHealthPublisher
/// AbLegacy instance owning several PLCs) surface ONE unreachable device that would otherwise be
/// invisible behind an aggregate-Healthy driver row.
/// </param>
/// <param name="skippedTags">
/// Authored tags the driver could not build into its poll table, from
/// <see cref="ISkippedTagReporter.GetSkippedTags"/>; null when the driver is not an
/// <see cref="ISkippedTagReporter"/>. Contradicts the "Healthy, 0 errors, reads advancing" reading
/// of a driver that is in fact ignoring part or all of its configuration (Gitea #541).
/// </param>
void Publish(
string clusterId,
string driverInstanceId,
@@ -35,7 +41,8 @@ public interface IDriverHealthPublisher
int errorCount5Min,
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null);
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
SkippedTagReport? skippedTags = null);
}
/// <summary>
@@ -57,6 +64,7 @@ public sealed class NullDriverHealthPublisher : IDriverHealthPublisher
int errorCount5Min,
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null)
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
SkippedTagReport? skippedTags = null)
{ /* no-op */ }
}
@@ -0,0 +1,176 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Optional driver capability: report the authored tags this driver decided, at initialise time,
/// that it cannot poll — so an operator can see that a driver reporting <c>Healthy</c> is in fact
/// ignoring some or all of its configuration.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists (Gitea #541).</b> Every driver builds its <c>RawPath → definition</c>
/// table at initialise and <i>tolerates</i> a per-tag miss — an unmappable <c>TagConfig</c>, an
/// unparseable address, a capability-matrix rejection. Tolerance is the right call: one bad tag
/// must not take down the other few thousand. But a skip is not an error and happens before any
/// read, so the driver stays <c>Healthy</c>, <c>errors/5 min</c> stays 0, <c>Last error</c> stays
/// empty, and <c>Last read</c> advances live off the tags that <i>did</i> map. On the Makino in
/// Gitea #540 that combination held while the driver ignored 20 of its 22 tags, and no surface
/// anywhere said otherwise.
/// </para>
/// <para>
/// <b>Deliberately additive to driver state, never a change of it.</b> Latching a driver
/// non-Healthy over a config problem would collide with the Gitea #527 comms-loss detector,
/// which trips on <i>90 s without a successful read AND non-Healthy</i>. A driver whose tags
/// were <i>all</i> skipped has no successful reads by construction, so it would satisfy both
/// halves and reconnect-loop forever against a device that is perfectly fine. The signal
/// therefore rides alongside the state.
/// </para>
/// <para>
/// <b>The count is not the whole story — the denominator is.</b> "20 skipped" is unreadable
/// without knowing whether that is 20 of 22 or 20 of 20 000, which is why
/// <see cref="SkippedTagReport.AuthoredCount"/> travels with it.
/// </para>
/// <para>
/// Implementations should accumulate into a <see cref="SkippedTagLedger"/> rather than
/// hand-rolling a counter: the ledger is one implementation of the reset/commit/cap rules
/// instead of one per driver, which is precisely the duplication #541 was filed about.
/// </para>
/// </remarks>
public interface ISkippedTagReporter
{
/// <summary>
/// Snapshot of what this driver skipped on its most recent tag-table build.
/// </summary>
/// <remarks>
/// Must be a pure in-memory read with no I/O — it is called on the health-publish path, is
/// deliberately NOT routed through the resilience pipeline, and must never block.
/// </remarks>
/// <returns>The current report; never null. An all-zero report means "nothing skipped".</returns>
SkippedTagReport GetSkippedTags();
}
/// <summary>One authored tag a driver could not build into its poll table, and why.</summary>
/// <param name="RawPath">The authored raw path, as it appears in the deployment artifact and the AdminUI.</param>
/// <param name="Reason">
/// Operator-facing explanation, e.g. <c>"invalid Address 'axis:5'"</c>. Should name the offending
/// value where there is one — the operator's next move is to go and fix that value.
/// </param>
public sealed record SkippedTag(string RawPath, string Reason);
/// <summary>
/// What a driver skipped when it last built its tag table.
/// </summary>
/// <param name="AuthoredCount">How many raw tags the deployment authored for this driver.</param>
/// <param name="SkippedCount">
/// How many of them were skipped — the TRUE total, which may exceed <paramref name="Sample"/>'s
/// length. A misconfigured deployment can skip thousands of tags, and this record rides a
/// pub/sub message published on every driver state change.
/// </param>
/// <param name="Sample">
/// The first <see cref="SkippedTagLedger.MaxSample"/> skips, in encounter order — enough for an
/// operator to recognise the pattern and go fix it. Empty when nothing was skipped.
/// </param>
public sealed record SkippedTagReport(int AuthoredCount, int SkippedCount, IReadOnlyList<SkippedTag> Sample)
{
/// <summary>A driver that has authored nothing and skipped nothing.</summary>
public static readonly SkippedTagReport Empty = new(0, 0, []);
/// <summary>Whether any authored tag was skipped — i.e. whether this is worth an operator's attention.</summary>
public bool HasSkips => SkippedCount > 0;
/// <summary>
/// Whether the driver skipped every tag it was given. Worth distinguishing: such a driver polls
/// nothing at all, yet is the MOST likely to look healthy, because it has no failing reads to
/// report — it has no reads.
/// </summary>
public bool SkippedEverything => AuthoredCount > 0 && SkippedCount >= AuthoredCount;
}
/// <summary>
/// Accumulator behind <see cref="ISkippedTagReporter"/>. Drivers stage skips while rebuilding their
/// tag table and commit the result in one step, so a health publish landing mid-rebuild reads the
/// previous complete answer rather than a half-built one.
/// </summary>
/// <remarks>
/// <para>
/// The build/swap shape mirrors what the tag tables themselves already do (<c>SqlDriver</c>
/// builds a new dictionary and <c>Volatile.Write</c>s it in). The reason is the same: a rebuild
/// that throws half-way must leave the previous snapshot standing rather than publish a
/// truncated one that would read as "we now skip fewer tags".
/// </para>
/// <para>
/// Reads and writes are lock-guarded. This is a cold path — one build per initialise, one read
/// per health publish — so contention is irrelevant and correctness under the concurrent
/// actor-thread read is not.
/// </para>
/// </remarks>
public sealed class SkippedTagLedger
{
/// <summary>
/// How many individual skips are carried in <see cref="SkippedTagReport.Sample"/>. The count is
/// always exact; only the detail list is capped, so a 10 000-tag misconfiguration cannot inflate
/// a telemetry message that is published on every state change.
/// </summary>
public const int MaxSample = 20;
private readonly object _gate = new();
private SkippedTagReport _visible = SkippedTagReport.Empty;
private int _stagedAuthored;
private int _stagedSkipped;
private List<SkippedTag>? _stagedSample;
/// <summary>
/// Starts staging a new tag-table build. The visible snapshot is left alone until
/// <see cref="Commit"/>, so an abandoned build cannot publish a partial result.
/// </summary>
/// <param name="authoredCount">How many raw tags this build was handed.</param>
public void BeginRebuild(int authoredCount)
{
lock (_gate)
{
_stagedAuthored = authoredCount;
_stagedSkipped = 0;
_stagedSample = new List<SkippedTag>(Math.Min(authoredCount, MaxSample));
}
}
/// <summary>
/// Records one skipped tag against the in-progress build. A call outside a
/// <see cref="BeginRebuild"/>/<see cref="Commit"/> pair is ignored rather than throwing: this
/// runs inside a driver's initialise, where a bookkeeping exception would fail the very init
/// the ledger exists to describe.
/// </summary>
/// <param name="rawPath">The authored raw path that was skipped.</param>
/// <param name="reason">Operator-facing reason; should name the offending value.</param>
public void Skip(string rawPath, string reason)
{
lock (_gate)
{
if (_stagedSample is null) return;
_stagedSkipped++;
if (_stagedSample.Count < MaxSample)
_stagedSample.Add(new SkippedTag(rawPath ?? "", reason ?? ""));
}
}
/// <summary>
/// Publishes the staged build as the visible snapshot, atomically. A call with no staged build
/// is a no-op.
/// </summary>
public void Commit()
{
lock (_gate)
{
if (_stagedSample is null) return;
_visible = new SkippedTagReport(_stagedAuthored, _stagedSkipped, _stagedSample);
_stagedSample = null;
}
}
/// <summary>The current published snapshot.</summary>
/// <returns>The last committed report, or <see cref="SkippedTagReport.Empty"/> before the first commit.</returns>
public SkippedTagReport Snapshot()
{
lock (_gate) return _visible;
}
}