fix(runtime,galaxy): #869 review round 1 — confirmation off the actor scheduler, stop/async-fault tests

- Start the ack confirmation with Task.Run + ConfigureAwait(false) so it never
  depends on the ReceiveAsync handler's ActorTaskScheduler (the PostStop
  abandon-and-revert report must run after the actor stopped).
- DriverInstanceActorAckConfirmationTests: a confirmation that faults after the
  handler returned is reported; one pending at actor stop is reported; a
  confirmed ack reports nothing (positive control).
- Deterministic tracker matching test (Raise/Clear/Retrigger/other alarm do not
  confirm; case-insensitive match does).
- AdminUI min/max on the confirm-timeout field; Galaxy.md documents the
  reconnect, UNACK_RTN-as-Clear and RPC-budget limits.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-11 13:11:49 -05:00
parent 4e492e6550
commit 77872b1d1c
5 changed files with 204 additions and 7 deletions
@@ -0,0 +1,152 @@
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");
}
/// <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();
driver.Confirmation = () => Task.Delay(20);
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");
}
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<AlarmAcknowledgeRequest> _acks = new();
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)
{
foreach (var a in acknowledgements) _acks.Enqueue(a);
Interlocked.Increment(ref _ackCalls);
return Task.CompletedTask;
}
public Task ConfirmAcknowledgedAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
{
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";
}
}
}