39db75083d
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.
131 lines
6.2 KiB
C#
131 lines
6.2 KiB
C#
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>
|
|
/// Gitea #488 — the periodic desired-vs-actual subscription reconcile that rides
|
|
/// <see cref="DriverInstanceActor"/>'s existing <c>health-poll</c> tick.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The hole it closes: the desired ref set is re-applied on a <b>Connected transition</b> and on a
|
|
/// deploy's <c>SetDesiredSubscriptions</c>, and nowhere else. A subscription lost while the driver
|
|
/// <i>stayed</i> Connected — the shape reproduced here, a subscribe that failed and was never
|
|
/// retried — left the actor permanently subscribed to nothing while reporting Healthy.
|
|
/// </para>
|
|
/// <para>
|
|
/// All three tests drive a short <c>healthPollInterval</c> rather than the production 30 s. The
|
|
/// two absence assertions are bounded by a positive control (a counted number of ticks provably
|
|
/// processed), never by a bare sleep.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class DriverInstanceActorSubscriptionReconcileTests : RuntimeActorTestBase
|
|
{
|
|
private static readonly TimeSpan FastPoll = TimeSpan.FromMilliseconds(100);
|
|
|
|
/// <summary>
|
|
/// The reconcile itself: the first subscribe fails, leaving the actor Connected with a desired set
|
|
/// and no handle. Nothing else would ever retry it — no reconnect, no deploy — so a second
|
|
/// subscribe can only come from the periodic reconcile.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_lost_subscription_is_re_established_while_still_connected()
|
|
{
|
|
var driver = new SubscribableStubDriver { SubscribeFailuresBeforeSuccess = 1 };
|
|
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver, healthPollInterval: FastPoll));
|
|
|
|
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
|
|
new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
|
|
// The connect-path subscribe runs and throws, so the handle stays null.
|
|
AwaitCondition(() => driver.SubscribeCount >= 1, PresenceBudget);
|
|
|
|
// Only the reconcile can produce this second attempt — and it must succeed this time.
|
|
AwaitCondition(() => driver.SubscribeCount >= 2, PresenceBudget);
|
|
driver.LastSubscribedRefs.ShouldBe(new[] { "tag-a" });
|
|
}
|
|
|
|
/// <summary>
|
|
/// A healthy subscription is left alone — the reconcile must not re-subscribe on every tick, which
|
|
/// would churn the backend every 30 s in production for no reason.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_healthy_subscription_is_not_re_subscribed_on_every_tick()
|
|
{
|
|
var publisher = new CountingHealthPublisher();
|
|
var driver = new SubscribableStubDriver();
|
|
var actor = Sys.ActorOf(DriverInstanceActor.Props(
|
|
driver, healthPublisher: publisher, healthPollInterval: FastPoll));
|
|
|
|
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
|
|
new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
AwaitCondition(() => driver.SubscribeCount >= 1, PresenceBudget);
|
|
|
|
// POSITIVE CONTROL for the absence below. Every health-poll tick publishes a snapshot, so the
|
|
// publisher count is a direct observation of ticks PROCESSED. Waiting for several of them is what
|
|
// makes "SubscribeCount is still 1" mean "the reconcile ran and declined" rather than "the timer
|
|
// had not fired yet" — the latter would pass instantly and prove nothing.
|
|
var before = publisher.Count;
|
|
AwaitCondition(() => publisher.Count >= before + 4, PresenceBudget);
|
|
|
|
driver.SubscribeCount.ShouldBe(1,
|
|
"the handle is live, so the reconcile must leave it alone");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A driver that is not <see cref="ISubscribable"/> can still be handed a desired set. Reconciling
|
|
/// it would tell <c>Subscribe</c> every tick and fail every tick, forever — so the reconcile is
|
|
/// gated on the capability.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_non_subscribable_driver_is_never_reconciled()
|
|
{
|
|
var publisher = new CountingHealthPublisher();
|
|
var driver = new StubDriver(); // IDriver only — no ISubscribable
|
|
var actor = Sys.ActorOf(DriverInstanceActor.Props(
|
|
driver, healthPublisher: publisher, healthPollInterval: FastPoll));
|
|
|
|
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
|
|
new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
|
|
// Same positive control: assert the reconcile stays silent across several PROCESSED ticks.
|
|
// EventFilter bounds the absence to this block, and the reconcile announces itself at Info —
|
|
// the first test above is the proof that this message does appear when the reconcile fires.
|
|
EventFilter.Info(contains: "periodic reconcile").Expect(0, () =>
|
|
{
|
|
var before = publisher.Count;
|
|
AwaitCondition(() => publisher.Count >= before + 4, PresenceBudget);
|
|
});
|
|
}
|
|
|
|
/// <summary>Counts health-snapshot publishes, which is one per <c>health-poll</c> tick once the actor
|
|
/// is running — the observable that turns "time passed" into "ticks were processed".</summary>
|
|
private sealed class CountingHealthPublisher : IDriverHealthPublisher
|
|
{
|
|
private int _count;
|
|
|
|
/// <summary>Number of snapshots published so far.</summary>
|
|
public int Count => Volatile.Read(ref _count);
|
|
|
|
/// <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)
|
|
=> Interlocked.Increment(ref _count);
|
|
}
|
|
}
|