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
@@ -65,6 +65,7 @@ public sealed class InterfaceIndependenceTests
[InlineData(typeof(IHistoryProvider))]
[InlineData(typeof(IRediscoverable))]
[InlineData(typeof(IHostConnectivityProbe))]
[InlineData(typeof(ISkippedTagReporter))]
[InlineData(typeof(IDriverConfigEditor))]
[InlineData(typeof(IAddressSpaceBuilder))]
public void EveryCapabilityInterface_IsPublic(Type type)
@@ -0,0 +1,210 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// Gitea #541. The rules a driver relies on when it accumulates skips into a
/// <see cref="SkippedTagLedger"/> — one implementation of them, rather than twelve.
/// </summary>
public sealed class SkippedTagLedgerTests
{
[Fact]
public void A_fresh_ledger_reports_nothing()
{
var report = new SkippedTagLedger().Snapshot();
report.AuthoredCount.ShouldBe(0);
report.SkippedCount.ShouldBe(0);
report.Sample.ShouldBeEmpty();
report.HasSkips.ShouldBeFalse();
}
[Fact]
public void A_committed_build_reports_its_skips_with_the_authored_denominator()
{
var ledger = new SkippedTagLedger();
ledger.BeginRebuild(22);
ledger.Skip("Line1/Makino/Identity/SeriesNumber", "invalid Address");
ledger.Skip("Line1/Makino/Identity/Version", "invalid Address");
ledger.Commit();
var report = ledger.Snapshot();
report.AuthoredCount.ShouldBe(22);
report.SkippedCount.ShouldBe(2);
report.Sample.Select(s => s.RawPath)
.ShouldBe(["Line1/Makino/Identity/SeriesNumber", "Line1/Makino/Identity/Version"]);
report.HasSkips.ShouldBeTrue();
report.SkippedEverything.ShouldBeFalse();
}
/// <summary>
/// The Makino case from Gitea #540 — the one the whole issue is about. A driver skipping every
/// tag it has is the MOST likely to look healthy, because it has no failing reads to report.
/// </summary>
[Fact]
public void Skipping_every_authored_tag_is_distinguishable_from_skipping_some()
{
var ledger = new SkippedTagLedger();
ledger.BeginRebuild(3);
ledger.Skip("a", "r");
ledger.Skip("b", "r");
ledger.Skip("c", "r");
ledger.Commit();
ledger.Snapshot().SkippedEverything.ShouldBeTrue();
}
/// <summary>
/// A driver that authored nothing has not "skipped everything" — there was nothing to skip. The
/// distinction matters because the UI paints <c>SkippedEverything</c> as the loudest state, and an
/// empty driver is not an alarm.
/// </summary>
[Fact]
public void An_empty_deployment_has_not_skipped_everything()
{
var ledger = new SkippedTagLedger();
ledger.BeginRebuild(0);
ledger.Commit();
ledger.Snapshot().SkippedEverything.ShouldBeFalse();
}
/// <summary>
/// The count is exact; only the detail list is capped. A misconfigured deployment can skip
/// thousands of tags, and this report is published on every driver state change.
/// </summary>
[Fact]
public void The_sample_is_capped_but_the_count_is_not()
{
var ledger = new SkippedTagLedger();
ledger.BeginRebuild(500);
for (var i = 0; i < 500; i++) ledger.Skip($"tag-{i}", "TagConfig did not map to a definition");
ledger.Commit();
var report = ledger.Snapshot();
report.SkippedCount.ShouldBe(500);
report.Sample.Count.ShouldBe(SkippedTagLedger.MaxSample);
report.Sample[0].RawPath.ShouldBe("tag-0", "the sample keeps the FIRST skips, in encounter order");
}
/// <summary>
/// The reason the ledger has a commit step at all. A health publish landing part-way through a
/// rebuild must read the previous complete answer, not a half-built one — a truncated snapshot
/// would read as "we now skip fewer tags", which is the opposite of the truth.
/// </summary>
[Fact]
public void An_in_progress_rebuild_is_invisible_until_it_commits()
{
var ledger = new SkippedTagLedger();
ledger.BeginRebuild(10);
ledger.Skip("old", "r");
ledger.Commit();
ledger.BeginRebuild(10);
ledger.Skip("new-1", "r");
var midRebuild = ledger.Snapshot();
midRebuild.SkippedCount.ShouldBe(1);
midRebuild.Sample.Single().RawPath.ShouldBe("old");
ledger.Skip("new-2", "r");
ledger.Commit();
ledger.Snapshot().Sample.Select(s => s.RawPath).ShouldBe(["new-1", "new-2"]);
}
/// <summary>
/// An abandoned rebuild — a driver init that threw part-way, which several drivers do on an
/// unknown device — leaves the last good snapshot standing rather than publishing a partial one.
/// </summary>
[Fact]
public void A_rebuild_that_never_commits_leaves_the_previous_snapshot_standing()
{
var ledger = new SkippedTagLedger();
ledger.BeginRebuild(5);
ledger.Skip("known-bad", "invalid Address");
ledger.Commit();
ledger.BeginRebuild(5);
ledger.Skip("would-have-been-reported", "invalid Address");
// …driver throws here; no Commit.
ledger.Snapshot().Sample.Single().RawPath.ShouldBe("known-bad");
}
/// <summary>
/// A redeploy that fixes every tag must clear the report. Accumulating across rebuilds would
/// leave a permanent false alarm on a driver that is now correct.
/// </summary>
[Fact]
public void A_clean_rebuild_clears_a_previous_report()
{
var ledger = new SkippedTagLedger();
ledger.BeginRebuild(2);
ledger.Skip("bad", "invalid Address");
ledger.Commit();
ledger.BeginRebuild(2);
ledger.Commit();
var report = ledger.Snapshot();
report.HasSkips.ShouldBeFalse();
report.SkippedCount.ShouldBe(0);
report.AuthoredCount.ShouldBe(2);
}
/// <summary>
/// A stray <c>Skip</c> outside a rebuild is dropped rather than thrown. This runs inside a
/// driver's initialise: a bookkeeping exception would fail the very init the ledger describes.
/// </summary>
[Fact]
public void A_skip_outside_a_rebuild_is_ignored_rather_than_thrown()
{
var ledger = new SkippedTagLedger();
Should.NotThrow(() => ledger.Skip("orphan", "r"));
ledger.Snapshot().SkippedCount.ShouldBe(0);
}
/// <summary>
/// Concurrent reads during a build must not tear. The read is on the actor's health-publish path
/// while the build runs on the driver's init path, so this is the real production interleaving.
/// </summary>
[Fact]
public async Task Reading_while_a_build_runs_never_observes_a_torn_report()
{
var ledger = new SkippedTagLedger();
ledger.BeginRebuild(1000);
ledger.Commit();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var writer = Task.Run(() =>
{
for (var round = 0; round < 50 && !cts.IsCancellationRequested; round++)
{
ledger.BeginRebuild(1000);
for (var i = 0; i < 100; i++) ledger.Skip($"t{i}", "r");
ledger.Commit();
}
}, cts.Token);
var reader = Task.Run(() =>
{
while (!writer.IsCompleted && !cts.IsCancellationRequested)
{
var r = ledger.Snapshot();
// Every committed snapshot is internally consistent: the sample never exceeds either the
// cap or the true count it is drawn from.
r.Sample.Count.ShouldBeLessThanOrEqualTo(SkippedTagLedger.MaxSample);
r.Sample.Count.ShouldBeLessThanOrEqualTo(r.SkippedCount);
}
}, cts.Token);
await Task.WhenAll(writer, reader);
}
}
@@ -0,0 +1,92 @@
using System.Reflection;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// Gitea #541. Pins the fleet-wide invariant: <b>every driver reports what it skipped.</b>
/// </summary>
/// <remarks>
/// <para>
/// The defect this guards was never one driver's bug — it was one missing signal repeated across
/// every driver in the fleet, which is exactly the shape that a per-driver test cannot catch and a
/// thirteenth driver would silently re-introduce. So discovery here is <b>reflective, not a
/// hand-copied list</b>: it scans the driver assemblies deployed to bin for concrete
/// <see cref="IDriver"/> implementations, the same convention
/// <c>DriverTypeNamesGuardTests</c> uses to find factories.
/// </para>
/// <para>
/// The original issue named eight drivers. The real number is twelve — every driver in
/// <see cref="DriverTypeNames.All"/> has an authored-tag skip path, and <c>OpcUaClient</c>'s was
/// the quietest of them (a bare <c>continue</c> in <c>NamespaceMap</c>, with not even a log line).
/// A hand-maintained list would have shipped the four the issue missed still invisible.
/// </para>
/// </remarks>
public sealed class SkippedTagReporterFleetTests
{
/// <summary>Driver-adjacent assemblies that ship no runtime driver (contracts/addressing/CLI helpers,
/// browsers, transports, and the historian client packages).</summary>
private static readonly string[] NonDriverAssemblySuffixes =
[".Contracts", ".Addressing", ".Cli", ".Browser", ".Transport", ".Dialect", ".Client"];
/// <summary>
/// Every concrete <see cref="IDriver"/> implementation deployed to the test output directory.
/// </summary>
/// <returns>The discovered driver types.</returns>
private static IReadOnlyList<Type> DiscoverDriverTypes()
{
var found = new List<Type>();
var binDir = Path.GetDirectoryName(typeof(SkippedTagReporterFleetTests).Assembly.Location)!;
foreach (var dll in Directory.GetFiles(binDir, "ZB.MOM.WW.OtOpcUa.Driver.*.dll"))
{
var name = Path.GetFileNameWithoutExtension(dll);
if (NonDriverAssemblySuffixes.Any(s => name.EndsWith(s, StringComparison.Ordinal)))
continue;
Assembly asm;
try { asm = Assembly.LoadFrom(dll); }
catch { continue; }
Type[] types;
try { types = asm.GetTypes(); }
catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t is not null).ToArray()!; }
found.AddRange(types.Where(t =>
t is { IsClass: true, IsAbstract: false } && typeof(IDriver).IsAssignableFrom(t)));
}
return found;
}
/// <summary>
/// Every driver in the fleet implements <see cref="ISkippedTagReporter"/>.
/// </summary>
/// <remarks>
/// A driver that does not is one whose <c>Healthy</c> is unfalsifiable: it can ignore every tag it
/// was authored and no surface anywhere will say so.
/// </remarks>
[Fact]
public void Every_driver_reports_the_tags_it_skipped()
{
var drivers = DiscoverDriverTypes();
// A zero here means the bin scan broke, not that the fleet has no drivers — fail loudly rather
// than pass a vacuously-empty check, which is how an invariant test quietly stops testing.
drivers.Count.ShouldBeGreaterThanOrEqualTo(DriverTypeNames.All.Count,
"fewer IDriver implementations were discovered than there are registered driver types — the " +
"driver assemblies did not deploy to bin, or a driver moved out of a ZB.MOM.WW.OtOpcUa.Driver.* assembly");
var missing = drivers
.Where(t => !typeof(ISkippedTagReporter).IsAssignableFrom(t))
.Select(t => t.FullName)
.OrderBy(n => n, StringComparer.Ordinal)
.ToList();
missing.ShouldBeEmpty(
"these drivers do not report skipped tags, so each can ignore its whole configuration while " +
"reporting Healthy with 0 errors (Gitea #541). Accumulate skips into a SkippedTagLedger during " +
"the tag-table build and expose it via ISkippedTagReporter: " + string.Join(", ", missing));
}
}