perf(abcip): bounded-parallel tag reads (MaxConcurrentReads, default 8)
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 14 — <c>ReadAsync</c> runs its planned groups + per-tag fallbacks inside a bounded
|
||||
/// concurrency window (<see cref="AbCipDriverOptions.MaxConcurrentReads"/>) instead of strictly
|
||||
/// one device round-trip at a time, so libplctag can overlap requests. The window is ACROSS
|
||||
/// tags only: the per-tag runtime lock (05/STAB-4) still serialises Read → GetStatus → Decode
|
||||
/// against any one cached handle. Health is aggregated once after the batch (worst outcome
|
||||
/// wins, <c>LastSuccessfulRead</c> advances if any tag succeeded) because per-item writes to
|
||||
/// the shared health field would race under the window.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipParallelReadTests
|
||||
{
|
||||
private const string DeviceHost = "ab://10.0.0.5/1,0";
|
||||
|
||||
/// <summary>
|
||||
/// Holds every in-flight fake read until either <paramref name="target"/> reads overlap
|
||||
/// (proving the window opened) or a short grace period elapses (so a window of one, which
|
||||
/// can never reach the target, still completes instead of deadlocking).
|
||||
/// </summary>
|
||||
private sealed class ReadGate(int target)
|
||||
{
|
||||
private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private int _inFlight;
|
||||
|
||||
/// <summary>Gets the highest number of fake reads observed in flight simultaneously.</summary>
|
||||
public int MaxConcurrent { get; private set; }
|
||||
|
||||
/// <summary>Records one in-flight read and holds it open until the gate releases.</summary>
|
||||
/// <param name="cancellationToken">The read's cancellation token.</param>
|
||||
public async Task HoldAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var n = Interlocked.Increment(ref _inFlight);
|
||||
lock (this)
|
||||
{
|
||||
if (n > MaxConcurrent) MaxConcurrent = n;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (n >= target) _release.TrySetResult();
|
||||
try
|
||||
{
|
||||
await _release.Task.WaitAsync(TimeSpan.FromMilliseconds(150), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// The window never reached the target — the sequential control case.
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref _inFlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A fake whose read blocks on the shared gate and records its OWN overlap.</summary>
|
||||
private sealed class GatedFake(AbCipTagCreateParams p, ReadGate gate) : FakeAbCipTag(p)
|
||||
{
|
||||
private int _own;
|
||||
|
||||
/// <summary>Gets the highest number of concurrent reads seen on THIS handle.</summary>
|
||||
public int MaxOwnConcurrent { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var mine = Interlocked.Increment(ref _own);
|
||||
lock (this)
|
||||
{
|
||||
if (mine > MaxOwnConcurrent) MaxOwnConcurrent = mine;
|
||||
}
|
||||
try
|
||||
{
|
||||
await gate.HoldAsync(cancellationToken).ConfigureAwait(false);
|
||||
await base.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref _own);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static AbCipDriver NewDriver(
|
||||
IAbCipTagFactory factory, int maxConcurrentReads, params AbCipTagDefinition[] tags)
|
||||
{
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(DeviceHost)],
|
||||
RawTags = AbCipRawTags.From(tags),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
MaxConcurrentReads = maxConcurrentReads,
|
||||
};
|
||||
return new AbCipDriver(opts, "drv-1", factory);
|
||||
}
|
||||
|
||||
private static AbCipTagDefinition Tag(string name) =>
|
||||
new(name, DeviceHost, $"Motor1.{name}", AbCipDataType.DInt);
|
||||
|
||||
/// <summary>Four distinct tags under a window of eight overlap on the wire.</summary>
|
||||
[Fact]
|
||||
public async Task Reads_overlap_up_to_the_window()
|
||||
{
|
||||
var gate = new ReadGate(target: 2);
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new GatedFake(p, gate) { Value = 1 } };
|
||||
var drv = NewDriver(factory, maxConcurrentReads: 8,
|
||||
Tag("A"), Tag("B"), Tag("C"), Tag("D"));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["A", "B", "C", "D"], CancellationToken.None);
|
||||
|
||||
gate.MaxConcurrent.ShouldBeGreaterThanOrEqualTo(2,
|
||||
"a window of 8 must let distinct tags overlap on the device");
|
||||
snapshots.Count.ShouldBe(4);
|
||||
snapshots.ShouldAllBe(s => s.StatusCode == AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Falsification control for <see cref="Reads_overlap_up_to_the_window"/> — the same
|
||||
/// fixture with a window of one must observe no overlap at all, i.e. today's behaviour.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_window_of_one_is_sequential()
|
||||
{
|
||||
var gate = new ReadGate(target: 2);
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new GatedFake(p, gate) { Value = 1 } };
|
||||
var drv = NewDriver(factory, maxConcurrentReads: 1,
|
||||
Tag("A"), Tag("B"), Tag("C"), Tag("D"));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["A", "B", "C", "D"], CancellationToken.None);
|
||||
|
||||
gate.MaxConcurrent.ShouldBe(1, "MaxConcurrentReads = 1 must be observably sequential");
|
||||
snapshots.Count.ShouldBe(4);
|
||||
snapshots.ShouldAllBe(s => s.StatusCode == AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The window parallelises ACROSS tags, never within one — the same reference twice in a
|
||||
/// batch still serialises on the per-tag runtime lock (05/STAB-4).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Same_tag_requested_twice_is_still_serialised()
|
||||
{
|
||||
var gate = new ReadGate(target: 2);
|
||||
GatedFake? shared = null;
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => shared = new GatedFake(p, gate) { Value = 5 },
|
||||
};
|
||||
var drv = NewDriver(factory, maxConcurrentReads: 8, Tag("A"));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["A", "A"], CancellationToken.None);
|
||||
|
||||
shared.ShouldNotBeNull();
|
||||
shared!.MaxOwnConcurrent.ShouldBe(1,
|
||||
"operations on one shared libplctag Tag handle must never overlap");
|
||||
snapshots.Count.ShouldBe(2);
|
||||
snapshots.ShouldAllBe(s => s.StatusCode == AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health is written once after the batch: the worst per-item outcome wins, and
|
||||
/// <c>LastSuccessfulRead</c> still advances because at least one tag read succeeded — the
|
||||
/// contract the comms-loss watchdog reads.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Health_after_a_mixed_batch_is_worst_outcome_with_advanced_LastSuccessfulRead()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => p.TagName.Contains("Bad", StringComparison.Ordinal)
|
||||
? new FakeAbCipTag(p) { ThrowOnRead = true }
|
||||
: new FakeAbCipTag(p) { Value = 3 },
|
||||
};
|
||||
var drv = NewDriver(factory, maxConcurrentReads: 8, Tag("Good"), Tag("Bad"));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var beforeRead = drv.GetHealth().LastSuccessfulRead;
|
||||
beforeRead.ShouldNotBeNull();
|
||||
await Task.Delay(30, TestContext.Current.CancellationToken);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Good", "Bad"], CancellationToken.None);
|
||||
|
||||
snapshots[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
snapshots[1].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||
var health = drv.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Degraded, "the worst per-item outcome wins");
|
||||
health.LastSuccessfulRead.ShouldNotBeNull();
|
||||
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(beforeRead!.Value,
|
||||
"a successful tag in the batch must still advance LastSuccessfulRead");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user