perf(s7): span leftovers batch through multi-var read PDUs with per-chunk fallback

This commit is contained in:
Joseph Doherty
2026-08-17 04:05:42 -04:00
parent 5ae430237f
commit 20022083df
2 changed files with 444 additions and 12 deletions
@@ -74,6 +74,44 @@ public sealed class S7SpanReadParityTests
new S7TagDefinition("B", "DB1.DBW2", S7DataType.UInt16, Writable: false),
new S7TagDefinition("C", "DB1.DBW4", S7DataType.UInt16, Writable: false));
/// <summary>
/// <paramref name="count"/> narrow words spaced 8 bytes apart. With a gap budget below 6
/// none of them can merge, so every one becomes a single-member span the driver demotes to
/// the leftover path — which is exactly the population the multi-var batch serves.
/// </summary>
/// <param name="count">How many scattered words to author.</param>
/// <returns>The raw-tag entries.</returns>
private static IReadOnlyList<RawTagEntry> ScatteredWords(int count) => S7RawTags.Entries(
[.. Enumerable.Range(0, count).Select(i =>
new S7TagDefinition($"S{i}", $"DB1.DBW{i * 8}", S7DataType.UInt16, Writable: false))]);
/// <summary>References for <see cref="ScatteredWords"/>, in authoring order.</summary>
/// <param name="count">How many scattered words the batch reads.</param>
/// <returns>The reference names.</returns>
private static string[] ScatteredRefs(int count) => [.. Enumerable.Range(0, count).Select(i => $"S{i}")];
/// <summary>
/// One of every narrow access width, spaced far enough apart that each is its own
/// single-member span — so the whole set reaches the multi-var pass and every box type
/// (<c>bool</c> / <c>byte</c> / <c>ushort</c> / <c>short</c> / <c>uint</c> / <c>int</c> /
/// <c>float</c>) is exercised through it.
/// </summary>
private static IReadOnlyList<RawTagEntry> ScatteredWidths() => S7RawTags.Entries(
new S7TagDefinition("WBit", "DB1.DBX0.3", S7DataType.Bool, Writable: false),
new S7TagDefinition("WByte", "DB1.DBB8", S7DataType.Byte, Writable: false),
new S7TagDefinition("WU16", "DB1.DBW16", S7DataType.UInt16, Writable: false),
new S7TagDefinition("WI16", "DB1.DBW24", S7DataType.Int16, Writable: false),
new S7TagDefinition("WU32", "DB1.DBD32", S7DataType.UInt32, Writable: false),
new S7TagDefinition("WI32", "DB1.DBD40", S7DataType.Int32, Writable: false),
new S7TagDefinition("WF32", "DB1.DBD48", S7DataType.Float32, Writable: false),
// A second AREA in the same batch — a multi-var request carries each item's own area/DB,
// which is precisely what a block read cannot do.
new S7TagDefinition("WMem", "MW0", S7DataType.UInt16, Writable: false));
/// <summary>References for <see cref="ScatteredWidths"/>, in authoring order.</summary>
private static readonly string[] ScatteredWidthRefs =
["WBit", "WByte", "WU16", "WI16", "WU32", "WI32", "WF32", "WMem"];
// ── Parity ───────────────────────────────────────────────────────────────────────────
/// <summary>
@@ -120,11 +158,11 @@ public sealed class S7SpanReadParityTests
/// read.
/// </summary>
/// <remarks>
/// 14 tags collapse to 4 wire requests: ONE block read covering the eleven DB1 tags from
/// byte 0 to byte 68, the Counter's own unavoidable block read, and two per-address string
/// reads — the far DB1 word (past the gap budget) and the lone Memory word, each of which
/// the driver deliberately leaves on the per-tag path because a single-member narrow span
/// saves no round-trip.
/// 14 tags collapse to 3 wire requests: ONE block read covering the eleven DB1 tags from
/// byte 0 to byte 68, the Counter's own unavoidable block read, and ONE multi-var request
/// carrying the two tags a span can't help — the far DB1 word (past the gap budget) and the
/// lone Memory word, which is in a different AREA entirely. Neither is worth a block read of
/// its own, and batching them together is something only a multi-var request can do.
/// </remarks>
[Fact]
public async Task Grouping_issues_far_fewer_wire_requests_than_there_are_tags()
@@ -136,12 +174,15 @@ public sealed class S7SpanReadParityTests
_ = await driver.ReadAsync(MixedRefs, ct);
(factory.ReadBytesCalls + factory.StringReadCalls).ShouldBeLessThan(MixedRefs.Length);
(factory.ReadBytesCalls + factory.StringReadCalls + factory.MultiVarCalls)
.ShouldBeLessThan(MixedRefs.Length);
factory.ReadBytesCalls.ShouldBeLessThan(MixedRefs.Length);
factory.ReadBytesCalls.ShouldBe(2);
// The nine narrow tags inside the big span issue NO string reads — only the two tags that
// ended up alone in a span of their own do.
factory.StringReadCalls.ShouldBe(2);
// NO per-address reads at all: the nine narrow tags inside the big span are served by its
// block read, and the two that ended up alone in a span of their own are batched together
// into a single multi-var request.
factory.StringReadCalls.ShouldBe(0);
factory.MultiVarCalls.ShouldBe(1);
}
// ── Failure parity ───────────────────────────────────────────────────────────────────
@@ -205,6 +246,133 @@ public sealed class S7SpanReadParityTests
results[2].StatusCode.ShouldBe(0u);
}
// ── Multi-var leftovers ──────────────────────────────────────────────────────────────
/// <summary>
/// The batchable leftovers ride ONE multi-var request per chunk of
/// <c>MultiVarReadMaxItems</c> and issue no per-address reads at all. 13 scattered words
/// that the span planner cannot merge used to cost 13 wire requests; they now cost 2.
/// </summary>
[Fact]
public async Task Batchable_leftovers_ride_multi_var_requests_instead_of_one_read_each()
{
var ct = TestContext.Current.CancellationToken;
var factory = new ImageS7PlcFactory(Images());
using var driver = new S7Driver(Options(ScatteredWords(13), maxGap: 2), "s7-multivar", factory);
await driver.InitializeAsync("{}", ct);
var results = await driver.ReadAsync(ScatteredRefs(13), ct);
results.ShouldAllBe(s => s.StatusCode == 0u);
results.ShouldAllBe(s => s.Value != null);
// ⌈13 / 12⌉ = 2 requests, and NOT ONE per-address read — the whole point of the batch.
factory.MultiVarCalls.ShouldBe(2);
factory.StringReadCalls.ShouldBe(0);
factory.ReadBytesCalls.ShouldBe(0);
// The chunking is a real split, not one oversized request plus an empty one.
factory.MultiVarItems.Select(c => c.Count).ShouldBe([12, 1]);
}
/// <summary>
/// THE multi-var gate: a tag served by a multi-var request decodes to the same value, the
/// same CLR box type and the same status as the per-tag path reading the same image.
/// </summary>
[Fact]
public async Task Multi_var_leftovers_match_the_per_tag_path_snapshot_for_snapshot()
{
var images = Images();
var ct = TestContext.Current.CancellationToken;
var batching = new ImageS7PlcFactory(images);
var perTag = new ImageS7PlcFactory(images);
using var batchingDriver = new S7Driver(Options(ScatteredWidths(), maxGap: 2), "s7-mv-parity", batching);
using var perTagDriver = new S7Driver(Options(ScatteredWidths(), maxGap: -1), "s7-mv-control", perTag);
await batchingDriver.InitializeAsync("{}", ct);
await perTagDriver.InitializeAsync("{}", ct);
var batched = await batchingDriver.ReadAsync(ScatteredWidthRefs, ct);
var individual = await perTagDriver.ReadAsync(ScatteredWidthRefs, ct);
for (var i = 0; i < ScatteredWidthRefs.Length; i++)
{
var name = ScatteredWidthRefs[i];
batched[i].StatusCode.ShouldBe(0u, $"'{name}' should have read Good");
batched[i].StatusCode.ShouldBe(individual[i].StatusCode, $"status differs for '{name}'");
batched[i].Value.ShouldNotBeNull($"'{name}' returned no value");
batched[i].Value!.GetType().ShouldBe(individual[i].Value!.GetType(), $"box type differs for '{name}'");
batched[i].Value.ShouldBe(individual[i].Value, $"value differs for '{name}'");
}
// Proves the batching side really took the multi-var path (otherwise this file's parity
// claim would be vacuous), and the control side really did not.
batching.MultiVarCalls.ShouldBe(1);
batching.StringReadCalls.ShouldBe(0);
perTag.MultiVarCalls.ShouldBe(0);
perTag.StringReadCalls.ShouldBe(ScatteredWidthRefs.Length);
}
/// <summary>
/// A multi-var request that faults falls back to the per-tag path for that chunk's tags and
/// still serves them Good. Batching is an optimisation: it must never be able to turn a tag
/// Bad that reading it one at a time would have served.
/// </summary>
[Fact]
public async Task A_failing_multi_var_chunk_falls_back_to_per_tag_reads_and_still_serves_good()
{
var ct = TestContext.Current.CancellationToken;
var factory = new ImageS7PlcFactory(Images())
{
// A device-layer fault — the shape that WOULD stamp BadDeviceFailure had it come from
// the per-tag path. Through the multi-var pass it must cost nothing but a retry.
MultiVarFault = new PlcException(ErrorCode.ReadData, "S7 CPU rejected the multi-var request"),
};
using var driver = new S7Driver(Options(ScatteredWords(5), maxGap: 2), "s7-mv-fallback", factory);
await driver.InitializeAsync("{}", ct);
var results = await driver.ReadAsync(ScatteredRefs(5), ct);
results.ShouldAllBe(s => s.StatusCode == 0u);
results.ShouldAllBe(s => s.Value != null);
factory.MultiVarCalls.ShouldBe(1);
// Every tag of the failed chunk was re-read individually.
factory.StringReadCalls.ShouldBe(5);
// And the failed batch attempt did NOT degrade the driver — nothing actually failed to read.
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// Tags the per-tag path owns — Timer and Counter, whose offsets are device numbers rather
/// than byte addresses — are never handed to a multi-var request, even though they share the
/// leftover list with the narrow scalars that are.
/// </summary>
[Fact]
public async Task Timer_and_counter_leftovers_never_enter_a_multi_var_request()
{
var ct = TestContext.Current.CancellationToken;
var tags = S7RawTags.Entries(
new S7TagDefinition("Tmr", "T2", S7DataType.Float64, Writable: false),
new S7TagDefinition("Cnt", "C1", S7DataType.Int32, Writable: false),
new S7TagDefinition("N0", "DB1.DBW0", S7DataType.UInt16, Writable: false),
new S7TagDefinition("N1", "DB1.DBW100", S7DataType.UInt16, Writable: false));
var factory = new ImageS7PlcFactory(Images());
using var driver = new S7Driver(Options(tags, maxGap: 2), "s7-mv-partition", factory);
await driver.InitializeAsync("{}", ct);
var results = await driver.ReadAsync(["Tmr", "Cnt", "N0", "N1"], ct);
results.ShouldAllBe(s => s.StatusCode == 0u);
results[0].Value.ShouldBeOfType<double>();
results[1].Value.ShouldBeOfType<int>();
// One multi-var request carrying ONLY the two narrow words; the Timer and the Counter kept
// their own block reads.
factory.MultiVarCalls.ShouldBe(1);
factory.MultiVarItems[0].Count.ShouldBe(2);
factory.MultiVarItems[0].ShouldAllBe(d => d.DataType == S7NetDataType.DataBlock);
factory.ReadBytesCalls.ShouldBe(2);
}
// ── Configurability ──────────────────────────────────────────────────────────────────
/// <summary>
@@ -265,11 +433,15 @@ public sealed class S7SpanReadParityTests
var counters = new byte[64];
for (var i = 0; i < counters.Length; i++) counters[i] = (byte)((i * 29 + 3) & 0xFF);
var timers = new byte[64];
for (var i = 0; i < timers.Length; i++) timers[i] = (byte)((i * 17 + 5) & 0xFF);
return new Dictionary<(S7NetDataType, int), byte[]>
{
[(S7NetDataType.DataBlock, 1)] = db1,
[(S7NetDataType.Memory, 0)] = mem,
[(S7NetDataType.Counter, 0)] = counters,
[(S7NetDataType.Timer, 0)] = timers,
};
}
@@ -289,9 +461,18 @@ public sealed class S7SpanReadParityTests
/// <summary>Number of per-address string reads issued — the un-grouped narrow path.</summary>
public int StringReadCalls;
/// <summary>Number of multi-var requests issued — one per leftover chunk.</summary>
public int MultiVarCalls;
/// <summary>The items of every multi-var request, in issue order, so a test can assert what was batched.</summary>
public List<IReadOnlyList<global::S7.Net.Types.DataItem>> MultiVarItems { get; } = new();
/// <summary>When set, every read throws this instead of serving the image.</summary>
public Exception? ReadFault { get; init; }
/// <summary>When set, only the MULTI-VAR read throws this — the per-tag fallback still serves the image.</summary>
public Exception? MultiVarFault { get; init; }
/// <inheritdoc />
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
=> new ImageS7Plc(this);
@@ -324,9 +505,40 @@ public sealed class S7SpanReadParityTests
}
/// <inheritdoc />
/// <remarks>
/// Decodes each item from the same byte image the other two read shapes serve, boxing
/// exactly as S7.Net's <c>ParseBytes</c> does for <c>Count == 1</c>: <c>Bit</c> a
/// <c>bool</c> at <c>BitAdr</c>, <c>Byte</c> a <c>byte</c>, <c>Word</c> a
/// <c>ushort</c>, <c>DWord</c> a <c>uint</c>. Like the real library it populates each
/// item's <c>Value</c> in place and hands the same list back.
/// </remarks>
public Task<IReadOnlyList<global::S7.Net.Types.DataItem>> ReadMultipleVarsAsync(
List<global::S7.Net.Types.DataItem> items, CancellationToken ct)
=> throw new NotSupportedException("ImageS7Plc: multi-var reads not served by the parity image fake");
{
// Counted (and captured) BEFORE the injected fault so a fallback test can assert the
// batch was ATTEMPTED — counting only successes would read zero.
Interlocked.Increment(ref owner.MultiVarCalls);
lock (owner.MultiVarItems) owner.MultiVarItems.Add([.. items]);
if (owner.MultiVarFault is { } mvFault) throw mvFault;
if (owner.ReadFault is { } fault) throw fault;
foreach (var item in items)
{
var image = owner.Images[(item.DataType, item.DB)];
var at = item.StartByteAdr;
item.Value = item.VarType switch
{
VarType.Bit => (object)((image[at] & (1 << item.BitAdr)) != 0),
VarType.Byte => image[at],
VarType.Word => (ushort)((image[at] << 8) | image[at + 1]),
VarType.DWord => ((uint)image[at] << 24) | ((uint)image[at + 1] << 16)
| ((uint)image[at + 2] << 8) | image[at + 3],
_ => throw new NotSupportedException($"fake cannot serve VarType {item.VarType}"),
};
}
return Task.FromResult<IReadOnlyList<global::S7.Net.Types.DataItem>>(items);
}
/// <inheritdoc />
public Task<object?> ReadAsync(string address, CancellationToken ct)