Merge branch 'worktree-agent-ae22af64445b321d4' into arch-review-remediation
This commit is contained in:
+291
@@ -0,0 +1,291 @@
|
||||
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>
|
||||
/// WP2.1b — the DataConnectionActor against a batch-capable adapter: chunked subscribe,
|
||||
/// chunked/bounded re-subscribe, the seed deadline, batched tag-resolution probes, the
|
||||
/// partial-failure surface and the coalesced quality push. The existing suite covers the
|
||||
/// same actor against a NON batch-capable adapter, which keeps the per-tag fallback pinned.
|
||||
/// </summary>
|
||||
public class DataConnectionActorBatchTests : TestKit
|
||||
{
|
||||
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.FromMilliseconds(150),
|
||||
TagResolutionRetryMaxInterval = TimeSpan.FromMilliseconds(600),
|
||||
WriteTimeout = TimeSpan.FromSeconds(5),
|
||||
SeedReadMaxAttempts = 1,
|
||||
SeedReadRetryDelay = TimeSpan.FromMilliseconds(10),
|
||||
SubscribeBatchSize = 5,
|
||||
SubscribeBatchDelay = TimeSpan.FromMilliseconds(50),
|
||||
SeedReadBatchSize = 4,
|
||||
SeedReadMaxParallelism = 2,
|
||||
SeedOverallTimeout = TimeSpan.FromSeconds(30),
|
||||
QualityFlushInterval = TimeSpan.FromMilliseconds(200)
|
||||
};
|
||||
|
||||
private IActorRef CreateActor(FakeBatchDataConnection adapter, DataConnectionOptions options, string name) =>
|
||||
Sys.ActorOf(Props.Create(() => new DataConnectionActor(
|
||||
name, adapter, options, _health, _factory, "OpcUa")), name);
|
||||
|
||||
private static string[] Tags(int count) => Enumerable.Range(1, count).Select(i => $"tag{i}").ToArray();
|
||||
|
||||
[Fact]
|
||||
public void Subscribe_IssuesOneAdapterRoundTripPerChunk_NotPerTag()
|
||||
{
|
||||
// 12 tags at SubscribeBatchSize 5 → 3 batch calls (5/5/2) and ZERO per-tag
|
||||
// subscribes. This is finding #3: the old path was one adapter call (and one OPC UA
|
||||
// ApplyChanges) per tag.
|
||||
var adapter = new FakeBatchDataConnection();
|
||||
var actor = CreateActor(adapter, Options(), "batch-chunking");
|
||||
|
||||
actor.Tell(new SubscribeTagsRequest("c1", "inst1", "batch-chunking", Tags(12), DateTimeOffset.UtcNow));
|
||||
ExpectMsg<SubscribeTagsResponse>(m => m.Success, TimeSpan.FromSeconds(5));
|
||||
|
||||
var batches = adapter.SubscribeBatches.ToList();
|
||||
Assert.Equal(3, batches.Count);
|
||||
Assert.Equal([5, 5, 2], batches.Select(b => b.Count));
|
||||
Assert.Equal(0, Volatile.Read(ref adapter.SingleSubscribeCalls));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Seed_UsesChunkedBulkReads_NotPerTagReads()
|
||||
{
|
||||
// Seeding 6 tags at SeedReadBatchSize 4 → 2 bulk reads, no single-tag reads.
|
||||
var adapter = new FakeBatchDataConnection();
|
||||
var actor = CreateActor(adapter, Options(), "batch-seed");
|
||||
|
||||
actor.Tell(new SubscribeTagsRequest("c1", "inst1", "batch-seed", Tags(6), DateTimeOffset.UtcNow));
|
||||
ExpectMsg<SubscribeTagsResponse>(m => m.Success, TimeSpan.FromSeconds(5));
|
||||
|
||||
var reads = adapter.ReadBatches.ToList();
|
||||
Assert.Equal(2, reads.Count);
|
||||
Assert.Equal(6, reads.Sum(r => r.Count));
|
||||
Assert.Equal(0, Volatile.Read(ref adapter.SingleReadCalls));
|
||||
// The seeded value still reaches the instance actor after registration.
|
||||
ExpectMsg<TagValueUpdate>(u => u.Quality == QualityCode.Good, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Seed_HangingReads_ReturnAtTheOverallDeadline_AndTheSubscribeIsStillAcked()
|
||||
{
|
||||
// A device whose reads never answer must not hold SubscribeCompleted (and therefore
|
||||
// the instance's ack) open: the whole seed is bounded by SeedOverallTimeout.
|
||||
var options = Options();
|
||||
options.SeedOverallTimeout = TimeSpan.FromMilliseconds(600);
|
||||
options.SeedReadTimeout = TimeSpan.FromSeconds(30); // deliberately longer than the deadline
|
||||
options.SeedReadMaxAttempts = 3;
|
||||
|
||||
var adapter = new FakeBatchDataConnection { HangReads = true };
|
||||
var actor = CreateActor(adapter, options, "batch-seed-deadline");
|
||||
|
||||
var started = DateTimeOffset.UtcNow;
|
||||
actor.Tell(new SubscribeTagsRequest("c1", "inst1", "batch-seed-deadline", Tags(3), DateTimeOffset.UtcNow));
|
||||
ExpectMsg<SubscribeTagsResponse>(m => m.Success, TimeSpan.FromSeconds(10));
|
||||
var elapsed = DateTimeOffset.UtcNow - started;
|
||||
|
||||
// Bounded by the overall deadline, not by SeedReadTimeout (30s) or the retry budget.
|
||||
Assert.True(elapsed < TimeSpan.FromSeconds(5), $"seed took {elapsed.TotalSeconds:F1}s");
|
||||
// No value was seeded — the tags stay Uncertain until a change notification.
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartialFailure_PerTagRow_MarksTagBad_ButTheRequestStillSucceeds()
|
||||
{
|
||||
// A per-tag failure row is a tag-resolution problem: Bad quality is pushed for that
|
||||
// tag and the subscribe is still acked Success (the surviving tags are live).
|
||||
var adapter = new FakeBatchDataConnection();
|
||||
adapter.FailingTags.Add("tag2");
|
||||
var actor = CreateActor(adapter, Options(), "batch-partial");
|
||||
|
||||
actor.Tell(new SubscribeTagsRequest("c1", "inst1", "batch-partial", Tags(3), DateTimeOffset.UtcNow));
|
||||
|
||||
ExpectMsg<TagValueUpdate>(u => u.TagPath == "tag2" && u.Quality == QualityCode.Bad, TimeSpan.FromSeconds(5));
|
||||
ExpectMsg<SubscribeTagsResponse>(m => m.Success, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrownBatch_AtConnectionLevel_FailsTheRequestAndDrivesReconnect()
|
||||
{
|
||||
// A THROWN batch means the whole chunk failed at connection level: the response must
|
||||
// say so, and the actor must enter Reconnecting (which pushes bad quality for the
|
||||
// connection).
|
||||
var adapter = new FakeBatchDataConnection
|
||||
{
|
||||
BatchSubscribeThrows = () => new InvalidOperationException("client is not connected")
|
||||
};
|
||||
var actor = CreateActor(adapter, Options(), "batch-connection-fault");
|
||||
|
||||
actor.Tell(new SubscribeTagsRequest("c1", "inst1", "batch-connection-fault", Tags(3), DateTimeOffset.UtcNow));
|
||||
|
||||
ExpectMsg<SubscribeTagsResponse>(m => !m.Success && m.ErrorMessage != null, TimeSpan.FromSeconds(5));
|
||||
ExpectMsg<ConnectionQualityChanged>(q => q.Quality == QualityCode.Bad, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolutionProbes_AreBatched_NotOnePerTag()
|
||||
{
|
||||
// Every unresolved tag is probed in ONE batch call per chunk. Pre-fix the retry tick
|
||||
// fired one SubscribeAsync task per unresolved tag, every tick, forever.
|
||||
var options = Options();
|
||||
options.SubscribeBatchSize = 10;
|
||||
var adapter = new FakeBatchDataConnection();
|
||||
foreach (var tag in Tags(6))
|
||||
adapter.FailingTags.Add(tag);
|
||||
|
||||
var actor = CreateActor(adapter, options, "batch-probe");
|
||||
actor.Tell(new SubscribeTagsRequest("c1", "inst1", "batch-probe", Tags(6), DateTimeOffset.UtcNow));
|
||||
|
||||
for (var i = 0; i < 6; i++)
|
||||
ExpectMsg<TagValueUpdate>(u => u.Quality == QualityCode.Bad, TimeSpan.FromSeconds(5));
|
||||
ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
var initialCalls = adapter.SubscribeBatches.Count;
|
||||
AwaitCondition(() => adapter.SubscribeBatches.Count > initialCalls, TimeSpan.FromSeconds(5));
|
||||
|
||||
// The probe round carried all six unresolved tags in a single call.
|
||||
var probe = adapter.SubscribeBatches.Skip(initialCalls).First();
|
||||
Assert.Equal(6, probe.Count);
|
||||
Assert.Equal(0, Volatile.Read(ref adapter.SingleSubscribeCalls));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolutionProbes_BackOff_SoADeadDeviceIsNotProbedAtFullRate()
|
||||
{
|
||||
// Floor 150ms, ceiling 600ms: a fixed-interval retry would fire ~13 rounds in 2s;
|
||||
// the backoff sequence (150, 300, 600, 600 …) fires far fewer.
|
||||
var options = Options();
|
||||
options.SubscribeBatchSize = 10;
|
||||
var adapter = new FakeBatchDataConnection();
|
||||
adapter.FailingTags.Add("tag1");
|
||||
|
||||
var actor = CreateActor(adapter, options, "batch-backoff");
|
||||
actor.Tell(new SubscribeTagsRequest("c1", "inst1", "batch-backoff", ["tag1"], DateTimeOffset.UtcNow));
|
||||
ExpectMsg<TagValueUpdate>(u => u.Quality == QualityCode.Bad, TimeSpan.FromSeconds(5));
|
||||
ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
var initialCalls = adapter.SubscribeBatches.Count;
|
||||
Thread.Sleep(2000);
|
||||
var rounds = adapter.SubscribeBatches.Count - initialCalls;
|
||||
|
||||
Assert.InRange(rounds, 2, 8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QualityCounters_ArePushedOnTransitionOnly_AndCoalesced()
|
||||
{
|
||||
// Repeated values at UNCHANGED quality move no counter and must produce no collector
|
||||
// push at all; a genuine transition produces exactly one push per coalescing window.
|
||||
var adapter = new FakeBatchDataConnection();
|
||||
var actor = CreateActor(adapter, Options(), "batch-quality");
|
||||
|
||||
actor.Tell(new SubscribeTagsRequest("c1", "inst1", "batch-quality", ["tag1"], DateTimeOffset.UtcNow));
|
||||
ExpectMsg<SubscribeTagsResponse>(m => m.Success, TimeSpan.FromSeconds(5));
|
||||
AwaitCondition(() => adapter.ValueCallback != null, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Let the seed's own transition flush, then start counting.
|
||||
Thread.Sleep(400);
|
||||
_health.ClearReceivedCalls();
|
||||
|
||||
for (var i = 0; i < 20; i++)
|
||||
adapter.ValueCallback!("tag1", new TagValue(i, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
Thread.Sleep(400);
|
||||
Assert.DoesNotContain(_health.ReceivedCalls(), c => c.GetMethodInfo().Name == "UpdateTagQuality");
|
||||
|
||||
// One genuine transition → exactly one coalesced push.
|
||||
adapter.ValueCallback!("tag1", new TagValue(1, QualityCode.Bad, DateTimeOffset.UtcNow));
|
||||
AwaitCondition(
|
||||
() => _health.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "UpdateTagQuality") == 1,
|
||||
TimeSpan.FromSeconds(3));
|
||||
Thread.Sleep(300);
|
||||
Assert.Single(_health.ReceivedCalls(), c => c.GetMethodInfo().Name == "UpdateTagQuality");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QualityCounters_FlushImmediatelyOnDisconnect()
|
||||
{
|
||||
// "Immediate bad quality on disconnect" must never wait for the coalescing timer.
|
||||
var options = Options();
|
||||
options.QualityFlushInterval = TimeSpan.FromSeconds(30); // would mask a deferred push
|
||||
var adapter = new FakeBatchDataConnection();
|
||||
var actor = CreateActor(adapter, options, "batch-quality-disconnect");
|
||||
|
||||
actor.Tell(new SubscribeTagsRequest("c1", "inst1", "batch-quality-disconnect", ["tag1"], DateTimeOffset.UtcNow));
|
||||
ExpectMsg<SubscribeTagsResponse>(m => m.Success, TimeSpan.FromSeconds(5));
|
||||
ExpectMsg<TagValueUpdate>(TimeSpan.FromSeconds(5)); // the seed
|
||||
_health.ClearReceivedCalls();
|
||||
|
||||
adapter.RaiseDisconnected();
|
||||
|
||||
ExpectMsg<ConnectionQualityChanged>(q => q.Quality == QualityCode.Bad, TimeSpan.FromSeconds(5));
|
||||
AwaitCondition(
|
||||
() => _health.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "UpdateTagQuality"),
|
||||
TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unsubscribe_ReleasesEveryHandleInOneRoundTrip()
|
||||
{
|
||||
var adapter = new FakeBatchDataConnection();
|
||||
var actor = CreateActor(adapter, Options(), "batch-unsubscribe");
|
||||
|
||||
actor.Tell(new SubscribeTagsRequest("c1", "inst1", "batch-unsubscribe", Tags(6), DateTimeOffset.UtcNow));
|
||||
ExpectMsg<SubscribeTagsResponse>(m => m.Success, TimeSpan.FromSeconds(5));
|
||||
|
||||
actor.Tell(new UnsubscribeTagsRequest("c2", "inst1", "batch-unsubscribe", DateTimeOffset.UtcNow));
|
||||
|
||||
AwaitCondition(() => adapter.UnsubscribeBatches.Count == 1, TimeSpan.FromSeconds(5));
|
||||
Assert.True(adapter.UnsubscribeBatches.TryDequeue(out var released));
|
||||
Assert.Equal(6, released!.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconnect_ReSubscribesInPacedChunks_AndRestoresTheResolvedCount()
|
||||
{
|
||||
// The reconnect re-subscribe is chunked and paced (SubscribeBatchDelay between
|
||||
// chunks) rather than firing one task per tag, and the per-tag results repopulate
|
||||
// _subscriptionIds so a later unsubscribe still releases every adapter handle.
|
||||
var options = Options();
|
||||
options.SubscribeBatchSize = 4;
|
||||
options.SubscribeBatchDelay = TimeSpan.FromMilliseconds(120);
|
||||
|
||||
var adapter = new FakeBatchDataConnection();
|
||||
var actor = CreateActor(adapter, options, "batch-resubscribe");
|
||||
|
||||
actor.Tell(new SubscribeTagsRequest("c1", "inst1", "batch-resubscribe", Tags(8), DateTimeOffset.UtcNow));
|
||||
ExpectMsg<SubscribeTagsResponse>(m => m.Success, TimeSpan.FromSeconds(5));
|
||||
|
||||
var beforeReconnect = adapter.SubscribeBatches.Count;
|
||||
adapter.RaiseDisconnected();
|
||||
|
||||
// Two re-subscribe chunks of 4.
|
||||
AwaitCondition(() => adapter.SubscribeBatches.Count >= beforeReconnect + 2, TimeSpan.FromSeconds(10));
|
||||
var reconnectChunks = adapter.SubscribeBatches.Skip(beforeReconnect).Take(2).ToList();
|
||||
Assert.Equal([4, 4], reconnectChunks.Select(c => c.Count));
|
||||
|
||||
// Paced: the second chunk is not issued back-to-back with the first.
|
||||
var times = adapter.SubscribeBatchTimes.Skip(beforeReconnect).Take(2).ToList();
|
||||
Assert.True(times[1] - times[0] >= TimeSpan.FromMilliseconds(100),
|
||||
$"chunks were {(times[1] - times[0]).TotalMilliseconds:F0}ms apart");
|
||||
|
||||
// Every tag is registered again, so unsubscribe releases all 8 handles.
|
||||
actor.Tell(new UnsubscribeTagsRequest("c2", "inst1", "batch-resubscribe", DateTimeOffset.UtcNow));
|
||||
AwaitCondition(
|
||||
() => adapter.UnsubscribeBatches.Any(b => b.Count == 8),
|
||||
TimeSpan.FromSeconds(10));
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory batch-capable <see cref="IDataConnection"/> for the WP2.1b seam tests. It
|
||||
/// records every batch call (so "one round trip per chunk, not per tag" is assertable),
|
||||
/// can fail individual tags or a whole batch, and can hang its bulk read so the seed
|
||||
/// deadline is observable.
|
||||
/// </summary>
|
||||
public sealed class FakeBatchDataConnection
|
||||
: IDataConnection, IBatchSubscribableConnection, IAlarmSubscribableConnection
|
||||
{
|
||||
private int _nextId;
|
||||
|
||||
/// <summary>Tag lists handed to <see cref="SubscribeBatchAsync"/>, one entry per call.</summary>
|
||||
public readonly ConcurrentQueue<IReadOnlyList<string>> SubscribeBatches = new();
|
||||
/// <summary>Id lists handed to <see cref="UnsubscribeBatchAsync"/>, one entry per call.</summary>
|
||||
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>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>
|
||||
public int SingleReadCalls;
|
||||
/// <summary>Wall-clock instant of each <see cref="SubscribeBatchAsync"/> call.</summary>
|
||||
public readonly ConcurrentQueue<DateTimeOffset> SubscribeBatchTimes = new();
|
||||
|
||||
/// <summary>Tags reported as failed rows (per-tag resolution failure).</summary>
|
||||
public readonly HashSet<string> FailingTags = new(StringComparer.Ordinal);
|
||||
/// <summary>When set, every batch subscribe throws this — a batch-level fault.</summary>
|
||||
public Func<Exception>? BatchSubscribeThrows;
|
||||
/// <summary>When true, bulk reads never return until the caller's token cancels.</summary>
|
||||
public bool HangReads;
|
||||
/// <summary>Value returned for every readable tag.</summary>
|
||||
public object? SeedValue = 42;
|
||||
|
||||
/// <summary>Callback the last batch subscribe registered; drives value pushes in tests.</summary>
|
||||
public SubscriptionCallback? ValueCallback;
|
||||
/// <summary>Callback the last alarm subscribe registered.</summary>
|
||||
public AlarmTransitionCallback? AlarmCallback;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ConnectionHealth Status { get; private set; } = ConnectionHealth.Disconnected;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event Action? Disconnected;
|
||||
|
||||
/// <summary>Raises <see cref="Disconnected"/> as a real adapter would on a transport fault.</summary>
|
||||
public void RaiseDisconnected() => Disconnected?.Invoke();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Status = ConnectionHealth.Connected;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DisconnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Status = ConnectionHealth.Disconnected;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TagSubscribeResult>> SubscribeBatchAsync(
|
||||
IReadOnlyList<string> tagPaths, SubscriptionCallback callback, CancellationToken cancellationToken = default)
|
||||
{
|
||||
SubscribeBatches.Enqueue(tagPaths.ToList());
|
||||
SubscribeBatchTimes.Enqueue(DateTimeOffset.UtcNow);
|
||||
ValueCallback = callback;
|
||||
|
||||
if (BatchSubscribeThrows is { } factory)
|
||||
throw factory();
|
||||
|
||||
IReadOnlyList<TagSubscribeResult> rows = tagPaths
|
||||
.Select(t => FailingTags.Contains(t)
|
||||
? new TagSubscribeResult(t, false, null, "node not found")
|
||||
: new TagSubscribeResult(t, true, $"sub-{Interlocked.Increment(ref _nextId)}", null))
|
||||
.ToList();
|
||||
return Task.FromResult(rows);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UnsubscribeBatchAsync(IReadOnlyList<string> subscriptionIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
UnsubscribeBatches.Enqueue(subscriptionIds.ToList());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string> SubscribeAsync(string tagPath, SubscriptionCallback callback, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref SingleSubscribeCalls);
|
||||
ValueCallback = callback;
|
||||
return Task.FromResult($"sub-{Interlocked.Increment(ref _nextId)}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ReadResult> ReadAsync(string tagPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref SingleReadCalls);
|
||||
if (HangReads)
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
return new ReadResult(true, new TagValue(SeedValue, QualityCode.Good, DateTimeOffset.UtcNow), null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync(
|
||||
IEnumerable<string> tagPaths, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tags = tagPaths.ToList();
|
||||
ReadBatches.Enqueue(tags);
|
||||
if (HangReads)
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
|
||||
return tags.ToDictionary(
|
||||
t => t,
|
||||
t => new ReadResult(true, new TagValue(SeedValue, QualityCode.Good, DateTimeOffset.UtcNow), null));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<WriteResult> WriteAsync(string tagPath, object? value, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new WriteResult(true, null));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyDictionary<string, WriteResult>> WriteBatchAsync(
|
||||
IDictionary<string, object?> values, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyDictionary<string, WriteResult>>(
|
||||
values.ToDictionary(kv => kv.Key, _ => new WriteResult(true, null)));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> WriteBatchAndWaitAsync(
|
||||
IDictionary<string, object?> values, string flagPath, object? flagValue, string responsePath,
|
||||
object? responseValue, TimeSpan timeout, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string> SubscribeAlarmsAsync(
|
||||
string sourceReference, string? conditionFilter, AlarmTransitionCallback callback,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
AlarmCallback = callback;
|
||||
return Task.FromResult($"alarm-{Interlocked.Increment(ref _nextId)}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UnsubscribeAlarmsAsync(string subscriptionId, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// WP2.1b — the primitives the batch seam is built from, tested in isolation because the
|
||||
/// OPC Foundation Session/Subscription types they drive cannot be faked without a live
|
||||
/// server: monitored-item shard placement, the apply-serialization gate, the bounded
|
||||
/// pipeline behind the MxGateway supervisory advise, the alarm-stream union prefix, and
|
||||
/// the tag-resolution backoff step.
|
||||
/// </summary>
|
||||
public class BatchSeamPrimitiveTests
|
||||
{
|
||||
// ── Shard placement ──
|
||||
|
||||
[Fact]
|
||||
public void Sharding_SplitsItemsAtTheBudget()
|
||||
{
|
||||
// 12,001 items at the 5,000 default → 3 shards (memo §2 sizing example).
|
||||
Assert.Equal(3, MonitoredItemShardPlanner.ShardCountFor(12_001, 5_000));
|
||||
// 37,500 tags → 8 shards.
|
||||
Assert.Equal(8, MonitoredItemShardPlanner.ShardCountFor(37_500, 5_000));
|
||||
|
||||
var placement = MonitoredItemShardPlanner.Plan([], 5_000, 12_001);
|
||||
Assert.Equal(12_001, placement.Count);
|
||||
Assert.Equal(0, placement[0]);
|
||||
Assert.Equal(0, placement[4_999]);
|
||||
Assert.Equal(1, placement[5_000]);
|
||||
Assert.Equal(2, placement[10_000]);
|
||||
Assert.Equal(2, placement[12_000]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sharding_FillsTheFirstShardWithFreeCapacityBeforeCreatingOne()
|
||||
{
|
||||
// Shard 0 full, shard 1 has one free slot: the next two items fill shard 1 then
|
||||
// open shard 2 — the "first shard with free capacity, else a new shard" policy,
|
||||
// which is also what makes an emptied-and-deleted shard's capacity reusable.
|
||||
var placement = MonitoredItemShardPlanner.Plan([5_000, 4_999], 5_000, 2);
|
||||
Assert.Equal([1, 2], placement);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sharding_NonPositiveBudgetDegradesToOneItemPerShard()
|
||||
{
|
||||
Assert.Equal([0, 1, 2], MonitoredItemShardPlanner.Plan([], 0, 3));
|
||||
}
|
||||
|
||||
// ── Apply serialization ──
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyGate_NeverRunsTwoSectionsConcurrently()
|
||||
{
|
||||
var gate = new AsyncSerialGate();
|
||||
var inFlight = 0;
|
||||
var maxObserved = 0;
|
||||
|
||||
var tasks = Enumerable.Range(0, 32).Select(_ => gate.RunAsync(async () =>
|
||||
{
|
||||
var now = Interlocked.Increment(ref inFlight);
|
||||
InterlockedMax(ref maxObserved, now);
|
||||
await Task.Delay(5);
|
||||
Interlocked.Decrement(ref inFlight);
|
||||
})).ToArray();
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
Assert.Equal(1, maxObserved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyGate_ReleasesWhenASectionThrows()
|
||||
{
|
||||
var gate = new AsyncSerialGate();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
gate.RunAsync(() => throw new InvalidOperationException("apply failed")));
|
||||
|
||||
// The gate must still admit the next caller — a faulted ApplyChanges must not
|
||||
// wedge every later subscribe/unsubscribe on this client.
|
||||
var ran = false;
|
||||
await gate.RunAsync(() =>
|
||||
{
|
||||
ran = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
Assert.True(ran);
|
||||
}
|
||||
|
||||
// ── Bounded pipeline (MxGateway supervisory advise) ──
|
||||
|
||||
[Fact]
|
||||
public async Task BulkPipeline_KeepsInFlightCountWithinTheWindow()
|
||||
{
|
||||
const int parallelism = 16;
|
||||
var inFlight = 0;
|
||||
var maxObserved = 0;
|
||||
var items = Enumerable.Range(0, 200).ToList();
|
||||
|
||||
var results = await BulkPipeline.RunAsync(
|
||||
items,
|
||||
parallelism,
|
||||
async (item, _) =>
|
||||
{
|
||||
var now = Interlocked.Increment(ref inFlight);
|
||||
InterlockedMax(ref maxObserved, now);
|
||||
await Task.Delay(2);
|
||||
Interlocked.Decrement(ref inFlight);
|
||||
return item * 2;
|
||||
},
|
||||
(item, _) => -item);
|
||||
|
||||
Assert.Equal(items.Count, results.Length);
|
||||
Assert.Equal(items.Select(i => i * 2), results);
|
||||
Assert.InRange(maxObserved, 1, parallelism);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BulkPipeline_CapturesPerItemFaultsWithoutAbortingTheBatch()
|
||||
{
|
||||
var results = await BulkPipeline.RunAsync(
|
||||
[1, 2, 3],
|
||||
4,
|
||||
(item, _) => item == 2
|
||||
? throw new InvalidOperationException("advise failed")
|
||||
: Task.FromResult(item),
|
||||
(item, _) => -item);
|
||||
|
||||
Assert.Equal([1, -2, 3], results);
|
||||
}
|
||||
|
||||
// ── Alarm-stream union prefix ──
|
||||
|
||||
[Theory]
|
||||
[InlineData(new[] { "Area1.Tank1", "Area1.Tank2" }, "Area1.Tank")]
|
||||
[InlineData(new[] { "Area1.Tank1" }, "Area1.Tank1")]
|
||||
[InlineData(new[] { "Area1.Tank1", "Area2.Tank1" }, "Area")]
|
||||
[InlineData(new[] { "Plant.A", "Zone.B" }, "")]
|
||||
[InlineData(new[] { "Area1.Tank1", "" }, "")]
|
||||
[InlineData(new string[0], "")]
|
||||
public void AlarmPrefix_IsTheLongestCommonPrefix(string[] sources, string expected)
|
||||
{
|
||||
Assert.Equal(expected, AlarmFilterPrefix.LongestCommonPrefix(sources));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmPrefix_CoversOnlySourcesUnderTheLivePrefix()
|
||||
{
|
||||
Assert.True(AlarmFilterPrefix.Covers("Area1.", "Area1.Tank1"));
|
||||
Assert.False(AlarmFilterPrefix.Covers("Area1.", "Area2.Tank1"));
|
||||
// A gateway-wide stream covers everything.
|
||||
Assert.True(AlarmFilterPrefix.Covers("", "Anything.At.All"));
|
||||
}
|
||||
|
||||
// ── Tag-resolution backoff ──
|
||||
|
||||
[Fact]
|
||||
public void Backoff_DoublesPerFailedRoundAndCapsAtTheMaximum()
|
||||
{
|
||||
var floor = TimeSpan.FromSeconds(10);
|
||||
var max = TimeSpan.FromMinutes(5);
|
||||
|
||||
var sequence = new List<TimeSpan>();
|
||||
var current = floor;
|
||||
for (var round = 0; round < 8; round++)
|
||||
{
|
||||
current = DataConnectionActor.NextTagResolutionInterval(current, floor, max);
|
||||
sequence.Add(current);
|
||||
}
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
TimeSpan.FromSeconds(20),
|
||||
TimeSpan.FromSeconds(40),
|
||||
TimeSpan.FromSeconds(80),
|
||||
TimeSpan.FromSeconds(160),
|
||||
TimeSpan.FromSeconds(300),
|
||||
TimeSpan.FromSeconds(300),
|
||||
TimeSpan.FromSeconds(300),
|
||||
TimeSpan.FromSeconds(300),
|
||||
], sequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backoff_MisconfiguredCeilingBelowFloorDegradesToAFixedInterval()
|
||||
{
|
||||
var floor = TimeSpan.FromSeconds(10);
|
||||
var next = DataConnectionActor.NextTagResolutionInterval(floor, floor, TimeSpan.FromSeconds(1));
|
||||
Assert.Equal(floor, next);
|
||||
}
|
||||
|
||||
private static void InterlockedMax(ref int target, int value)
|
||||
{
|
||||
int seen;
|
||||
do
|
||||
{
|
||||
seen = Volatile.Read(ref target);
|
||||
if (value <= seen)
|
||||
return;
|
||||
}
|
||||
while (Interlocked.CompareExchange(ref target, value, seen) != seen);
|
||||
}
|
||||
}
|
||||
+43
-1
@@ -11,6 +11,14 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact
|
||||
public MxGatewayConnectionOptions? ConnectedWith;
|
||||
public readonly List<string> Subscribed = new();
|
||||
public readonly List<string> Unsubscribed = new();
|
||||
/// <summary>One entry per SubscribeBulkAsync call, carrying that call's tag list.</summary>
|
||||
public readonly List<IReadOnlyList<string>> BulkSubscribeCalls = new();
|
||||
/// <summary>One entry per UnsubscribeBulkAsync call, carrying that call's id list.</summary>
|
||||
public readonly List<IReadOnlyList<string>> BulkUnsubscribeCalls = new();
|
||||
/// <summary>Tags the fake reports as failed rows from a bulk subscribe.</summary>
|
||||
public readonly HashSet<string> BulkSubscribeFailures = new(StringComparer.Ordinal);
|
||||
/// <summary>Every alarm-stream prefix the adapter opened a stream with (null = gateway-wide).</summary>
|
||||
public readonly List<string?> AlarmStreamPrefixes = new();
|
||||
public readonly TaskCompletionSource EventLoopGate = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public Action<MxValueUpdate>? OnUpdate;
|
||||
public Func<IReadOnlyList<string>, IReadOnlyList<MxReadOutcome>>? ReadHandler;
|
||||
@@ -41,6 +49,34 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MxSubscribeOutcome>> SubscribeBulkAsync(
|
||||
IReadOnlyList<string> tagPaths, CancellationToken ct = default)
|
||||
{
|
||||
BulkSubscribeCalls.Add(tagPaths.ToList());
|
||||
var outcomes = new List<MxSubscribeOutcome>(tagPaths.Count);
|
||||
foreach (var tag in tagPaths)
|
||||
{
|
||||
if (BulkSubscribeFailures.Contains(tag))
|
||||
{
|
||||
outcomes.Add(new MxSubscribeOutcome(tag, false, null, "not found"));
|
||||
continue;
|
||||
}
|
||||
|
||||
Subscribed.Add(tag);
|
||||
outcomes.Add(new MxSubscribeOutcome(
|
||||
tag, true, (++_nextHandle).ToString(), null));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<MxSubscribeOutcome>>(outcomes);
|
||||
}
|
||||
|
||||
public Task UnsubscribeBulkAsync(IReadOnlyList<string> subscriptionIds, CancellationToken ct = default)
|
||||
{
|
||||
BulkUnsubscribeCalls.Add(subscriptionIds.ToList());
|
||||
Unsubscribed.AddRange(subscriptionIds);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MxReadOutcome>> ReadAsync(IReadOnlyList<string> tags, CancellationToken ct = default)
|
||||
=> Task.FromResult(ReadHandler!(tags));
|
||||
|
||||
@@ -62,7 +98,13 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact
|
||||
string? alarmFilterPrefix,
|
||||
Action<ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms.NativeAlarmTransition> onTransition,
|
||||
CancellationToken ct = default)
|
||||
=> Task.CompletedTask; // no alarm feed in the fake
|
||||
{
|
||||
// No alarm feed in the fake — but the prefix each stream is opened with is
|
||||
// recorded so the union-filter (longest-common-prefix) behaviour is testable.
|
||||
lock (AlarmStreamPrefixes)
|
||||
AlarmStreamPrefixes.Add(alarmFilterPrefix);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// WP2.1b — the MxGateway adapter's batch subscribe seam and the alarm-stream union
|
||||
/// filter. The plain-vs-supervisory advise choice itself lives inside
|
||||
/// <c>RealMxGatewayClient</c> (it needs a live MXAccess session); its bounded-pipeline
|
||||
/// mechanism is pinned separately by <see cref="BatchSeamPrimitiveTests"/>.
|
||||
/// </summary>
|
||||
[Collection("DataConnectionManagerActor")]
|
||||
public class MxGatewayBatchSeamTests
|
||||
{
|
||||
private static MxGatewayDataConnection NewAdapter(FakeMxGatewayClient fake) =>
|
||||
new(fake, NullLogger<MxGatewayDataConnection>.Instance);
|
||||
|
||||
private static Dictionary<string, string> Details() => new()
|
||||
{
|
||||
["Endpoint"] = "http://gw:5000",
|
||||
["ApiKey"] = "key",
|
||||
["ClientName"] = "client-a",
|
||||
["WriteUserId"] = "0",
|
||||
["ReadTimeoutMs"] = "2000",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeBatch_IssuesExactlyOneBulkCallPerBatch()
|
||||
{
|
||||
var fake = new FakeMxGatewayClient();
|
||||
var adapter = NewAdapter(fake);
|
||||
await adapter.ConnectAsync(Details());
|
||||
|
||||
var results = await adapter.SubscribeBatchAsync(
|
||||
["A.x", "A.y", "A.z"], (_, _) => { });
|
||||
|
||||
// ONE bulk RPC for the whole batch — replacing the historical AddItem + Advise
|
||||
// pair PER TAG (6 RPCs for these three tags).
|
||||
Assert.Single(fake.BulkSubscribeCalls);
|
||||
Assert.Equal(3, fake.BulkSubscribeCalls[0].Count);
|
||||
Assert.Equal(3, results.Count);
|
||||
Assert.All(results, r => Assert.True(r.Success));
|
||||
Assert.All(results, r => Assert.NotNull(r.SubscriptionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeBatch_ReportsPerTagFailuresWithoutFailingTheBatch()
|
||||
{
|
||||
var fake = new FakeMxGatewayClient();
|
||||
fake.BulkSubscribeFailures.Add("A.y");
|
||||
var adapter = NewAdapter(fake);
|
||||
await adapter.ConnectAsync(Details());
|
||||
|
||||
var results = await adapter.SubscribeBatchAsync(["A.x", "A.y"], (_, _) => { });
|
||||
|
||||
Assert.True(results.Single(r => r.TagPath == "A.x").Success);
|
||||
var failed = results.Single(r => r.TagPath == "A.y");
|
||||
Assert.False(failed.Success);
|
||||
Assert.Null(failed.SubscriptionId);
|
||||
Assert.NotNull(failed.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeBatch_ReleasesEveryIdInOneBulkCall()
|
||||
{
|
||||
var fake = new FakeMxGatewayClient();
|
||||
var adapter = NewAdapter(fake);
|
||||
await adapter.ConnectAsync(Details());
|
||||
|
||||
var results = await adapter.SubscribeBatchAsync(["A.x", "A.y"], (_, _) => { });
|
||||
await adapter.UnsubscribeBatchAsync(results.Select(r => r.SubscriptionId!).ToList());
|
||||
|
||||
Assert.Single(fake.BulkUnsubscribeCalls);
|
||||
Assert.Equal(2, fake.BulkUnsubscribeCalls[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlarmStream_OpensOnTheLongestCommonPrefix_AndOnlyRestartsForAnEscapingSource()
|
||||
{
|
||||
var fake = new FakeMxGatewayClient();
|
||||
var adapter = NewAdapter(fake);
|
||||
await adapter.ConnectAsync(Details());
|
||||
|
||||
void NoTransition(NativeAlarmTransition _) { }
|
||||
|
||||
// First source → the stream opens scoped to it.
|
||||
await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, NoTransition);
|
||||
await WaitForPrefixCountAsync(fake, 1);
|
||||
Assert.Equal("Area1.Tank1", fake.AlarmStreamPrefixes[0]);
|
||||
|
||||
// A sibling under the same prefix widens the union → one restart on "Area1.Tank".
|
||||
await adapter.SubscribeAlarmsAsync("Area1.Tank2", null, NoTransition);
|
||||
await WaitForPrefixCountAsync(fake, 2);
|
||||
Assert.Equal("Area1.Tank", fake.AlarmStreamPrefixes[1]);
|
||||
|
||||
// A source ALREADY covered by the live prefix must NOT restart the stream.
|
||||
await adapter.SubscribeAlarmsAsync("Area1.Tank3.Sub", null, NoTransition);
|
||||
await Task.Delay(100);
|
||||
Assert.Equal(2, fake.AlarmStreamPrefixes.Count);
|
||||
|
||||
// A source outside the prefix widens it again — here down to gateway-wide.
|
||||
await adapter.SubscribeAlarmsAsync("Zone9.Pump", null, NoTransition);
|
||||
await WaitForPrefixCountAsync(fake, 3);
|
||||
Assert.Null(fake.AlarmStreamPrefixes[2]); // "" → gateway-wide
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlarmStream_UnsubscribeNeverRestartsTheStream()
|
||||
{
|
||||
var fake = new FakeMxGatewayClient();
|
||||
var adapter = NewAdapter(fake);
|
||||
await adapter.ConnectAsync(Details());
|
||||
|
||||
var first = await adapter.SubscribeAlarmsAsync("Area1.Tank1", null, _ => { });
|
||||
// Wait for the first stream to actually open before widening: the open runs on a
|
||||
// Task.Run whose token the restart cancels, so a restart racing an unstarted task
|
||||
// would leave the first prefix unrecorded.
|
||||
await WaitForPrefixCountAsync(fake, 1);
|
||||
await adapter.SubscribeAlarmsAsync("Area1.Tank2", null, _ => { });
|
||||
await WaitForPrefixCountAsync(fake, 2);
|
||||
|
||||
// Dropping a source leaves the prefix too BROAD at worst — bandwidth, not
|
||||
// correctness — so no restart is issued.
|
||||
await adapter.UnsubscribeAlarmsAsync(first);
|
||||
await Task.Delay(100);
|
||||
Assert.Equal(2, fake.AlarmStreamPrefixes.Count);
|
||||
}
|
||||
|
||||
private static async Task WaitForPrefixCountAsync(FakeMxGatewayClient fake, int expected)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
lock (fake.AlarmStreamPrefixes)
|
||||
{
|
||||
if (fake.AlarmStreamPrefixes.Count >= expected)
|
||||
return;
|
||||
}
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
Assert.Fail($"alarm stream was opened {fake.AlarmStreamPrefixes.Count} time(s), expected {expected}");
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
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.Alarms;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// WP2.1b — the alarm subscriber first-segment bucket index must make EXACTLY the routing
|
||||
/// decisions the previous linear scan made. The expectation in each case is computed with
|
||||
/// the original rule (plain <c>StartsWith</c> against every subscribed source), so the
|
||||
/// test pins equivalence rather than restating the new implementation.
|
||||
/// </summary>
|
||||
public class DataConnectionActorAlarmIndexTests : TestKit
|
||||
{
|
||||
private readonly ISiteHealthCollector _health = Substitute.For<ISiteHealthCollector>();
|
||||
private readonly IDataConnectionFactory _factory = Substitute.For<IDataConnectionFactory>();
|
||||
private readonly DataConnectionOptions _options = new()
|
||||
{
|
||||
ReconnectInterval = TimeSpan.FromMilliseconds(100),
|
||||
TagResolutionRetryInterval = TimeSpan.FromMilliseconds(200),
|
||||
WriteTimeout = TimeSpan.FromSeconds(5)
|
||||
};
|
||||
|
||||
// Sources chosen to exercise every index path:
|
||||
// - "Area1" → NO separator: the residue list (can match other buckets)
|
||||
// - "Area1.Tank" → bucket "Area1", shared prefix with the next one
|
||||
// - "Area1.Tank1.Sub" → bucket "Area1", deeper
|
||||
// - "Area2.Tank" → a different bucket
|
||||
private static readonly string[] Sources = ["Area1", "Area1.Tank", "Area1.Tank1.Sub", "Area2.Tank"];
|
||||
|
||||
private static readonly (string SourceRef, string SourceObjectRef)[] Transitions =
|
||||
[
|
||||
("Area1.Tank1.Hi", "Area1.Tank1"), // Area1 (residue) + Area1.Tank
|
||||
("Area1.Tank1.Sub.Hi", "Area1.Tank1.Sub"), // + Area1.Tank1.Sub
|
||||
("Area1X.Pump.Hi", "Area1X.Pump"), // sub-segment prefix: Area1 ONLY
|
||||
("Area2.Tank9.Hi", "Area2.Tank9"), // Area2.Tank only
|
||||
("Zone.A.Hi", "Zone.A"), // nobody
|
||||
];
|
||||
|
||||
private static NativeAlarmTransition Transition(string sourceRef, string sourceObj) =>
|
||||
new(sourceRef, sourceObj, "AnalogLimit.Hi", AlarmTransitionKind.Raise,
|
||||
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 500),
|
||||
"Process", "hi", "hi", "", "", null, DateTimeOffset.UtcNow, "92", "90");
|
||||
|
||||
/// <summary>The ORIGINAL linear-scan rule, kept here as the oracle.</summary>
|
||||
private static bool LinearScanMatches(string sourceRef, string transitionSourceRef, string transitionSourceObjectRef) =>
|
||||
transitionSourceObjectRef.StartsWith(sourceRef, StringComparison.Ordinal)
|
||||
|| transitionSourceRef.StartsWith(sourceRef, StringComparison.Ordinal);
|
||||
|
||||
[Fact]
|
||||
public void BucketIndex_RoutesExactlyLikeTheLinearScan()
|
||||
{
|
||||
AlarmTransitionCallback? cb = null;
|
||||
var adapter = Substitute.For<IDataConnection, IAlarmSubscribableConnection>();
|
||||
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
((IAlarmSubscribableConnection)adapter)
|
||||
.SubscribeAlarmsAsync(Arg.Any<string>(), Arg.Any<string?>(),
|
||||
Arg.Do<AlarmTransitionCallback>(c => cb = c), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => Task.FromResult("alarm-" + ci.ArgAt<string>(0)));
|
||||
|
||||
var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor(
|
||||
"conn", adapter, _options, _health, _factory, "OpcUa")), "alarm-index");
|
||||
|
||||
// One probe per source so the routing decision per source is observable.
|
||||
var probes = Sources.ToDictionary(s => s, _ => CreateTestProbe());
|
||||
foreach (var source in Sources)
|
||||
{
|
||||
actor.Tell(new SubscribeAlarmsRequest("c", "inst-" + source, "conn", source, null, DateTimeOffset.UtcNow),
|
||||
probes[source].Ref);
|
||||
probes[source].ExpectMsg<SubscribeAlarmsResponse>(m => m.Success, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
Assert.NotNull(cb);
|
||||
|
||||
foreach (var (sourceRef, sourceObjectRef) in Transitions)
|
||||
{
|
||||
cb!(Transition(sourceRef, sourceObjectRef));
|
||||
|
||||
foreach (var source in Sources)
|
||||
{
|
||||
if (LinearScanMatches(source, sourceRef, sourceObjectRef))
|
||||
{
|
||||
probes[source].ExpectMsg<NativeAlarmTransitionUpdate>(
|
||||
u => u.Transition.SourceReference == sourceRef, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing extra was delivered to anybody.
|
||||
foreach (var source in Sources)
|
||||
probes[source].ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotComplete_StillBroadcastsToEverySubscriber_BypassingTheIndex()
|
||||
{
|
||||
// The framing sentinel carries an EMPTY source reference, so it matches no bucket.
|
||||
// It must still reach every alarm subscriber, or statically-active conditions
|
||||
// delivered only in the snapshot would buffer forever.
|
||||
AlarmTransitionCallback? cb = null;
|
||||
var adapter = Substitute.For<IDataConnection, IAlarmSubscribableConnection>();
|
||||
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
((IAlarmSubscribableConnection)adapter)
|
||||
.SubscribeAlarmsAsync(Arg.Any<string>(), Arg.Any<string?>(),
|
||||
Arg.Do<AlarmTransitionCallback>(c => cb = c), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => Task.FromResult("alarm-" + ci.ArgAt<string>(0)));
|
||||
|
||||
var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor(
|
||||
"conn", adapter, _options, _health, _factory, "OpcUa")), "alarm-index-snapshot");
|
||||
|
||||
var probes = Sources.ToDictionary(s => s, _ => CreateTestProbe());
|
||||
foreach (var source in Sources)
|
||||
{
|
||||
actor.Tell(new SubscribeAlarmsRequest("c", "inst-" + source, "conn", source, null, DateTimeOffset.UtcNow),
|
||||
probes[source].Ref);
|
||||
probes[source].ExpectMsg<SubscribeAlarmsResponse>(m => m.Success, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
Assert.NotNull(cb);
|
||||
cb!(new NativeAlarmTransition(
|
||||
"", "", "", AlarmTransitionKind.SnapshotComplete,
|
||||
new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0),
|
||||
"", "", "", "", "", null, DateTimeOffset.UtcNow, "", ""));
|
||||
|
||||
foreach (var source in Sources)
|
||||
{
|
||||
probes[source].ExpectMsg<NativeAlarmTransitionUpdate>(
|
||||
u => u.Transition.Kind == AlarmTransitionKind.SnapshotComplete, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsubscribedSource_IsDroppedFromTheIndex()
|
||||
{
|
||||
AlarmTransitionCallback? cb = null;
|
||||
var adapter = Substitute.For<IDataConnection, IAlarmSubscribableConnection>();
|
||||
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
((IAlarmSubscribableConnection)adapter)
|
||||
.SubscribeAlarmsAsync(Arg.Any<string>(), Arg.Any<string?>(),
|
||||
Arg.Do<AlarmTransitionCallback>(c => cb = c), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => Task.FromResult("alarm-" + ci.ArgAt<string>(0)));
|
||||
|
||||
var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor(
|
||||
"conn", adapter, _options, _health, _factory, "OpcUa")), "alarm-index-unsub");
|
||||
|
||||
var probe = CreateTestProbe();
|
||||
actor.Tell(new SubscribeAlarmsRequest("c", "inst", "conn", "Area1.Tank", null, DateTimeOffset.UtcNow), probe.Ref);
|
||||
probe.ExpectMsg<SubscribeAlarmsResponse>(m => m.Success, TimeSpan.FromSeconds(5));
|
||||
|
||||
actor.Tell(new UnsubscribeAlarmsRequest("c2", "inst", "conn", "Area1.Tank", DateTimeOffset.UtcNow), probe.Ref);
|
||||
Thread.Sleep(200);
|
||||
|
||||
Assert.NotNull(cb);
|
||||
cb!(Transition("Area1.Tank1.Hi", "Area1.Tank1"));
|
||||
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
}
|
||||
@@ -323,33 +323,41 @@ public class OpcUaDataConnectionTests
|
||||
[Fact]
|
||||
public async Task ReadBatch_ReadsAllTags()
|
||||
{
|
||||
// WP2.1b: ReadBatchAsync is TRUE bulk — ONE client ReadValuesAsync call carrying
|
||||
// every requested node, not a per-tag loop.
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
_mockClient.ReadValueAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns((1.0, DateTime.UtcNow, 0u));
|
||||
_mockClient.ReadValuesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => Task.FromResult<IReadOnlyList<OpcUaReadOutcome>>(
|
||||
ci.Arg<IReadOnlyList<string>>()
|
||||
.Select(n => new OpcUaReadOutcome(n, 1.0, DateTime.UtcNow, 0u, null))
|
||||
.ToList()));
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
var results = await _adapter.ReadBatchAsync(["tag1", "tag2", "tag3"]);
|
||||
|
||||
Assert.Equal(3, results.Count);
|
||||
Assert.All(results.Values, r => Assert.True(r.Success));
|
||||
Assert.Single(_mockClient.ReceivedCalls(), c => c.GetMethodInfo().Name == "ReadValuesAsync");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DCL007_ReadBatch_ReturnsPerTagResults_WhenOneTagFails()
|
||||
{
|
||||
// Regression test for DataConnectionLayer-007. ReadBatchAsync looped calling
|
||||
// ReadAsync per tag; ReadAsync re-throws any non-cancellation exception, so a
|
||||
// single failing tag aborted the whole batch and the caller got NO results for
|
||||
// the tags that did read successfully — even though ReadResult already carries
|
||||
// a per-tag Success/ErrorMessage shape. After the fix the batch catches per-tag
|
||||
// exceptions and returns a complete map.
|
||||
// Regression test for DataConnectionLayer-007. ReadBatchAsync originally looped
|
||||
// calling ReadAsync per tag; ReadAsync re-throws any non-cancellation exception, so
|
||||
// a single failing tag aborted the whole batch and the caller got NO results for
|
||||
// the tags that did read successfully — even though ReadResult already carries a
|
||||
// per-tag Success/ErrorMessage shape. The batch is now one bulk service call, and
|
||||
// the same invariant holds: a per-node failure row never aborts the batch, and
|
||||
// every requested tag comes back in the map.
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
_mockClient.ReadValueAsync("good1", Arg.Any<CancellationToken>())
|
||||
.Returns((1.0, DateTime.UtcNow, 0u));
|
||||
_mockClient.ReadValueAsync("bad", Arg.Any<CancellationToken>())
|
||||
.Returns<(object?, DateTime, uint)>(_ => throw new InvalidOperationException("node not found"));
|
||||
_mockClient.ReadValueAsync("good2", Arg.Any<CancellationToken>())
|
||||
.Returns((2.0, DateTime.UtcNow, 0u));
|
||||
_mockClient.ReadValuesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => Task.FromResult<IReadOnlyList<OpcUaReadOutcome>>(
|
||||
ci.Arg<IReadOnlyList<string>>()
|
||||
.Select(n => n == "bad"
|
||||
? new OpcUaReadOutcome(n, null, DateTime.UtcNow, 0x80340000u, "node not found")
|
||||
: new OpcUaReadOutcome(n, 1.0, DateTime.UtcNow, 0u, null))
|
||||
.ToList()));
|
||||
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
|
||||
@@ -365,28 +373,25 @@ public class OpcUaDataConnectionTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DCL017_WriteBatch_ReturnsPerTagResults_WhenConnectionDropsMidBatch()
|
||||
public async Task DCL017_WriteBatch_ReturnsPerTagResults_WhenSomeTagsFail()
|
||||
{
|
||||
// Regression test for DataConnectionLayer-017. WriteBatchAsync looped calling
|
||||
// WriteAsync per tag; WriteAsync first calls EnsureConnected(), which throws
|
||||
// InvalidOperationException when the client is disconnected. WriteBatchAsync did
|
||||
// not catch that, so a connection dropping partway through a batch made the whole
|
||||
// WriteBatchAsync throw — the caller lost the per-tag outcomes for the tags that
|
||||
// already wrote. After the fix (mirroring DCL-007's ReadBatchAsync) each per-tag
|
||||
// failure is recorded as a failed WriteResult and the batch returns a complete map.
|
||||
var writeCount = 0;
|
||||
// First write succeeds; then the client "disconnects" so EnsureConnected throws.
|
||||
_mockClient.IsConnected.Returns(_ => Interlocked.Increment(ref writeCount) <= 1);
|
||||
_mockClient.WriteValueAsync(Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.Returns((uint)0);
|
||||
|
||||
// Connect leaves IsConnected true for the first WriteAsync's EnsureConnected check.
|
||||
// Regression test for DataConnectionLayer-017. WriteBatchAsync originally looped
|
||||
// calling WriteAsync per tag; a mid-batch fault made the whole call throw and the
|
||||
// caller lost the per-tag outcomes for the tags that already wrote. The batch is
|
||||
// now ONE bulk service call (WP2.1b), and the invariant is unchanged: per-node
|
||||
// failures are reported as failed WriteResult rows and every requested tag is
|
||||
// present in the returned map.
|
||||
_mockClient.IsConnected.Returns(true);
|
||||
await _adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
|
||||
// Re-arm: IsConnected true for tag1's check, false for tag2 and tag3.
|
||||
var checks = 0;
|
||||
_mockClient.IsConnected.Returns(_ => Interlocked.Increment(ref checks) <= 1);
|
||||
_mockClient.WriteValuesAsync(
|
||||
Arg.Any<IReadOnlyList<(string NodeId, object? Value)>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => Task.FromResult<IReadOnlyList<OpcUaWriteOutcome>>(
|
||||
ci.Arg<IReadOnlyList<(string NodeId, object? Value)>>()
|
||||
.Select(v => v.NodeId == "tag1"
|
||||
? new OpcUaWriteOutcome(v.NodeId, 0u, null)
|
||||
: new OpcUaWriteOutcome(v.NodeId, 0x80AE0000u, null))
|
||||
.ToList()));
|
||||
|
||||
var results = await _adapter.WriteBatchAsync(new Dictionary<string, object?>
|
||||
{
|
||||
@@ -398,11 +403,12 @@ public class OpcUaDataConnectionTests
|
||||
// Every requested tag is present in the result map — the batch was not aborted.
|
||||
Assert.Equal(3, results.Count);
|
||||
Assert.True(results["tag1"].Success);
|
||||
// tag2 and tag3 fail at the connection check but are reported per-tag.
|
||||
Assert.False(results["tag2"].Success);
|
||||
Assert.NotNull(results["tag2"].ErrorMessage);
|
||||
Assert.False(results["tag3"].Success);
|
||||
Assert.NotNull(results["tag3"].ErrorMessage);
|
||||
// ONE bulk write, not three single writes.
|
||||
Assert.Single(_mockClient.ReceivedCalls(), c => c.GetMethodInfo().Name == "WriteValuesAsync");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -415,8 +421,9 @@ public class OpcUaDataConnectionTests
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
_mockClient.WriteValueAsync(Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.Returns<uint>(_ => throw new OperationCanceledException());
|
||||
_mockClient.WriteValuesAsync(
|
||||
Arg.Any<IReadOnlyList<(string NodeId, object? Value)>>(), Arg.Any<CancellationToken>())
|
||||
.Returns<IReadOnlyList<OpcUaWriteOutcome>>(_ => throw new OperationCanceledException());
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
_adapter.WriteBatchAsync(new Dictionary<string, object?> { ["tag1"] = 1 }, cts.Token));
|
||||
|
||||
Reference in New Issue
Block a user