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:
@@ -25,8 +25,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// handles GC-collected tags.</para>
|
||||
/// </remarks>
|
||||
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
|
||||
|
||||
private AbCipDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly IAbCipTagFactory _tagFactory;
|
||||
@@ -284,10 +290,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// is mapped by the pure factory; the entry's DeviceName (routing key) + WriteIdempotent flag are
|
||||
// threaded onto the def (they live on the RawTagEntry, not inside the blob). A mapper miss is
|
||||
// skipped (logged), never thrown — a bad tag must not fail the whole driver init.
|
||||
_skippedTags.BeginRebuild(_options.RawTags.Count);
|
||||
foreach (var entry in _options.RawTags)
|
||||
{
|
||||
if (!AbCipTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var mapped))
|
||||
{
|
||||
_skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
|
||||
_logger.LogWarning(
|
||||
"AbCip tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
|
||||
_driverInstanceId, entry.RawPath);
|
||||
@@ -335,6 +343,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
}
|
||||
_skippedTags.Commit();
|
||||
|
||||
// Probe loops — one per device when enabled + a ProbeTagPath is configured.
|
||||
if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeTagPath))
|
||||
|
||||
@@ -11,8 +11,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
/// host-resolver capabilities ship in PRs 2 and 3.
|
||||
/// </summary>
|
||||
public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||
IHostConnectivityProbe, IPerCallHostResolver, ISkippedTagReporter, IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
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>
|
||||
private AbLegacyDriverOptions _options;
|
||||
@@ -131,10 +137,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
// def (it lives on the RawTagEntry, not inside the blob), and its DeviceName is resolved to
|
||||
// the owning device's host so the existing DeviceHostAddress-keyed routing continues to work.
|
||||
// A mapper miss is skipped (logged), never thrown — a bad tag must not fail the whole init.
|
||||
_skippedTags.BeginRebuild(_options.RawTags.Count);
|
||||
foreach (var entry in _options.RawTags)
|
||||
{
|
||||
if (!AbLegacyTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
|
||||
{
|
||||
_skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
|
||||
_logger.LogWarning(
|
||||
"AbLegacy tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
|
||||
_driverInstanceId, entry.RawPath);
|
||||
@@ -146,6 +154,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
DeviceHostAddress = ResolveDeviceHost(entry.DeviceName),
|
||||
};
|
||||
}
|
||||
_skippedTags.Commit();
|
||||
|
||||
// Validate tag types against their device's family profile. Long (32-bit integer)
|
||||
// and String (ST-file) are not supported by all PCCC families; reject them early
|
||||
|
||||
@@ -35,8 +35,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
/// A historized Bad records <c>BadInternalError</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscribable, IReadable, IDisposable
|
||||
public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscribable, IReadable, ISkippedTagReporter, IDisposable
|
||||
{
|
||||
/// <summary>Authored tags this driver could not build into its calc table (Gitea #541). A raw tag with
|
||||
/// no <c>scriptId</c> is not a calc tag and computes nothing.</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
|
||||
|
||||
// OPC UA status codes surfaced by the calc driver.
|
||||
private const uint StatusGood = 0u;
|
||||
private const uint StatusBadWaitingForInitialData = 0x80320000u;
|
||||
@@ -120,10 +127,12 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
// Retain surviving tags' state; anything not re-authored is dropped when we swap this in below.
|
||||
var retainedState = new Dictionary<string, TagState>(StringComparer.Ordinal);
|
||||
|
||||
_skippedTags.BeginRebuild(config.RawTags.Count);
|
||||
foreach (var entry in config.RawTags)
|
||||
{
|
||||
if (!CalculationTagDefinition.TryFromTagConfig(entry.TagConfig, entry.RawPath, out var def))
|
||||
{
|
||||
_skippedTags.Skip(entry.RawPath, "no scriptId — not a calc tag");
|
||||
_logger.LogWarning(
|
||||
"Calculation {Driver}: raw tag {RawPath} has no scriptId; skipping (not a calc tag)",
|
||||
_driverInstanceId, entry.RawPath);
|
||||
@@ -151,6 +160,8 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
}
|
||||
}
|
||||
|
||||
_skippedTags.Commit();
|
||||
|
||||
// Swap in the retained/new state map — a removed tag's state is dropped so it stops publishing.
|
||||
_stateByRawPath.Clear();
|
||||
foreach (var (rawPath, state) in retainedState) _stateByRawPath[rawPath] = state;
|
||||
|
||||
@@ -19,8 +19,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
/// fail fast.
|
||||
/// </remarks>
|
||||
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541). FOCAS has three
|
||||
/// skip paths — an unmappable TagConfig, an unparseable address, and a capability-matrix rejection — and
|
||||
/// #540 was caused by the second of them going unreported while the driver read Healthy.</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
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>
|
||||
private FocasDriverOptions _options;
|
||||
@@ -190,10 +198,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// bad tag must not fail the whole driver init. The ONE hard failure is an unknown device
|
||||
// segment: a tag routed to a device not in the Devices list is a driver-level config error
|
||||
// that fails fast (Driver.FOCAS-003).
|
||||
_skippedTags.BeginRebuild(_options.RawTags.Count);
|
||||
foreach (var entry in _options.RawTags)
|
||||
{
|
||||
if (!FocasTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
|
||||
{
|
||||
_skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
|
||||
_logger.LogWarning(
|
||||
"FOCAS tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
|
||||
_driverInstanceId, entry.RawPath);
|
||||
@@ -217,6 +227,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var parsed = FocasAddress.TryParse(def.Address);
|
||||
if (parsed is null)
|
||||
{
|
||||
_skippedTags.Skip(entry.RawPath, $"invalid Address '{def.Address}'");
|
||||
_logger.LogWarning(
|
||||
"FOCAS tag has invalid Address '{Address}'; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
|
||||
def.Address, _driverInstanceId, entry.RawPath);
|
||||
@@ -224,6 +235,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
if (FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is { } reason)
|
||||
{
|
||||
_skippedTags.Skip(entry.RawPath, $"'{def.Address}' rejected by capability matrix: {reason}");
|
||||
_logger.LogWarning(
|
||||
"FOCAS tag '{RawPath}' ({Address}) rejected by capability matrix: {Reason}; skipping. Driver={DriverInstanceId}",
|
||||
entry.RawPath, def.Address, reason, _driverInstanceId);
|
||||
@@ -239,6 +251,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// above; reusing the parsed record avoids per-tick allocs on subscription pollers.
|
||||
_parsedAddressesByTagName[entry.RawPath] = parsed;
|
||||
}
|
||||
// Publish the skip tally only once the whole table is built. An unknown-device throw above
|
||||
// leaves the previous snapshot standing rather than a truncated one that would read as
|
||||
// "we now skip fewer tags".
|
||||
_skippedTags.Commit();
|
||||
|
||||
if (_options.Probe.Enabled)
|
||||
{
|
||||
|
||||
@@ -38,8 +38,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class GalaxyDriver
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IDisposable, IAsyncDisposable
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Authored tags this driver could not dial (Gitea #541) — a TagConfig with no usable
|
||||
/// <c>attributeRef</c> has no MXAccess address to bind.</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
|
||||
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly GalaxyDriverOptions _options;
|
||||
private readonly ILogger<GalaxyDriver> _logger;
|
||||
@@ -215,7 +222,7 @@ public sealed class GalaxyDriver
|
||||
|
||||
// Build the v3 RawPath ⇄ attributeRef tables from the authored raw tags. Empty when no tags
|
||||
// are authored (live-browse path), leaving every translation an identity no-op.
|
||||
(_attributeRefByRawPath, _rawTagByAttributeRef) = BuildRawTagTables(_options.RawTags, _logger);
|
||||
(_attributeRefByRawPath, _rawTagByAttributeRef) = BuildRawTagTables(_options.RawTags, _logger, _skippedTags);
|
||||
|
||||
// Forward the aggregator's transitions through IHostConnectivityProbe.
|
||||
_hostStatuses.OnHostStatusChanged += (_, args) => OnHostStatusChanged?.Invoke(this, args);
|
||||
@@ -231,16 +238,25 @@ public sealed class GalaxyDriver
|
||||
/// still dials both. Case-insensitive to match the driver's reference handling.
|
||||
/// </summary>
|
||||
private static (Dictionary<string, string> Forward, Dictionary<string, RawTagEntry> Reverse)
|
||||
BuildRawTagTables(IReadOnlyList<RawTagEntry> rawTags, ILogger logger)
|
||||
BuildRawTagTables(IReadOnlyList<RawTagEntry> rawTags, ILogger logger, SkippedTagLedger skipped)
|
||||
{
|
||||
var forward = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var reverse = new Dictionary<string, RawTagEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
skipped.BeginRebuild(rawTags.Count);
|
||||
foreach (var entry in rawTags)
|
||||
{
|
||||
if (string.IsNullOrEmpty(entry.RawPath)) continue;
|
||||
if (string.IsNullOrEmpty(entry.RawPath))
|
||||
{
|
||||
// Counted, though there is no path to name it by: an artifact carrying such an entry
|
||||
// is malformed, and reporting "N authored, N-1 bound" with nothing accounting for the
|
||||
// difference is exactly the silence #541 is about.
|
||||
skipped.Skip("", "entry has no RawPath");
|
||||
continue;
|
||||
}
|
||||
var attributeRef = TryReadAttributeRef(entry.TagConfig);
|
||||
if (string.IsNullOrEmpty(attributeRef))
|
||||
{
|
||||
skipped.Skip(entry.RawPath, "TagConfig has no 'attributeRef' — cannot dial MXAccess");
|
||||
logger.LogWarning(
|
||||
"GalaxyDriver raw tag {RawPath} has no 'attributeRef' in its TagConfig — skipped (cannot dial MXAccess).",
|
||||
entry.RawPath);
|
||||
@@ -249,6 +265,7 @@ public sealed class GalaxyDriver
|
||||
forward[entry.RawPath] = attributeRef;
|
||||
reverse.TryAdd(attributeRef, entry); // first-wins on a shared attributeRef
|
||||
}
|
||||
skipped.Commit();
|
||||
return (forward, reverse);
|
||||
}
|
||||
|
||||
|
||||
@@ -72,8 +72,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MTConnectDriver
|
||||
: IDriver, IReadable, IWritable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable
|
||||
: IDriver, IReadable, IWritable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable,
|
||||
ISkippedTagReporter
|
||||
{
|
||||
/// <summary>Authored tags this driver could not bind to an Agent DataItem (Gitea #541).</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
|
||||
|
||||
/// <summary>
|
||||
/// The Agent could not be reached, answered unusably, or blew the per-call deadline. Distinct
|
||||
/// from the index's <c>BadNoCommunication</c>, which means the Agent answered and said the
|
||||
@@ -3221,10 +3228,13 @@ public sealed class MTConnectDriver
|
||||
definitions[tag.FullName] = tag;
|
||||
}
|
||||
|
||||
_skippedTags.BeginRebuild(options.RawTags.Count);
|
||||
|
||||
if (options.RawTags.Count == 0)
|
||||
{
|
||||
MTConnectTagDefinition[] only = [.. definitions.Values];
|
||||
|
||||
_skippedTags.Commit();
|
||||
return new TagBinding(
|
||||
FrozenDictionary<string, string>.Empty,
|
||||
FrozenDictionary<string, IReadOnlyList<string>>.Empty,
|
||||
@@ -3242,6 +3252,10 @@ public sealed class MTConnectDriver
|
||||
|
||||
if (!TryReadRawTag(entry, out var dataItemId, out var authored))
|
||||
{
|
||||
_skippedTags.Skip(
|
||||
entry.RawPath,
|
||||
"names no readable DataItem id (expected 'fullName', 'dataItemId' or 'address') "
|
||||
+ "or declares an unrecognised driverDataType");
|
||||
_logger.LogWarning(
|
||||
"MTConnect driver {DriverInstanceId} could not map the raw tag {RawPath} to an Agent DataItem; it names no readable id (expected 'fullName', 'dataItemId' or 'address') or declares an unrecognised driverDataType. The tag is skipped and reports BadNodeIdUnknown; every other tag is unaffected.",
|
||||
_driverInstanceId, entry.RawPath);
|
||||
@@ -3277,6 +3291,7 @@ public sealed class MTConnectDriver
|
||||
|
||||
MTConnectTagDefinition[] indexTags = [.. definitions.Values];
|
||||
|
||||
_skippedTags.Commit();
|
||||
return new TagBinding(
|
||||
idByRawPath.ToFrozenDictionary(StringComparer.Ordinal),
|
||||
rawPathsById.ToFrozenDictionary(
|
||||
|
||||
@@ -19,8 +19,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
/// <see cref="PollGroupEngine"/> since Modbus has no native push model.
|
||||
/// </remarks>
|
||||
public sealed class ModbusDriver
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, ISkippedTagReporter, IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
|
||||
|
||||
// ---- instance fields (grouped at top for auditability) ----
|
||||
|
||||
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize
|
||||
@@ -219,15 +225,20 @@ public sealed class ModbusDriver
|
||||
// blob is mapped by the pure factory; the entry's WriteIdempotent flag is threaded onto the
|
||||
// def (it lives on the RawTagEntry, not inside the blob). A mapper miss is skipped (logged),
|
||||
// never thrown — a bad tag must not fail the whole driver init.
|
||||
_skippedTags.BeginRebuild(_options.RawTags.Count);
|
||||
foreach (var entry in _options.RawTags)
|
||||
{
|
||||
if (ModbusTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
|
||||
_tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
|
||||
else
|
||||
{
|
||||
_skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
|
||||
_logger.LogWarning(
|
||||
"Modbus tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
|
||||
_driverInstanceId, entry.RawPath);
|
||||
}
|
||||
}
|
||||
_skippedTags.Commit();
|
||||
WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
|
||||
|
||||
// PR 23: kick off the probe loop once the transport is up. Initial state stays
|
||||
|
||||
@@ -64,8 +64,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
/// </remarks>
|
||||
public sealed class MqttDriver
|
||||
: IDriver, ITagDiscovery, ISubscribable, IReadable, IWritable, IHostConnectivityProbe, IRediscoverable,
|
||||
IAsyncDisposable, IDisposable
|
||||
ISkippedTagReporter, IAsyncDisposable, IDisposable
|
||||
{
|
||||
/// <summary>Authored tags this driver could not build into its ingest table (Gitea #541).</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
|
||||
|
||||
// ---- Write status codes (Gitea #508) ----
|
||||
//
|
||||
// NAMED constants, never inline literals: StatusCodeParityTests checks each against the pinned SDK
|
||||
@@ -1149,6 +1155,17 @@ public sealed class MqttDriver
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea #541. Derived from the driver's OWN resolution path rather than from either manager's
|
||||
// internal bookkeeping, so what is reported skipped is exactly what read/subscribe will fail to
|
||||
// find — the two cannot drift, and one branch covers both ingest paths.
|
||||
_skippedTags.BeginRebuild(options.RawTags.Count);
|
||||
foreach (var entry in options.RawTags)
|
||||
{
|
||||
if (!TryResolve(entry.RawPath, out _))
|
||||
_skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
|
||||
}
|
||||
_skippedTags.Commit();
|
||||
|
||||
if (mapped != options.RawTags.Count)
|
||||
{
|
||||
_logger?.LogWarning(
|
||||
|
||||
@@ -29,8 +29,37 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
/// monitored-item handles. That mechanic lands in PR 69.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, IHistoryProvider, IDisposable, IAsyncDisposable
|
||||
public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, IHistoryProvider, ISkippedTagReporter, IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Authored tags whose TagConfig carried no usable <c>nodeId</c>, so there is no upstream node
|
||||
/// to dial (Gitea #541). This driver's skip path was the quietest of the twelve: <c>NamespaceMap</c>
|
||||
/// drops such an entry with a bare <c>continue</c> and not even a log line.</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes the skip report from the freshly-built map. Derived by re-asking the map to resolve
|
||||
/// each authored RawPath rather than by instrumenting <c>NamespaceMap</c>, so what is reported
|
||||
/// skipped is exactly what read/write/subscribe will fail to resolve — and the shared Contracts
|
||||
/// type stays free of driver bookkeeping.
|
||||
/// </summary>
|
||||
/// <param name="map">The map just built from the live session.</param>
|
||||
private void RecordSkippedTags(NamespaceMap map)
|
||||
{
|
||||
var authored = _options.RawTags;
|
||||
_skippedTags.BeginRebuild(authored.Count);
|
||||
foreach (var entry in authored)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.RawPath))
|
||||
_skippedTags.Skip("", "entry has no RawPath");
|
||||
else if (!map.TryResolve(entry.RawPath, out _))
|
||||
_skippedTags.Skip(entry.RawPath, "TagConfig has no usable 'nodeId' — no upstream node to dial");
|
||||
}
|
||||
_skippedTags.Commit();
|
||||
}
|
||||
|
||||
private readonly ILogger<OpcUaClientDriver> _logger;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="OpcUaClientDriver"/> class.</summary>
|
||||
@@ -241,6 +270,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
// ingests the authored RawTags so read/write/subscribe/history references (RawPaths)
|
||||
// resolve to their upstream node ids.
|
||||
_namespaceMap = NamespaceMap.FromSession(session, _options.RawTags);
|
||||
RecordSkippedTags(_namespaceMap);
|
||||
|
||||
Session = session;
|
||||
_connectedEndpointUrl = connectedUrl;
|
||||
@@ -2035,6 +2065,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
// address space re-resolve correctly against this fresh map. v3: re-ingest the
|
||||
// authored RawTags so RawPath resolution survives the reconnect too.
|
||||
_namespaceMap = NamespaceMap.FromSession(newSession, _options.RawTags);
|
||||
RecordSkippedTags(_namespaceMap);
|
||||
TransitionTo(HostState.Running);
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
return;
|
||||
|
||||
@@ -30,8 +30,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class S7Driver
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, ISkippedTagReporter, IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
|
||||
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly ILogger<S7Driver> _logger;
|
||||
private readonly IS7PlcFactory _plcFactory;
|
||||
@@ -185,15 +191,20 @@ public sealed class S7Driver
|
||||
_tagsByRawPath.Clear();
|
||||
_parsedByRawPath.Clear();
|
||||
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
|
||||
_skippedTags.BeginRebuild(_options.RawTags.Count);
|
||||
foreach (var entry in _options.RawTags)
|
||||
{
|
||||
if (S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
|
||||
_tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
|
||||
else
|
||||
{
|
||||
_skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
|
||||
_logger.LogWarning(
|
||||
"S7 tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
|
||||
_driverInstanceId, entry.RawPath);
|
||||
}
|
||||
}
|
||||
_skippedTags.Commit();
|
||||
|
||||
// Phase 4d combined config guard. Timer/Counter addresses and the wide/structured
|
||||
// types (Int64/UInt64/Float64/String/DateTime) are now SUPPORTED, but only in
|
||||
|
||||
@@ -27,8 +27,41 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// manufacture an exception to force backoff.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlDriver
|
||||
: IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IAsyncDisposable
|
||||
: IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, ISkippedTagReporter, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
|
||||
/// <summary>
|
||||
/// Blob-level skips from the most recent <see cref="BuildTagTable"/>, retained because Sql drops a
|
||||
/// tag at TWO points and the report must be the union of both.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A tag can fail to map from its blob (<see cref="BuildTagTable"/>) OR be rejected against the live
|
||||
/// catalog (<see cref="ApplyCatalogGateAsync"/>) — a renamed column, a dropped view. Both leave the
|
||||
/// tag out of the polled table publishing <c>BadNodeIdUnknown</c> with the driver Healthy, so both
|
||||
/// are skips. Reporting only the first would make a Sql driver that lost half its tags to schema
|
||||
/// drift claim "0 skipped", which is worse than saying nothing at all.
|
||||
/// </remarks>
|
||||
private IReadOnlyList<SkippedTag> _blobSkips = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public SkippedTagReport GetSkippedTags() => _skippedTags.Snapshot();
|
||||
|
||||
/// <summary>
|
||||
/// Republishes the skip ledger as the union of the blob-level skips and the catalog gate's
|
||||
/// rejections, against the true authored denominator (<c>RawTags.Count</c>, not the post-mapping
|
||||
/// table size — a tag that failed to map is still a tag the operator authored).
|
||||
/// </summary>
|
||||
/// <param name="gateRejections">Catalog-gate rejections; empty before the gate has run.</param>
|
||||
private void PublishSkipLedger(IEnumerable<SkippedTag> gateRejections)
|
||||
{
|
||||
_skippedTags.BeginRebuild(_options.RawTags.Count);
|
||||
foreach (var skip in _blobSkips) _skippedTags.Skip(skip.RawPath, skip.Reason);
|
||||
foreach (var skip in gateRejections) _skippedTags.Skip(skip.RawPath, skip.Reason);
|
||||
_skippedTags.Commit();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The driver-type string this driver registers under — <see cref="DriverTypeNames.Sql"/>, the single
|
||||
/// source of truth the deploy pipeline stores in <c>DriverInstance.DriverType</c> and every dispatch
|
||||
@@ -699,6 +732,7 @@ public sealed class SqlDriver
|
||||
private void BuildTagTable()
|
||||
{
|
||||
var table = new Dictionary<string, SqlTagDefinition>(_options.RawTags.Count, StringComparer.Ordinal);
|
||||
var blobSkips = new List<SkippedTag>();
|
||||
foreach (var entry in _options.RawTags)
|
||||
{
|
||||
try
|
||||
@@ -706,9 +740,12 @@ public sealed class SqlDriver
|
||||
if (SqlEquipmentTagParser.TryParse(entry.TagConfig, entry.RawPath, out var definition))
|
||||
table[entry.RawPath] = definition;
|
||||
else
|
||||
{
|
||||
blobSkips.Add(new SkippedTag(entry.RawPath, "TagConfig did not map to a definition"));
|
||||
_logger.LogWarning(
|
||||
"Sql tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
|
||||
_driverInstanceId, entry.RawPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -717,12 +754,19 @@ public sealed class SqlDriver
|
||||
// strand health at Initializing across every DriverInstanceActor retry. Skip the offending
|
||||
// tag — the same "one bad tag must not take the whole driver down" rule the malformed-blob
|
||||
// branch above follows — rather than fault the driver over a single entry.
|
||||
blobSkips.Add(new SkippedTag(entry.RawPath, $"TagConfig threw while parsing: {ex.GetType().Name}"));
|
||||
_logger.LogError(
|
||||
ex, "Sql tag config threw while parsing; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
|
||||
_driverInstanceId, entry.RawPath);
|
||||
}
|
||||
}
|
||||
|
||||
// The gate has not run yet, so the report covers blob-level skips only; ApplyCatalogGateAsync
|
||||
// republishes it with its own rejections folded in. Publishing here rather than waiting means a
|
||||
// driver whose gate never runs (nothing authored, or the gate threw) still reports what it knows.
|
||||
_blobSkips = blobSkips;
|
||||
PublishSkipLedger([]);
|
||||
|
||||
Volatile.Write(ref _tagsByRawPath, table);
|
||||
|
||||
// Nothing is pollable until the catalog gate has validated it. Clearing here (rather than leaving a
|
||||
@@ -775,6 +819,11 @@ public sealed class SqlDriver
|
||||
|
||||
var result = SqlCatalogGate.Apply(authored.Values, catalog, _dialect);
|
||||
|
||||
// Gitea #541: a gate rejection leaves the tag unpolled and publishing BadNodeIdUnknown with the
|
||||
// driver Healthy — the same invisible outcome as a blob that did not map, so it is the same signal.
|
||||
PublishSkipLedger(result.Rejected.Select(r =>
|
||||
new SkippedTag(r.RawPath, $"rejected by the catalog gate: {r.Field} — {r.Reason}")));
|
||||
|
||||
foreach (var rejection in result.Rejected)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
|
||||
@@ -11,8 +11,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
/// resolver land in PRs 2 and 3.
|
||||
/// </summary>
|
||||
public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable, IDisposable, IAsyncDisposable
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable, ISkippedTagReporter, IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
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.
|
||||
private TwinCATDriverOptions _options;
|
||||
@@ -127,6 +133,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
// DeviceName is resolved to the target's live host (def.DeviceHostAddress) via ResolveDeviceHost
|
||||
// so the existing device routing (_devices keyed by HostAddress) is unchanged. A mapper miss is
|
||||
// skipped (logged), never thrown — a bad tag must not fail the whole driver init.
|
||||
_skippedTags.BeginRebuild(_options.RawTags.Count);
|
||||
foreach (var entry in _options.RawTags)
|
||||
{
|
||||
if (TwinCATTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
|
||||
@@ -136,10 +143,14 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
DeviceHostAddress = ResolveDeviceHost(entry.DeviceName),
|
||||
};
|
||||
else
|
||||
{
|
||||
_skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
|
||||
_logger.LogWarning(
|
||||
"TwinCAT tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
|
||||
_driverInstanceId, entry.RawPath);
|
||||
}
|
||||
}
|
||||
_skippedTags.Commit();
|
||||
|
||||
if (_options.Probe.Enabled)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user