feat(otopcua): multi-device-per-driver FixedTree partition (follow-up E)

This commit is contained in:
Joseph Doherty
2026-06-26 15:00:11 -04:00
parent 51721df563
commit 50f08635ec
4 changed files with 392 additions and 36 deletions
@@ -577,11 +577,23 @@ public static class AddressSpaceComposer
var raw = hostEl.GetString();
if (string.IsNullOrWhiteSpace(raw)) return null;
// Deterministic normalization (trim + lower-case) so both seams produce the identical string.
return raw.Trim().ToLowerInvariant();
return NormalizeDeviceHost(raw);
}
catch (JsonException) { return null; }
}
/// <summary>
/// The SINGLE SOURCE OF TRUTH for device-host normalization: trims surrounding whitespace and
/// lower-cases (invariant). <see cref="TryExtractDeviceHost"/> applies this to a <c>Device</c>'s
/// parsed <c>HostAddress</c>, and the FixedTree-partition path (<c>DriverHostActor</c>) applies the
/// SAME function to a driver-discovered device-host folder segment before comparing the two — so an
/// <see cref="EquipmentNode.DeviceHost"/> and a captured folder segment for the same device compare
/// equal regardless of case/whitespace. Idempotent (a value already normalized is unchanged).
/// </summary>
/// <param name="host">The raw host string (non-null; a non-empty <c>HostAddress</c> or folder segment).</param>
/// <returns>The normalized host (trimmed + lower-cased).</returns>
public static string NormalizeDeviceHost(string host) => host.Trim().ToLowerInvariant();
/// <summary>Parses the optional <c>alarm</c> object from a tag's <c>TagConfig</c> JSON. Returns null
/// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
/// artifact-decode side (<c>DeploymentArtifact.ExtractTagAlarm</c>) MUST parse identically (byte-parity).</summary>
@@ -174,6 +174,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private readonly Dictionary<string, Dictionary<string, DiscoveredInjectionPlan>> _discoveredByDriver =
new(StringComparer.Ordinal);
/// <summary>Per-driver signature of the last-logged device-host PARTITION diagnostic (unmatched / ambiguous
/// / degenerate host), folded with the current revision, so the ~15 repeated re-discovery passes within a
/// connect don't re-warn an unchanged condition: it is WARNED once when it first appears (or changes), and
/// DEBUG-logged on the identical repeat passes. Folding in <see cref="_currentRevision"/> makes a redeploy
/// re-warn once. Best-effort LOG-LEVEL dedup ONLY — never affects grafting; the matched-plan re-apply is
/// separately short-circuited by <see cref="PlansRoutingEqual"/>. Cleared for a driver whose partition comes
/// back clean so a later recurrence re-warns; bounded by driver count (a few). Only touched on the
/// multi-candidate path (<see cref="PartitionDiscoveredByDeviceHost"/>).</summary>
private readonly Dictionary<string, string> _lastPartitionWarnSignature = new(StringComparer.Ordinal);
/// <summary>
/// Cached local <see cref="RedundancyRole"/> from the latest <see cref="RedundancyStateChanged"/>
/// snapshot (null = unknown until the first snapshot arrives, or no local node match). The inbound
@@ -611,44 +621,51 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_localNode, msg.DriverInstanceId);
return;
}
if (equipmentIds.Count > 1)
{
// NEXT TASK (multi-device partition) REPLACES THIS BRANCH: a driver that fans out to multiple
// equipments (one per device-host) will partition its discovered FixedTree by DeviceHost and graft
// each partition under its matching equipment, populating multiple inner-map entries. Until then we
// keep the conservative warn+skip — a single-equipment graft is the only shape this task handles.
_log.Warning("DriverHost {Node}: driver {Driver} maps to {Count} equipments — discovered-node injection skipped (multi-equipment-per-driver is a follow-up)",
_localNode, msg.DriverInstanceId, equipmentIds.Count);
return;
}
var equipmentId = equipmentIds[0];
// Authored refs for THIS driver (both value + alarm tags) so a discovered node never shadows an
// authored one — the mapper drops any captured node whose FullReference is already authored. May be
// EMPTY for a tag-less equipment, which is fine: Map dedups against an empty set (keeps everything).
// Authored refs for THIS driver (DRIVER-WIDE — both value + alarm tags) so a discovered node never
// shadows an authored one — the mapper drops any captured node whose FullReference is already authored.
// May be EMPTY for a tag-less equipment, which is fine: Map dedups against an empty set (keeps
// everything). Safe even for the multi-device partition below: a FOCAS FullReference is host-prefixed,
// so a device-X discovered node can't collide with a device-Y authored ref — the driver-wide set is
// correct per partition.
var authoredRefs = _lastComposition.EquipmentTags
.Where(t => string.Equals(t.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal))
.Select(t => t.FullName)
.ToHashSet(StringComparer.Ordinal);
var plan = DiscoveredNodeMapper.Map(equipmentId, msg.Nodes, authoredRefs);
if (plan.Variables.Count == 0) return; // nothing new to inject (all captured nodes were authored)
// Build this discovery's per-equipment plan map.
// • EXACTLY ONE candidate ⇒ map the WHOLE captured tree under it (the mapper collapses the single
// device-host folder ⇒ clean EQ-n/FOCAS/...). Unchanged from before.
// • MORE THAN ONE candidate ⇒ PARTITION the captured tree by its (normalized) device-host folder
// segment and graft each device's subset under the equipment whose DeviceHost matches (follow-up E
// part 2). Unmatched/ambiguous hosts are warn-skipped (safe), not mis-grafted; a degenerate case
// (>1 candidate, none has a DeviceHost) warn-skips the whole driver. See PartitionDiscoveredByDeviceHost.
Dictionary<string, DiscoveredInjectionPlan> newPlans;
if (equipmentIds.Count == 1)
{
var plan = DiscoveredNodeMapper.Map(equipmentIds[0], msg.Nodes, authoredRefs);
if (plan.Variables.Count == 0) return; // nothing new to inject (all captured nodes were authored)
newPlans = new Dictionary<string, DiscoveredInjectionPlan>(StringComparer.Ordinal) { [equipmentIds[0]] = plan };
}
else
{
newPlans = PartitionDiscoveredByDeviceHost(msg, equipmentIds, authoredRefs);
if (newPlans.Count == 0) return; // degenerate / no host matched a graftable partition — already logged
}
// The driver's per-equipment plan map for this discovery. Single-equipment today ⇒ one entry; the
// multi-device task will add an entry per partitioned equipment here.
var newPlans = new Dictionary<string, DiscoveredInjectionPlan>(StringComparer.Ordinal) { [equipmentId] = plan };
// Unchanged-plan short-circuit: the driver re-discovers every ~2s (up to ~15 passes) until the
// FixedTree set stabilises, re-sending DiscoveredNodesReady each pass. Re-applying an IDENTICAL set
// would re-send SetDesiredSubscriptions, forcing the child to UnsubscribeAsync (dropping the WHOLE
// handle — authored tags included) then re-Subscribe — blipping authored-tag values up to ~15× across
// the discovery window. Skip when the WHOLE per-equipment routing is unchanged from the last applied
// pass; a GROWING set still differs (superset) and re-applies. This is _discoveredByDriver's first reader.
// Unchanged-plan short-circuit (shared by the single- AND multi-device paths): the driver re-discovers
// every ~2s (up to ~15 passes) until the FixedTree set stabilises, re-sending DiscoveredNodesReady each
// pass. Re-applying an IDENTICAL set would re-send SetDesiredSubscriptions, forcing the child to
// UnsubscribeAsync (dropping the WHOLE handle — authored tags included) then re-Subscribe — blipping
// authored-tag values up to ~15× across the discovery window. Skip when the WHOLE per-equipment routing
// is unchanged from the last applied pass; a GROWING set still differs (superset) and re-applies. (This
// is also why an unmatched/ambiguous partition warning settles: once the matched partitions stabilise we
// short-circuit here, and the partition warns are themselves signature-deduped — see ShouldWarnPartition.)
if (_discoveredByDriver.TryGetValue(msg.DriverInstanceId, out var cached)
&& PlansRoutingEqual(cached, newPlans))
{
_log.Debug("DriverHost {Node}: discovered set for driver {Driver} unchanged ({Count} node(s)) — re-apply skipped",
_localNode, msg.DriverInstanceId, plan.Variables.Count);
var total = newPlans.Values.Sum(p => p.Variables.Count);
_log.Debug("DriverHost {Node}: discovered set for driver {Driver} unchanged ({Count} node(s) across {Equipment} equipment(s)) — re-apply skipped",
_localNode, msg.DriverInstanceId, total, newPlans.Count);
return;
}
@@ -656,6 +673,148 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans);
}
/// <summary>
/// Partitions a multi-device driver's captured FixedTree by its (normalized) device-host folder segment
/// (<c>FolderPathSegments[1]</c>) and maps each device's subset under the candidate equipment whose
/// <see cref="EquipmentNode.DeviceHost"/> matches — the follow-up E part-2 multi-device graft. Returns
/// the per-equipment plan map (one entry per device that matched AND had at least one new variable);
/// EMPTY when nothing is graftable.
/// <list type="bullet">
/// <item>Builds <c>normalizedHost → equipmentId</c> from the candidate <see cref="EquipmentNode"/>s
/// that carry a non-null DeviceHost. Two distinct candidates sharing a host is AMBIGUOUS — that host
/// is un-mapped (its nodes are warn-skipped) rather than grafted onto an arbitrary equipment.</item>
/// <item><b>I1 divergence:</b> a candidate WITHOUT a DeviceHost (e.g. resolved via authored tags only,
/// no device binding) simply gets no partition — the FixedTree is the device's structure, so it
/// belongs under the device-bound equipment. No crash; that candidate is just not a partition target.</item>
/// <item>If NO candidate has a DeviceHost at all there is nothing to partition on ⇒ DEGENERATE ⇒
/// warn-skip the whole driver (returns empty).</item>
/// <item>A discovered partition whose host is unmatched (or whose node has &lt;2 folder segments, so no
/// host folder) is warn-skipped — its nodes are NOT mis-grafted; the matched partitions still graft.</item>
/// </list>
/// The device-host folder segment AND the stored DeviceHost are both run through the SAME
/// <see cref="AddressSpaceComposer.NormalizeDeviceHost"/> (single source of truth), so they compare equal
/// regardless of case/whitespace.
/// <para><b>Warn-spam taming.</b> The unmatched/ambiguous/degenerate condition is warned ONCE then
/// Debug-logged on the repeated re-discovery passes (see <see cref="ShouldWarnPartition"/>).</para>
/// <para><b>Mid-connect partition shrink (M2).</b> If a later pass yields FEWER device partitions than a
/// prior pass within the same connect, the dropped partition's routes + materialised nodes are NOT
/// actively pruned until the next full redeploy (<see cref="PushDesiredSubscriptions"/> Clears + rebuilds
/// the maps). This matches the existing "FixedTree grows-then-stabilises within a connect" assumption —
/// no mid-connect pruning is built here (out of scope).</para>
/// </summary>
private Dictionary<string, DiscoveredInjectionPlan> PartitionDiscoveredByDeviceHost(
DriverInstanceActor.DiscoveredNodesReady msg,
IReadOnlyList<string> equipmentIds,
IReadOnlySet<string> authoredRefs)
{
var driverId = msg.DriverInstanceId;
var candidateSet = equipmentIds.ToHashSet(StringComparer.Ordinal);
// normalizedHost → equipmentId, from candidate EquipmentNodes that carry a DeviceHost. A host shared by
// two DISTINCT candidates is ambiguous: un-map it (warn-skip) so its nodes aren't grafted arbitrarily.
var hostToEquipment = new Dictionary<string, string>(StringComparer.Ordinal);
var ambiguousHosts = new HashSet<string>(StringComparer.Ordinal);
foreach (var node in _lastComposition!.EquipmentNodes)
{
if (!candidateSet.Contains(node.EquipmentId) || node.DeviceHost is null) continue;
// DeviceHost is already normalized at compose/decode time; re-normalize through the shared helper so
// the comparison is the single source of truth (idempotent — harmless if it was already normalized).
var host = AddressSpaceComposer.NormalizeDeviceHost(node.DeviceHost);
if (ambiguousHosts.Contains(host)) continue;
if (hostToEquipment.TryGetValue(host, out var existing))
{
if (!string.Equals(existing, node.EquipmentId, StringComparison.Ordinal))
{
hostToEquipment.Remove(host);
ambiguousHosts.Add(host);
}
continue;
}
hostToEquipment[host] = node.EquipmentId;
}
// DEGENERATE: >1 candidate but none resolved a DeviceHost ⇒ nothing to partition on ⇒ warn-skip the
// whole driver. (Falls through the same warn-once dedup as the unmatched case.)
if (hostToEquipment.Count == 0 && ambiguousHosts.Count == 0)
{
if (ShouldWarnPartition(driverId, "degenerate"))
_log.Warning("DriverHost {Node}: driver {Driver} maps to {Count} equipments but none has a DeviceHost — discovered-node injection skipped (no device-host to partition on)",
_localNode, driverId, equipmentIds.Count);
else
_log.Debug("DriverHost {Node}: driver {Driver} still has no DeviceHost on any of {Count} equipments — skipped (repeat)",
_localNode, driverId, equipmentIds.Count);
return new Dictionary<string, DiscoveredInjectionPlan>(StringComparer.Ordinal);
}
// Partition the captured tree by its device-host folder segment (FolderPathSegments[1]); a node with
// <2 segments has no host folder (null ⇒ unmatched). Keep only nodes whose host matches a candidate.
var matchedNodes = new Dictionary<string, List<DiscoveredNode>>(StringComparer.Ordinal);
var unmatchedHosts = new HashSet<string>(StringComparer.Ordinal);
foreach (var n in msg.Nodes)
{
var key = n.FolderPathSegments.Count >= 2
? AddressSpaceComposer.NormalizeDeviceHost(n.FolderPathSegments[1])
: null;
if (key is not null && hostToEquipment.ContainsKey(key))
{
if (!matchedNodes.TryGetValue(key, out var list))
matchedNodes[key] = list = new List<DiscoveredNode>();
list.Add(n);
}
else
{
unmatchedHosts.Add(key ?? "(no-device-host-folder)");
}
}
// Map each matched device's subset under its equipment. ONE device per partition ⇒ the mapper collapses
// that partition's single host folder ⇒ clean EQ-n/FOCAS/...; a plan with zero new variables (all
// shadowed by authored refs) contributes no entry.
var plans = new Dictionary<string, DiscoveredInjectionPlan>(StringComparer.Ordinal);
foreach (var (host, nodes) in matchedNodes)
{
var equipmentId = hostToEquipment[host];
var plan = DiscoveredNodeMapper.Map(equipmentId, nodes, authoredRefs);
if (plan.Variables.Count > 0) plans[equipmentId] = plan;
}
// Surface unmatched/ambiguous hosts ONCE (then Debug on the repeated passes). The matched partitions
// above still graft regardless. When the partition came back fully clean, drop the driver's signature so
// a later recurrence re-warns.
if (unmatchedHosts.Count > 0 || ambiguousHosts.Count > 0)
{
var unmatched = string.Join(",", unmatchedHosts.OrderBy(h => h, StringComparer.Ordinal));
var ambiguous = string.Join(",", ambiguousHosts.OrderBy(h => h, StringComparer.Ordinal));
if (ShouldWarnPartition(driverId, "u:" + unmatched + "|a:" + ambiguous))
_log.Warning("DriverHost {Node}: driver {Driver}: discovered device-host partition(s) skipped — unmatched=[{Unmatched}] ambiguous=[{Ambiguous}]; matched partitions still grafted",
_localNode, driverId, unmatched, ambiguous);
else
_log.Debug("DriverHost {Node}: driver {Driver}: device-host partition(s) still skipped — unmatched=[{Unmatched}] ambiguous=[{Ambiguous}] (repeat)",
_localNode, driverId, unmatched, ambiguous);
}
else
{
_lastPartitionWarnSignature.Remove(driverId);
}
return plans;
}
/// <summary>Best-effort LOG-LEVEL dedup for the device-host partition diagnostics: returns true (⇒ WARN)
/// when <paramref name="conditionKey"/> is newly-seen for the driver this revision, false (⇒ DEBUG) on the
/// identical repeat passes that the ~15×/connect re-discovery produces. Folds the current revision in so a
/// redeploy re-warns once. Records the signature as a side effect. Never affects grafting behavior — only
/// the log level — so a stale entry (e.g. after a transient single↔multi candidate flip) at worst demotes
/// one duplicate warn to Debug.</summary>
private bool ShouldWarnPartition(string driverId, string conditionKey)
{
var signature = (_currentRevision?.ToString() ?? "none") + "|" + conditionKey;
var isNew = !_lastPartitionWarnSignature.TryGetValue(driverId, out var prev)
|| !string.Equals(prev, signature, StringComparison.Ordinal);
_lastPartitionWarnSignature[driverId] = signature;
return isNew;
}
/// <summary>Routing-map equality: same count + every key maps to the same NodeId. Lets
/// <see cref="HandleDiscoveredNodes"/> skip re-applying an unchanged discovered set across the driver's
/// repeated post-connect re-discovery passes (a grown/changed set differs and re-applies).</summary>