1f7ab80b81
A span's block is the union of its members' ranges, so one member that runs past the DB's real end (or over-declares a STRING) made the CPU refuse the whole block, and every neighbour within it was Bad on every poll. The planner's stated invariant (a grouped read cannot fault where per-tag reads would succeed) was false; the multi-var path already fell back, spans did not. - A refused multi-member span hands its members back; they are read one at a time for that pass, bypassing the multi-var pass. - _noGroup remembers the members refused on their own (or every member when none was) and keeps them out of later plans, so the rest group again. It is cleared by every initialize publish. Non-refusal span failures are unchanged (every member stamped). - The once-per-tag refusal Warning memo (R07:H4) is now also cleared by the publish, and shutdown resets the write-side Degraded owner, as documented. - Live gate: MW10 + MD254 (2 bytes past the 256-byte M area) merged into one refused block; MW10 keeps reading 4097 on every pass. Refs #742 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
205 lines
10 KiB
C#
205 lines
10 KiB
C#
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
|
|
|
/// <summary>
|
|
/// Pure planner that turns one read batch into contiguous <c>(area, DB)</c> byte spans, so the
|
|
/// driver can serve many tags with one <c>ReadBytesAsync</c> instead of one wire request per
|
|
/// tag. No I/O, no driver state — the grouping rules are decided here and executed by
|
|
/// <see cref="S7Driver.ReadAsync"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>The safety invariant is that a span never extends past its last member's end.</b> A
|
|
/// span may deliberately read bytes nobody asked for BETWEEN its members (that is what the
|
|
/// gap budget buys), but every byte it requests lies inside a range some member was going
|
|
/// to read anyway.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>That does NOT make a grouped read as safe as the per-tag reads</b> (R07:P2): the block is
|
|
/// the union of its members' ranges, so ONE member the CPU refuses (running past the DB's real
|
|
/// end, an over-declared STRING) makes it refuse the whole block, neighbours included.
|
|
/// <see cref="S7Driver.ReadAsync"/> owns that case: a refused multi-member span is re-read one
|
|
/// member at a time, and the members that must not be grouped are left out of later plans.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Timer and Counter tags are never grouped.</b> Their
|
|
/// <see cref="S7ParsedAddress.ByteOffset"/> holds the timer/counter DEVICE NUMBER rather
|
|
/// than a byte address, and S7.Net addresses those areas in per-device units — merging one
|
|
/// with a byte-addressed neighbour, or even with another Timer, would read from a
|
|
/// completely unrelated place. They come back as leftovers for the per-tag path.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal static class S7ReadSpanPlanner
|
|
{
|
|
/// <summary>One tag inside a planned span, positioned relative to the span's start byte.</summary>
|
|
/// <param name="OriginalIndex">The tag's index in the caller's read batch — where its result must be written.</param>
|
|
/// <param name="Tag">The resolved tag definition (carries the data type, array count and string length).</param>
|
|
/// <param name="Addr">The tag's parsed address (carries the area, DB, access size and bit position).</param>
|
|
/// <param name="WidthBytes">Bytes this tag occupies — the exact number its decoder consumes.</param>
|
|
/// <param name="RelativeOffset">The tag's first byte expressed against <see cref="Span.StartByte"/>; the slice offset into the block.</param>
|
|
internal sealed record Member(
|
|
int OriginalIndex,
|
|
S7TagDefinition Tag,
|
|
S7ParsedAddress Addr,
|
|
int WidthBytes,
|
|
int RelativeOffset);
|
|
|
|
/// <summary>One planned block read: a contiguous byte range in one area, serving several tags.</summary>
|
|
/// <param name="Area">The S7 area the block is read from.</param>
|
|
/// <param name="DbNumber">Data-block number (0 for non-DB areas).</param>
|
|
/// <param name="StartByte">First byte of the block — the lowest member's byte offset.</param>
|
|
/// <param name="LengthBytes">Block length — always exactly the furthest member's end minus <paramref name="StartByte"/>.</param>
|
|
/// <param name="Members">The tags this block serves, in ascending byte order.</param>
|
|
internal sealed record Span(
|
|
S7Area Area,
|
|
int DbNumber,
|
|
int StartByte,
|
|
int LengthBytes,
|
|
IReadOnlyList<Member> Members);
|
|
|
|
/// <summary>The planned grouping of one read batch.</summary>
|
|
/// <param name="Spans">Block reads to issue, one wire request each.</param>
|
|
/// <param name="LeftoverIndexes">Batch indexes the planner declined — the caller reads these one at a time.</param>
|
|
internal sealed record Plan(IReadOnlyList<Span> Spans, IReadOnlyList<int> LeftoverIndexes);
|
|
|
|
/// <summary>
|
|
/// Groups the resolved tags of one read batch into block-read spans.
|
|
/// </summary>
|
|
/// <param name="resolved">
|
|
/// The batch's resolved tags, each paired with its parsed address and its index in the
|
|
/// caller's reference list. Unresolvable references must be handled by the caller and left
|
|
/// out of this list.
|
|
/// </param>
|
|
/// <param name="maxGapBytes">
|
|
/// Largest hole to read through when merging (inclusive). <c>0</c> merges only strictly
|
|
/// adjacent or overlapping tags; any value below zero disables grouping and returns every
|
|
/// index as a leftover.
|
|
/// </param>
|
|
/// <param name="maxSpanBytes">
|
|
/// Largest planned span. A run that would exceed it is broken; a single tag wider than it
|
|
/// still forms its own span, because a tag is never split across requests.
|
|
/// </param>
|
|
/// <returns>The spans to read plus the indexes the caller must read individually.</returns>
|
|
internal static Plan Build(
|
|
IReadOnlyList<(int OriginalIndex, S7TagDefinition Tag, S7ParsedAddress Addr)> resolved,
|
|
int maxGapBytes,
|
|
int maxSpanBytes)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(resolved);
|
|
|
|
var leftovers = new List<int>();
|
|
|
|
// The off switch. Checked here (as well as at the call site) so the disabled behaviour is
|
|
// a property of the planner and can be proven without standing up a driver.
|
|
if (maxGapBytes < 0)
|
|
{
|
|
foreach (var (index, _, _) in resolved) leftovers.Add(index);
|
|
return new Plan([], leftovers);
|
|
}
|
|
|
|
List<(int Index, S7TagDefinition Tag, S7ParsedAddress Addr, int Width)>? eligible = null;
|
|
foreach (var (index, tag, addr) in resolved)
|
|
{
|
|
// Timer/Counter offsets are device numbers, not byte addresses — see the type remarks.
|
|
if (addr.Area is S7Area.Timer or S7Area.Counter)
|
|
{
|
|
leftovers.Add(index);
|
|
continue;
|
|
}
|
|
(eligible ??= []).Add((index, tag, addr, MemberWidthBytes(tag, addr)));
|
|
}
|
|
|
|
if (eligible is null) return new Plan([], leftovers);
|
|
|
|
var spans = new List<Span>();
|
|
|
|
// Group by (area, DB): two areas — or two data blocks — are separate address spaces, so a
|
|
// byte range in one says nothing about the same range in the other.
|
|
foreach (var group in eligible.GroupBy(e => (e.Addr.Area, e.Addr.DbNumber)))
|
|
{
|
|
OpenSpan? open = null;
|
|
|
|
// Ascending byte order. OrderBy is stable, so tags sharing a byte (two Bools at
|
|
// different bit positions) keep their batch order inside the span.
|
|
foreach (var (index, tag, addr, width) in group.OrderBy(e => e.Addr.ByteOffset))
|
|
{
|
|
var start = addr.ByteOffset;
|
|
var end = start + width; // exclusive
|
|
|
|
if (open is not null)
|
|
{
|
|
// A negative gap means the tags overlap (a word inside an already-open dword,
|
|
// or a second Bool in the same byte) — that is a gap of zero or better, so it
|
|
// always passes a non-negative budget without any clamping.
|
|
var gap = start - open.EndByte;
|
|
var mergedEnd = Math.Max(open.EndByte, end);
|
|
|
|
if (gap <= maxGapBytes && mergedEnd - open.StartByte <= maxSpanBytes)
|
|
{
|
|
open.Members.Add(new Member(index, tag, addr, width, start - open.StartByte));
|
|
// Max, not `end`: an overlapping member must never pull the span's end back.
|
|
open.EndByte = mergedEnd;
|
|
continue;
|
|
}
|
|
|
|
spans.Add(Seal(group.Key.Area, group.Key.DbNumber, open));
|
|
}
|
|
|
|
// Start a fresh span. Deliberately unconditional — a tag wider than maxSpanBytes
|
|
// gets its own oversized span rather than being split, and because the merge test
|
|
// above measures the RESULTING length, that oversized span can never absorb a
|
|
// neighbour afterwards.
|
|
open = new OpenSpan(start, end);
|
|
open.Members.Add(new Member(index, tag, addr, width, 0));
|
|
}
|
|
|
|
if (open is not null) spans.Add(Seal(group.Key.Area, group.Key.DbNumber, open));
|
|
}
|
|
|
|
return new Plan(spans, leftovers);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bytes one tag occupies inside a contiguous block. The three arms are checked in exactly
|
|
/// the order <see cref="S7Driver.ReadAsync"/> routes a tag to its decoder, so the width
|
|
/// planned here is always the width the decoder that runs will consume.
|
|
/// </summary>
|
|
/// <param name="tag">The resolved tag definition.</param>
|
|
/// <param name="addr">The tag's parsed address.</param>
|
|
/// <returns>The tag's byte width.</returns>
|
|
internal static int MemberWidthBytes(S7TagDefinition tag, S7ParsedAddress addr)
|
|
{
|
|
if (tag.ArrayCount is >= 1)
|
|
return tag.ArrayCount.Value * S7Driver.ElementByteSize(addr.Size);
|
|
if (S7Driver.IsBufferType(tag, addr))
|
|
return S7Driver.ScalarByteWidth(tag, addr);
|
|
return S7Driver.NarrowScalarWidth(addr);
|
|
}
|
|
|
|
/// <summary>Freezes an in-progress span into its immutable <see cref="Span"/> form.</summary>
|
|
/// <param name="area">The area the span reads from.</param>
|
|
/// <param name="dbNumber">The data-block number.</param>
|
|
/// <param name="open">The in-progress span.</param>
|
|
/// <returns>The sealed span; its length is exactly the furthest member's end minus the start.</returns>
|
|
private static Span Seal(S7Area area, int dbNumber, OpenSpan open)
|
|
=> new(area, dbNumber, open.StartByte, open.EndByte - open.StartByte, open.Members);
|
|
|
|
/// <summary>
|
|
/// Mutable accumulator for a span still being extended. <see cref="StartByte"/> is fixed at
|
|
/// creation (members arrive in ascending order, so nothing can ever start earlier);
|
|
/// <see cref="EndByte"/> is exclusive and only ever grows.
|
|
/// </summary>
|
|
/// <param name="startByte">First byte of the span.</param>
|
|
/// <param name="endByte">Exclusive end byte of the span so far.</param>
|
|
private sealed class OpenSpan(int startByte, int endByte)
|
|
{
|
|
/// <summary>First byte of the span — fixed for the span's lifetime.</summary>
|
|
public int StartByte { get; } = startByte;
|
|
|
|
/// <summary>Exclusive end byte of the furthest member seen so far.</summary>
|
|
public int EndByte { get; set; } = endByte;
|
|
|
|
/// <summary>Members accumulated so far, in ascending byte order.</summary>
|
|
public List<Member> Members { get; } = [];
|
|
}
|
|
}
|