refactor(drivers): delete the dead discovered-node injection path (§8.2, #507)

#507 was filed as "injection inert in v3, re-migrate onto the raw subtree".
Reading it, the retained code is not revivable: it resolves equipment from
EquipmentNode.DriverInstanceId UNION EquipmentTags, and BOTH are structurally
empty in v3 (AddressSpaceComposer always constructs EquipmentNode with a null
DriverInstanceId; DeploymentArtifact hard-codes an empty EquipmentTags set).
Removing the guard would have changed a log line and injected nothing. So it
is deleted rather than fixed, and #507 closes as superseded by /raw
browse-commit.

Removed: HandleDiscoveredNodes, PartitionDiscoveredByDeviceHost,
ShouldWarnPartition, PlansRoutingEqual, ApplyDiscoveredPlansForDriver,
_discoveredByDriver, the redeploy re-inject tail, DiscoveredNodeMapper,
DiscoveredInjection, AddressSpaceApplier.MaterialiseDiscoveredNodes,
OpcUaPublishActor.MaterialiseDiscoveredNodes, and the Runtime-local copies of
CapturingAddressSpaceBuilder/DiscoveredNode.

The connect-time discovery loop goes too (StartDiscovery, RediscoverTick,
HandleRediscoverAsync, DiscoveredNodesReady, TriggerRediscovery). With
injection gone it had no consumer, and leaving it would either dead-letter or
keep browsing real devices up to ~15x per connect to drop the result.
ITagDiscovery itself stays — the /raw browse picker drives it through
Commons/Browsing/DiscoveryDriverBrowser, which never went through the actor,
so the picker is unaffected.

deferment.md §4.4 called the 18 DiscoveryInjectionDormantV3 tests "Real —
blocked on #507". They are not: they assert an equipment-rooted graft
(EquipmentRootNodeId == "EQ-1") that v3 cannot produce, so they could never
have unskipped as written. Deleted along with DiscoveredNodeMapperTests, the
CapturingAddressSpaceBuilder tests, and the MaterialiseDiscoveredNodes tests
in AddressSpaceApplierTests/OpcUaPublishActorTests. Runtime.Tests skipped:
31 -> 13.

IHostConnectivityProbe is deliberately NOT resolved here. GetHostStatuses()
has no production call site and nothing writes a DriverHostStatus row, but the
table was re-created in the v3 initial migration, so deleting on inference
would be wrong. Split to Gitea #521 with both options costed.

CLAUDE.md, docs/drivers/{Galaxy,TwinCAT,MTConnect}.md updated — they described
the seam as dead.

Build clean; Runtime.Tests 476 passed / 13 skipped; OpcUaServer.Tests 362
passed / 4 skipped.
This commit is contained in:
Joseph Doherty
2026-07-27 18:57:09 -04:00
parent 09a401b881
commit adce27e7fa
22 changed files with 117 additions and 4116 deletions
@@ -245,36 +245,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private readonly NativeAlarmProjector _nativeAlarmProjector = new();
/// <summary>The composition from the most-recent apply (set at the END of
/// <see cref="PushDesiredSubscriptions"/>). Discovered-node injection
/// (<see cref="HandleDiscoveredNodes"/>) reads it to resolve the equipment bound to a driver (from the
/// composition's <c>EquipmentNodes</c> whose <c>DriverInstanceId</c> matches, UNION the authored
/// <c>EquipmentTags</c> for that driver — so a driver with zero authored tags can still graft onto an
/// equipment bound via <c>EquipmentNode.DriverInstanceId</c>) and to recompute the authored value + alarm
/// subscription sets when merging FixedTree refs. Null until the first apply — a
/// <see cref="DriverInstanceActor.DiscoveredNodesReady"/> arriving before any apply is ignored.</summary>
/// <see cref="PushDesiredSubscriptions"/>). Null until the first apply.</summary>
private AddressSpaceComposition? _lastComposition;
/// <summary>The most-recent discovered-injection plan(s) per driver instance, cached so the redeploy
/// re-inject tail can re-apply the live graft after an address-space rebuild without re-running discovery.
/// Keyed by DriverInstanceId at the OUTER level, then by EquipmentId at the INNER level (driver → (equipment
/// → plan)). Today only the single-equipment case is populated, so the inner map always has exactly one
/// entry; the inner map is shaped per-equipment now so the follow-up multi-device-partition task can hold
/// multiple (equipmentId → plan) entries per driver without reshaping this cache. Inner dict is mutable
/// (the redeploy tail drops stale per-equipment entries in place); both levels are Ordinal-keyed.
/// Last-writer-wins on a re-discovery (the whole inner map is replaced).</summary>
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 Primary
@@ -894,7 +867,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
Receive<RestartDriver>(HandleRestartDriver);
Receive<ReconnectDriver>(HandleReconnectDriver);
@@ -928,7 +900,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
Receive<RestartDriver>(HandleRestartDriver);
Receive<ReconnectDriver>(HandleReconnectDriver);
@@ -973,349 +944,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
}
}
/// <summary>
/// Handles a driver child's post-connect <see cref="DriverInstanceActor.DiscoveredNodesReady"/>:
/// resolves the equipment the driver is bound to from the most-recent applied composition (its
/// <c>EquipmentNodes</c> bound by <c>DriverInstanceId</c> UNION its authored <c>EquipmentTags</c>),
/// maps the captured FixedTree under it via <see cref="DiscoveredNodeMapper"/> (deduping any node that
/// shadows an authored equipment-tag ref), caches the per-equipment plan map, and grafts it onto the
/// served address space + live-value maps + subscription set via
/// <see cref="ApplyDiscoveredPlansForDriver"/>. Idempotent / duplicate-safe: the mapper is pure,
/// materialisation is idempotent, and the routing-map extension + subscription merge are set-based.
/// </summary>
private void HandleDiscoveredNodes(DriverInstanceActor.DiscoveredNodesReady msg)
{
// v3 Batch 4 (review M1): discovered-node INJECTION is DORMANT in v3 and hard-guarded here. In v2 a
// driver-connected FixedTree was grafted under an equipment folder (equipment bound a driver); v3
// retired that binding — equipment references raw tags via UnsTagReference, and discovered raw tags are
// authored explicitly through the Batch-2 /raw browse-commit flow, NOT injected at runtime. The
// downstream mapper/materialiser were half-migrated to the Raw tree but are still rooted at an
// equipment id, so letting this fire would materialise incoherent nodes. Short-circuit BEFORE any
// caching/routing so _discoveredByDriver stays empty (the redeploy re-inject tail is therefore inert
// too) and there is exactly one enforcement point. Re-migrating injection onto the raw device subtree
// is a separate follow-up.
_log.Debug(
"DriverHost {Node}: discovered-node injection is dormant in v3 (DiscoveredNodesReady from {Driver} ignored; discovered raw tags are authored via /raw browse-commit)",
_localNode, msg.DriverInstanceId);
return;
#pragma warning disable CS0162 // Unreachable code — retained for the raw-subtree re-migration follow-up.
if (_lastComposition is null)
{
_log.Debug("DriverHost {Node}: DiscoveredNodesReady from {Driver} before any composition applied — ignored",
_localNode, msg.DriverInstanceId);
return;
}
// Resolve the equipment bound to this driver from BOTH the composition's EquipmentNodes (whose
// DriverInstanceId matches — this lets a driver with ZERO authored tags graft onto a tag-less
// equipment) UNION the authored EquipmentTags for the driver (the original resolution). Distinct so a
// driver that is both EquipmentNode-bound AND has authored tags under the same equipment resolves once.
var fromNodes = _lastComposition.EquipmentNodes
.Where(e => e.DriverInstanceId is not null && string.Equals(e.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal))
.Select(e => e.EquipmentId);
var fromTags = _lastComposition.EquipmentTags
.Where(t => string.Equals(t.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal))
.Select(t => t.EquipmentId);
var equipmentIds = fromNodes.Concat(fromTags).Distinct(StringComparer.Ordinal).ToList();
if (equipmentIds.Count == 0)
{
_log.Info("DriverHost {Node}: no equipment for driver {Driver} — skipping discovered-node injection",
_localNode, msg.DriverInstanceId);
return;
}
// 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);
// 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
}
// 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))
{
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;
}
_discoveredByDriver[msg.DriverInstanceId] = newPlans;
ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans);
#pragma warning restore CS0162
}
/// <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 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="DeviceConfigIntent.NormalizeHost"/> (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.</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 = DeviceConfigIntent.NormalizeHost(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
? DeviceConfigIntent.NormalizeHost(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.
// NOTE: DiscoveredNodeMapper.Map's collapse predicate compares the host segment with RAW
// StringComparer.Ordinal, whereas we grouped on the NORMALIZED host. Harmless: a real FOCAS device
// emits one consistent HostAddress string per device, so a partition is single-host either way (collapse
// fires). Even if two raw spellings of the same host slipped into one partition, the only effect would be
// a retained (non-collapsed) host folder — never a mis-graft or NodeId collision (the equipment scope
// already isolates them).
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>
private static bool RoutingEquals(IReadOnlyDictionary<string, string> a, IReadOnlyDictionary<string, string> b)
=> a.Count == b.Count
&& a.All(kv => b.TryGetValue(kv.Key, out var v) && string.Equals(v, kv.Value, StringComparison.Ordinal));
/// <summary>Per-equipment plan-map routing equality: same equipment keys + each equipment's plan has the
/// same <see cref="DiscoveredInjectionPlan.RoutingByRef"/> (via <see cref="RoutingEquals"/>). Lets
/// <see cref="HandleDiscoveredNodes"/> short-circuit a re-discovery whose WHOLE per-driver set is unchanged
/// (a grown/changed set on any equipment differs and re-applies).</summary>
private static bool PlansRoutingEqual(
IReadOnlyDictionary<string, DiscoveredInjectionPlan> a,
IReadOnlyDictionary<string, DiscoveredInjectionPlan> b)
=> a.Count == b.Count
&& a.All(kv => b.TryGetValue(kv.Key, out var p) && RoutingEquals(kv.Value.RoutingByRef, p.RoutingByRef));
/// <summary>
/// Grafts a driver's per-equipment <see cref="DiscoveredInjectionPlan"/> map onto the served state in
/// two phases so the resubscribe stays a single push per driver (the shape the multi-device-partition
/// follow-up needs without resubscribe churn):
/// <list type="number">
/// <item><b>Materialise per equipment</b> — for each <c>(equipmentId, plan)</c> entry, extend the
/// live-value routing map (mirroring <see cref="PushDesiredSubscriptions"/>' fan-out so
/// <see cref="ForwardToMux"/> lands FixedTree values on the right node) and Tell the publish actor
/// <see cref="ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes"/> for
/// that equipment (idempotent).</item>
/// <item><b>Subscribe ONCE per driver</b> — compute the union of the driver's authored value refs
/// (recomputed the same way <see cref="PushDesiredSubscriptions"/> does) and the FixedTree refs of
/// ALL the driver's cached plans, then Tell the child a single
/// <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> so the poll engine reads them and the
/// values flow. For a single-equipment driver this equals the prior per-plan behavior.</item>
/// </list>
/// Extracted as a standalone method so the redeploy re-inject tail can re-apply the cached plans after
/// an address-space rebuild without re-running discovery.
/// </summary>
private void ApplyDiscoveredPlansForDriver(
string driverId, IReadOnlyDictionary<string, DiscoveredInjectionPlan> plansByEquipment)
{
// (a) Per-equipment: extend the live-value routing map (fan-out, mirroring PushDesiredSubscriptions'
// pattern) + materialise the discovered folders + variables under that equipment (idempotent). This is
// purely ADDITIVE across passes: a shrinking discovery set would leave the dropped refs' stale routes
// until the next full apply (PushDesiredSubscriptions) clears + rebuilds the maps — acceptable because
// a FOCAS FixedTree only grows-then-stabilises, never shrinks within a connect.
var totalVariables = 0;
foreach (var (equipmentId, plan) in plansByEquipment)
{
foreach (var (driverRef, nodeId) in plan.RoutingByRef)
{
var key = (driverId, driverRef);
if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
_nodeIdByDriverRef[key] = set = new HashSet<NodeRealmRef>();
// v3 Batch 4: discovered (FixedTree) nodes graft onto the RAW device subtree, so they route
// through the Raw realm.
set.Add(new NodeRealmRef(nodeId, AddressSpaceRealm.Raw));
_driverRefByNodeId[(AddressSpaceRealm.Raw, nodeId)] = key;
}
_opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes(
equipmentId, plan.Folders, plan.Variables));
totalVariables += plan.Variables.Count;
}
// (b) ONE subscription push per driver: merge the FixedTree refs from ALL the driver's plans into the
// driver's desired subscription set so the poll engine reads them and ForwardToMux routes the values.
// Recompute the authored value + alarm refs the same way PushDesiredSubscriptions does, then union the
// FixedTree refs onto the value set. Doing the union here (rather than once per plan) means the
// multi-device task adds inner-map entries without changing this single-send shape.
if (!_children.TryGetValue(driverId, out var entry)) return;
// The _lastComposition null-guards below are defensive: HandleDiscoveredNodes already proved it
// non-null, but the redeploy tail also calls this from the PushDesiredSubscriptions tail — keep them
// so that re-apply path can't NRE.
var authoredValueRefs = _lastComposition is null
? Enumerable.Empty<string>()
: _lastComposition.EquipmentTags
.Where(t => t.Alarm is null && string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal))
.Select(t => t.FullName);
var alarmRefs = _lastComposition is null
? Array.Empty<string>()
: _lastComposition.EquipmentTags
.Where(t => t.Alarm is not null && string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal))
.Select(t => t.FullName)
.Distinct(StringComparer.Ordinal)
.ToArray();
var discoveredRefs = plansByEquipment.Values.SelectMany(p => p.RoutingByRef.Keys);
var union = authoredValueRefs.Concat(discoveredRefs).Distinct(StringComparer.Ordinal).ToArray();
entry.Actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(union, SubscriptionPublishingInterval, alarmRefs));
_log.Info("DriverHost {Node}: injected {Count} discovered node(s) for driver {Driver} across {Equipment} equipment(s)",
_localNode, totalVariables, driverId, plansByEquipment.Count);
}
/// <summary>
/// Routes a native alarm transition (published by a driver child as
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/>) to its materialised Part 9 condition
@@ -1719,10 +1347,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Receive<RouteNativeAlarmAck>(msg =>
_log.Debug("DriverHost {Node}: dropping native-alarm ack for {Node2} while Stale (config DB unreachable)",
_localNode, msg.ConditionNodeId));
// A driver child's post-connect DiscoveredNodesReady can't be injected while Stale (no composition is
// applied yet, so the equipment can't be resolved). Drop it — the re-discovery loop re-sends it
// and the post-recovery re-apply self-heals it once an apply runs (matches the no-op drops above).
Receive<DriverInstanceActor.DiscoveredNodesReady>(_ => { });
// A child connectivity transition while the host is Stale has no live address space to annotate — drop
// it (the post-recovery rebuild re-materialises conditions, and the child re-announces on its next entry).
Receive<DriverInstanceActor.ConnectivityChanged>(_ => { });
@@ -2316,15 +1940,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
}
}
// Snapshot the cached (FixedTree-discovered) driver set BEFORE the bulk loop, while _discoveredByDriver
// is still untouched (the re-inject tail below drops/removes entries). Cached drivers are SKIPPED in the
// bulk loop because the tail sends each of them EXACTLY ONE SetDesiredSubscriptions for this pass: the
// authoreddiscovered union (ApplyDiscoveredPlansForDriver) for a survivor, or — if its plan is fully
// dropped — an authored-only fallback. Sending the bulk authored-only set HERE too would force the child
// to drop the whole handle (authored tags included) then re-subscribe — an extra unsub/resub blip of the
// authored values once per cached driver per redeploy. Net effect: exactly ONE send per driver per pass.
var cachedDriverIds = _discoveredByDriver.Keys.ToHashSet(StringComparer.Ordinal);
// One authored-only push (value refs + alarm refs from the maps built above), shared by the bulk loop AND
// the dropped-driver fallback so the two CANNOT drift: the fallback's correctness depends on sending the
// SAME payload the bulk loop would have, so it's a shared helper (structural), not a comment-maintained
@@ -2341,9 +1956,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
var total = 0;
foreach (var (driverId, entry) in _children)
{
// Cached drivers are owned exclusively by the re-inject tail (one send each) — skip here. Non-cached
// drivers keep the bulk authored-only send exactly as before.
if (cachedDriverIds.Contains(driverId)) continue;
total += SendAuthoredOnly(entry.Actor, driverId);
}
@@ -2380,94 +1992,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_localNode, composition.EquipmentScriptedAlarms.Count);
}
// Cache the applied composition LAST so discovered-node injection (HandleDiscoveredNodes) can resolve
// the equipment bound to a driver + recompute the authored subscription sets when a driver later
// reports its FixedTree. Set here (not in ApplyAndAck) so both the fresh-apply and bootstrap-restore
// paths — which both route through this method — leave a current composition.
// Cache the applied composition LAST. Set here (not in ApplyAndAck) so both the fresh-apply and
// bootstrap-restore paths — which both route through this method — leave a current composition.
_lastComposition = composition;
// Re-inject discovered (FixedTree) nodes after the authored rebuild. PushDesiredSubscriptions cleared
// _nodeIdByDriverRef and re-pushed authored-only subscriptions above; without this, an IN-PROCESS
// redeploy / re-apply (one that runs while the host is alive, so _discoveredByDriver is populated)
// would drop the injected FixedTree routes + materialised nodes until the driver happens to reconnect
// and re-discover. This loop is INERT on the bootstrap-restore path (RestoreApplied): there the actor
// is freshly constructed so _discoveredByDriver is empty — restart survival comes from the
// post-connect re-discovery loop, NOT this re-apply. Re-resolve each cached driver's candidate equipments
// from the CURRENT composition (the SAME EquipmentNodes-UNION-EquipmentTags logic HandleDiscoveredNodes
// uses), then validate each cached (equipmentId → plan) entry PER ENTRY: drop the entry if its
// equipmentId is no longer a resolved candidate for the driver, OR the plan's NodeIds aren't scoped to
// that equipmentId (a rebind). A driver whose inner map empties out is removed entirely. The surviving
// entries are re-applied via the single-send-per-driver structure. (The single-equipment case today has
// exactly one inner entry; the multi-device task adds more.)
foreach (var driverId in _discoveredByDriver.Keys.ToList()) // snapshot — we mutate the dict below
{
var fromNodes = composition.EquipmentNodes
.Where(e => e.DriverInstanceId is not null && string.Equals(e.DriverInstanceId, driverId, StringComparison.Ordinal))
.Select(e => e.EquipmentId);
var fromTags = composition.EquipmentTags
.Where(t => string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal))
.Select(t => t.EquipmentId);
var candidates = fromNodes.Concat(fromTags).ToHashSet(StringComparer.Ordinal);
var plansByEquipment = _discoveredByDriver[driverId];
// Track whether ANY entry was dropped (no-longer-candidate or rebind) so we can re-trigger this
// driver's discovery exactly ONCE after the inner map is processed (see the post-loop block).
var droppedAny = false;
foreach (var equipmentId in plansByEquipment.Keys.ToList()) // snapshot — we mutate the inner dict
{
var plan = plansByEquipment[equipmentId];
if (!candidates.Contains(equipmentId))
{
plansByEquipment.Remove(equipmentId);
droppedAny = true;
_log.Debug("DriverHost {Node}: dropped cached discovered nodes for {Driver}/{Equipment} — equipment no longer resolves", _localNode, driverId, equipmentId);
continue;
}
// If the equipment was rebound (the cached plan's NodeIds are scoped to the OLD equipment), drop +
// let re-discovery rebuild against the new equipment. The plan's NodeIds are "{equipmentId}/...".
var planEquipmentConsistent = plan.Variables.Count > 0
&& plan.Variables[0].NodeId.StartsWith(equipmentId + "/", StringComparison.Ordinal);
if (!planEquipmentConsistent)
{
plansByEquipment.Remove(equipmentId);
droppedAny = true;
_log.Debug("DriverHost {Node}: dropped cached discovered nodes for {Driver}/{Equipment} — equipment rebound", _localNode, driverId, equipmentId);
}
}
// Re-trigger discovery when ANY entry was dropped (no-longer-candidate or rebind). A CONFIG-UNCHANGED
// rebind (the driver's DriverConfig is identical, only its authored tag's EquipmentId moved) is NOT
// restarted by ReconcileDrivers — the child stays Connected — so without this nudge the FixedTree
// subtree would stay ABSENT under the new equipment until the driver's next natural reconnect. We now
// ask the child to re-run discovery so it re-grafts promptly: the next pass resolves against the new
// _lastComposition (the now-bound equipment). This is a DISCOVERY action, not lifecycle control — no
// stop/restart; it is idempotent, and the child no-ops it if not Connected (handled in
// DriverInstanceActor). Sent at most ONCE per driver per re-inject pass (here, after the inner map is
// processed — so even when the inner map empties below), guarded on the child still existing.
if (droppedAny && _children.TryGetValue(driverId, out var rediscoverEntry))
rediscoverEntry.Actor.Tell(new DriverInstanceActor.TriggerRediscovery());
if (plansByEquipment.Count == 0)
{
_discoveredByDriver.Remove(driverId);
// Drop the driver's partition warn-signature too so a permanently-removed/rebound driver doesn't
// leak a stale entry (log-level-only state; bounded by driver count — just tidiness).
_lastPartitionWarnSignature.Remove(driverId);
// FALLBACK (one-send invariant): this driver was SKIPPED in the bulk loop (it was cached), and its
// plan is now FULLY DROPPED — so ApplyDiscoveredPlansForDriver won't run for it and it would
// otherwise receive ZERO sends this pass, losing its AUTHORED subscriptions. Send the authored-only
// set NOW (the SAME payload the bulk loop computes), so the authored tags subscribe in THIS pass.
// (The TriggerRediscovery above handles the async FixedTree re-graft separately; this just keeps
// the authored values live meanwhile.) Guarded on the child still existing — a driver removed by
// ReconcileDrivers has no child and correctly gets no send. Shares SendAuthoredOnly with the bulk
// loop so the payload can't drift; a ZERO-authored driver sends an empty set → Unsubscribe (drops
// the stale FixedTree handle without a spurious subscribe).
if (_children.TryGetValue(driverId, out var fallbackEntry))
SendAuthoredOnly(fallbackEntry.Actor, driverId);
continue;
}
ApplyDiscoveredPlansForDriver(driverId, plansByEquipment);
}
}
private void SpawnChild(DriverInstanceSpec spec)