b815446940
Both `IDriver` members had no production call site. Their only consumer was `MemoryTracking`, deleted with the driver-tier recycle machinery in #522, and `GetMemoryFootprint`'s doc-comment claimed "polled every 30s by Core" — which had not been true since v3, even before #522 removed the poller. Owner decision on #525 was (a) delete rather than (b) build the process-wide watchdog that would consume them. The driver-side implementations are in git history and were mostly reusable; what was always missing is the trigger, and a member nobody calls is how the misleading doc-comment survived this long. Removed from `IDriver`, all 12 driver implementations, and every test stub — plus the support code that existed only to feed them, which is the part a member-only deletion would have left behind as dead weight: - MTConnect's three per-entry byte-weight constants, its cached `ProbeModelDataItemCount`, and `CountDataItems` — a full device-tree walk run on every cache fill to populate a number with no remaining reader. - OpcUaClient's `_discoveredNodeCount` field and the discovery-pass write to it. - MQTT's `ApproxBytesPerAuthoredTag`. Two behavioural notes, neither a change in what the software does: - `AbCipTemplateCache` now has no production reset path. Its only caller was AB CIP's flush, which itself had no caller — so nothing changes except how visible the fact is. Recorded on the type, along with the correction that its comment's claim about a device `ReinitializeAsync` also flushing it was never true. A UDT redefined on a live controller keeps its stale shape until the driver instance is rebuilt. - MTConnect's `/probe` cache is still dropped, by `InvalidateProbeModel()` on an Agent restart. That is now `internal` rather than `private` so the tests which used the flush purely to empty the cache drive the real path instead — they now cover a case that actually occurs in production. Several unrelated doc comments cited `FlushOptionalCachesAsync` as the canonical CAS example and are repointed to it. Tests: the ones that existed only to assert on the removed members are deleted (3 OpcUaClient, 3 Galaxy, 2 MQTT, 1 MTConnect, 1 AbCip). Worth noting one was vacuous — `FlushOptionalCachesAsync_resets_footprint_counter` asserted a footprint of 0 before the flush and 0 after with no discovery in between, so it would have passed against a flush that did nothing at all. Full suite green except 7 failures verified identical on master before and after (4 AbLegacy integration with the compose profile down, 3 Host.IntegrationTests), checked by name rather than assumed. Docs corrected where they described this as live: `DriverLifecycle.md` (which carried the #525 placeholder), `drivers/AbCip.md`, `drivers/AbLegacy.md`, and `v2/driver-stability.md` — whose "polls this every 30 s" + per-driver `DriverConfig` memory budget were never built as written and are now banner-ed as design intent rather than description. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
246 lines
11 KiB
C#
246 lines
11 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>
|
|
/// Covers the <see cref="IRediscoverable"/> consumer (Gitea #518). Nine drivers raise
|
|
/// <see cref="IRediscoverable.OnRediscoveryNeeded"/> when they observe that a remote's tag set may have
|
|
/// changed — a Galaxy redeploy, a TwinCAT symbol-version bump, an MTConnect agent restart, a Sparkplug
|
|
/// rebirth. Before this wiring NOTHING in <c>src/</c> subscribed, so every one of those raises went into
|
|
/// the void.
|
|
/// <para><b>The signal is advisory.</b> It does not rebuild the served address space: v3 authors raw tags
|
|
/// explicitly through the <c>/raw</c> browse-commit flow, so a runtime graft would create nodes no
|
|
/// operator approved. It rides the driver-health snapshot to the AdminUI as a re-browse prompt.</para>
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class DriverInstanceActorRediscoverySignalTests : RuntimeActorTestBase
|
|
{
|
|
/// <summary>
|
|
/// The load-bearing case: a driver raises rediscovery while otherwise perfectly healthy, and the
|
|
/// signal reaches the health publisher carrying the driver's reason.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Rediscovery_raise_reaches_the_health_publisher_with_its_reason()
|
|
{
|
|
var driver = new RediscoverableStubDriver();
|
|
var publisher = new RecordingHealthPublisher();
|
|
var parent = CreateTestProbe();
|
|
parent.IgnoreMessages(_ => true);
|
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, healthPublisher: publisher));
|
|
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
|
|
|
|
driver.RaiseRediscovery("deploy-time-changed");
|
|
|
|
AwaitAssert(
|
|
() =>
|
|
{
|
|
var withSignal = publisher.Published.LastOrDefault(p => p.RediscoveryNeededUtc is not null);
|
|
withSignal.ShouldNotBeNull();
|
|
withSignal!.RediscoveryReason.ShouldBe("deploy-time-changed");
|
|
},
|
|
TimeSpan.FromSeconds(3));
|
|
}
|
|
|
|
/// <summary>
|
|
/// <b>Regression guard for the dedup trap.</b> <c>PublishHealthSnapshot</c> suppresses a publish whose
|
|
/// (state, lastSuccessfulRead, lastError, errorCount) tuple repeats. A rediscovery raise on an
|
|
/// otherwise-unchanged Healthy driver leaves all four identical, so unless the rediscovery timestamp
|
|
/// is part of the fingerprint the dedup swallows the very publish carrying the signal and the operator
|
|
/// never sees the prompt.
|
|
/// <para>Positive control: the assertion is that the count STRICTLY INCREASES across the raise. An
|
|
/// "eventually non-null" assertion alone would pass on the warm-up publish and prove nothing. Revert
|
|
/// <c>_rediscoveryNeededUtc</c> out of the fingerprint tuple and this test must fail.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void Rediscovery_raise_is_not_swallowed_by_the_unchanged_health_dedup()
|
|
{
|
|
var driver = new RediscoverableStubDriver();
|
|
var publisher = new RecordingHealthPublisher();
|
|
var parent = CreateTestProbe();
|
|
parent.IgnoreMessages(_ => true);
|
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, healthPublisher: publisher));
|
|
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
|
|
|
|
// Let the actor settle so nothing else is in flight, then capture the baseline. Everything about
|
|
// the driver's health is now stable — only the rediscovery flag will change.
|
|
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
|
var before = publisher.Published.Count;
|
|
|
|
driver.RaiseRediscovery("symbol-version-changed");
|
|
|
|
AwaitAssert(
|
|
() => publisher.Published.Count.ShouldBeGreaterThan(before),
|
|
TimeSpan.FromSeconds(3));
|
|
publisher.Published[^1].RediscoveryNeededUtc.ShouldNotBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The signal is sticky: a later health publish still carries it. The remote's tag set stayed
|
|
/// changed, and only an operator re-browsing resolves that — which this actor cannot observe. A
|
|
/// flag that cleared itself on the next heartbeat would be a prompt the operator could miss entirely.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Rediscovery_flag_persists_on_subsequent_health_publishes()
|
|
{
|
|
var driver = new RediscoverableStubDriver();
|
|
var publisher = new RecordingHealthPublisher();
|
|
var parent = CreateTestProbe();
|
|
parent.IgnoreMessages(_ => true);
|
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, healthPublisher: publisher));
|
|
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
|
|
driver.RaiseRediscovery("agent-instance-changed");
|
|
AwaitAssert(
|
|
() => publisher.Published.Any(p => p.RediscoveryNeededUtc is not null),
|
|
TimeSpan.FromSeconds(3));
|
|
|
|
// Force a further publish by changing something else about the driver's health.
|
|
driver.SetLastError("transient read failure");
|
|
AwaitAssert(
|
|
() => publisher.Published.Any(p => p.LastError == "transient read failure"),
|
|
TimeSpan.FromSeconds(3));
|
|
|
|
var latest = publisher.Published[^1];
|
|
latest.RediscoveryNeededUtc.ShouldNotBeNull();
|
|
latest.RediscoveryReason.ShouldBe("agent-instance-changed");
|
|
}
|
|
|
|
/// <summary>
|
|
/// <b>Leak guard.</b> The <see cref="IDriver"/> instance can OUTLIVE the actor — the host stops a
|
|
/// child and respawns another around the same driver object — so a missing <c>-=</c> in
|
|
/// <c>PostStop</c> accumulates one handler per respawn, each holding a dead <c>Self</c>. Asserts the
|
|
/// driver's invocation list is empty again after the actor stops.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Stopping_the_actor_detaches_the_rediscovery_handler()
|
|
{
|
|
var driver = new RediscoverableStubDriver();
|
|
var parent = CreateTestProbe();
|
|
parent.IgnoreMessages(_ => true);
|
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
|
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
AwaitAssert(() => driver.SubscriberCount.ShouldBe(1), TimeSpan.FromSeconds(3));
|
|
|
|
Watch(actor);
|
|
actor.Tell(PoisonPill.Instance);
|
|
ExpectTerminated(actor, TimeSpan.FromSeconds(3));
|
|
|
|
driver.SubscriberCount.ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>A driver that does NOT implement <see cref="IRediscoverable"/> must publish a null flag —
|
|
/// the field is absent, not defaulted to "needs re-browse".</summary>
|
|
[Fact]
|
|
public void A_non_rediscoverable_driver_never_sets_the_flag()
|
|
{
|
|
var publisher = new RecordingHealthPublisher();
|
|
var parent = CreateTestProbe();
|
|
parent.IgnoreMessages(_ => true);
|
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(new StubDriver(), healthPublisher: publisher));
|
|
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
|
|
|
|
publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null);
|
|
}
|
|
|
|
/// <summary>Captures every health publish so a test can assert on the rediscovery fields.</summary>
|
|
private sealed record HealthPublish(
|
|
string ClusterId,
|
|
string DriverInstanceId,
|
|
DriverHealth Health,
|
|
int ErrorCount5Min,
|
|
DateTime? RediscoveryNeededUtc,
|
|
string? RediscoveryReason)
|
|
{
|
|
public string? LastError => Health.LastError;
|
|
}
|
|
|
|
private sealed class RecordingHealthPublisher : IDriverHealthPublisher
|
|
{
|
|
private readonly List<HealthPublish> _published = [];
|
|
|
|
/// <summary>Thread-safe snapshot — <c>Publish</c> is called from 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)
|
|
{
|
|
lock (_published)
|
|
{
|
|
_published.Add(new HealthPublish(
|
|
clusterId, driverInstanceId, health, errorCount5Min, rediscoveryNeededUtc, rediscoveryReason));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A stub driver exposing <see cref="IRediscoverable"/>, a hook to raise it, and the live subscriber
|
|
/// count (for the detach guard).
|
|
/// <para><b>Implemented from scratch rather than derived from the shared <c>StubDriver</c> on
|
|
/// purpose.</b> That one returns <c>GetHealth() => new(Healthy, DateTime.UtcNow, null)</c>, so its
|
|
/// health fingerprint differs on EVERY call and the dedup under test never engages — which would make
|
|
/// <see cref="Rediscovery_raise_is_not_swallowed_by_the_unchanged_health_dedup"/> pass vacuously
|
|
/// whether or not the fix is present. This stub's health is STABLE until a test changes it.</para>
|
|
/// </summary>
|
|
private sealed class RediscoverableStubDriver : IDriver, IRediscoverable
|
|
{
|
|
private static readonly DateTime FixedLastRead = new(2026, 7, 27, 12, 0, 0, DateTimeKind.Utc);
|
|
private volatile string? _lastError;
|
|
|
|
/// <inheritdoc />
|
|
public event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
|
|
|
|
/// <inheritdoc />
|
|
public string DriverInstanceId => "rediscoverable-stub-1";
|
|
|
|
/// <inheritdoc />
|
|
public string DriverType => "Stub";
|
|
|
|
/// <summary>Number of live subscribers on <see cref="OnRediscoveryNeeded"/>.</summary>
|
|
public int SubscriberCount => OnRediscoveryNeeded?.GetInvocationList().Length ?? 0;
|
|
|
|
/// <summary>Raises the event exactly as a real driver's watcher does.</summary>
|
|
public void RaiseRediscovery(string reason)
|
|
=> OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs(reason, ScopeHint: null));
|
|
|
|
/// <summary>Changes the reported health so a test can force a publish that is NOT the rediscovery one.</summary>
|
|
public void SetLastError(string error) => _lastError = error;
|
|
|
|
/// <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, _lastError);
|
|
|
|
}
|
|
}
|