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:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user