Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs
T
Joseph Doherty 5067509dd4 fix(runtime): keep native-alarm feed across data re-subscribes; reconcile retries alarms (#662)
A data re-subscribe (every deploy's bulk SetDesiredSubscriptions push to a
kept driver) dropped the prior data subscription through the full
DetachSubscription, which also unhooked OnAlarmEvent and forgot the alarm
handle. On any deploy that did not respawn the driver, the alarm
subscription kept logging "established" while every event reached nobody
until a reconnect or respawn. The data path now uses DetachDataSubscription;
only a lost connection and PostStop detach both.

The #488 health-poll reconcile only looked at data, so an alarm subscribe
that failed on (re)connect gated the feed for the life of the connection.
ReconcileAlarmSubscription now re-attaches a missing handler and
re-subscribes a stale/missing alarm handle every tick.

OpcUaClient removes a half-built alarm subscription from its session when
SubscribeAlarmsAsync throws, so the new retries cannot accumulate.

Tests: actor-level data-resubscribe + reconcile-retry tests and an
end-to-end NativeAlarmFeedSurvivesDeployTests through DriverHostActor; all
three fail against the unfixed actor.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 07:30:25 -04:00

530 lines
30 KiB
C#

using Akka.Actor;
using Akka.Event;
using Shouldly;
using Xunit;
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.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Covers WS-4b: <see cref="DriverInstanceActor"/> subscribes a driver's native
/// <see cref="IAlarmSource.OnAlarmEvent"/> (when the driver is an <see cref="IAlarmSource"/>)
/// and forwards every transition to its parent as
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/> — mirroring the OnDataChange →
/// <see cref="DriverInstanceActor.AttributeValueBatchPublished"/> forward. The driver fires
/// <c>OnAlarmEvent</c> on its OWN thread; the actor marshals it onto the actor thread via Self.
/// </summary>
public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
{
/// <summary>
/// Driving an <see cref="IAlarmSource"/> driver to Connected then raising a native alarm forwards
/// it to the parent as <see cref="DriverInstanceActor.AttributeAlarmPublished"/> carrying the
/// driver-instance id + the original <see cref="AlarmEventArgs"/> (SourceNodeId preserved).
/// </summary>
[Fact]
public async Task Native_alarm_is_forwarded_to_parent_as_AttributeAlarmPublished()
{
var driver = new AlarmSourceStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
// The alarm handler is attached after Become(Connected); wait for it to be wired before raising.
AwaitCondition(() => driver.AlarmSubscriberCount == 1, TimeSpan.FromSeconds(2));
// Also establish a data-change subscription so the OnDataChange path is wired (the alarm attach is
// independent of Subscribe — this proves both event paths coexist on one driver).
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
new DriverInstanceActor.Subscribe(new[] { "tag-z" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
driver.RaiseAlarm(new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "src-node-7",
ConditionId: "cond-1",
AlarmType: "AnalogLimitAlarm.HiHi",
Message: "level too high",
Severity: AlarmSeverity.High,
SourceTimestampUtc: DateTime.UtcNow,
Kind: AlarmTransitionKind.Raise));
var published = parent.ExpectMsg<DriverInstanceActor.AttributeAlarmPublished>(TimeSpan.FromSeconds(2));
published.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
published.Args.SourceNodeId.ShouldBe("src-node-7");
published.Args.ConditionId.ShouldBe("cond-1");
published.Args.Kind.ShouldBe(AlarmTransitionKind.Raise);
// The same driver's OnDataChange still flows independently — alarm + value events coexist.
driver.FireDataChange("tag-z", value: 9.0, statusCode: 0u);
parent.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(TimeSpan.FromSeconds(2))
.Items[0].FullReference.ShouldBe("tag-z");
}
/// <summary>
/// The native-alarm path is gated at the driver: an <see cref="IAlarmSource"/> suppresses
/// <c>OnAlarmEvent</c> until at least one alarm subscription exists (e.g. GalaxyDriver gates its
/// central feed). When the host pushes a <see cref="DriverInstanceActor.SetDesiredSubscriptions"/>
/// carrying native-alarm refs to a <c>Connected</c> driver — the live deploy path — the actor must
/// call <see cref="IAlarmSource.SubscribeAlarmsAsync"/> with those refs to un-gate the feed, and must
/// NOT re-subscribe when the same set is redeployed (the feed is already un-gated).
/// </summary>
[Fact]
public async Task Alarm_subscription_is_established_when_alarm_refs_are_pushed_while_connected()
{
var driver = new AlarmSourceStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.AlarmSubscriberCount == 1, TimeSpan.FromSeconds(2)); // reached Connected
driver.SubscribeAlarmsCallCount.ShouldBe(0, "no alarm subscription before any alarm refs are pushed");
// A deploy delivers the desired set with a native-alarm ref while the driver is already Connected.
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
Array.Empty<string>(), TimeSpan.FromMilliseconds(100), new[] { "Temp.HiHi" }));
AwaitCondition(() => driver.SubscribeAlarmsCallCount == 1, TimeSpan.FromSeconds(2));
driver.LastAlarmRefs.ShouldBe(new[] { "Temp.HiHi" });
// Redeploying the same alarm set must NOT re-subscribe (idempotent — already un-gated). Round-trip a
// data Subscribe to flush the mailbox so the second SetDesiredSubscriptions is fully processed first.
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
Array.Empty<string>(), TimeSpan.FromMilliseconds(100), new[] { "Temp.HiHi" }));
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
new DriverInstanceActor.Subscribe(new[] { "tag-z" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
driver.SubscribeAlarmsCallCount.ShouldBe(1, "an already-established alarm subscription is not re-issued");
}
/// <summary>
/// A driver that is NOT an <see cref="IAlarmSource"/> (only <see cref="ISubscribable"/>) connects
/// and serves data changes normally — <c>AttachAlarmSource</c> is a safe no-op (no crash) and no
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/> is ever produced.
/// </summary>
[Fact]
public async Task Non_alarm_source_driver_serves_data_changes_and_never_publishes_alarms()
{
var driver = new SubscribableOnlyStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
new DriverInstanceActor.Subscribe(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
driver.FireDataChange("tag-a", value: 1.5, statusCode: 0u);
// Data-change forwarding still works (no crash on AttachAlarmSource for a non-IAlarmSource driver).
var dc = parent.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(TimeSpan.FromSeconds(2));
dc.Items[0].FullReference.ShouldBe("tag-a");
// An AttributeAlarmPublished can never be produced for a non-IAlarmSource driver.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>
/// A native alarm transition that races in while the actor is in <c>Reconnecting</c> is silently
/// dropped (debug log only) — it NEVER reaches the parent as
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/> AND is NOT dead-lettered (the
/// <c>Reconnecting</c> state's explicit <c>Receive&lt;NativeAlarmRaised&gt;</c> handler consumes and
/// discards it, so it never becomes unhandled). The feed re-delivers active alarms once the actor
/// re-enters <c>Connected</c>, so dropping here is safe.
/// (<see cref="DriverInstanceActor"/> line ~345: the <c>Reconnecting</c> state's
/// <c>Receive&lt;NativeAlarmRaised&gt;</c> logs a debug message and discards.)
///
/// <para>
/// To reproduce the race deterministically: a <c>ForceReconnect</c> is enqueued first, then
/// the driver fires an alarm while its <c>OnAlarmEvent</c> handler is still attached (the actor
/// hasn't yet processed <c>ForceReconnect</c>). The handler's <c>self.Tell(NativeAlarmRaised)</c>
/// lands second in the mailbox. The actor then processes <c>ForceReconnect</c> → Reconnecting
/// (detaches handler), then processes the queued <c>NativeAlarmRaised</c> in Reconnecting
/// → drops it. The default 10 s reconnect interval ensures no retry fires during the check.
/// </para>
///
/// <para>
/// Both properties are actively asserted: (a) the parent probe receives no
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/>; (b) a dead-letter probe subscribed
/// to <see cref="AllDeadLetters"/> on the <see cref="ActorSystem.EventStream"/> also receives
/// nothing — proving the drop handler is present (removing it would cause a dead-letter and fail
/// this assertion). <c>NativeAlarmRaised</c> is private to the actor, so the dead-letter probe
/// subscribes to the unfiltered <see cref="AllDeadLetters"/> channel; this is safe because
/// exactly one message is injected and the reconnect timer (10 s) cannot fire in the window.
/// </para>
/// </summary>
[Fact]
public void Native_alarm_during_reconnect_is_dropped_not_forwarded()
{
// Subscribe a dead-letter probe BEFORE injecting the alarm so we don't miss any early publish.
// NativeAlarmRaised is private, so we subscribe to the unfiltered AllDeadLetters channel.
// Only one message is injected and the 10 s reconnect timer can't fire in this window, so
// a plain "no dead letters at all" assertion is safe and non-flaky.
var deadLetters = CreateTestProbe();
Sys.EventStream.Subscribe(deadLetters.Ref, typeof(AllDeadLetters));
// Long reconnect interval (default 10 s) so the retry doesn't fire during the assertion window.
var driver = new AlarmSourceStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
// Drive to Connected; confirm the alarm handler is attached.
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.AlarmSubscriberCount == 1, TimeSpan.FromSeconds(2));
// Enqueue ForceReconnect FIRST — the actor hasn't processed it yet, so the handler is
// still wired. The test thread then immediately fires the alarm on the driver; the handler's
// self.Tell(NativeAlarmRaised) lands SECOND in the mailbox.
actor.Tell(new DriverInstanceActor.ForceReconnect());
driver.RaiseAlarm(new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "src-node-reconnect",
ConditionId: "cond-reconnect",
AlarmType: "T",
Message: "during-reconnect raise",
Severity: AlarmSeverity.High,
SourceTimestampUtc: DateTime.UtcNow,
Kind: AlarmTransitionKind.Raise));
// (a) The parent must NOT receive AttributeAlarmPublished from that alarm.
// The actor processes: (1) ForceReconnect → Reconnecting (handler detached);
// (2) NativeAlarmRaised → dropped (debug log, no forward).
// Wait generously — the default reconnect interval of 10 s means no retry fires here.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
// (b) The alarm must also NOT have dead-lettered — the Reconnecting state's explicit
// Receive<NativeAlarmRaised> handler must have consumed it. If that handler were removed the
// message would become unhandled → AllDeadLetters, and this assertion would catch the regression.
deadLetters.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
// The actor must still be alive — Watch + no Terminated.
Watch(actor);
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(100));
}
/// <summary>
/// Gitea #662 case 1 — re-subscribing a Connected driver's DATA (what every deploy's
/// <c>SetDesiredSubscriptions</c> push does) must leave the native-alarm feed alone. The data
/// re-subscribe drops the prior data subscription first, and that teardown used to run the full
/// detach — unhooking <c>OnAlarmEvent</c> and forgetting the alarm handle — so the alarm subscription
/// stayed "established" while every event it carried reached nobody until a reconnect.
/// </summary>
[Fact]
public async Task A_data_re_subscribe_keeps_the_native_alarm_feed_attached()
{
var driver = new AlarmSourceStubDriver();
var parent = CreateTestProbe();
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.AlarmSubscriberCount == 1, TimeSpan.FromSeconds(2));
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
new[] { "tag-a" }, TimeSpan.FromMilliseconds(100), new[] { "Temp.HiHi" }));
AwaitCondition(() => driver.SubscribeAlarmsCallCount == 1, TimeSpan.FromSeconds(2));
// A second data subscribe while the first handle is live — the drop-then-resubscribe path.
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
new DriverInstanceActor.Subscribe(new[] { "tag-a", "tag-b" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
driver.AlarmSubscriberCount.ShouldBe(1, "the data re-subscribe must not unhook OnAlarmEvent");
driver.SubscribeAlarmsCallCount.ShouldBe(1,
"the alarm handle is still live, so nothing should re-issue (and duplicate) the driver-side subscription");
driver.RaiseAlarm(new AlarmEventArgs(
new StubAlarmHandle(), "Temp.HiHi", "Temp.HiHi", "T", "after re-subscribe", AlarmSeverity.High,
DateTime.UtcNow, Kind: AlarmTransitionKind.Raise));
parent.FishForMessage<DriverInstanceActor.AttributeAlarmPublished>(_ => true, TimeSpan.FromSeconds(2))
.Args.ConditionId.ShouldBe("Temp.HiHi");
}
/// <summary>
/// Gitea #662 case 2 — an alarm subscribe that fails on (re)connect (a session not yet ready after an
/// outage) is retried by the periodic reconcile while the driver stays Connected. Before #662 the
/// reconcile looked at data only, so the feed stayed gated for the life of the connection. Nothing
/// else here can produce the second call: no reconnect, no deploy.
/// </summary>
[Fact]
public void A_failed_alarm_subscribe_is_retried_by_the_periodic_reconcile()
{
var driver = new AlarmSourceStubDriver { AlarmSubscribeFailuresBeforeSuccess = 1 };
var publisher = new CountingHealthPublisher();
var parent = CreateTestProbe();
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, healthPublisher: publisher, healthPollInterval: TimeSpan.FromMilliseconds(100)));
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
Array.Empty<string>(), TimeSpan.FromMilliseconds(100), new[] { "Temp.HiHi" }));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.SubscribeAlarmsCallCount >= 1, PresenceBudget);
AwaitCondition(() => driver.SubscribeAlarmsCallCount >= 2, PresenceBudget);
driver.LastAlarmRefs.ShouldBe(new[] { "Temp.HiHi" });
driver.RaiseAlarm(new AlarmEventArgs(
new StubAlarmHandle(), "Temp.HiHi", "Temp.HiHi", "T", "after retry", AlarmSeverity.High,
DateTime.UtcNow, Kind: AlarmTransitionKind.Raise));
parent.FishForMessage<DriverInstanceActor.AttributeAlarmPublished>(_ => true, TimeSpan.FromSeconds(2))
.Args.ConditionId.ShouldBe("Temp.HiHi");
// Converged: a live, matching handle is left alone on later ticks. POSITIVE CONTROL: each tick
// publishes one health snapshot, so waiting for four more makes the unchanged count mean "the
// reconcile ran and declined", not "no tick had fired yet".
var settled = driver.SubscribeAlarmsCallCount;
var before = publisher.Count;
AwaitCondition(() => publisher.Count >= before + 4, PresenceBudget);
driver.SubscribeAlarmsCallCount.ShouldBe(settled, "a live, matching alarm handle must not be re-subscribed every tick");
}
/// <summary>
/// After a full reconnect cycle (Connected → Reconnecting → Connected), a single raised alarm still
/// yields EXACTLY ONE <see cref="DriverInstanceActor.AttributeAlarmPublished"/> — the
/// <c>_alarmEventHandler is not null</c> guard in <c>AttachAlarmSource</c> plus the detach on the
/// Connected → Reconnecting transition prevent a double-attach (which would publish twice).
/// </summary>
[Fact]
public void Reconnect_does_not_double_attach_alarm_handler()
{
var driver = new AlarmSourceStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.AlarmSubscriberCount == 1, TimeSpan.FromSeconds(2));
// Force Connected → Reconnecting (detaches) → Connected (re-attaches). InitializeCount climbs on
// the retry; the alarm handler count must settle back to exactly one (no leaked extra handler).
var initBefore = driver.InitializeCount;
actor.Tell(new DriverInstanceActor.DisconnectObserved("backend blip"));
AwaitCondition(() => driver.InitializeCount > initBefore, TimeSpan.FromSeconds(3));
AwaitCondition(() => driver.AlarmSubscriberCount == 1, TimeSpan.FromSeconds(3));
driver.RaiseAlarm(new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "src-node-1",
ConditionId: "cond-x",
AlarmType: "T",
Message: "m",
Severity: AlarmSeverity.Low,
SourceTimestampUtc: DateTime.UtcNow,
Kind: AlarmTransitionKind.Raise));
// Exactly one forward — a double-attach would surface as a second AttributeAlarmPublished.
parent.ExpectMsg<DriverInstanceActor.AttributeAlarmPublished>(TimeSpan.FromSeconds(2))
.Args.SourceNodeId.ShouldBe("src-node-1");
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>#477 Layer 2 — the actor announces connectivity to its parent (the host): Connected on
/// reaching the Connected state, then Connected=false on a <see cref="DriverInstanceActor.DisconnectObserved"/>,
/// then Connected=true again on the reconnect. This is the signal the host uses to annotate the driver's
/// native alarm conditions' Quality (comms lost → Bad, restored → Good).</summary>
[Fact]
public void ConnectivityChanged_is_announced_to_parent_on_connect_disconnect_and_reconnect()
{
var driver = new AlarmSourceStubDriver();
var parent = CreateTestProbe(); // deliberately does NOT ignore ConnectivityChanged — it's the subject.
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// Initial connect → Connected=true.
parent.FishForMessage<DriverInstanceActor.ConnectivityChanged>(m => true, TimeSpan.FromSeconds(3))
.Connected.ShouldBeTrue();
// Disconnect → Connected=false (this is what drives the conditions Bad).
actor.Tell(new DriverInstanceActor.DisconnectObserved("backend blip"));
parent.FishForMessage<DriverInstanceActor.ConnectivityChanged>(m => true, TimeSpan.FromSeconds(3))
.Connected.ShouldBeFalse();
// Reconnect → Connected=true again (drives them back Good).
parent.FishForMessage<DriverInstanceActor.ConnectivityChanged>(m => true, TimeSpan.FromSeconds(3))
.Connected.ShouldBeTrue();
}
// --- stub drivers ----------------------------------------------------------------------------
private class StubDriver : IDriver
{
/// <summary>Gets the number of times initialization was called.</summary>
public int InitializeCount;
/// <summary>Gets the driver instance ID.</summary>
public string DriverInstanceId => "alarm-stub-driver-1";
/// <summary>Gets the driver type.</summary>
public string DriverType => "Stub";
/// <summary>Initializes the driver.</summary>
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
Interlocked.Increment(ref InitializeCount);
return Task.CompletedTask;
}
/// <summary>Reinitializes the driver.</summary>
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) =>
Task.CompletedTask;
/// <summary>Shuts down the driver.</summary>
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>Gets the health status of the driver.</summary>
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
}
/// <summary>A driver that implements <see cref="ISubscribable"/> + <see cref="IAlarmSource"/> and lets the
/// test raise <see cref="IAlarmSource.OnAlarmEvent"/> on demand (the driver fires it on its own thread in
/// production; here the test thread stands in for that).</summary>
private sealed class AlarmSourceStubDriver : StubDriver, ISubscribable, IAlarmSource
{
private readonly StubHandle _subHandle = new();
/// <summary>Occurs when data changes.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>Server-pushed alarm transition.</summary>
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
/// <summary>Number of live subscribers on <see cref="OnAlarmEvent"/> — proves attach/detach.</summary>
public int AlarmSubscriberCount => OnAlarmEvent?.GetInvocationList().Length ?? 0;
/// <summary>Subscribes to the specified full references.</summary>
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
Task.FromResult<ISubscriptionHandle>(_subHandle);
/// <summary>Unsubscribes from the specified subscription handle.</summary>
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) =>
Task.CompletedTask;
private int _subscribeAlarmsCallCount;
/// <summary>Number of <see cref="SubscribeAlarmsAsync"/> calls — proves the actor established the
/// native-alarm subscription that un-gates the feed (incremented on the actor dispatcher thread).</summary>
public int SubscribeAlarmsCallCount => Volatile.Read(ref _subscribeAlarmsCallCount);
/// <summary>The node set handed to the most recent <see cref="SubscribeAlarmsAsync"/> call.</summary>
public IReadOnlyList<string>? LastAlarmRefs { get; private set; }
/// <summary>Subscribes to alarm events for the specified node set.</summary>
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
LastAlarmRefs = sourceNodeIds;
var call = Interlocked.Increment(ref _subscribeAlarmsCallCount);
if (call <= AlarmSubscribeFailuresBeforeSuccess)
throw new InvalidOperationException($"alarm subscribe failure {call} (test-injected)");
return Task.FromResult<IAlarmSubscriptionHandle>(new StubAlarmHandle());
}
/// <summary>How many leading <see cref="SubscribeAlarmsAsync"/> calls throw — the "session not ready
/// after an outage" shape from #662.</summary>
public int AlarmSubscribeFailuresBeforeSuccess { get; init; }
/// <summary>Cancels an alarm subscription.</summary>
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
Task.CompletedTask;
/// <summary>Acknowledges a batch of alarms.</summary>
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
Task.CompletedTask;
/// <summary>Fires <see cref="OnAlarmEvent"/> — stands in for the driver's native feed.</summary>
public void RaiseAlarm(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
/// <summary>Fires a data change event (keeps OnDataChange exercised; the actor wires both events).</summary>
public void FireDataChange(string fullRef, object? value, uint statusCode)
{
var snapshot = new DataValueSnapshot(value, statusCode, DateTime.UtcNow, DateTime.UtcNow);
OnDataChange?.Invoke(this, new DataChangeEventArgs(_subHandle, fullRef, snapshot));
}
private sealed class StubHandle : ISubscriptionHandle
{
/// <summary>Gets the diagnostic ID of the subscription.</summary>
public string DiagnosticId => "stub-sub";
}
}
/// <summary>A driver that is <see cref="ISubscribable"/> but NOT <see cref="IAlarmSource"/> — proves
/// <c>AttachAlarmSource</c> is a no-op (no crash) and no alarm forward ever happens.</summary>
private sealed class SubscribableOnlyStubDriver : StubDriver, ISubscribable
{
private readonly StubHandle _subHandle = new();
/// <summary>Occurs when data changes.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>Subscribes to the specified full references.</summary>
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
Task.FromResult<ISubscriptionHandle>(_subHandle);
/// <summary>Unsubscribes from the specified subscription handle.</summary>
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) =>
Task.CompletedTask;
/// <summary>Fires a data change event with the specified parameters.</summary>
public void FireDataChange(string fullRef, object? value, uint statusCode)
{
var snapshot = new DataValueSnapshot(value, statusCode, DateTime.UtcNow, DateTime.UtcNow);
OnDataChange?.Invoke(this, new DataChangeEventArgs(_subHandle, fullRef, snapshot));
}
private sealed class StubHandle : ISubscriptionHandle
{
/// <summary>Gets the diagnostic ID of the subscription.</summary>
public string DiagnosticId => "stub-sub";
}
}
/// <summary>Counts health-snapshot publishes — one per processed <c>health-poll</c> tick.</summary>
private sealed class CountingHealthPublisher : IDriverHealthPublisher
{
private int _count;
/// <summary>Number of snapshots published so far.</summary>
public int Count => Volatile.Read(ref _count);
/// <inheritdoc />
public void Publish(
string clusterId,
string driverInstanceId,
DriverHealth health,
int errorCount5Min,
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
SkippedTagReport? skippedTags = null,
IReadOnlyDictionary<string, string>? diagnostics = null)
=> Interlocked.Increment(ref _count);
}
/// <summary>Minimal <see cref="IAlarmSubscriptionHandle"/> for building <see cref="AlarmEventArgs"/>.</summary>
private sealed class StubAlarmHandle : IAlarmSubscriptionHandle
{
/// <summary>Gets the diagnostic ID of the alarm subscription.</summary>
public string DiagnosticId => "stub-alarm-sub";
}
}