perf(ablegacy): coalesce adjacent N/B/F/L scalar reads into one PCCC request

Review 07 P7 (rest): adjacent words on one data file were never coalesced
(one round trip per tag) although the array path proves the transport can read
a span. Within a device pass, scalar tags on one N/B/F/L file within 8
elements of each other (block <= 200 data bytes, under the 236-byte typed-read
limit) now ride one multi-element read and are decoded from their offsets with
exactly the CLR types the single-tag path returns (int / float / bool).

Only shapes the single path decodes identically are coalesced: indirect,
sub-element, array, file-number-less (S:/I:/O:) and width-mismatched tags keep
the single path. A block the device refuses (an authored element past the end
of the file, a native PLC-5 non-N file) falls back to per-tag reads for its
members and its shape is remembered; a comms fault fails the members together.

New driver-config key coalesceReads (default true) with an AdminUI switch
(Razor change: AbLegacyDriverForm, for the Wave 4 live check). Adds an
env-gated ab_server parity case (coalesced == per-tag values).

Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 14:41:20 -04:00
parent e5527d0201
commit 530f4bdcd4
9 changed files with 563 additions and 6 deletions
@@ -0,0 +1,69 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
/// <summary>
/// Review 07 P7: a coalesced read of nearby N7 words and bits must decode exactly what per-tag reads return,
/// on the wire, on both transports. Seeds distinct values first when writes are allowed (simulator only);
/// otherwise compares whatever the simulator holds.
/// </summary>
[Collection(AbLegacyServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Simulator", "ab_server-PCCC")]
public sealed class AbLegacyReadCoalescingIntegrationTests(AbLegacyServerFixture sim)
{
/// <summary>Coalesced and per-tag reads of the same tags agree value for value.</summary>
/// <param name="profile">The simulator profile.</param>
/// <param name="transport">The transport stack the driver runs on.</param>
[AbLegacyTheory]
[MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
public async Task Coalesced_reads_match_single_reads(AbLegacyServerProfile profile, AbLegacyTransport transport)
{
if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
var ct = TestContext.Current.CancellationToken;
var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
AbLegacyTagDefinition[] tags =
[
new("W0", uri, "N7:0", AbLegacyDataType.Int),
new("W1", uri, "N7:1", AbLegacyDataType.Int),
new("W4", uri, "N7:4", AbLegacyDataType.Int),
new("W4b3", uri, "N7:4/3", AbLegacyDataType.Bit),
];
string[] refs = [.. tags.Select(t => t.Name)];
AbLegacyDriver Make(bool coalesce) => new(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(uri, profile.Family)],
RawTags = AbLegacyRawTags.Entries(tags),
Timeout = TimeSpan.FromSeconds(5),
Probe = new AbLegacyProbeOptions { Enabled = false },
Transport = transport,
CoalesceReads = coalesce,
}, driverInstanceId: $"ablegacy-coalesce-{coalesce}-{profile.Family}-{transport}");
await using var single = Make(coalesce: false);
await using var coalesced = Make(coalesce: true);
await single.InitializeAsync("{}", ct);
await coalesced.InitializeAsync("{}", ct);
var writesRefused = profile.Family == AbLegacyPlcFamily.Plc5 && transport == AbLegacyTransport.Native;
if (Environment.GetEnvironmentVariable(IntegrationWriteGate.AllowWritesVar) == "1"
&& Environment.GetEnvironmentVariable(IntegrationWriteGate.DifferentialEndpointVar) is not { Length: > 0 }
&& !writesRefused)
{
(await single.WriteAsync(
[new WriteRequest("W0", (short)101), new WriteRequest("W1", (short)-202), new WriteRequest("W4", (short)0x0008)], ct))
.ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good);
}
var a = await single.ReadAsync(refs, ct);
var b = await coalesced.ReadAsync(refs, ct);
a.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
b.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
b.Select(s => s.Value).ShouldBe(a.Select(s => s.Value));
}
}
@@ -45,6 +45,7 @@ public sealed class AbLegacyDeviceIsolationTests
Devices = [new AbLegacyDeviceOptions(HostA), new AbLegacyDeviceOptions(HostB)],
RawTags = AbLegacyRawTags.Entries(tags),
Probe = new AbLegacyProbeOptions { Enabled = false },
CoalesceReads = false, // per-tag isolation is under test here; coalescing has its own tests
}, "drv-p7", factory);
private static AbLegacyTagDefinition[] Tags(string prefix, string host, int n, string file = "N7") =>
@@ -0,0 +1,182 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Review 07 P7: adjacent N/B/F/L elements of one device were never coalesced (one PCCC round trip per tag),
/// although the array path proves the transport can read a span. Scalar tags near each other on one file now
/// ride ONE multi-element read and are decoded from their offsets — with exactly the CLR types the single-tag
/// path produces — and a block the device refuses falls back to per-tag reads.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyReadCoalescingTests
{
private const string Host = "ab://10.0.0.5/1,0";
private static (AbLegacyDriver Drv, FakeAbLegacyTagFactory Factory, List<AbLegacyTagCreateParams> Created) NewDriver(
Func<AbLegacyTagCreateParams, FakeAbLegacyTag> make, bool coalesce, params AbLegacyTagDefinition[] tags)
{
var created = new List<AbLegacyTagCreateParams>();
var factory = new FakeAbLegacyTagFactory
{
Customise = p =>
{
lock (created) created.Add(p);
return make(p);
},
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
RawTags = AbLegacyRawTags.Entries(tags),
Probe = new AbLegacyProbeOptions { Enabled = false },
CoalesceReads = coalesce,
}, "drv-coalesce", factory);
return (drv, factory, created);
}
private static AbLegacyTagDefinition Tag(string name, string address, AbLegacyDataType type) =>
new(name, Host, address, type);
[Fact]
public async Task Nearby_N_file_words_and_bits_ride_one_read_and_decode_like_single_reads()
{
var (drv, _, created) = NewDriver(
p => new FakeAbLegacyTag(p) { ArrayValue = new short[] { 10, 11, 12, 4, 14, -15 } },
coalesce: true,
Tag("W0", "N7:0", AbLegacyDataType.Int),
Tag("W1", "N7:1", AbLegacyDataType.AnalogInt),
Tag("B32", "N7:3/2", AbLegacyDataType.Bit),
Tag("B31", "N7:3/1", AbLegacyDataType.Bit),
Tag("W5", "N7:5", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["W0", "W1", "B32", "B31", "W5"], CancellationToken.None);
r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
r[0].Value.ShouldBe(10); // int, exactly as DecodeValue(Int) returns
r[1].Value.ShouldBe(11);
r[2].Value.ShouldBe(true); // word 3 = 0b100: bit 2 set
r[3].Value.ShouldBe(false);
r[4].Value.ShouldBe(-15);
created.Count.ShouldBe(1);
created[0].TagName.ShouldBe("N7:0");
created[0].ElementCount.ShouldBe(6);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Float_and_long_files_coalesce_at_their_element_width()
{
var (drv, _, created) = NewDriver(
p => p.TagName.StartsWith('F')
? new FakeAbLegacyTag(p) { ArrayValue = new[] { 1.5f, 0f, 2.5f } }
: new FakeAbLegacyTag(p) { ArrayValue = new[] { 7, int.MinValue } },
coalesce: true,
Tag("F0", "F8:0", AbLegacyDataType.Float),
Tag("F2", "F8:2", AbLegacyDataType.Float),
Tag("L0", "L9:0", AbLegacyDataType.Long),
Tag("L1b31", "L9:1/31", AbLegacyDataType.Bit));
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["F0", "F2", "L0", "L1b31"], CancellationToken.None);
r.Select(s => s.Value).ShouldBe(new object[] { 1.5f, 2.5f, 7, true });
created.Select(c => (c.TagName, c.ElementCount)).OrderBy(x => x.TagName)
.ShouldBe(new[] { ("F8:0", 3), ("L9:0", 2) });
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Far_apart_or_ineligible_tags_keep_the_single_tag_path()
{
var (drv, _, created) = NewDriver(
p => new FakeAbLegacyTag(p) { Value = 1 },
coalesce: true,
Tag("Near", "N7:0", AbLegacyDataType.Int),
Tag("Far", "N7:40", AbLegacyDataType.Int), // gap > MaxCoalesceGap
Tag("Mistyped", "N7:1", AbLegacyDataType.Float), // width mismatch: single path answers as before
Tag("Acc", "T4:0.ACC", AbLegacyDataType.Int), // sub-element
Tag("Status", "S:1", AbLegacyDataType.Int), // no file number
Tag("Ind", "N7:[N7:0]", AbLegacyDataType.Int)); // indirect
await drv.InitializeAsync("{}", CancellationToken.None);
_ = await drv.ReadAsync(["Near", "Far", "Mistyped", "Acc", "Status", "Ind"], CancellationToken.None);
created.ShouldAllBe(c => c.ElementCount == 1);
created.Select(c => c.TagName).ShouldContain("N7:40");
created.Select(c => c.TagName).ShouldContain("T4:0.ACC");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_refused_block_falls_back_to_single_reads_and_is_not_retried()
{
// N7:9 is past the end of the file: the BLOCK is refused; singly, only that tag is.
var (drv, _, created) = NewDriver(
p => p.ElementCount > 1 || p.TagName == "N7:9"
? new FakeAbLegacyTag(p) { Status = (int)AbStatus.OutOfBounds }
: new FakeAbLegacyTag(p) { Value = 3 },
coalesce: true,
Tag("A", "N7:0", AbLegacyDataType.Int),
Tag("B", "N7:2", AbLegacyDataType.Int),
Tag("C", "N7:9", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
for (var poll = 0; poll < 3; poll++)
{
var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
r[2].StatusCode.ShouldBe(AbLegacyStatusMapper.MapStatus(AbStatus.OutOfBounds));
}
created.Count(c => c.ElementCount > 1).ShouldBe(1); // the refused shape is remembered
drv.GetHealth().State.ShouldBe(DriverState.Healthy); // refusals are not comms faults
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_block_comms_fault_fails_its_members_together_and_degrades()
{
var (drv, _, _) = NewDriver(
p => new FakeAbLegacyTag(p) { ThrowOnRead = true, Exception = new System.Net.Sockets.SocketException(10054) },
coalesce: true,
Tag("A", "N7:0", AbLegacyDataType.Int),
Tag("B", "N7:1", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task CoalesceReads_false_reads_every_tag_singly()
{
var (drv, _, created) = NewDriver(
p => new FakeAbLegacyTag(p) { Value = 1 },
coalesce: false,
Tag("A", "N7:0", AbLegacyDataType.Int),
Tag("B", "N7:1", AbLegacyDataType.Int));
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
created.Count.ShouldBe(2);
created.ShouldAllBe(c => c.ElementCount == 1);
await drv.ShutdownAsync(CancellationToken.None);
}
[Theory]
[InlineData("""{"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", true)]
[InlineData("""{"coalesceReads":false,"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", false)]
[InlineData("""{"coalesceReads":true,"someFutureKey":1,"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", true)]
public void ParseOptions_reads_coalesceReads_defaulting_true(string json, bool expected) =>
AbLegacyDriverFactoryExtensions.ParseOptions("drv", json).CoalesceReads.ShouldBe(expected);
}