fix(hosts): a driver that never built a tag table reports NO ANSWER, not zero skips

Found on the docker-dev live gate, not in tests. Two drivers that faulted
during initialise (rtu-gate, opcua1) each rendered "0 tags" on /hosts against
two authored tags apiece, because a ledger that had never committed returned
SkippedTagReport.Empty.

A zero report is a positive claim -- "this driver skipped nothing" -- and
making it on behalf of a driver that never got as far as looking is exactly
the quiet inaccuracy #541 exists to remove. GetSkippedTags() and
SkippedTagLedger.Snapshot() now return null until the first committed build,
so the column renders the same em-dash it already used for a driver that
cannot report at all. Both are 'no answer'; neither is 'nothing skipped'.

Knock-on: a FOCAS driver whose only init threw now reports null rather than a
zero report, which is strictly more honest than the assertion the test
originally made.
This commit is contained in:
Joseph Doherty
2026-08-05 20:16:31 -04:00
parent 39db75083d
commit dd900f970a
19 changed files with 94 additions and 51 deletions
@@ -44,8 +44,15 @@ public interface ISkippedTagReporter
/// 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();
/// <returns>
/// The report from the driver's most recent tag-table build, or <see langword="null"/> when it has
/// not built one yet — a driver that faulted during initialise has <b>no answer</b>, which is not
/// the same as the answer "nothing skipped". Returning a zero report there would state that a
/// driver with a dozen authored tags has none, which is the class of quiet inaccuracy this whole
/// capability exists to remove. (Found on the docker-dev rig, where two faulted drivers each
/// rendered "0 tags" against two authored ones.)
/// </returns>
SkippedTagReport? GetSkippedTags();
}
/// <summary>One authored tag a driver could not build into its poll table, and why.</summary>
@@ -71,9 +78,6 @@ public sealed record SkippedTag(string RawPath, string Reason);
/// </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;
@@ -113,7 +117,9 @@ public sealed class SkippedTagLedger
public const int MaxSample = 20;
private readonly object _gate = new();
private SkippedTagReport _visible = SkippedTagReport.Empty;
/// <summary>Null until the first <see cref="Commit"/> — "no answer yet", never a zero report.</summary>
private SkippedTagReport? _visible;
private int _stagedAuthored;
private int _stagedSkipped;
@@ -168,8 +174,9 @@ public sealed class SkippedTagLedger
}
/// <summary>The current published snapshot.</summary>
/// <returns>The last committed report, or <see cref="SkippedTagReport.Empty"/> before the first commit.</returns>
public SkippedTagReport Snapshot()
/// <returns>The last committed report, or <see langword="null"/> before the first commit — a driver
/// that has not built a tag table has no answer, and a zero report would be a false one.</returns>
public SkippedTagReport? Snapshot()
{
lock (_gate) return _visible;
}
@@ -31,7 +31,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
private AbCipDriverOptions _options;
private readonly string _driverInstanceId;
@@ -17,7 +17,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize
/// (#516). Only ever written on the init path, before any reader/session uses it.</summary>
@@ -42,7 +42,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
// OPC UA status codes surfaced by the calc driver.
private const uint StatusGood = 0u;
@@ -27,7 +27,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config (#516). Written only
/// on the init path, together with <see cref="_clientFactory"/>, before either is used.</summary>
@@ -45,7 +45,7 @@ public sealed class GalaxyDriver
private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
private readonly string _driverInstanceId;
private readonly GalaxyDriverOptions _options;
@@ -79,7 +79,7 @@ public sealed class MTConnectDriver
private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
/// <summary>
/// The Agent could not be reached, answered unusably, or blew the per-call deadline. Distinct
@@ -25,7 +25,7 @@ public sealed class ModbusDriver
private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
// ---- instance fields (grouped at top for auditability) ----
@@ -70,7 +70,7 @@ public sealed class MqttDriver
private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
// ---- Write status codes (Gitea #508) ----
//
@@ -37,7 +37,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
/// <summary>
/// Recomputes the skip report from the freshly-built map. Derived by re-asking the map to resolve
@@ -36,7 +36,7 @@ public sealed class S7Driver
private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
private readonly string _driverInstanceId;
private readonly ILogger<S7Driver> _logger;
@@ -46,7 +46,7 @@ public sealed class SqlDriver
private IReadOnlyList<SkippedTag> _blobSkips = [];
/// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
/// <summary>
/// Republishes the skip ledger as the union of the blob-level skips and the catalog gate's
@@ -17,7 +17,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
private readonly SkippedTagLedger _skippedTags = new();
/// <inheritdoc />
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
// Mutable so ReinitializeAsync can apply a new config generation. The constructor seeds
// it; InitializeAsync re-parses driverConfigJson over the top of it.
@@ -234,8 +234,11 @@ else
Healthy, Errors/5 min stays 0, Last error stays empty, and Last
read keeps advancing off the tags that DID map. On the Makino in
#540 that read as a perfectly healthy driver ignoring 20 of its 22
tags. "—" means the driver cannot report skips, which is distinct
from reporting none. *@
tags. "—" means NO ANSWER — either the driver cannot report skips,
or it faulted before building a tag table — which is distinct from
the positive claim "this driver skipped nothing". A pre-fix build
rendered "0 tags" for two faulted drivers on the docker-dev rig
that had two authored tags apiece. *@
@if (d.SkippedTags is null)
{
<span class="text-muted">—</span>
@@ -44,9 +44,10 @@ public sealed record HostsDriverInstanceInfo(string DriverInstanceId, string Clu
/// <param name="HostStatuses">Per-host connectivity for a multi-device driver, ordered by host name; null
/// when the driver reports no per-host detail. Lets one unreachable device show even while the driver
/// row itself is Healthy.</param>
/// <param name="SkippedTags">Authored tags the driver could not build into its poll table; null when the
/// driver cannot report them. Lets "ignoring 20 of 22 tags" show even while every other column on the
/// row — state, last read, error count, last error — reads perfectly healthy (Gitea #541).</param>
/// <param name="SkippedTags">Authored tags the driver could not build into its poll table; null when there
/// is no answer — the driver cannot report skips, or it faulted before building a tag table. Lets
/// "ignoring 20 of 22 tags" show even while every other column on the row — state, last read, error
/// count, last error — reads perfectly healthy (Gitea #541).</param>
public sealed record HostsDriverRow(
string DriverInstanceId, string? Name, string? DriverType, string State,
DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc,