256915b7e4
ReadAsync walked the whole batch sequentially, so one SLOW CNC (TCP-accept-then-stall, the live 31i-B case) cost Timeout per tag per poll while no other device was read. ReadAsync now resolves every reference against one table snapshot, buckets the rest by device and reads the devices with Task.WhenAll; each device's tags still go in order on its own serialised socket (the PMC coalescing pre-pass runs per device, first). Results land at their own indices. Also precomputes each device's fixed-tree key prefix (it was built per reference, twice per poll). Refs #740 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
116 lines
5.5 KiB
C#
116 lines
5.5 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
|
|
|
/// <summary>
|
|
/// #740 / R07:P9 — <see cref="FocasDriver.ReadAsync"/> walked the whole batch sequentially, so a SLOW CNC
|
|
/// (TCP-accept-then-stall, the live 31i-B case) cost its timeout per tag per poll during which no other
|
|
/// device was read. Devices are now read concurrently, each still serialised on its own socket.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class FocasConcurrentDeviceReadTests
|
|
{
|
|
private const string HostA = "focas://10.0.0.5:8193";
|
|
private const string HostB = "focas://10.0.0.6:8193";
|
|
|
|
/// <summary>
|
|
/// A client that records which device it dialled. Device A's read only completes once device B's read has
|
|
/// STARTED — so a sequential walk (A first) can never finish A, while a concurrent one does. Also counts
|
|
/// the peak number of reads in flight on any one client.
|
|
/// </summary>
|
|
private sealed class OrderingClient(TaskCompletionSource bStarted) : FakeFocasClient
|
|
{
|
|
private string _host = "";
|
|
private int _inFlight;
|
|
public int PeakInFlight;
|
|
|
|
public override Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
|
|
{
|
|
_host = address.ToString();
|
|
return base.ConnectAsync(address, timeout, ct);
|
|
}
|
|
|
|
public override async Task<(object? value, uint status)> ReadAsync(
|
|
FocasAddress address, FocasDataType type, CancellationToken ct)
|
|
{
|
|
var now = Interlocked.Increment(ref _inFlight);
|
|
InterlockedMax(ref PeakInFlight, now);
|
|
try
|
|
{
|
|
if (_host.Contains("10.0.0.6", StringComparison.Ordinal)) bStarted.TrySetResult();
|
|
else await bStarted.Task.WaitAsync(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false);
|
|
await Task.Delay(20, ct).ConfigureAwait(false);
|
|
return ((sbyte)1, FocasStatusMapper.Good);
|
|
}
|
|
finally { Interlocked.Decrement(ref _inFlight); }
|
|
}
|
|
|
|
private static void InterlockedMax(ref int target, int value)
|
|
{
|
|
int seen;
|
|
while ((seen = Volatile.Read(ref target)) < value
|
|
&& Interlocked.CompareExchange(ref target, value, seen) != seen) { }
|
|
}
|
|
}
|
|
|
|
/// <summary>A stalled device no longer holds every other device's read hostage — and each device stays serialised.</summary>
|
|
[Fact]
|
|
public async Task Devices_are_read_concurrently_and_each_stays_serialised()
|
|
{
|
|
var bStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var factory = new FakeFocasClientFactory { Customise = () => new OrderingClient(bStarted) };
|
|
var drv = new FocasDriver(new FocasDriverOptions
|
|
{
|
|
Devices = [new FocasDeviceOptions(HostA, DeviceName: "A"), new FocasDeviceOptions(HostB, DeviceName: "B")],
|
|
RawTags =
|
|
[
|
|
FocasRawTags.Entry(new FocasTagDefinition("raw/A1", "A", "R100", FocasDataType.Byte)),
|
|
FocasRawTags.Entry(new FocasTagDefinition("raw/A2", "A", "R101", FocasDataType.Byte)),
|
|
FocasRawTags.Entry(new FocasTagDefinition("raw/B1", "B", "R100", FocasDataType.Byte)),
|
|
],
|
|
Probe = new FocasProbeOptions { Enabled = false },
|
|
// Off so every tag takes the per-tag path — this pins device-level concurrency, not coalescing.
|
|
PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = false },
|
|
}, "focas-p9", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var snaps = await drv.ReadAsync(["raw/A1", "raw/A2", "raw/B1"], CancellationToken.None);
|
|
|
|
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good,
|
|
"device A's read can only finish once device B's has started — a sequential walk times A out");
|
|
factory.Clients.Count.ShouldBe(2);
|
|
factory.Clients.Cast<OrderingClient>().ShouldAllBe(c => c.PeakInFlight == 1,
|
|
"one device's socket is still request→response: never two reads in flight on it");
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
/// <summary>Results land at their own indices whatever order the devices finish in.</summary>
|
|
[Fact]
|
|
public async Task Results_keep_their_request_order_across_devices()
|
|
{
|
|
var factory = new FakeFocasClientFactory();
|
|
var drv = new FocasDriver(new FocasDriverOptions
|
|
{
|
|
Devices = [new FocasDeviceOptions(HostA, DeviceName: "A"), new FocasDeviceOptions(HostB, DeviceName: "B")],
|
|
RawTags =
|
|
[
|
|
FocasRawTags.Entry(new FocasTagDefinition("raw/A1", "A", "R100", FocasDataType.Byte)),
|
|
FocasRawTags.Entry(new FocasTagDefinition("raw/B1", "B", "R200", FocasDataType.Byte)),
|
|
],
|
|
Probe = new FocasProbeOptions { Enabled = false },
|
|
}, "focas-p9-order", factory)
|
|
;
|
|
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1, ["R200"] = (sbyte)2 } };
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var snaps = await drv.ReadAsync(["raw/B1", "nope", "raw/A1"], CancellationToken.None);
|
|
|
|
snaps[0].Value.ShouldBe((sbyte)2);
|
|
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
|
snaps[2].Value.ShouldBe((sbyte)1);
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
}
|