perf(s7): span leftovers batch through multi-var read PDUs with per-chunk fallback
This commit is contained in:
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using S7.Net;
|
||||
using S7NetDataType = global::S7.Net.DataType;
|
||||
using S7DataItem = global::S7.Net.Types.DataItem;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
@@ -514,11 +515,19 @@ public sealed class S7Driver
|
||||
/// <c>ReadBytesAsync</c>, its members decoded by slicing the returned block — this is
|
||||
/// what turns one wire request per tag into one per contiguous block. Whatever the
|
||||
/// planner declines (Timer/Counter, whose offsets are device numbers) plus any span not
|
||||
/// worth issuing (see <see cref="PrefersPerTagRead"/>) then goes through the unchanged
|
||||
/// worth issuing (see <see cref="PrefersPerTagRead"/>) falls to the leftover list.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Those leftovers get a SECOND grouping pass: the ones a
|
||||
/// <see cref="S7DataItem"/> expresses cleanly — the narrow scalars, which is what a
|
||||
/// demoted single-member span is — are batched into multi-var requests by
|
||||
/// <see cref="ReadLeftoversByMultiVarAsync"/>, which crosses <c>(area, DB)</c>
|
||||
/// boundaries a block read cannot. Whatever remains (Timer/Counter, unparseable
|
||||
/// addresses, and any chunk whose multi-var request faulted) goes through the unchanged
|
||||
/// per-tag <see cref="ReadOneAsync"/> path.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Setting <c>SpanReadMaxGapBytes</c> below zero skips the first pass entirely, which
|
||||
/// Setting <c>SpanReadMaxGapBytes</c> below zero skips BOTH passes, which
|
||||
/// restores the pre-grouping behaviour exactly. Either way the result array is written
|
||||
/// by original index, so the caller sees its own ordering regardless of how the batch
|
||||
/// was partitioned, and both passes share <see cref="RecordReadFailure"/> so a fault
|
||||
@@ -617,6 +626,18 @@ public sealed class S7Driver
|
||||
// Sorted so a batch is served in reference order regardless of how it was partitioned —
|
||||
// the per-tag tail is otherwise ordered by the planner's grouping, not by the caller's.
|
||||
perTag.Sort();
|
||||
|
||||
// Second grouping pass: the leftovers a DataItem can express ride ONE multi-var request
|
||||
// per chunk instead of a wire request each. Deliberately gated on the SAME switch as the
|
||||
// span pass — SpanReadMaxGapBytes < 0 is documented to restore the pre-grouping wire
|
||||
// shape exactly, and batching the (then complete) per-tag list would break that. What
|
||||
// comes back is the tags still owed an individual read, still in reference order.
|
||||
if (_options.SpanReadMaxGapBytes >= 0)
|
||||
{
|
||||
perTag = await ReadLeftoversByMultiVarAsync(
|
||||
plc, resolved, perTag, results, now, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var i in perTag)
|
||||
{
|
||||
try
|
||||
@@ -647,6 +668,205 @@ public sealed class S7Driver
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Items one multi-var request may carry. <b>The caller owns chunking — S7.Net does NOT
|
||||
/// split a multi-var read</b>, it throws when the request or the projected response exceeds
|
||||
/// the negotiated PDU (<c>AssertPduSizeForRead</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The request costs a 19-byte header plus <b>12 bytes per item</b>; the response costs a
|
||||
/// 14-byte header plus 4 bytes of item header plus the item's own data. Against the common
|
||||
/// 240-byte PDU that permits 18 items request-side and — for the 4-byte-or-narrower scalars
|
||||
/// this pass batches — 28 response-side. 12 sits well inside both, leaving headroom for a
|
||||
/// CPU that negotiates a smaller PDU than 240, which is the failure this constant exists to
|
||||
/// avoid: an oversized request is a hard throw, not a paged read. It is deliberately NOT a
|
||||
/// config key — the safe value is a property of the protocol, not of a deployment.
|
||||
/// </remarks>
|
||||
private const int MultiVarReadMaxItems = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Fewest batchable leftovers worth issuing a multi-var request for. ONE leftover is one
|
||||
/// wire request either way, so batching it would change the observable wire shape of every
|
||||
/// single-tag read for zero benefit — the same reasoning
|
||||
/// <see cref="PrefersPerTagRead"/> applies to a lone narrow span.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The gate is on the PASS, not on each chunk: a batch of 13 still splits into 12 + 1, and
|
||||
/// that trailing single is left in the multi-var path. It costs one wire request either way,
|
||||
/// so re-routing it would buy nothing and would make the chunk loop's index bookkeeping
|
||||
/// answer a second question.
|
||||
/// </remarks>
|
||||
private const int MultiVarReadMinItems = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Serves the span planner's leftovers that a <see cref="S7DataItem"/> can express by
|
||||
/// batching them into multi-var requests, and returns the indexes still owed an individual
|
||||
/// <see cref="ReadOneAsync"/> — in ascending reference order.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Only a narrow scalar is batched.</b> A <see cref="S7DataItem"/> carrying the same
|
||||
/// area / DB / offset / bit / <c>VarType</c> that S7.Net's own address parser derives
|
||||
/// from the tag's address string, with <c>Count == 1</c>, is decoded by the SAME
|
||||
/// <c>ParseBytes</c> call the per-address read path uses — so its boxed value is
|
||||
/// byte-for-byte and type-for-type what <see cref="ReadOneAsync"/> would have produced,
|
||||
/// and <see cref="ReinterpretRawValue"/> applies to it unchanged.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Timer and Counter deliberately stay on the per-tag path</b> even though S7.Net's
|
||||
/// <c>VarType</c> names them. Their per-tag path does not go through <c>ParseBytes</c>
|
||||
/// at all — it block-reads the 2-byte word and decodes it in
|
||||
/// <see cref="DecodeScalarBlock"/>, which surfaces a Counter as <c>int</c> where
|
||||
/// S7.Net's own <c>Counter.FromByteArray</c> returns <c>ushort</c>, and which carries a
|
||||
/// documented BCD follow-up for legacy C-area counters. Batching them would fork that
|
||||
/// decode into a second site that must be kept in step by hand. Correctness beats the
|
||||
/// round-trip. Arrays, wide/buffer scalars and unparseable addresses are likewise left
|
||||
/// alone — the first two already read a block apiece, and the last has no address to
|
||||
/// build an item from.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A multi-var fault is never a tag's fault.</b> The whole chunk falls back to the
|
||||
/// per-tag path, which is what decides the tag's status — batching must not be able to
|
||||
/// stamp a tag Bad that reading it one at a time would have served, so
|
||||
/// <see cref="RecordReadFailure"/> is deliberately NOT called for the request itself.
|
||||
/// S7.Net offers no per-item error surface to be finer-grained with: a
|
||||
/// <see cref="S7DataItem"/> carries only its <c>Value</c>, and the response parser
|
||||
/// throws on the FIRST item the CPU refused (before populating any later item), so
|
||||
/// per-chunk fallback is the finest granularity the library actually admits. Per-ITEM
|
||||
/// decoding, by contrast, is guarded individually — exactly as the span path guards its
|
||||
/// members — because a mis-authored tag must not spoil the neighbours it shared a
|
||||
/// request with, and it fails identically on the per-tag path anyway.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="plc">The live connection (caller holds <see cref="_gate"/>).</param>
|
||||
/// <param name="resolved">The batch's resolved tags, indexed by reference position.</param>
|
||||
/// <param name="leftovers">Indexes the span pass left for the per-tag path, ascending.</param>
|
||||
/// <param name="results">The batch's result array, written in place at each tag's original index.</param>
|
||||
/// <param name="now">The batch timestamp, shared by every tag in the batch.</param>
|
||||
/// <param name="ct">Cancellation token for the reads.</param>
|
||||
/// <returns>The indexes still owed an individual read, ascending.</returns>
|
||||
private async Task<List<int>> ReadLeftoversByMultiVarAsync(
|
||||
IS7Plc plc,
|
||||
S7TagDefinition?[] resolved,
|
||||
List<int> leftovers,
|
||||
DataValueSnapshot[] results,
|
||||
DateTime now,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var stillPerTag = new List<int>();
|
||||
List<(int Index, S7TagDefinition Tag, S7ParsedAddress Addr)>? batchable = null;
|
||||
|
||||
foreach (var i in leftovers)
|
||||
{
|
||||
var tag = resolved[i]!;
|
||||
if (tag.ArrayCount is >= 1
|
||||
|| !TryGetParsedAddress(tag, out var addr)
|
||||
|| IsBufferType(tag, addr))
|
||||
{
|
||||
stillPerTag.Add(i);
|
||||
continue;
|
||||
}
|
||||
(batchable ??= []).Add((i, tag, addr));
|
||||
}
|
||||
|
||||
// Nothing to gain: leave the (at most one) batchable tag exactly where it was.
|
||||
if (batchable is null || batchable.Count < MultiVarReadMinItems)
|
||||
{
|
||||
if (batchable is not null)
|
||||
foreach (var (index, _, _) in batchable) stillPerTag.Add(index);
|
||||
stillPerTag.Sort();
|
||||
return stillPerTag;
|
||||
}
|
||||
|
||||
for (var start = 0; start < batchable.Count; start += MultiVarReadMaxItems)
|
||||
{
|
||||
var chunk = batchable.GetRange(start, Math.Min(MultiVarReadMaxItems, batchable.Count - start));
|
||||
|
||||
IReadOnlyList<S7DataItem> answered;
|
||||
try
|
||||
{
|
||||
var items = new List<S7DataItem>(chunk.Count);
|
||||
foreach (var (_, _, addr) in chunk) items.Add(ToDataItem(addr));
|
||||
|
||||
answered = await plc.ReadMultipleVarsAsync(items, ct).ConfigureAwait(false);
|
||||
|
||||
// The contract is "the same items back, in the supplied order". Anything else is
|
||||
// unmappable to the batch's indexes, so treat it as a failed chunk rather than
|
||||
// guessing which tag got which value.
|
||||
if (answered is null || answered.Count != chunk.Count)
|
||||
{
|
||||
throw new System.IO.InvalidDataException(
|
||||
$"S7.Net returned {answered?.Count.ToString() ?? "no"} multi-var item(s) " +
|
||||
$"for a {chunk.Count}-item request");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Same reasoning as the per-tag and span paths: a cancelled read abandons a half-read
|
||||
// response on the single gated stream, so the handle must be reopened (STAB-15a).
|
||||
_plcDead = true;
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex,
|
||||
"S7 driver {DriverInstanceId}: multi-var read of {ItemCount} leftover tag(s) failed; " +
|
||||
"falling back to per-tag reads for that chunk", _driverInstanceId, chunk.Count);
|
||||
foreach (var (index, _, _) in chunk) stillPerTag.Add(index);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var k = 0; k < chunk.Count; k++)
|
||||
{
|
||||
var (index, tag, addr) = chunk[k];
|
||||
try
|
||||
{
|
||||
var raw = answered[k].Value
|
||||
?? throw new System.IO.InvalidDataException(
|
||||
$"S7.Net returned null multi-var value for '{tag.Address}'");
|
||||
results[index] = new DataValueSnapshot(ReinterpretRawValue(tag, addr, raw), 0u, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Decoding is pure and synchronous — no cancellation can surface here, so there
|
||||
// is deliberately no OperationCanceledException arm to shadow the request one.
|
||||
results[index] = new DataValueSnapshot(null, RecordReadFailure(ex), null, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The fallback chunks were appended out of order behind the non-batchable tags.
|
||||
stillPerTag.Sort();
|
||||
return stillPerTag;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the multi-var request item for one narrow scalar — the exact
|
||||
/// <c>(DataType, DB, StartByteAdr, BitAdr, VarType, Count)</c> tuple S7.Net's own address
|
||||
/// parser derives from the equivalent address string, so the item reads and decodes
|
||||
/// identically to <see cref="ReadOneAsync"/>'s per-address read.
|
||||
/// </summary>
|
||||
/// <param name="addr">The tag's parsed address; its <see cref="S7Size"/> picks the var type.</param>
|
||||
/// <returns>The request item, with no value yet.</returns>
|
||||
private static S7DataItem ToDataItem(S7ParsedAddress addr) => new()
|
||||
{
|
||||
DataType = ToS7NetArea(addr.Area),
|
||||
DB = addr.DbNumber,
|
||||
StartByteAdr = addr.ByteOffset,
|
||||
// Non-Bit sizes parse to a bit offset of 0, which S7.Net ignores for those var types.
|
||||
BitAdr = (byte)addr.BitOffset,
|
||||
VarType = addr.Size switch
|
||||
{
|
||||
S7Size.Bit => VarType.Bit,
|
||||
S7Size.Byte => VarType.Byte,
|
||||
S7Size.Word => VarType.Word,
|
||||
S7Size.DWord => VarType.DWord,
|
||||
_ => throw new NotSupportedException($"S7 size {addr.Size} has no multi-var VarType mapping"),
|
||||
},
|
||||
Count = 1,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// True when a planned span is better served by the per-tag path: it holds exactly ONE
|
||||
/// narrow scalar, so issuing it as a block read saves no round-trip and buys nothing.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user