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));
}
}
@@ -0,0 +1,170 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Gitea #541, proved against the driver where the defect was actually found. #540 fixed the
/// addressing that produced the 20 unreadable tags on the wonder Makino; this covers the half that
/// made them <b>invisible</b> — the driver reported <c>Healthy</c> with 0 errors and a live-advancing
/// last read while ignoring 20 of its 22 tags, and nothing anywhere said so.
/// <para>FOCAS is the useful driver to prove it on because it has all three skip shapes: an unmappable
/// TagConfig, an unparseable address, and a capability-matrix rejection.</para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasSkippedTagReportingTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasDriver NewDriver(params FocasTagDefinition[] tags) =>
new(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions { Enabled = false },
RawTags = FocasRawTags.Entries(tags),
}, "drv-1", new FakeFocasClientFactory());
/// <summary>The driver advertises the capability at all — the fleet guard asserts this reflectively,
/// but a driver-local pin fails with a message that names FOCAS.</summary>
[Fact]
public void The_driver_reports_skipped_tags()
=> NewDriver().ShouldBeAssignableTo<ISkippedTagReporter>();
/// <summary>A driver whose tags all map reports zero skips against the real denominator.</summary>
[Fact]
public async Task A_clean_deployment_reports_no_skips()
{
var drv = NewDriver(
new FocasTagDefinition("raw/A", Host, "R100", FocasDataType.Byte),
new FocasTagDefinition("raw/B", Host, "MACRO:500", FocasDataType.Float64));
await drv.InitializeAsync("{}", CancellationToken.None);
var report = drv.GetSkippedTags();
report.AuthoredCount.ShouldBe(2);
report.SkippedCount.ShouldBe(0);
report.HasSkips.ShouldBeFalse();
report.SkippedEverything.ShouldBeFalse();
}
/// <summary>
/// <b>The wonder Makino, reproduced.</b> Twenty tags carrying the exact address shape #540
/// authored — a browse full reference with the device host in it — plus two good ones. The driver
/// is Healthy with no error either way; the skip report is the only thing that differs.
/// </summary>
[Fact]
public async Task The_makino_case_a_healthy_driver_that_is_ignoring_most_of_its_tags()
{
FocasTagDefinition[] broken =
[
.. Enumerable.Range(0, 20).Select(i => new FocasTagDefinition(
$"raw/Broken{i}", Host, $"10.0.0.5:8193/Identity/Field{i}", FocasDataType.String, Writable: false)),
];
var drv = NewDriver([
.. broken,
new FocasTagDefinition("raw/Good1", Host, "MACRO:500", FocasDataType.Float64),
new FocasTagDefinition("raw/Good2", Host, "MACRO:501", FocasDataType.Float64),
]);
await drv.InitializeAsync("{}", CancellationToken.None);
// Exactly the reading an operator got on /hosts, and why it was believed for weeks.
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetHealth().LastError.ShouldBeNull();
// …and the fact that contradicts it.
var report = drv.GetSkippedTags();
report.AuthoredCount.ShouldBe(22);
report.SkippedCount.ShouldBe(20);
report.SkippedEverything.ShouldBeFalse("two tags do poll — this driver is partly working");
report.Sample.ShouldAllBe(s => s.Reason.Contains("invalid Address"));
report.Sample[0].RawPath.ShouldBe("raw/Broken0");
}
/// <summary>
/// The sample is capped but the count is not. Twenty-two skips against a cap of twenty: a UI that
/// rendered the sample as the whole story would under-report by two.
/// </summary>
[Fact]
public async Task The_count_is_exact_even_when_the_sample_is_capped()
{
var drv = NewDriver([
.. Enumerable.Range(0, 25).Select(i => new FocasTagDefinition(
$"raw/Broken{i}", Host, "not-an-address", FocasDataType.String, Writable: false)),
]);
await drv.InitializeAsync("{}", CancellationToken.None);
var report = drv.GetSkippedTags();
report.SkippedCount.ShouldBe(25);
report.Sample.Count.ShouldBe(SkippedTagLedger.MaxSample);
report.SkippedEverything.ShouldBeTrue("this driver polls nothing at all");
}
/// <summary>
/// Re-initialising replaces the report rather than accumulating onto it. Config edits respawn the
/// driver (#516) and Sql/FOCAS re-parse in place, so a ledger that accumulated would leave a
/// permanent false alarm on a driver whose deployment has since been fixed.
/// </summary>
[Fact]
public async Task Re_initialising_replaces_the_report_rather_than_accumulating()
{
var drv = NewDriver(
new FocasTagDefinition("raw/Bad", Host, "not-an-address", FocasDataType.String, Writable: false),
new FocasTagDefinition("raw/Good", Host, "MACRO:500", FocasDataType.Float64));
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetSkippedTags().SkippedCount.ShouldBe(1);
await drv.InitializeAsync("{}", CancellationToken.None);
var report = drv.GetSkippedTags();
report.SkippedCount.ShouldBe(1, "the same one bad tag — not two");
report.AuthoredCount.ShouldBe(2);
}
/// <summary>
/// A tag routed to an unknown device is a driver-level config error that throws, and an init that
/// throws must leave the previous report standing rather than publish the half-built one it got to.
/// </summary>
[Fact]
public async Task An_init_that_throws_leaves_the_previous_report_standing()
{
var good = new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions { Enabled = false },
RawTags = FocasRawTags.Entries(
[
new FocasTagDefinition("raw/Bad", Host, "not-an-address", FocasDataType.String, Writable: false),
new FocasTagDefinition("raw/Good", Host, "MACRO:500", FocasDataType.Float64),
]),
};
var drv = new FocasDriver(good, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetSkippedTags().Sample.Single().RawPath.ShouldBe("raw/Bad");
// A second driver over an artifact whose tag names a device that is not configured: the loop skips
// the first entry, then throws on the second.
var bad = new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions { Enabled = false },
RawTags = FocasRawTags.Entries(
[
new FocasTagDefinition("raw/AlsoBad", Host, "not-an-address", FocasDataType.String, Writable: false),
new FocasTagDefinition("raw/Orphan", "focas://10.9.9.9:8193", "MACRO:500", FocasDataType.Float64),
]),
};
var drv2 = new FocasDriver(bad, "drv-2", new FakeFocasClientFactory());
await Should.ThrowAsync<InvalidOperationException>(
() => drv2.InitializeAsync("{}", CancellationToken.None));
drv2.GetSkippedTags().SkippedCount.ShouldBe(0,
"an abandoned build must not publish; a partial report would read as 'we skip fewer tags now'");
}
}
@@ -22,6 +22,53 @@ public sealed class SqlCatalogGateDriverTests
private const string DriverInstanceId = "sql-gate";
private const string ConfigJson = """{"provider":"SqlServer"}""";
/// <summary>
/// Gitea #541. Sql drops a tag at TWO points — a blob that does not map, and a rejection against
/// the live catalog — and both leave the tag unpolled and publishing <c>BadNodeIdUnknown</c> with
/// the driver <c>Healthy</c>. The skip report must be the union.
/// <para>This is the case that makes Sql different from the other eleven drivers, and the one worth
/// a live-catalog test rather than a unit one: a gate rejection is precisely what a schema drift
/// (renamed column, dropped view) produces months after a deployment sealed green. Reporting only
/// the blob-level skips would have this driver claim "0 skipped" while polling half its tags —
/// worse than reporting nothing, because the column would be actively reassuring.</para>
/// </summary>
[Fact]
public async Task A_catalog_gate_rejection_is_reported_as_a_skipped_tag()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(
fixture,
KvEntry("Speed", SqlitePollFixture.PresentKey),
KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "no_such_column"));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
// The reading an operator gets from every other column on /hosts.
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.GetHealth().LastError.ShouldBeNull();
// …and the one that contradicts it.
var report = ((ISkippedTagReporter)driver).GetSkippedTags();
report.AuthoredCount.ShouldBe(2);
report.SkippedCount.ShouldBe(1, "the gate rejected one tag; the blob mapped both");
report.Sample.Single().RawPath.ShouldBe("Bogus");
report.Sample.Single().Reason.ShouldContain("catalog gate");
}
/// <summary>A driver whose tags all map and all pass the gate reports zero skips.</summary>
[Fact]
public async Task A_driver_whose_tags_all_pass_the_gate_reports_no_skips()
{
using var fixture = new SqlitePollFixture();
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var report = ((ISkippedTagReporter)driver).GetSkippedTags();
report.AuthoredCount.ShouldBe(1);
report.HasSkips.ShouldBeFalse();
}
[Fact]
public async Task A_tag_naming_a_real_table_and_columns_polls_normally()
{
@@ -213,4 +213,96 @@ public sealed class HostsDriverViewTests
row.HostStatuses.ShouldBeNull();
row.DegradedHosts.ShouldBeEmpty();
}
// ---- skipped tags (Gitea #541) ----
/// <summary>
/// The Makino case from Gitea #540, as the row renders it: every driver-level field reads healthy
/// while 20 of 22 tags are not being polled. The skip field is the only thing on the row that
/// contradicts it.
/// </summary>
[Fact]
public void A_healthy_row_still_reports_the_tags_its_driver_is_ignoring()
{
var snapshot = Snap("MAIN", "focas-z-34184") with
{
SkippedTags = new SkippedTagReport(22, 20,
[new SkippedTag("Makino/Identity/SeriesNumber", "invalid Address")]),
};
var row = HostsDriverView.Build([snapshot], nodes: null, instances: null).Single().Drivers.Single();
row.State.ShouldBe("Healthy");
row.ErrorCount5Min.ShouldBe(0);
row.LastError.ShouldBeNull();
row.HasSkippedTags.ShouldBeTrue("the row must contradict its own healthy-looking columns");
row.SkippedTags!.SkippedCount.ShouldBe(20);
}
/// <summary>
/// A truncated sample must SAY it is truncated. Without the note a capped list reads as the whole
/// list, and an operator who fixes the twenty named tags believes the job is done.
/// </summary>
[Fact]
public void The_skip_tooltip_admits_when_the_sample_is_shorter_than_the_count()
{
var snapshot = Snap("MAIN", "drv-a") with
{
SkippedTags = new SkippedTagReport(500, 137, [new SkippedTag("tag-0", "TagConfig did not map")]),
};
var title = HostsDriverView.Build([snapshot], nodes: null, instances: null)
.Single().Drivers.Single().SkippedTagsTitle();
title.ShouldContain("137 of 500");
title.ShouldContain("+136 more not shown");
}
/// <summary>A complete sample carries no truncation note.</summary>
[Fact]
public void The_skip_tooltip_omits_the_truncation_note_when_the_sample_is_complete()
{
var snapshot = Snap("MAIN", "drv-a") with
{
SkippedTags = new SkippedTagReport(5, 1, [new SkippedTag("tag-0", "TagConfig did not map")]),
};
HostsDriverView.Build([snapshot], nodes: null, instances: null)
.Single().Drivers.Single().SkippedTagsTitle()
.ShouldNotContain("more not shown");
}
/// <summary>
/// A driver polling NONE of its tags gets its own wording — it is the most likely of all to look
/// healthy, because it has no failing reads to report.
/// </summary>
[Fact]
public void A_driver_polling_nothing_is_called_out_as_such()
{
var snapshot = Snap("MAIN", "drv-a") with
{
SkippedTags = new SkippedTagReport(3, 3, [new SkippedTag("a", "r")]),
};
var row = HostsDriverView.Build([snapshot], nodes: null, instances: null).Single().Drivers.Single();
row.SkippedTags!.SkippedEverything.ShouldBeTrue();
row.SkippedTagsTitle().ShouldContain("NONE");
}
/// <summary>
/// A driver that cannot report skips keeps null — distinct from one reporting zero. The column
/// renders "—" for the former and "N tags" for the latter, and collapsing them would claim an
/// unreporting driver is positively known to be polling everything.
/// </summary>
[Fact]
public void A_driver_without_a_skip_report_keeps_null_and_claims_nothing()
{
var row = HostsDriverView.Build([Snap("MAIN", "drv-a")], nodes: null, instances: null)
.Single().Drivers.Single();
row.SkippedTags.ShouldBeNull();
row.HasSkippedTags.ShouldBeFalse();
row.SkippedTagsTitle().ShouldBeEmpty();
}
}
@@ -220,6 +220,62 @@ public sealed class TelemetryProtoMapCentralTests
TelemetryProtoMapCentral.ToHealth(absent).HostStatuses.ShouldBeNull();
}
/// <summary>
/// The skipped-tag report (Gitea #541) survives the wire with the same tri-state discipline:
/// <b>null</b> (the driver cannot report skips) must stay distinguishable from a <b>zero report</b>
/// (it can, and skipped nothing). Unlike the repeated host_statuses field this needs no companion
/// presence flag — a singular message field carries real presence in proto3 — but the distinction it
/// protects is the same one, and collapsing it would render an unreporting driver as positively
/// known-clean.
/// </summary>
[Fact]
public void ToHealth_skipped_tags_round_trip_with_the_null_vs_zero_distinction_intact()
{
var populated = new DriverHealth
{
ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
SkippedTags = new SkippedTags
{
AuthoredCount = 22,
// Deliberately larger than the sample: the cap is on the detail, never on the count.
SkippedCount = 20,
Sample =
{
new SkippedTag { RawPath = "Makino/Identity/SeriesNumber", Reason = "invalid Address" },
},
},
};
var mapped = TelemetryProtoMapCentral.ToHealth(populated).SkippedTags;
mapped.ShouldNotBeNull();
mapped!.AuthoredCount.ShouldBe(22);
mapped.SkippedCount.ShouldBe(20);
mapped.Sample.Count.ShouldBe(1, "a truncated sample must arrive truncated, not padded or dropped");
mapped.Sample[0].RawPath.ShouldBe("Makino/Identity/SeriesNumber");
mapped.Sample[0].Reason.ShouldBe("invalid Address");
// A driver that reports, and skipped nothing: a zero report, NOT null.
var clean = new DriverHealth
{
ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
SkippedTags = new SkippedTags { AuthoredCount = 12, SkippedCount = 0 },
};
var mappedClean = TelemetryProtoMapCentral.ToHealth(clean).SkippedTags;
mappedClean.ShouldNotBeNull();
mappedClean!.HasSkips.ShouldBeFalse();
mappedClean.AuthoredCount.ShouldBe(12);
// A driver that cannot report at all: null, NOT a zero report.
var absentSkips = new DriverHealth
{
ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
};
TelemetryProtoMapCentral.ToHealth(absentSkips).SkippedTags.ShouldBeNull();
}
/// <summary>
/// An unparseable host state degrades to <see cref="HostState.Unknown"/> rather than throwing. A node
/// running a newer build that added an enum member must not be able to kill central's telemetry
@@ -210,7 +210,8 @@ public sealed class DriverInstanceActorHostStatusTests : RuntimeActorTestBase
int errorCount5Min,
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null)
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
SkippedTagReport? skippedTags = null)
{
lock (_published) _published.Add(new HealthPublish(health, hostStatuses));
}
@@ -186,7 +186,8 @@ public sealed class DriverInstanceActorRediscoverySignalTests : RuntimeActorTest
int errorCount5Min,
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null)
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
SkippedTagReport? skippedTags = null)
{
lock (_published)
{
@@ -0,0 +1,269 @@
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Covers the <see cref="ISkippedTagReporter"/> consumer (Gitea #541) — the signal that contradicts a
/// driver reporting <c>Healthy</c>, 0 errors and a live-advancing last read while ignoring most of the
/// tags it was authored.
/// <para><b>Why it does not go to the DB</b> — the same reason the #521 host statuses do not: a
/// driver-only node has no ConfigDb connection since per-cluster mesh Phase 4, so the data rides the
/// driver-health snapshot and the Phase 5 telemetry stream instead.</para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class DriverInstanceActorSkippedTagTests : RuntimeActorTestBase
{
/// <summary>The base case: a reporting driver's skips reach the health publisher.</summary>
[Fact]
public void Skipped_tags_reach_the_health_publisher()
{
var driver = new SkipStubDriver();
driver.SetReport(authored: 22, skipped: ["Makino/Identity/SeriesNumber", "Makino/Identity/Version"]);
var publisher = new RecordingHealthPublisher();
var actor = SpawnDriverActor(driver, publisher);
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitAssert(
() =>
{
var latest = publisher.Published.LastOrDefault();
latest.ShouldNotBeNull();
latest!.SkippedTags.ShouldNotBeNull();
latest.SkippedTags!.AuthoredCount.ShouldBe(22);
latest.SkippedTags.SkippedCount.ShouldBe(2);
latest.SkippedTags.Sample.Select(s => s.RawPath)
.ShouldBe(["Makino/Identity/SeriesNumber", "Makino/Identity/Version"]);
},
TimeSpan.FromSeconds(3));
}
/// <summary>
/// <b>The load-bearing case, and the entire reason this channel exists.</b> The Makino in Gitea #540:
/// a driver ignoring 20 of its 22 tags while every driver-level field reads perfectly healthy.
/// <para>This is the same dedup trap that already bit the rediscovery signal and the host statuses.
/// <c>PublishHealthSnapshot</c> suppresses a publish whose fingerprint repeats, and a skipped tag by
/// construction leaves (state, lastSuccessfulRead, lastError, errorCount) ALL unchanged — a skip is
/// not an error and happens before any read. Unless the skip digest is part of the fingerprint, the
/// dedup swallows exactly the publish that carries the news.</para>
/// <para><b>Falsifiability:</b> the assertion is that the publish count STRICTLY INCREASES across the
/// change. An "eventually reports skips" assertion would be satisfied by the warm-up publish alone
/// and would prove nothing. Drop <c>SkippedTagDigest</c> from the fingerprint tuple in
/// <c>DriverInstanceActor</c> and this test must go red.</para>
/// </summary>
[Fact]
public void A_redeploy_that_starts_skipping_tags_is_not_swallowed_by_the_unchanged_health_dedup()
{
var driver = new SkipStubDriver();
driver.SetReport(authored: 22, skipped: []);
var publisher = new RecordingHealthPublisher();
var actor = SpawnDriverActor(driver, publisher);
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
// Settle, so the baseline is a quiet actor: from here only the skip report changes. The driver's
// own health stays Healthy throughout — that is the point.
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
var before = publisher.Published.Count;
driver.SetReport(authored: 22, skipped: [.. Enumerable.Range(0, 20).Select(i => $"Makino/Tag{i}")]);
AwaitAssert(
() => publisher.Published.Count.ShouldBeGreaterThan(before),
TimeSpan.FromSeconds(3));
var latest = publisher.Published[^1];
latest.Health.State.ShouldBe(DriverState.Healthy,
"the driver never faulted — a skip is a config problem, not an error, and must not latch state");
latest.SkippedTags!.SkippedCount.ShouldBe(20);
latest.SkippedTags.AuthoredCount.ShouldBe(22);
}
/// <summary>
/// A redeploy that swaps WHICH tags are broken without changing HOW MANY must still publish.
/// Digesting the counts alone would leave an operator staring at a stale sample naming tags that
/// are now fine, while the ones actually broken went unmentioned.
/// </summary>
[Fact]
public void Swapping_which_tags_are_skipped_republishes_even_though_the_count_is_unchanged()
{
var driver = new SkipStubDriver();
driver.SetReport(authored: 5, skipped: ["old-broken"]);
var publisher = new RecordingHealthPublisher();
var actor = SpawnDriverActor(driver, publisher);
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
var before = publisher.Published.Count;
driver.SetReport(authored: 5, skipped: ["newly-broken"]);
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(before), TimeSpan.FromSeconds(3));
publisher.Published[^1].SkippedTags!.Sample.Single().RawPath.ShouldBe("newly-broken");
}
/// <summary>
/// The other half of the dedup contract: when nothing changes, the digest must NOT churn. A digest
/// comparing the sample list by reference would differ on every call and re-publish on every 30 s
/// heartbeat forever — turning the dedup off without anyone noticing.
/// </summary>
[Fact]
public void An_unchanged_skip_report_does_not_defeat_the_dedup()
{
var driver = new SkipStubDriver();
driver.SetReport(authored: 22, skipped: ["Makino/Identity/SeriesNumber"]);
var publisher = new RecordingHealthPublisher();
var actor = SpawnDriverActor(driver, publisher);
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
var before = publisher.Published.Count;
// A fresh-but-equal report, rebuilt exactly as a redeploy that changed nothing would.
driver.SetReport(authored: 22, skipped: ["Makino/Identity/SeriesNumber"]);
// Long enough for many heartbeats to fire and be deduped away.
ExpectNoMsg(TimeSpan.FromMilliseconds(600));
publisher.Published.Count.ShouldBe(before,
"an equal-but-distinct report must be deduped, not re-published — the digest must compare by VALUE");
}
/// <summary>
/// A driver that cannot report skips publishes null — NOT an empty report. The two mean different
/// things at the UI ("no skip detail available" vs "this driver skipped nothing"), and collapsing
/// them would render an unreporting driver as one that is positively known to be clean.
/// </summary>
[Fact]
public void A_driver_that_cannot_report_publishes_null_rather_than_an_empty_report()
{
var publisher = new RecordingHealthPublisher();
var actor = SpawnDriverActor(new StubDriver(), publisher);
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
publisher.Published.ShouldAllBe(p => p.SkippedTags == null);
}
/// <summary>
/// A reporter that throws must not take the health publish down with it. Losing the skip detail is
/// strictly better than losing the whole health channel.
/// </summary>
[Fact]
public void A_throwing_reporter_degrades_to_null_without_killing_the_health_publish()
{
var driver = new SkipStubDriver { ThrowOnGetSkippedTags = true };
var publisher = new RecordingHealthPublisher();
var actor = SpawnDriverActor(driver, publisher);
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
publisher.Published[^1].Health.State.ShouldBe(DriverState.Healthy);
publisher.Published[^1].SkippedTags.ShouldBeNull();
}
/// <summary>
/// Spawns the actor with a fast health heartbeat. The heartbeat is the production path by which a
/// changed skip report reaches the publisher — unlike host connectivity, a skip raises no event,
/// because it is discovered during the tag-table build and never afterwards. Driving the tests
/// through it rather than through a synthetic poke means the dedup assertions are made against the
/// mechanism that actually runs in production.
/// </summary>
private IActorRef SpawnDriverActor(IDriver driver, IDriverHealthPublisher publisher)
{
var parent = CreateTestProbe();
parent.IgnoreMessages(_ => true);
return parent.ChildActorOf(DriverInstanceActor.Props(
driver, healthPublisher: publisher, healthPollInterval: TimeSpan.FromMilliseconds(50)));
}
/// <summary>Captures every health publish so a test can assert on the skipped-tag field.</summary>
private sealed record HealthPublish(DriverHealth Health, SkippedTagReport? SkippedTags);
private sealed class RecordingHealthPublisher : IDriverHealthPublisher
{
private readonly List<HealthPublish> _published = [];
/// <summary>Thread-safe snapshot — <c>Publish</c> runs on the actor thread while the test asserts
/// from its own.</summary>
public IReadOnlyList<HealthPublish> Published
{
get { lock (_published) return _published.ToArray(); }
}
/// <inheritdoc />
public void Publish(
string clusterId,
string driverInstanceId,
DriverHealth health,
int errorCount5Min,
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
SkippedTagReport? skippedTags = null)
{
lock (_published) _published.Add(new HealthPublish(health, skippedTags));
}
}
/// <summary>
/// A stub driver exposing <see cref="ISkippedTagReporter"/>, backed by a real
/// <see cref="SkippedTagLedger"/> so the test exercises the production accumulator rather than a
/// parallel fake of it.
/// <para><b>Its own health is deliberately STABLE</b> (a fixed last-read timestamp). The shared
/// <c>StubDriver</c> returns <c>DateTime.UtcNow</c> from <c>GetHealth()</c>, so its fingerprint
/// differs on every call, the dedup never engages, and every dedup assertion here would pass
/// vacuously whether or not the fix is present.</para>
/// </summary>
private sealed class SkipStubDriver : IDriver, ISkippedTagReporter
{
private static readonly DateTime FixedLastRead = new(2026, 8, 5, 12, 0, 0, DateTimeKind.Utc);
private readonly SkippedTagLedger _ledger = new();
/// <summary>When set, <see cref="GetSkippedTags"/> throws — the misbehaving-reporter case.</summary>
public bool ThrowOnGetSkippedTags { get; init; }
/// <inheritdoc />
public string DriverInstanceId => "skip-stub-1";
/// <inheritdoc />
public string DriverType => "Stub";
/// <summary>Rebuilds the ledger as a real driver's tag-table build does.</summary>
public void SetReport(int authored, IReadOnlyList<string> skipped)
{
_ledger.BeginRebuild(authored);
foreach (var rawPath in skipped) _ledger.Skip(rawPath, "TagConfig did not map to a definition");
_ledger.Commit();
}
/// <inheritdoc />
public SkippedTagReport GetSkippedTags()
{
if (ThrowOnGetSkippedTags) throw new InvalidOperationException("reporter is broken");
return _ledger.Snapshot();
}
/// <inheritdoc />
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, null);
}
}
@@ -123,7 +123,8 @@ public sealed class DriverInstanceActorSubscriptionReconcileTests : RuntimeActor
int errorCount5Min,
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null)
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
SkippedTagReport? skippedTags = null)
=> Interlocked.Increment(ref _count);
}
}