fix(dcl): drop values for unsubscribed tags before quality bookkeeping; cancel the reconnect re-seed when its session ends (review 2026-10-21 02/L2, 02/L4)

This commit is contained in:
Joseph Doherty
2026-10-22 06:26:06 -04:00
parent 8c1bd2c3fc
commit d51ea5136a
4 changed files with 163 additions and 13 deletions
@@ -0,0 +1,107 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
/// <summary>
/// Low-severity housekeeping findings from review 2026-10-21 (02/L2, 02/L4): bookkeeping that
/// outlived the subscription or the session it belonged to.
/// </summary>
[Collection("DataConnectionManagerActor")]
public class DataConnectionHousekeepingTests : TestKit
{
private const string Tag = "sensor/temp";
private readonly ISiteHealthCollector _health = Substitute.For<ISiteHealthCollector>();
private readonly IDataConnectionFactory _factory = Substitute.For<IDataConnectionFactory>();
private static DataConnectionOptions Options() => new()
{
ReconnectInterval = TimeSpan.FromMilliseconds(100),
TagResolutionRetryInterval = TimeSpan.FromSeconds(30),
WriteTimeout = TimeSpan.FromSeconds(5),
SeedReadMaxAttempts = 1,
SeedReadRetryDelay = TimeSpan.FromMilliseconds(10),
SeedOverallTimeout = TimeSpan.FromSeconds(60),
SeedReadTimeout = TimeSpan.FromSeconds(60),
QualityFlushInterval = TimeSpan.FromMilliseconds(100)
};
private IActorRef CreateActor(FakeBatchDataConnection adapter, string name) =>
Sys.ActorOf(Props.Create(() => new DataConnectionActor(
name, adapter, Options(), _health, _factory, "OpcUa")), name);
private int[]? LastQualityPush() =>
_health.ReceivedCalls()
.Where(c => c.GetMethodInfo().Name == nameof(ISiteHealthCollector.UpdateTagQuality))
.Select(c => c.GetArguments())
.Select(a => new[] { (int)a[1]!, (int)a[2]!, (int)a[3]! })
.LastOrDefault();
private int ConnectedTransitions(string name) =>
_health.ReceivedCalls().Count(c =>
c.GetMethodInfo().Name == nameof(ISiteHealthCollector.UpdateConnectionHealth) &&
c.GetArguments() is [string n, ConnectionHealth h, ..] && n == name && h == ConnectionHealth.Connected);
/// <summary>
/// 02/L2: a value for a tag nobody subscribes to any more (delivered after the last
/// unsubscribe, before the device unadvise lands) must not re-enter the quality bookkeeping —
/// that leaked a <c>_lastTagQuality</c> entry and a phantom Good count above the subscribed
/// total until the next reconnect.
/// </summary>
[Fact]
public void ValueForUnsubscribedTag_DoesNotReEnterQualityBookkeeping()
{
const string name = "late-value-after-unsubscribe";
var adapter = new FakeBatchDataConnection();
var actor = CreateActor(adapter, name);
actor.Tell(new SubscribeTagsRequest("c1", "inst1", name, [Tag], DateTimeOffset.UtcNow));
ExpectMsg<SubscribeTagsResponse>(m => m.Success, TimeSpan.FromSeconds(5));
AwaitCondition(() => LastQualityPush() is [1, 0, 0], TimeSpan.FromSeconds(5));
actor.Tell(new UnsubscribeTagsRequest("c2", "inst1", name, DateTimeOffset.UtcNow));
AwaitCondition(() => !adapter.UnsubscribeBatches.IsEmpty, TimeSpan.FromSeconds(5));
AwaitCondition(() => LastQualityPush() is [0, 0, 0], TimeSpan.FromSeconds(5));
// The late callback.
adapter.ValueCallback!(Tag, new TagValue(7, QualityCode.Good, DateTimeOffset.UtcNow));
Thread.Sleep(500); // > QualityFlushInterval: a dirty counter would have been pushed
Assert.Equal([0, 0, 0], LastQualityPush()!);
}
/// <summary>
/// 02/L4: the reconnect re-seed is bound to its session. A drop while it is still reading must
/// cancel it instead of leaving a full-tag-set read running against a struggling device for
/// up to <c>SeedOverallTimeout</c> — one more per flap.
/// </summary>
[Fact]
public void ReconnectReseed_IsCancelled_WhenTheSessionDropsAgain()
{
const string name = "reseed-cancelled-on-drop";
var adapter = new FakeBatchDataConnection();
var actor = CreateActor(adapter, name);
actor.Tell(new SubscribeTagsRequest("c1", "inst1", name, [Tag], DateTimeOffset.UtcNow));
ExpectMsg<SubscribeTagsResponse>(m => m.Success, TimeSpan.FromSeconds(5));
adapter.HangReads = true;
var readsBefore = adapter.ReadTokens.Count;
adapter.RaiseDisconnected();
AwaitCondition(() => ConnectedTransitions(name) >= 2, TimeSpan.FromSeconds(5));
AwaitCondition(() => adapter.ReadTokens.Count > readsBefore, TimeSpan.FromSeconds(5));
var reseedToken = adapter.ReadTokens.ElementAt(readsBefore);
Assert.False(reseedToken.IsCancellationRequested);
adapter.RaiseDisconnected();
AwaitCondition(() => reseedToken.IsCancellationRequested, TimeSpan.FromSeconds(5));
}
}
@@ -22,6 +22,8 @@ public sealed class FakeBatchDataConnection
public readonly ConcurrentQueue<IReadOnlyList<string>> UnsubscribeBatches = new();
/// <summary>Tag lists handed to <see cref="ReadBatchAsync"/>, one entry per call.</summary>
public readonly ConcurrentQueue<IReadOnlyList<string>> ReadBatches = new();
/// <summary>The cancellation token handed to each <see cref="ReadBatchAsync"/> call, in order.</summary>
public readonly ConcurrentQueue<CancellationToken> ReadTokens = new();
/// <summary>Count of SINGLE-tag subscribe calls; must stay 0 on a batch-capable adapter.</summary>
public int SingleSubscribeCalls;
/// <summary>Count of SINGLE-tag read calls; must stay 0 on a batch-capable adapter.</summary>
@@ -143,6 +145,7 @@ public sealed class FakeBatchDataConnection
{
var tags = tagPaths.ToList();
ReadBatches.Enqueue(tags);
ReadTokens.Enqueue(cancellationToken);
if (HangReads)
await Task.Delay(Timeout.Infinite, cancellationToken);