5067509dd4
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
88 lines
4.4 KiB
C#
88 lines
4.4 KiB
C#
using Akka.Actor;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
|
|
/// <summary>
|
|
/// Gitea #662 — a native-alarm feed must survive a deploy that does NOT respawn its driver, end to end
|
|
/// through the real <see cref="Runtime.Drivers.DriverHostActor"/> and its real
|
|
/// <see cref="Runtime.Drivers.DriverInstanceActor"/> child.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Found live on the docker-dev rig: a deploy that only disabled ANOTHER driver re-pushed the desired set to
|
|
/// an unchanged OpcUaClient driver, whose data re-subscribe tore down the alarm event handler too. The
|
|
/// condition then froze while upstream kept emitting, and only a respawn brought it back. Every existing
|
|
/// end-to-end alarm test drives the driver directly (the host's driver is <c>Enabled=false</c>), so none of
|
|
/// them could see the instance actor drop the feed.
|
|
/// </remarks>
|
|
public sealed class NativeAlarmFeedSurvivesDeployTests : RuntimeActorTestBase
|
|
{
|
|
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
|
private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64));
|
|
private static readonly NodeId TestNode = NodeId.Parse("node-1:4053");
|
|
|
|
private const string DriverId = "DI-1";
|
|
private const string AlarmXPath = "Plant/Modbus/dev1/alarmX";
|
|
private const string AlarmConfig = """{"alarm":{"alarmType":"AlarmCondition","severity":500}}""";
|
|
|
|
private static readonly (string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[]
|
|
RawTags =
|
|
[
|
|
(DriverId, "Modbus", "dev1", "speed", "Float", false),
|
|
(DriverId, "Modbus", "dev1", "alarmX", "Float", false),
|
|
];
|
|
|
|
private static readonly Dictionary<string, string> TagConfigs = new() { ["alarmX"] = AlarmConfig };
|
|
|
|
[Fact]
|
|
public void An_upstream_alarm_still_reaches_its_condition_after_a_non_respawning_deploy()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var factory = new RecordingSubscribableDriverFactory("Modbus");
|
|
var revA = V3ArtifactSeeder.SeedV3Deployment(
|
|
db, RevA, RawTags, Array.Empty<(string, string, string, string, string?)>(),
|
|
enabled: true, tagConfigByTag: TagConfigs);
|
|
var (host, publish, _) = V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, revA, RevA, factory);
|
|
|
|
RecordingSubscribableDriver driver = null!;
|
|
AwaitAssert(() =>
|
|
{
|
|
factory.Created.Count.ShouldBe(1);
|
|
driver = factory.Created[0];
|
|
driver.SubscribeCount.ShouldBeGreaterThanOrEqualTo(1);
|
|
driver.LastSubscribedAlarmRefs.ShouldBe(new[] { AlarmXPath });
|
|
driver.AlarmSubscriberCount.ShouldBe(1);
|
|
}, duration: PresenceBudget);
|
|
var dataSubscribesBefore = driver.SubscribeCount;
|
|
|
|
// Revision B only ADDS another driver: DI-1's merged DriverConfig is byte-identical, so it is kept,
|
|
// and the bulk push re-sends its (unchanged) desired set — which re-subscribes its data.
|
|
var revB = V3ArtifactSeeder.SeedV3Deployment(
|
|
db, RevB, RawTags, Array.Empty<(string, string, string, string, string?)>(),
|
|
enabled: true, tagConfigByTag: TagConfigs, taglessDrivers: ["DI-2"]);
|
|
host.Tell(new DispatchDeployment(revB, RevB, CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
driver.SubscribeCount.ShouldBeGreaterThan(dataSubscribesBefore,
|
|
"precondition: the deploy must re-subscribe DI-1's data, or this test exercises nothing");
|
|
}, duration: PresenceBudget);
|
|
driver.InitializeCount.ShouldBe(1, "precondition: DI-1 must NOT have been respawned or re-initialised");
|
|
driver.ShutdownCount.ShouldBe(0);
|
|
factory.Created[0].ShouldBeSameAs(driver);
|
|
|
|
driver.AlarmSubscriberCount.ShouldBe(1, "the alarm event handler must survive the data re-subscribe");
|
|
driver.FireAlarm(AlarmXPath, AlarmTransitionKind.Raise);
|
|
|
|
var update = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(
|
|
m => m.AlarmNodeId == AlarmXPath && m.State.Active, PresenceBudget);
|
|
update.State.Acknowledged.ShouldBeFalse();
|
|
}
|
|
}
|