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:
+107
@@ -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));
|
||||
}
|
||||
}
|
||||
+3
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user