perf(dcl): batch subscribe/read/write seam, bounded reconnect, sharded subscriptions

This commit is contained in:
Joseph Doherty
2026-08-14 21:14:04 -04:00
parent ee193cd2bb
commit d15c5f02ea
25 changed files with 3131 additions and 324 deletions
@@ -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));
}
}
@@ -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;
}
@@ -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);
}
}
@@ -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;
@@ -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}");
}
}
@@ -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));