Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostCommsLossRecoveryTests.cs
T

238 lines
12 KiB
C#

using System.Diagnostics.Metrics;
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
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.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using static ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness.V3ArtifactSeeder;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// <b>The 03/S15 contract test: comms loss → value nodes Bad → the driver's own re-publish clears
/// them.</b> A real <see cref="DriverInstanceActor"/> is spawned by a real
/// <see cref="DriverHostActor"/> around a scripted driver, so the whole seam runs: the child's
/// <c>Reconnecting</c> entry announces comms loss, the host stamps the value nodes
/// <c>BadNoCommunication</c>, the child reconnects and re-subscribes, and the driver's initial push is
/// what makes the nodes Good again.
///
/// <para>⚠️ <b>Vacuity rule — the pushed value is IDENTICAL to the pre-outage one.</b> The host writes
/// nothing Good on reconnect by design, so recovery depends entirely on a re-publish landing; and every
/// dedup on that path (a driver's last-value cache, a consumer's last-published memory) keys on value.
/// A recovery test whose driver pushes a <em>different</em> value therefore passes against a build with
/// no force-republish anywhere — the value change alone defeats every dedup — and is not this
/// contract's guard. The same-value push is the whole point.</para>
///
/// <para>The falsification control is
/// <see cref="A_silent_resubscribe_leaves_the_nodes_Bad_and_trips_the_audit"/>: a driver whose
/// <c>SubscribeAsync</c> pushes nothing (the pre-STAB-18 MQTT shape) leaves the nodes Bad and is caught
/// only by the audit. It proves the test above can fail.</para>
/// </summary>
public sealed class DriverHostCommsLossRecoveryTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-commsloss-recovery");
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('d', 64));
/// <summary>Audit window measured from the RECONNECT, not from the outage — the initial push lands
/// within milliseconds of Connected, so this is generous.</summary>
private static readonly TimeSpan AuditDelay = TimeSpan.FromSeconds(2);
/// <summary>The child's reconnect retry runs on <c>DriverInstanceActor.DefaultReconnectInterval</c>
/// (10 s) and the host exposes no seam to shorten it, so every budget here clears that.</summary>
private static readonly TimeSpan ReconnectBudget = TimeSpan.FromSeconds(25);
private const string SpeedRef = "Plant/Modbus/dev1/speed";
private const string UnsSpeedNode = "filling/line1/station1/speed";
[Fact]
public void An_identical_value_pushed_on_resubscribe_recovers_the_nodes()
{
var driver = new ScriptedSubscribeDriver { InstanceId = "drv-recover", InitialPush = (SpeedRef, 5.0, OpcUaStatus.Good) };
var (host, publish) = Arrange(driver, "drv-recover");
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
// Steady state: the driver's FIRST subscribe already pushed 5 Good, and the host fanned it to
// raw + UNS. (The push on subscribe is the behaviour under test, so it is also how the pre-outage
// value gets there — no separate seeding call, which would leave an extra batch in the probe.)
var first = publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget);
first.Items.Select(i => i.NodeId).ShouldBe(new[] { SpeedRef, UnsSpeedNode }, ignoreOrder: true);
first.Items.ShouldAllBe(i => Equals(i.Value, 5.0));
// The outage: the child re-enters Reconnecting and announces comms loss to the host.
host.Tell(new ReconnectDriver("c1", "drv-recover", "test-operator", Guid.NewGuid()));
var staled = publish.ExpectMsg<OpcUaPublishActor.AttributeStatusBatchUpdate>(ReconnectBudget);
staled.Items.Select(i => i.NodeId).ShouldBe(new[] { SpeedRef, UnsSpeedNode }, ignoreOrder: true);
staled.Items.ShouldAllBe(i => i.StatusCode == OpcUaStatus.BadNoCommunication);
// Recovery: the reconnect re-subscribes, and the driver pushes the SAME value it held before.
var recovered = publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(ReconnectBudget);
recovered.Items.Select(i => i.NodeId).ShouldBe(new[] { SpeedRef, UnsSpeedNode }, ignoreOrder: true);
recovered.Items.ShouldAllBe(i => i.StatusCode == OpcUaStatus.Good);
recovered.Items.ShouldAllBe(i => Equals(i.Value, 5.0));
// …and the audit finds nothing outstanding.
EventFilter.Warning(contains: "still BadNoCommunication")
.Expect(0, AuditDelay + TimeSpan.FromSeconds(3), () => { });
meter.SumWithTag("driver_instance", "drv-recover").ShouldBe(0);
}
/// <summary>
/// <b>The falsification control.</b> Same scenario, but the driver's <c>SubscribeAsync</c> pushes
/// nothing — exactly what MQTT did before 05/STAB-18, and what any push driver without an initial
/// push does. The nodes stay Bad (the host writes no Good of its own), and the ONLY thing that says
/// so is the recovery audit.
/// </summary>
[Fact]
public void A_silent_resubscribe_leaves_the_nodes_Bad_and_trips_the_audit()
{
var driver = new ScriptedSubscribeDriver { InstanceId = "drv-silent", InitialPush = null };
var (host, publish) = Arrange(driver, "drv-silent");
using var meter = new MeterRecorder("otopcua.driver.comms_latched_refs");
driver.FireDataChange(SpeedRef, 5.0, OpcUaStatus.Good);
publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget);
EventFilter.Warning(contains: "still BadNoCommunication").ExpectOne(ReconnectBudget, () =>
{
host.Tell(new ReconnectDriver("c1", "drv-silent", "test-operator", Guid.NewGuid()));
publish.ExpectMsg<OpcUaPublishActor.AttributeStatusBatchUpdate>(ReconnectBudget);
// Nothing Good ever arrives — assert it rather than merely not looking.
publish.ExpectNoMsg(TimeSpan.FromSeconds(1));
});
// One ref (the raw tag); its UNS mirror shares it.
AwaitCondition(() => meter.SumWithTag("driver_instance", "drv-silent") == 1, TimeSpan.FromSeconds(5));
driver.SubscribeCount.ShouldBeGreaterThanOrEqualTo(2,
"the child must actually have re-subscribed — otherwise this proves nothing about the push");
}
private (IActorRef Host, Akka.TestKit.TestProbe Publish) Arrange(ScriptedSubscribeDriver driver, string driverId)
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3Deployment(db, RevA,
rawTags: new[]
{
(Driver: driverId, DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
},
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) },
enabled: true);
var (host, publish, _) = SpawnHostAndApply(
this, db, TestNode, deploymentId, RevA,
commsRecoveryAuditDelay: AuditDelay,
driverFactory: new SingleDriverFactory(driver));
// The child is spawned + initialised by the apply; wait for its first subscribe so the scripted
// pushes below are attributed to a live subscription.
AwaitCondition(() => driver.SubscribeCount >= 1, PresenceBudget);
return (host, publish);
}
/// <summary>Hands the host one scripted driver, whatever type/instance it asks for.</summary>
private sealed class SingleDriverFactory(IDriver driver) : IDriverFactory
{
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => driver;
public IReadOnlyCollection<string> SupportedTypes { get; } = ["Modbus"];
}
/// <summary>
/// A subscribable driver whose <see cref="SubscribeAsync"/> optionally performs an <b>initial
/// push</b> — the poll engine's <c>forceRaise: true</c> and MQTT's post-STAB-18 behaviour, modelled
/// at the seam the host actually observes. With <see cref="InitialPush"/> null it is the silent
/// shape the control test needs.
/// </summary>
private sealed class ScriptedSubscribeDriver : IDriver, ISubscribable
{
private readonly StubHandle _handle = new();
private int _subscribeCount;
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>The value re-published on every (re)subscribe, or null for a silent driver.</summary>
public (string Ref, object? Value, uint StatusCode)? InitialPush { get; init; }
/// <summary>The instance id the child actor adopts — <c>DriverInstanceActor</c> takes it from the
/// DRIVER, not from the spec, and the host's ref map is keyed by the spec's id. They must agree or
/// every published value is dropped as unmapped.</summary>
public required string InstanceId { get; init; }
public int SubscribeCount => Volatile.Read(ref _subscribeCount);
public string DriverInstanceId => InstanceId;
public string DriverType => "Modbus";
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;
// Stable health: the #527 watchdog must not fire on its own here — the outage in these tests is
// driven explicitly, and a health-driven trip would make the timing non-deterministic.
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _subscribeCount);
if (InitialPush is { } push && fullReferences.Contains(push.Ref))
FireDataChange(push.Ref, push.Value, push.StatusCode);
return Task.FromResult<ISubscriptionHandle>(_handle);
}
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask;
public void FireDataChange(string fullRef, object? value, uint statusCode)
{
var snapshot = new DataValueSnapshot(value, statusCode, DateTime.UtcNow, DateTime.UtcNow);
OnDataChange?.Invoke(this, new DataChangeEventArgs(_handle, fullRef, snapshot));
}
}
/// <summary>Tag-filtered counter recorder — see the note in
/// <c>DriverHostActorCommsLossValueNodesTests</c> on why the instrument name alone is not enough.</summary>
private sealed class MeterRecorder : IDisposable
{
private readonly MeterListener _listener;
private readonly List<(long Value, KeyValuePair<string, object?>[] Tags)> _measurements = new();
private readonly object _gate = new();
public MeterRecorder(string instrumentName)
{
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == OtOpcUaTelemetry.MeterName && instrument.Name == instrumentName)
listener.EnableMeasurementEvents(instrument);
},
};
_listener.SetMeasurementEventCallback<long>((_, value, tags, _) =>
{
lock (_gate) _measurements.Add((value, tags.ToArray()));
});
_listener.Start();
}
public long SumWithTag(string key, string value)
{
lock (_gate)
return _measurements.Where(m => m.Tags.Any(t => t.Key == key && Equals(t.Value, value)))
.Sum(m => m.Value);
}
public void Dispose() => _listener.Dispose();
}
}