fix(s7): a refused span falls back per member and is not regrouped (R07:P2)
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
This commit is contained in:
@@ -221,6 +221,14 @@ public sealed class S7Driver
|
||||
/// by every initialize publish.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, byte> _refusalWarned = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Tags the span planner must not group (R07:P2): members of a span the CPU refused as a block —
|
||||
/// the ones that were refused on their own, or every member when none was. They are read
|
||||
/// individually for the rest of the generation; a re-initialize (a corrected DB, a fixed tag)
|
||||
/// groups everything again. Guarded by <see cref="_gate"/>.
|
||||
/// </summary>
|
||||
private readonly HashSet<string> _noGroup = new(StringComparer.Ordinal);
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -338,6 +346,8 @@ public sealed class S7Driver
|
||||
|
||||
_options = options;
|
||||
Volatile.Write(ref _tables, new TagTables(tags, parsed));
|
||||
_refusalWarned.Clear();
|
||||
_noGroup.Clear();
|
||||
_skippedTags.BeginRebuild(options.RawTags.Count);
|
||||
foreach (var (rawPath, reason) in skipped)
|
||||
{
|
||||
@@ -453,6 +463,7 @@ public sealed class S7Driver
|
||||
{
|
||||
if (_generation != gen) return; // a newer initialize owns the state now
|
||||
_writeFault = null;
|
||||
_writeOwnedError = null;
|
||||
_connectBackoff.RecordSuccess();
|
||||
Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null);
|
||||
}
|
||||
@@ -732,6 +743,13 @@ public sealed class S7Driver
|
||||
// Indexes the per-tag path must serve: everything, when grouping is off; otherwise the
|
||||
// planner's leftovers (Timer/Counter) plus any tag whose address won't parse.
|
||||
var perTag = new List<int>();
|
||||
// Indexes that must take the per-tag path WITHOUT the multi-var pass: tags the planner may
|
||||
// not group (R07:P2) and the members of a span refused this pass. Batching them into a
|
||||
// multi-var request would just refuse the whole chunk again.
|
||||
List<int>? forcedPerTag = null;
|
||||
// Members of each span refused this pass, and which of them were then refused on their own.
|
||||
List<IReadOnlyList<int>>? refusedSpans = null;
|
||||
HashSet<int>? refusedAlone = null;
|
||||
|
||||
if (_options.SpanReadMaxGapBytes >= 0)
|
||||
{
|
||||
@@ -739,6 +757,11 @@ public sealed class S7Driver
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
if (resolved[i] is not { } tag) continue;
|
||||
if (_noGroup.Contains(tag.Name))
|
||||
{
|
||||
(forcedPerTag ??= []).Add(i);
|
||||
continue;
|
||||
}
|
||||
if (!TryGetParsedAddress(tag, out var addr))
|
||||
{
|
||||
// Defensive: every tag pre-parses at init. A tag that somehow didn't goes
|
||||
@@ -769,7 +792,12 @@ public sealed class S7Driver
|
||||
foreach (var member in span.Members) results[member.OriginalIndex] = DeadHandleSnapshot(now);
|
||||
continue;
|
||||
}
|
||||
await ReadSpanAsync(plc, span, results, now, outcome, cancellationToken).ConfigureAwait(false);
|
||||
if (await ReadSpanAsync(plc, span, results, now, outcome, cancellationToken).ConfigureAwait(false)
|
||||
is { } refusedMembers)
|
||||
{
|
||||
(refusedSpans ??= []).Add(refusedMembers);
|
||||
(forcedPerTag ??= []).AddRange(refusedMembers);
|
||||
}
|
||||
}
|
||||
perTag.AddRange(plan.LeftoverIndexes);
|
||||
}
|
||||
@@ -794,6 +822,11 @@ public sealed class S7Driver
|
||||
perTag = await ReadLeftoversByMultiVarAsync(
|
||||
plc, resolved, perTag, results, now, outcome, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
if (forcedPerTag is not null)
|
||||
{
|
||||
perTag.AddRange(forcedPerTag);
|
||||
perTag.Sort();
|
||||
}
|
||||
|
||||
foreach (var i in perTag)
|
||||
{
|
||||
@@ -823,9 +856,25 @@ public sealed class S7Driver
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (refusedSpans is not null && IsRefusal(ex)) (refusedAlone ??= []).Add(i);
|
||||
results[i] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome, [resolved[i]!.Name]), null, now);
|
||||
}
|
||||
}
|
||||
|
||||
// Remember what must not be grouped again (R07:P2). Skipped when the handle died mid-pass:
|
||||
// the per-member reads then proved nothing about which member the CPU refuses.
|
||||
if (refusedSpans is not null && !_plcDead)
|
||||
{
|
||||
foreach (var members in refusedSpans)
|
||||
{
|
||||
var bad = members.Where(m => refusedAlone?.Contains(m) == true).ToList();
|
||||
var memo = bad.Count > 0 ? bad : [.. members];
|
||||
foreach (var m in memo) _noGroup.Add(resolved[m]!.Name);
|
||||
_logger.LogInformation(
|
||||
"S7 CPU refused a grouped read of {MemberCount} tag(s); reading {Tags} individually from now on. Driver={DriverInstanceId}",
|
||||
members.Count, string.Join(", ", memo.Select(m => resolved[m]!.Name)), _driverInstanceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1222,8 +1271,12 @@ public sealed class S7Driver
|
||||
/// <param name="now">The batch timestamp, shared by every tag in the batch.</param>
|
||||
/// <param name="outcome">The pass's outcome accumulator; every member records into it.</param>
|
||||
/// <param name="ct">Cancellation token for the read.</param>
|
||||
/// <returns>A task that completes once every member of the span has been stamped.</returns>
|
||||
private async Task ReadSpanAsync(
|
||||
/// <returns>
|
||||
/// <see langword="null"/> once every member has been stamped; or, when the CPU REFUSED the block
|
||||
/// of a multi-member span (R07:P2 — one member past the DB's end refuses them all), the members'
|
||||
/// original indexes, unstamped, for the caller to read one at a time.
|
||||
/// </returns>
|
||||
private async Task<IReadOnlyList<int>?> ReadSpanAsync(
|
||||
IS7Plc plc, S7ReadSpanPlanner.Span span, DataValueSnapshot[] results, DateTime now,
|
||||
ReadPassOutcome outcome, CancellationToken ct)
|
||||
{
|
||||
@@ -1244,12 +1297,24 @@ public sealed class S7Driver
|
||||
_plcDead = true;
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (span.Members.Count > 1 && IsRefusal(ex))
|
||||
{
|
||||
// The planner's old invariant ("a grouped read cannot fault where the per-tag reads would
|
||||
// have succeeded") is false: the block is the UNION of the members' ranges, so one member
|
||||
// running past the DB's real end, or over-declaring a STRING, makes the CPU refuse every
|
||||
// neighbour with it. Hand the members back for individual reads.
|
||||
_logger.LogDebug(ex,
|
||||
"S7 driver {DriverInstanceId}: CPU refused the block read of {Area} db{Db} @{Start}+{Length}; " +
|
||||
"re-reading its {Count} member(s) individually", _driverInstanceId, span.Area, span.DbNumber,
|
||||
span.StartByte, span.LengthBytes, span.Members.Count);
|
||||
return [.. span.Members.Select(m => m.OriginalIndex)];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var status = RecordReadFailure(ex, outcome, span.Members.Select(m => m.Tag.Name));
|
||||
foreach (var member in span.Members)
|
||||
results[member.OriginalIndex] = new DataValueSnapshot(null, status, null, now);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var member in span.Members)
|
||||
@@ -1268,6 +1333,7 @@ public sealed class S7Driver
|
||||
new DataValueSnapshot(null, RecordReadFailure(ex, outcome, [member.Tag.Name]), null, now);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -11,8 +11,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
/// <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. A grouped read therefore cannot fault (area overrun, DB overrun) where
|
||||
/// the individual per-tag reads would have succeeded.
|
||||
/// 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
|
||||
|
||||
Reference in New Issue
Block a user