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
142 lines
7.4 KiB
C#
142 lines
7.4 KiB
C#
using System.Collections.Concurrent;
|
|
using Akka.Actor;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
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>
|
|
/// Runtime guard for the Phase 6.1 resilience wiring (arch-review #10 / RESILIENCE-DISPATCH-GAP):
|
|
/// proves the <see cref="DriverInstanceActor"/> routes each driver-capability call through the
|
|
/// injected <see cref="IDriverCapabilityInvoker"/> at RUNTIME, with the correct
|
|
/// <see cref="DriverCapability"/> and resolved host key — not just that the calls compile wrapped
|
|
/// (the <c>OTOPCUA0001</c> analyzer is the compile-time guard; this is the behavioural one).
|
|
/// A recording invoker stands in for the real <c>CapabilityInvoker</c> so the test stays in the
|
|
/// Polly-free Runtime layer.
|
|
/// </summary>
|
|
public sealed class DriverInstanceActorResilienceWiringTests : RuntimeActorTestBase
|
|
{
|
|
/// <summary>
|
|
/// A bulk subscribe issued on connect (via <c>ResubscribeDesired</c>) routes through the invoker
|
|
/// with <see cref="DriverCapability.Subscribe"/> keyed on the driver-instance id (single-host
|
|
/// fallback for a set that may span hosts). The driver's <c>SubscribeAsync</c> only runs from
|
|
/// inside the invoker's call-site, so <c>SubscribeCount >= 1</c> already proves the route.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Subscribe_routes_through_the_capability_invoker()
|
|
{
|
|
var invoker = new RecordingInvoker();
|
|
var driver = new SubscribableStubDriver();
|
|
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver, invoker: invoker));
|
|
|
|
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
|
|
new[] { "tag-a", "tag-b" }, TimeSpan.FromMilliseconds(100)));
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
AwaitCondition(() => driver.SubscribeCount >= 1, TimeSpan.FromSeconds(3));
|
|
|
|
// Barrier: round-trip a Connected-only Ask so the async subscribe handler has fully recorded.
|
|
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
|
|
new DriverInstanceActor.Subscribe(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)),
|
|
TimeSpan.FromSeconds(3));
|
|
|
|
invoker.Calls.ShouldContain(c => c.Capability == DriverCapability.Subscribe && c.Host == driver.DriverInstanceId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A write routes through the invoker's <c>ExecuteWriteAsync</c> path (recorded as
|
|
/// <see cref="DriverCapability.Write"/>) with the host resolved via
|
|
/// <see cref="IPerCallHostResolver"/> — proving per-host breaker keying is wired, not the
|
|
/// single-host fallback. A write that arrives before <c>Connected</c> fast-fails WITHOUT
|
|
/// touching the invoker, so the loop drives to Connected and the recorded call is the real one.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Write_routes_through_the_invoker_with_resolved_host()
|
|
{
|
|
var invoker = new RecordingInvoker();
|
|
var driver = new HostResolvingWritableDriver(); // ResolveHost("tag-1") => "plc-7"
|
|
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver, invoker: invoker));
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
|
|
// Retry until the write succeeds (i.e. the actor reached Connected). Connecting-state writes
|
|
// fast-fail on a synchronous path that never reaches the invoker, so they don't pollute Calls.
|
|
DriverInstanceActor.WriteAttributeResult? reply = null;
|
|
for (var i = 0; i < 60 && reply is not { Success: true }; i++)
|
|
{
|
|
reply = await actor.Ask<DriverInstanceActor.WriteAttributeResult>(
|
|
new DriverInstanceActor.WriteAttribute("tag-1", 42), TimeSpan.FromSeconds(1));
|
|
if (reply is not { Success: true }) await Task.Delay(25);
|
|
}
|
|
|
|
reply.ShouldNotBeNull();
|
|
reply!.Success.ShouldBeTrue();
|
|
driver.Writes.Count.ShouldBeGreaterThanOrEqualTo(1); // the invoker's call-site actually ran
|
|
invoker.Calls.ShouldContain(c => c.Capability == DriverCapability.Write && c.Host == "plc-7");
|
|
// 03/S12: the dispatch site must default writes to NON-idempotent so the invoker's no-retry arm is
|
|
// authoritative — a command-shaped write (pulse, counter, supervisory) must never replay.
|
|
invoker.WriteCalls.ShouldContain(c => c.Host == "plc-7" && c.IsIdempotent == false);
|
|
}
|
|
|
|
/// <summary>Records every capability + resolved-host key routed through it, then delegates to the
|
|
/// real call-site so the driver still runs. Stands in for <c>CapabilityInvoker</c> without pulling
|
|
/// Polly / Core into the Runtime test.</summary>
|
|
private sealed class RecordingInvoker : IDriverCapabilityInvoker
|
|
{
|
|
public ConcurrentQueue<(DriverCapability Capability, string Host)> Calls { get; } = new();
|
|
public ConcurrentQueue<(string Host, bool IsIdempotent)> WriteCalls { get; } = new();
|
|
|
|
public ValueTask<TResult> ExecuteAsync<TResult>(
|
|
DriverCapability capability, string hostName,
|
|
Func<CancellationToken, ValueTask<TResult>> callSite, CancellationToken cancellationToken)
|
|
{
|
|
Calls.Enqueue((capability, hostName));
|
|
return callSite(cancellationToken);
|
|
}
|
|
|
|
public ValueTask ExecuteAsync(
|
|
DriverCapability capability, string hostName,
|
|
Func<CancellationToken, ValueTask> callSite, CancellationToken cancellationToken)
|
|
{
|
|
Calls.Enqueue((capability, hostName));
|
|
return callSite(cancellationToken);
|
|
}
|
|
|
|
public ValueTask<TResult> ExecuteWriteAsync<TResult>(
|
|
string hostName, bool isIdempotent,
|
|
Func<CancellationToken, ValueTask<TResult>> callSite, CancellationToken cancellationToken)
|
|
{
|
|
Calls.Enqueue((DriverCapability.Write, hostName));
|
|
WriteCalls.Enqueue((hostName, isIdempotent));
|
|
return callSite(cancellationToken);
|
|
}
|
|
}
|
|
|
|
/// <summary>A writable driver that also resolves a per-tag host, so the write path exercises
|
|
/// <see cref="DriverInstanceActor"/>'s <c>ResolveHost</c> (via <see cref="IPerCallHostResolver"/>).</summary>
|
|
private sealed class HostResolvingWritableDriver : IDriver, IWritable, IPerCallHostResolver
|
|
{
|
|
public List<WriteRequest> Writes { get; } = new();
|
|
|
|
public string DriverInstanceId => "host-resolving-driver";
|
|
public string DriverType => "Stub";
|
|
|
|
public string ResolveHost(string fullReference) => "plc-7";
|
|
|
|
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
|
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
|
|
|
public Task<IReadOnlyList<WriteResult>> WriteAsync(
|
|
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
|
{
|
|
Writes.AddRange(writes);
|
|
IReadOnlyList<WriteResult> results = writes.Select(_ => new WriteResult(0u)).ToList();
|
|
return Task.FromResult(results);
|
|
}
|
|
}
|
|
}
|