feat(otopcua): re-trigger discovery on config-unchanged rebind (follow-up C)

This commit is contained in:
Joseph Doherty
2026-06-26 14:06:50 -04:00
parent adcd7b57c1
commit 533671487e
2 changed files with 162 additions and 8 deletions
@@ -1313,34 +1313,43 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
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}/...".
// KNOWN LIMITATION (follow-up, alongside the multi-device-per-driver limitation): a
// CONFIG-UNCHANGED rebind (the driver's DriverConfig is identical, only its authored tag's
// EquipmentId moved) drops the cached plan here but does NOT itself re-trigger discovery —
// ReconcileDrivers only restarts a child on a DriverConfig change, so a config-unchanged child is
// never stopped/reconnected. The FixedTree subtree therefore stays ABSENT under the new equipment
// until the driver's next reconnect/restart re-discovers it. We deliberately do NOT add re-trigger
// logic here (it would couple the subscription pass to driver-lifecycle control); the drop is the
// safe, correct fail-state (a stale EQ-1-scoped graft under EQ-2 would be worse).
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);