b421c7ca91
A pending local ack can outlive its own delivery (sources that send no Acknowledge back; a Clear now only carries it through), so a later failed re-ack — or a stale Galaxy confirmation timing out after the alarm re-occurred and was acked again — could revert an ack it does not own. MarkAcknowledged now issues a sequence (0 when the ack changed nothing), carried on RouteAlarmAck and echoed on AlarmAckFailed; TryRevertAcknowledge reverts only on a matching sequence. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
181 lines
8.3 KiB
C#
181 lines
8.3 KiB
C#
using System.Collections.Concurrent;
|
|
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>
|
|
/// lmxopcua#869 â <see cref="DriverInstanceActor"/> asks an <see cref="IAlarmAcknowledgeConfirmation"/> driver to
|
|
/// confirm an ack AFTER <see cref="IAlarmSource.AcknowledgeAsync"/> returned, off its mailbox, and reports
|
|
/// <see cref="DriverInstanceActor.AlarmAckFailed"/> to the router when the confirmation fails â including one that
|
|
/// fails long after the ack handler returned, and one abandoned because the actor stopped.
|
|
/// </summary>
|
|
public sealed class DriverInstanceActorAckConfirmationTests : RuntimeActorTestBase
|
|
{
|
|
private static readonly TimeSpan Timeout = PresenceBudget;
|
|
|
|
/// <summary>A confirmation that faults asynchronously, after the ack handler returned, is reported failed.</summary>
|
|
[Fact]
|
|
public void A_confirmation_that_fails_later_is_reported_as_a_failed_ack()
|
|
{
|
|
var driver = new ConfirmingDriver();
|
|
var actor = SpawnConnected(driver);
|
|
var gate = new TaskCompletionSource();
|
|
driver.Confirmation = () => gate.Task;
|
|
var router = CreateTestProbe();
|
|
|
|
actor.Tell(Ack("n1"), router.Ref);
|
|
AwaitCondition(() => driver.ConfirmCalls == 1, Timeout, TestContext.Current.CancellationToken);
|
|
gate.SetException(new DriverOperationRefusedException("not confirmed within 10 s (BadTimeout)"));
|
|
|
|
var failed = router.ExpectMsg<DriverInstanceActor.AlarmAckFailed>(Timeout, cancellationToken: TestContext.Current.CancellationToken);
|
|
failed.ConditionNodeId.ShouldBe("n1");
|
|
failed.Reason.ShouldContain("BadTimeout");
|
|
driver.ForeignConfirmations.ShouldBe(0, "the confirmation must get the SAME request instance the ack was sent with");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A confirmation still pending when the actor stops is abandoned AND reported failed, so the host reverts the
|
|
/// ack this incarnation can no longer confirm. (A confirmation continuation posted back to the stopped actor
|
|
/// would dead-letter and never report.)
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_confirmation_pending_when_the_actor_stops_is_reported_as_a_failed_ack()
|
|
{
|
|
var driver = new ConfirmingDriver();
|
|
var actor = SpawnConnected(driver);
|
|
driver.Confirmation = () => new TaskCompletionSource().Task; // never completes on its own
|
|
var router = CreateTestProbe();
|
|
|
|
actor.Tell(Ack("n-stop"), router.Ref);
|
|
AwaitCondition(() => driver.ConfirmCalls == 1, Timeout, TestContext.Current.CancellationToken);
|
|
Sys.Stop(actor);
|
|
|
|
var failed = router.ExpectMsg<DriverInstanceActor.AlarmAckFailed>(Timeout, cancellationToken: TestContext.Current.CancellationToken);
|
|
failed.ConditionNodeId.ShouldBe("n-stop");
|
|
failed.Reason.ShouldContain("stopped");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A confirmation that succeeds reports nothing. Positive control: a later ack whose confirmation fails is
|
|
/// reported, and it is the FIRST report the router receives â so none came for the confirmed one.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_confirmed_ack_reports_nothing()
|
|
{
|
|
var driver = new ConfirmingDriver();
|
|
var actor = SpawnConnected(driver);
|
|
var router = CreateTestProbe();
|
|
|
|
// Already complete: the actor's confirmation task finishes (and would report) right after the call that
|
|
// bumps ConfirmCalls, long before the second ack below makes its actor round trip.
|
|
driver.Confirmation = () => Task.CompletedTask;
|
|
actor.Tell(Ack("n-ok"), router.Ref);
|
|
AwaitCondition(() => driver.ConfirmCalls == 1, Timeout, TestContext.Current.CancellationToken);
|
|
|
|
driver.Confirmation = () => Task.FromException(new DriverOperationRefusedException("unconfirmed"));
|
|
actor.Tell(Ack("n-bad"), router.Ref);
|
|
|
|
router.ExpectMsg<DriverInstanceActor.AlarmAckFailed>(Timeout, cancellationToken: TestContext.Current.CancellationToken).ConditionNodeId.ShouldBe("n-bad");
|
|
}
|
|
|
|
/// <summary>
|
|
/// lmxopcua#869 review — the failure echoes the routed ack's <see cref="DriverInstanceActor.RouteAlarmAck.AckSequence"/>,
|
|
/// which is what lets the host revert only the acknowledge that failed (keyed in
|
|
/// <c>NativeAlarmProjector.TryRevertAcknowledge(nodeId, ackSequence, …)</c>).
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_failed_confirmation_echoes_the_ack_sequence()
|
|
{
|
|
var driver = new ConfirmingDriver();
|
|
var actor = SpawnConnected(driver);
|
|
driver.Confirmation = () => Task.FromException(new DriverOperationRefusedException("unconfirmed"));
|
|
var router = CreateTestProbe();
|
|
|
|
actor.Tell(Ack("n-seq") with { AckSequence = 42 }, router.Ref);
|
|
|
|
router.ExpectMsg<DriverInstanceActor.AlarmAckFailed>(Timeout, cancellationToken: TestContext.Current.CancellationToken)
|
|
.AckSequence.ShouldBe(42L);
|
|
}
|
|
|
|
private static DriverInstanceActor.RouteAlarmAck Ack(string conditionNodeId) =>
|
|
new("Tank.Hi", "c", "alice", ConditionNodeId: conditionNodeId);
|
|
|
|
/// <summary>Spawns the actor as a child of the test actor and waits for its Connected announcement.</summary>
|
|
private IActorRef SpawnConnected(ConfirmingDriver driver)
|
|
{
|
|
var actor = ChildActorOf(DriverInstanceActor.Props(driver));
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
FishForMessage<DriverInstanceActor.ConnectivityChanged>(c => c.Connected, Timeout);
|
|
return actor;
|
|
}
|
|
|
|
private sealed class ConfirmingDriver : IDriver, IAlarmSource, IAlarmAcknowledgeConfirmation
|
|
{
|
|
private int _ackCalls;
|
|
private int _confirmCalls;
|
|
private readonly ConcurrentQueue<IReadOnlyList<AlarmAcknowledgeRequest>> _ackedLists = new();
|
|
private int _foreignConfirmations;
|
|
|
|
/// <summary>Confirmations asked for a request list that was NOT the instance passed to AcknowledgeAsync —
|
|
/// the contract a driver matching by instance (Galaxy) relies on.</summary>
|
|
public int ForeignConfirmations => Volatile.Read(ref _foreignConfirmations);
|
|
|
|
public Func<Task>? Confirmation { get; set; }
|
|
|
|
public int AckCalls => Volatile.Read(ref _ackCalls);
|
|
|
|
public int ConfirmCalls => Volatile.Read(ref _confirmCalls);
|
|
|
|
public string DriverInstanceId => "drv-confirm";
|
|
|
|
public string DriverType => "Test";
|
|
|
|
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, null, null);
|
|
|
|
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
|
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken) =>
|
|
Task.FromResult<IAlarmSubscriptionHandle>(new Handle());
|
|
|
|
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
|
|
Task.CompletedTask;
|
|
|
|
public Task AcknowledgeAsync(
|
|
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
|
{
|
|
_ackedLists.Enqueue(acknowledgements);
|
|
Interlocked.Increment(ref _ackCalls);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task ConfirmAcknowledgedAsync(
|
|
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
|
{
|
|
if (!_ackedLists.Any(l => ReferenceEquals(l, acknowledgements))) Interlocked.Increment(ref _foreignConfirmations);
|
|
Interlocked.Increment(ref _confirmCalls);
|
|
return (Confirmation?.Invoke() ?? Task.CompletedTask).WaitAsync(cancellationToken);
|
|
}
|
|
|
|
public event EventHandler<AlarmEventArgs>? OnAlarmEvent
|
|
{
|
|
add { }
|
|
remove { }
|
|
}
|
|
|
|
private sealed class Handle : IAlarmSubscriptionHandle
|
|
{
|
|
public string DiagnosticId => "h";
|
|
}
|
|
}
|
|
}
|