feat(focas): coalesce adjacent PMC reads into one pmc_rdpmcrng (#266)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 16:59:07 -04:00
parent 5b36c717de
commit 8d916f2af7
11 changed files with 790 additions and 11 deletions
@@ -498,8 +498,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
// #266 — merge adjacent / near PMC reads of one device into fewer pmc_rdpmcrng calls. Indices it
// serves are marked here and skipped by the per-tag loop below; everything else (including every
// single-tag range and any range the CNC rejected) takes the unchanged per-tag path.
var served = _options.PmcCoalesce.Enabled
? await CoalescePmcReadsAsync(fullReferences, results, now, cancellationToken).ConfigureAwait(false)
: null;
for (var i = 0; i < fullReferences.Count; i++)
{
if (served is not null && served[i]) continue;
var reference = fullReferences[i];
// Fixed-tree T1 — fixed-tree references are synthesized from the cached
@@ -581,6 +589,117 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return results;
}
/// <summary>
/// PMC coalescing pre-pass (Gitea #266). Groups the batch's PMC tags by device, plans merged ranges
/// (<see cref="FocasPmcCoalescer"/>) and serves every multi-member range with ONE
/// <c>pmc_rdpmcrng</c>, slicing each tag's value out of the returned slots.
/// </summary>
/// <remarks>
/// Failure handling is the point of the design. A rejected range is NOT mis-attributed to its members:
/// <list type="bullet">
/// <item>a comms-level failure (timeout / transport) fails every member with that status — the
/// link is down, so re-reading each member would multiply timeouts;</item>
/// <item>any other non-Good status (typically a spare "gap" byte that is not a valid PMC address)
/// leaves the members UNSERVED, so the per-tag loop reads them individually and each gets its own
/// true status — and the range is remembered so later polls skip the doomed merged read.</item>
/// </list>
/// Returns null when nothing was served.
/// </remarks>
private async Task<bool[]?> CoalescePmcReadsAsync(
IReadOnlyList<string> references, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
Dictionary<DeviceState, List<FocasPmcReadItem>>? byDevice = null;
for (var i = 0; i < references.Count; i++)
{
try
{
var reference = references[i];
if (_fixedTreeActive && TryReadFixedTree(reference, now) is not null) continue;
if (!_resolver.TryResolve(reference, out var def)) continue;
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
var parsed = ResolveParsedAddress(def);
if (parsed.Kind != FocasAreaKind.Pmc || parsed.PmcLetter is null) continue;
(byDevice ??= []).TryAdd(device, []);
byDevice[device].Add(new FocasPmcReadItem(i, parsed.PmcLetter, parsed.Number, def.DataType, parsed.BitIndex));
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
/* malformed / unresolvable — the per-tag loop reports it with its usual status */
}
}
if (byDevice is null) return null;
bool[]? served = null;
foreach (var (device, items) in byDevice)
{
if (items.Count < 2) continue;
var plans = FocasPmcCoalescer.Plan(items, _options.PmcCoalesce.MaxSpanBytes, _options.PmcCoalesce.MaxGapBytes);
foreach (var plan in plans)
{
if (plan.Members.Count < 2) continue;
if (device.CoalesceRejected.ContainsKey(plan.Key)) continue;
var members = plan.Members;
try
{
var client = await EnsureConnectedAsync(device, ct).ConfigureAwait(false);
var (slots, status) = await client
.ReadPmcSlotsAsync(plan.Letter, plan.SlotType, plan.Start, plan.End, ct)
.ConfigureAwait(false);
if (status == FocasStatusMapper.Good && slots is not null)
{
foreach (var m in members)
{
var slot = plan.SlotOf(m);
results[m.Index] = slot < slots.Count
? new DataValueSnapshot(FocasPmcCoalescer.MapSlot(slots[slot], m.Type, m.BitIndex), status, now, now)
: new DataValueSnapshot(null, FocasStatusMapper.BadOutOfRange, now, now);
(served ??= new bool[references.Count])[m.Index] = true;
}
Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, now, null));
}
else if (status == FocasStatusMapper.BadCommunicationError)
{
FailMembers(plan, status, references, results, now, ref served);
}
else
{
// The CNC refused the merged range. Don't guess which member is at fault: remember the
// range and let the per-tag loop give each member its own answer.
device.CoalesceRejected[plan.Key] = 0;
_logger.LogDebug(
"FOCAS PMC range {Key} on {Host} rejected (0x{Status:X8}) — reading its {Count} tags individually",
plan.Key, device.Options.HostAddress, status, members.Count);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (OperationCanceledException)
{
FailMembers(plan, FocasStatusMapper.BadCommunicationError, references, results, now, ref served);
}
catch (Exception)
{
FailMembers(plan, FocasStatusMapper.BadCommunicationError, references, results, now, ref served);
}
}
}
return served;
void FailMembers(FocasPmcRangePlan plan, uint status, IReadOnlyList<string> refs,
DataValueSnapshot[] res, DateTime stamp, ref bool[]? servedRef)
{
foreach (var m in plan.Members)
{
res[m.Index] = new DataValueSnapshot(null, status, null, stamp);
(servedRef ??= new bool[refs.Count])[m.Index] = true;
}
Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
Volatile.Read(ref _health).LastSuccessfulRead,
$"FOCAS status 0x{status:X8} reading PMC range {plan.Key}"));
}
}
// ---- IWritable ----
/// <inheritdoc />
@@ -1722,6 +1841,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// holding no value.
/// </summary>
public ConcurrentDictionary<string, double> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// PMC ranges (<see cref="FocasPmcRangePlan.Key"/>) the CNC rejected as a merged read, so later polls skip
/// straight to per-tag reads instead of paying a doomed round-trip every cycle (#266).
/// </summary>
public ConcurrentDictionary<string, byte> CoalesceRejected { get; } = new(StringComparer.Ordinal);
/// <summary>Gets or sets the last <c>cnc_statinfo</c> snapshot (#257); null until the first answer.</summary>
public FocasStatus? LastStatus { get; set; }
/// <summary>Gets or sets the last program information snapshot.</summary>