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:
@@ -1,71 +0,0 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IAddressSpaceBuilder"/> that RECORDS the streamed tree instead of creating OPC UA
|
||||
/// nodes — used to capture an <see cref="ITagDiscovery"/> driver's discovered hierarchy so the
|
||||
/// runtime can graft it under an equipment node. Folder nesting is tracked (each child builder
|
||||
/// carries its accumulated path), so every variable records its full <see cref="DiscoveredNode.FolderPathSegments"/>.
|
||||
/// <para>Value nodes only: <see cref="AddProperty"/> is ignored and alarm marking returns a no-op sink
|
||||
/// (discovered alarms are out of scope — alarms come via the config path).</para>
|
||||
/// <para>Single-threaded: a driver's <c>DiscoverAsync</c> streams on one caller; the root and its child
|
||||
/// builders share one <see cref="List{T}"/>. Not thread-safe by design.</para>
|
||||
/// </summary>
|
||||
public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
private readonly List<DiscoveredNode> _nodes;
|
||||
private readonly IReadOnlyList<string> _path;
|
||||
|
||||
/// <summary>Create a root capturing builder with an empty folder path and a fresh node list.</summary>
|
||||
public CapturingAddressSpaceBuilder() : this([], []) { }
|
||||
|
||||
private CapturingAddressSpaceBuilder(List<DiscoveredNode> nodes, IReadOnlyList<string> path)
|
||||
{
|
||||
_nodes = nodes;
|
||||
_path = path;
|
||||
}
|
||||
|
||||
/// <summary>All variables captured across the whole tree (shared by the root and every child scope).</summary>
|
||||
public IReadOnlyList<DiscoveredNode> Nodes => _nodes;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
=> new CapturingAddressSpaceBuilder(_nodes, [.. _path, browseName]);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
_nodes.Add(new DiscoveredNode(
|
||||
FolderPathSegments: _path,
|
||||
BrowseName: browseName,
|
||||
DisplayName: displayName,
|
||||
FullReference: attributeInfo.FullName,
|
||||
DataType: attributeInfo.DriverDataType,
|
||||
IsArray: attributeInfo.IsArray,
|
||||
ArrayDim: attributeInfo.ArrayDim,
|
||||
Writable: attributeInfo.SecurityClass != SecurityClassification.ViewOnly,
|
||||
IsHistorized: attributeInfo.IsHistorized));
|
||||
return new NullHandle(attributeInfo.FullName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { /* metadata only — ignored */ }
|
||||
|
||||
/// <summary>A variable handle whose alarm marking is a no-op (discovered alarms are out of scope).</summary>
|
||||
private sealed class NullHandle(string fullRef) : IVariableHandle
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string FullReference => fullRef;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
|
||||
/// <summary>A null sink that ignores alarm condition transitions.</summary>
|
||||
private sealed class NullSink : IAlarmConditionSink
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// A flattened variable captured from a driver's <see cref="ITagDiscovery.DiscoverAsync"/> stream
|
||||
/// by <see cref="CapturingAddressSpaceBuilder"/>. Folder nesting is preserved in
|
||||
/// <see cref="FolderPathSegments"/> so the injector can re-root the node under an equipment.
|
||||
/// </summary>
|
||||
public sealed record DiscoveredNode(
|
||||
IReadOnlyList<string> FolderPathSegments,
|
||||
string BrowseName,
|
||||
string DisplayName,
|
||||
string FullReference,
|
||||
DriverDataType DataType,
|
||||
bool IsArray,
|
||||
uint? ArrayDim,
|
||||
bool Writable,
|
||||
bool IsHistorized);
|
||||
@@ -1,118 +0,0 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
/// <summary>The mapped result of grafting discovered nodes under an equipment node.</summary>
|
||||
/// <param name="Folders">
|
||||
/// Folders to ensure, in insertion order (parent-before-child within each node's prefix chain) — NOT
|
||||
/// globally depth-sorted. The applier sorts by depth before ensuring, so consumers must not assume a
|
||||
/// global parent-before-child ordering across the whole list.
|
||||
/// </param>
|
||||
/// <param name="Variables">Variables to ensure under the (post-collapse) folders.</param>
|
||||
/// <param name="RoutingByRef">Driver FullReference -> equipment NodeId, for live-value routing.</param>
|
||||
public sealed record DiscoveredInjectionPlan(
|
||||
IReadOnlyList<DiscoveredFolder> Folders,
|
||||
IReadOnlyList<DiscoveredVariable> Variables,
|
||||
IReadOnlyDictionary<string, string> RoutingByRef); // driver FullReference -> equipment NodeId
|
||||
|
||||
/// <summary>
|
||||
/// Pure mapper: re-roots a driver's captured discovery tree under an equipment node, deduping
|
||||
/// authored Config-DB refs and collapsing the single device-host folder. See the design doc
|
||||
/// 2026-06-26-otopcua-fixedtree-equipment-injection-design.md.
|
||||
/// </summary>
|
||||
public static class DiscoveredNodeMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps captured <paramref name="nodes"/> into folders + variables (NodeIds scoped under
|
||||
/// <paramref name="equipmentId"/>) plus a driver-FullReference → equipment-NodeId routing map.
|
||||
/// </summary>
|
||||
/// <param name="equipmentId">The owning equipment's NodeId (root of the grafted subtree).</param>
|
||||
/// <param name="nodes">The captured discovery tree (from <c>CapturingAddressSpaceBuilder</c>).</param>
|
||||
/// <param name="authoredRefs">
|
||||
/// Driver FullReferences already authored as Config-DB equipment tags for this driver —
|
||||
/// skipped so a discovered node never shadows an authored one.
|
||||
/// </param>
|
||||
/// <returns>The folders, variables, and routing map to apply against the OPC UA address space.</returns>
|
||||
public static DiscoveredInjectionPlan Map(
|
||||
string rootNodeId, IReadOnlyList<DiscoveredNode> nodes, IReadOnlySet<string> authoredRefs)
|
||||
{
|
||||
// v3 Batch 4: discovered nodes graft onto the RAW device subtree — a discovered node's NodeId is
|
||||
// <rootDevicePath>/<folder…>/<name> (slash-joined RawPath), NOT the retired equipment-scoped
|
||||
// {equipmentId}/{folderPath}/{name}. Root-relative combine (the root is an already-built RawPath).
|
||||
static string Combine(string root, string tail) => root + RawPaths.SeparatorString + tail;
|
||||
var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList();
|
||||
|
||||
// Device-folder collapse: when every kept node shares one identical index-1 segment (the single
|
||||
// device-host folder under the driver root, e.g. "10.0.0.5:8193"), drop it so the path reads
|
||||
// FOCAS/Identity/... rather than FOCAS/10.0.0.5:8193/Identity/.... With >=2 distinct devices the
|
||||
// level is retained so identical leaf names across devices don't collide (degrades gracefully).
|
||||
var collapseIndex1 = kept.Count > 0
|
||||
&& kept.All(n => n.FolderPathSegments.Count >= 2)
|
||||
&& kept.Select(n => n.FolderPathSegments[1]).Distinct(StringComparer.Ordinal).Count() == 1;
|
||||
|
||||
static IReadOnlyList<string> Effective(IReadOnlyList<string> segs, bool collapse)
|
||||
=> collapse ? [segs[0], .. segs.Skip(2)] : segs;
|
||||
|
||||
var folders = new Dictionary<string, DiscoveredFolder>(StringComparer.Ordinal);
|
||||
var variables = new List<DiscoveredVariable>();
|
||||
var routing = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var n in kept)
|
||||
{
|
||||
var segs = Effective(n.FolderPathSegments, collapseIndex1);
|
||||
|
||||
// Ensure every prefix folder, deduped, each parented at its prefix (the first segment's
|
||||
// parent is the equipment itself).
|
||||
for (var i = 0; i < segs.Count; i++)
|
||||
{
|
||||
var folderPath = string.Join('/', segs.Take(i + 1));
|
||||
var nodeId = Combine(rootNodeId, folderPath);
|
||||
if (folders.ContainsKey(nodeId)) continue;
|
||||
var parent = i == 0 ? rootNodeId : Combine(rootNodeId, string.Join('/', segs.Take(i)));
|
||||
folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]);
|
||||
}
|
||||
|
||||
var varFolderPath = string.Join('/', segs);
|
||||
var varNodeId = string.IsNullOrEmpty(varFolderPath)
|
||||
? Combine(rootNodeId, n.BrowseName)
|
||||
: Combine(rootNodeId, varFolderPath + RawPaths.SeparatorString + n.BrowseName);
|
||||
// A folder-less variable parents directly at the root device node.
|
||||
var varParent = string.IsNullOrEmpty(varFolderPath)
|
||||
? rootNodeId
|
||||
: Combine(rootNodeId, varFolderPath);
|
||||
variables.Add(new DiscoveredVariable(
|
||||
varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim));
|
||||
routing[n.FullReference] = varNodeId;
|
||||
}
|
||||
|
||||
return new DiscoveredInjectionPlan(folders.Values.ToList(), variables, routing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="DriverDataType"/> to the OPC-UA-built-in type STRING that
|
||||
/// <c>OtOpcUaNodeManager.EnsureVariable</c>'s <c>ResolveBuiltInDataType</c> accepts — so a
|
||||
/// discovered variable resolves to the same built-in type as an authored equipment tag. Most
|
||||
/// enum names pass through verbatim; <see cref="DriverDataType.Float32"/>/<see cref="DriverDataType.Float64"/>
|
||||
/// map to the SDK's "Float"/"Double" names, and <see cref="DriverDataType.Reference"/> (a Galaxy
|
||||
/// attribute reference) is carried as an OPC UA String per the enum's own contract.
|
||||
/// </summary>
|
||||
private static string ToBuiltinTypeString(DriverDataType dt) => dt switch
|
||||
{
|
||||
DriverDataType.Boolean => "Boolean",
|
||||
DriverDataType.Int16 => "Int16",
|
||||
DriverDataType.Int32 => "Int32",
|
||||
DriverDataType.Int64 => "Int64",
|
||||
DriverDataType.UInt16 => "UInt16",
|
||||
DriverDataType.UInt32 => "UInt32",
|
||||
DriverDataType.UInt64 => "UInt64",
|
||||
DriverDataType.Float32 => "Float",
|
||||
DriverDataType.Float64 => "Double",
|
||||
DriverDataType.String => "String",
|
||||
DriverDataType.DateTime => "DateTime",
|
||||
DriverDataType.Reference => "String",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(dt), dt, "Unmapped DriverDataType."),
|
||||
};
|
||||
}
|
||||
@@ -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 <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
|
||||
// authored∪discovered 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)
|
||||
|
||||
@@ -32,16 +32,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
public static readonly TimeSpan DefaultReconnectInterval = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>Default interval between bounded post-connect re-discovery passes.</summary>
|
||||
public static readonly TimeSpan DefaultRediscoverInterval = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>Default cap on the number of post-connect re-discovery passes.</summary>
|
||||
public const int DefaultRediscoverMaxAttempts = 15;
|
||||
|
||||
/// <summary>Default per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/> during
|
||||
/// bounded post-connect re-discovery. Bounds the mailbox suspension time; production default 30 s.</summary>
|
||||
public static readonly TimeSpan DefaultRediscoverDiscoverTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
public sealed record InitializeRequested(string DriverConfigJson);
|
||||
public sealed record InitializeSucceeded(int Generation);
|
||||
public sealed record InitializeFailed(string Reason, int Generation);
|
||||
@@ -124,21 +114,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// subscription that un-gates an <see cref="IAlarmSource"/> driver's feed. Handled async so the
|
||||
/// <see cref="IAlarmSource.SubscribeAlarmsAsync"/> call is bounded + off the synchronous handlers.</summary>
|
||||
private sealed record SubscribeAlarms;
|
||||
/// <summary>Published to the parent (DriverHostActor) after each post-connect discovery pass so it can
|
||||
/// graft the driver's discovered FixedTree nodes under the equipment. Empty/duplicate sets are fine —
|
||||
/// the parent dedups and injection is idempotent.</summary>
|
||||
public sealed record DiscoveredNodesReady(string DriverInstanceId, IReadOnlyList<DiscoveredNode> Nodes);
|
||||
|
||||
/// <summary>
|
||||
/// Sent by <see cref="DriverHostActor"/> to ask this driver child to re-run post-connect discovery
|
||||
/// after the host rebinds the driver to a new equipment. Handled only in <c>Connected</c>, where it
|
||||
/// re-kicks <see cref="StartDiscovery"/> — which already honours the driver's
|
||||
/// <see cref="ITagDiscovery.RediscoverPolicy"/> and the <see cref="ITagDiscovery"/> guard, tagging the
|
||||
/// fresh pass with the current init generation. In any non-Connected state it is a deliberate no-op:
|
||||
/// the driver's eventual (re)connect re-discovers anyway, so there is nothing to do and nothing to log.
|
||||
/// </summary>
|
||||
public sealed record TriggerRediscovery;
|
||||
|
||||
/// <summary>Self-sent when the wrapped driver raises <see cref="IRediscoverable.OnRediscoveryNeeded"/> —
|
||||
/// it observed that the remote's tag set may have changed. Marshals the event off the driver's thread
|
||||
/// onto the actor thread. Handled in EVERY behaviour, including Stubbed and Reconnecting: the raise has no
|
||||
@@ -146,10 +121,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// between connects), and dropping it in one state would lose the signal silently.</summary>
|
||||
private sealed record RediscoveryRaised(RediscoveryEventArgs Args);
|
||||
|
||||
/// <summary>Internal self-tick driving bounded post-connect re-discovery (FixedTree populates ~0–2s after connect).
|
||||
/// <paramref name="PreviousSignature"/> is the ordered-distinct full-reference signature of the prior pass's
|
||||
/// captured set (empty string on the first tick); re-discovery stops once a non-empty set repeats it.</summary>
|
||||
private sealed record RediscoverTick(int Generation, int Attempt, string PreviousSignature);
|
||||
public sealed class RetryConnect
|
||||
{
|
||||
public static readonly RetryConnect Instance = new();
|
||||
@@ -174,19 +145,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
private readonly IDriverHealthPublisher _healthPublisher;
|
||||
private readonly TimeSpan _reconnectInterval;
|
||||
|
||||
/// <summary>Interval between bounded post-connect re-discovery passes. Production default 2s; tests
|
||||
/// inject a tiny value so the loop runs without real-time waits.</summary>
|
||||
private readonly TimeSpan _rediscoverInterval;
|
||||
private readonly TimeSpan _healthPollInterval;
|
||||
|
||||
/// <summary>Cap on the number of post-connect re-discovery passes — a backstop so a never-stabilising
|
||||
/// (or perpetually-empty) discovered set cannot spin the loop forever. Production default 15.</summary>
|
||||
private readonly int _rediscoverMaxAttempts;
|
||||
|
||||
/// <summary>Per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/> during bounded post-connect
|
||||
/// re-discovery. Bounds the mailbox suspension time. Production default 30 s; tests may inject a shorter
|
||||
/// value. Stored to allow injection rather than hardcoding.</summary>
|
||||
private readonly TimeSpan _rediscoverDiscoverTimeout;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
private string? _currentConfigJson;
|
||||
|
||||
@@ -252,9 +212,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// stub paths don't need to provide one.</param>
|
||||
/// <param name="clusterId">Optional cluster identifier forwarded in <see cref="DriverHealthChanged"/> messages;
|
||||
/// defaults to an empty string when not provided (e.g. in unit tests).</param>
|
||||
/// <param name="rediscoverInterval">Optional interval between post-connect re-discovery passes; defaults to 2 seconds.</param>
|
||||
/// <param name="rediscoverMaxAttempts">Optional cap on re-discovery passes; defaults to 15.</param>
|
||||
/// <param name="rediscoverDiscoverTimeout">Optional per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param>
|
||||
/// <param name="invoker">Optional Phase 6.1 resilience invoker wrapping this driver's capability
|
||||
/// calls; defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when not supplied.</param>
|
||||
/// <param name="healthPollInterval">Optional period of the health-poll heartbeat, which also drives the
|
||||
@@ -267,9 +224,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
bool startStubbed = false,
|
||||
IDriverHealthPublisher? healthPublisher = null,
|
||||
string? clusterId = null,
|
||||
TimeSpan? rediscoverInterval = null,
|
||||
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
|
||||
TimeSpan? rediscoverDiscoverTimeout = null,
|
||||
IDriverCapabilityInvoker? invoker = null,
|
||||
TimeSpan? healthPollInterval = null) =>
|
||||
Akka.Actor.Props.Create(() => new DriverInstanceActor(
|
||||
@@ -278,9 +232,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
startStubbed,
|
||||
healthPublisher ?? NullDriverHealthPublisher.Instance,
|
||||
clusterId ?? string.Empty,
|
||||
rediscoverInterval,
|
||||
rediscoverMaxAttempts,
|
||||
rediscoverDiscoverTimeout,
|
||||
invoker,
|
||||
healthPollInterval));
|
||||
|
||||
@@ -311,9 +262,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// <param name="startStubbed">If true, start in stub mode for testing or unavailable platforms.</param>
|
||||
/// <param name="healthPublisher">Sink for health-change notifications; must not be null.</param>
|
||||
/// <param name="clusterId">Cluster identifier forwarded in health snapshots.</param>
|
||||
/// <param name="rediscoverInterval">Interval between post-connect re-discovery passes; defaults to 2 seconds.</param>
|
||||
/// <param name="rediscoverMaxAttempts">Cap on the number of re-discovery passes; defaults to 15.</param>
|
||||
/// <param name="rediscoverDiscoverTimeout">Per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param>
|
||||
/// <param name="invoker">Phase 6.1 resilience invoker wrapping this driver's capability calls;
|
||||
/// defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when null.</param>
|
||||
/// <param name="healthPollInterval">Period of the health-poll heartbeat, which also drives the
|
||||
@@ -324,9 +272,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
bool startStubbed = false,
|
||||
IDriverHealthPublisher? healthPublisher = null,
|
||||
string? clusterId = null,
|
||||
TimeSpan? rediscoverInterval = null,
|
||||
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
|
||||
TimeSpan? rediscoverDiscoverTimeout = null,
|
||||
IDriverCapabilityInvoker? invoker = null,
|
||||
TimeSpan? healthPollInterval = null)
|
||||
{
|
||||
@@ -336,9 +281,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
_clusterId = clusterId ?? string.Empty;
|
||||
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
|
||||
_reconnectInterval = reconnectInterval;
|
||||
_rediscoverInterval = rediscoverInterval ?? DefaultRediscoverInterval;
|
||||
_rediscoverMaxAttempts = rediscoverMaxAttempts;
|
||||
_rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout;
|
||||
_healthPollInterval = healthPollInterval ?? HealthPollInterval;
|
||||
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
|
||||
new KeyValuePair<string, object?>("event", startStubbed ? "spawn_stub" : "spawn"),
|
||||
@@ -382,9 +324,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
Receive<SetDesiredSubscriptions>(StoreDesiredSubscriptions);
|
||||
// Stubbed drivers never enter Connected, so they never kick discovery; swallow defensively in case a
|
||||
// re-discovery self-tick is ever routed here so it doesn't surface as an Akka Unhandled message.
|
||||
Receive<RediscoverTick>(_ => { });
|
||||
// A TriggerRediscovery is meaningless to a stubbed (never-Connected) driver — silently ignore it.
|
||||
Receive<TriggerRediscovery>(_ => { });
|
||||
Receive<RediscoveryRaised>(HandleRediscoveryRaised);
|
||||
Receive<HealthPollTick>(_ => PublishHealthSnapshot());
|
||||
}
|
||||
@@ -411,7 +350,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
ResubscribeDesired();
|
||||
AttachAlarmSource();
|
||||
SubscribeDesiredAlarms();
|
||||
StartDiscovery();
|
||||
});
|
||||
Receive<InitializeFailed>(msg =>
|
||||
{
|
||||
@@ -439,12 +377,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// A SubscribeAlarms self-tell (from Connected) can be overtaken by an already-queued disconnect into
|
||||
// this state; swallow it so it doesn't dead-letter — the next Connected entry re-subscribes.
|
||||
Receive<SubscribeAlarms>(_ => { });
|
||||
// Likewise the attempt-0 re-discovery self-tick (sent on Connected entry) can be overtaken by an
|
||||
// already-queued disconnect; swallow it — the next Connected entry re-kicks discovery.
|
||||
Receive<RediscoverTick>(_ => { });
|
||||
// A TriggerRediscovery arriving while not Connected is a deliberate no-op — the (re)connect path
|
||||
// re-runs discovery anyway. Swallow it so it stays a clean silent no-op (no Unhandled event).
|
||||
Receive<TriggerRediscovery>(_ => { });
|
||||
Receive<RediscoveryRaised>(HandleRediscoveryRaised);
|
||||
Receive<HealthPollTick>(_ => PublishHealthSnapshot());
|
||||
}
|
||||
@@ -461,7 +393,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
_log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting",
|
||||
_driverInstanceId, msg.Reason);
|
||||
Timers.Cancel("rediscover");
|
||||
DetachSubscription();
|
||||
RecordFault();
|
||||
Become(Reconnecting);
|
||||
@@ -470,25 +401,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
Receive<ForceReconnect>(_ =>
|
||||
{
|
||||
_log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId);
|
||||
Timers.Cancel("rediscover");
|
||||
DetachSubscription();
|
||||
Become(Reconnecting);
|
||||
PublishHealthSnapshot();
|
||||
});
|
||||
ReceiveAsync<RediscoverTick>(HandleRediscoverAsync);
|
||||
// The host asks for a fresh discovery pass after rebinding the driver to a new equipment. Cancel any
|
||||
// pending rediscover tick FIRST — mirroring ForceReconnect/DisconnectObserved — so a stale tick left
|
||||
// over from the prior loop can't fire alongside the freshly-kicked one, then re-kick the bounded loop
|
||||
// via StartDiscovery (honours RediscoverPolicy + the ITagDiscovery guard, tagged with the current
|
||||
// _initGeneration). Only handled here in Connected — non-Connected states no-op it below. A stale tick
|
||||
// that still slips through (one already mid-async-handler) is benign: the parent dedups
|
||||
// DiscoveredNodesReady and node injection is idempotent — the Cancel just avoids the avoidable double
|
||||
// pass in the common case.
|
||||
Receive<TriggerRediscovery>(_ =>
|
||||
{
|
||||
Timers.Cancel("rediscover");
|
||||
StartDiscovery();
|
||||
});
|
||||
ReceiveAsync<WriteAttribute>(HandleWriteAsync);
|
||||
ReceiveAsync<RouteAlarmAck>(HandleAcknowledgeAsync);
|
||||
ReceiveAsync<Subscribe>(HandleSubscribeAsync);
|
||||
@@ -602,7 +518,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
ResubscribeDesired();
|
||||
AttachAlarmSource();
|
||||
SubscribeDesiredAlarms();
|
||||
StartDiscovery(); // re-run discovery on reconnect — keeps the injected tree fresh if the backend's capabilities changed
|
||||
});
|
||||
// A failure here is a no-op regardless of generation — the retry timer keeps trying the
|
||||
// current config; only a (generation-matched) InitializeSucceeded transitions state.
|
||||
@@ -625,12 +540,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// A SubscribeAlarms self-tell (from Connected) can be overtaken by an already-queued disconnect into
|
||||
// this state; swallow it so it doesn't dead-letter — the next Connected entry re-subscribes.
|
||||
Receive<SubscribeAlarms>(_ => { });
|
||||
// Likewise the attempt-0 re-discovery self-tick (sent on Connected entry) can be overtaken by an
|
||||
// already-queued disconnect; swallow it — the next Connected entry re-kicks discovery.
|
||||
Receive<RediscoverTick>(_ => { });
|
||||
// A TriggerRediscovery arriving while not Connected is a deliberate no-op — the (re)connect path
|
||||
// re-runs discovery anyway. Swallow it so it stays a clean silent no-op (no Unhandled event).
|
||||
Receive<TriggerRediscovery>(_ => { });
|
||||
Receive<RediscoveryRaised>(HandleRediscoveryRaised);
|
||||
Receive<HealthPollTick>(_ => PublishHealthSnapshot());
|
||||
Timers.StartPeriodicTimer("retry-connect", RetryConnect.Instance, _reconnectInterval);
|
||||
@@ -971,97 +880,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Kick the bounded post-connect re-discovery loop on a <c>Connected</c> entry. A no-op unless the
|
||||
/// driver exposes <see cref="ITagDiscovery"/> (nothing to inject otherwise). Self-sends the first
|
||||
/// <see cref="RediscoverTick"/> tagged with the current init generation so a tick that outlives a reconnect
|
||||
/// is rejected by the generation guard in <see cref="HandleRediscoverAsync"/>.
|
||||
/// <para>Honours the driver's <see cref="ITagDiscovery.RediscoverPolicy"/>: <c>Never</c> opts out entirely
|
||||
/// (no tick scheduled); <c>Once</c> runs a single pass (the loop stops after the first publish in
|
||||
/// <see cref="HandleRediscoverAsync"/>); <c>UntilStable</c> retries each (re)connect, bounded by
|
||||
/// stop-on-stable (the discovered-set signature repeats) + the attempt cap.</para></summary>
|
||||
private void StartDiscovery()
|
||||
{
|
||||
if (_driver is not ITagDiscovery discovery) return; // driver doesn't expose discovery — nothing to inject
|
||||
if (discovery.RediscoverPolicy == DiscoveryRediscoverPolicy.Never)
|
||||
{
|
||||
// Driver opts out of post-connect discovery — don't even schedule the first tick.
|
||||
_log.Debug("DriverInstance {Id}: RediscoverPolicy=Never — skipping post-connect discovery", _driverInstanceId);
|
||||
return;
|
||||
}
|
||||
Self.Tell(new RediscoverTick(_initGeneration, Attempt: 0, PreviousSignature: string.Empty));
|
||||
}
|
||||
|
||||
/// <summary>Runs one post-connect discovery pass: captures the driver's streamed FixedTree via a
|
||||
/// <see cref="CapturingAddressSpaceBuilder"/> and ships the result to the parent as
|
||||
/// <see cref="DiscoveredNodesReady"/> (empty/duplicate sets are fine — the parent dedups and injection
|
||||
/// is idempotent). Retries on the <see cref="_rediscoverInterval"/> until the non-empty discovered SET
|
||||
/// has STABILISED (the ordered-distinct full-reference signature repeats — robust for incremental/paged
|
||||
/// browsers where a count alone could falsely settle a partial tree) or the <see cref="_rediscoverMaxAttempts"/>
|
||||
/// cap is hit, whichever comes first; keeps retrying while empty because a FOCAS-style FixedTree cache may
|
||||
/// still be populating.
|
||||
/// <para>Limitation: this assumes a driver's discovered set only GROWS toward a stable shape (true for
|
||||
/// FOCAS — its FixedTree appears once, and on the wonder deploy the driver-config <c>_options.Tags</c> is
|
||||
/// empty so the set is 0 until the cache populates). A driver that emits an initial non-empty set and
|
||||
/// later grows could stop early on a transient repeat; acceptable for current scope.</para></summary>
|
||||
private async Task HandleRediscoverAsync(RediscoverTick tick)
|
||||
{
|
||||
if (tick.Generation != _initGeneration) return; // stale (a reconnect superseded this pass)
|
||||
if (_driver is not ITagDiscovery discovery) return;
|
||||
|
||||
IReadOnlyList<DiscoveredNode> nodes;
|
||||
try
|
||||
{
|
||||
var builder = new CapturingAddressSpaceBuilder();
|
||||
// Bound the browse — ReceiveAsync suspends the mailbox for the whole handler, so an unbounded
|
||||
// DiscoverAsync would block DisconnectObserved / ForceReconnect / writes / health-poll behind it.
|
||||
using var cts = new CancellationTokenSource(_rediscoverDiscoverTimeout);
|
||||
// NO ConfigureAwait(false) on this outer await: a genuinely-async DiscoverAsync (Galaxy /
|
||||
// OpcUaClient / TwinCAT) must resume on the actor task scheduler so the Context.Parent.Tell +
|
||||
// Timers calls below run with a live ActorContext. ConfigureAwait(false) would resume
|
||||
// off-context and throw NotSupportedException("no active ActorContext"). The invoker's own
|
||||
// internal ConfigureAwait(false) does NOT propagate to this caller's await — the actor
|
||||
// continuation still resumes on the captured actor scheduler. (Discover retries per tier.)
|
||||
await _invoker.ExecuteAsync(
|
||||
DriverCapability.Discover,
|
||||
_driverInstanceId,
|
||||
async ct => await discovery.DiscoverAsync(builder, ct),
|
||||
cts.Token);
|
||||
nodes = builder.Nodes.ToArray(); // immutable snapshot — never hand the builder's live list across actors
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "DriverInstance {Id}: discovery pass {Attempt} failed; will retry", _driverInstanceId, tick.Attempt);
|
||||
nodes = Array.Empty<DiscoveredNode>();
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: under ReceiveAsync the mailbox is suspended for the whole handler, so
|
||||
// _initGeneration cannot change mid-await — the pre-await guard + Timers.Cancel("rediscover") on
|
||||
// disconnect + single-timer key reuse are the primary protections. Re-checked in case that changes.
|
||||
if (tick.Generation != _initGeneration) return;
|
||||
|
||||
Context.Parent.Tell(new DiscoveredNodesReady(_driverInstanceId, nodes));
|
||||
|
||||
// Honour the driver's re-discovery policy. A Once driver runs a single post-connect pass per
|
||||
// (re)connect regardless of whether DiscoverAsync is synchronous or async — one published pass is
|
||||
// complete, so the retry loop is skipped (no further tick scheduled). (Never never reaches here —
|
||||
// StartDiscovery returns before the first tick.) UntilStable falls through to the stop-on-stable +
|
||||
// attempt-cap logic below.
|
||||
if (discovery.RediscoverPolicy == DiscoveryRediscoverPolicy.Once)
|
||||
{
|
||||
_log.Debug("DriverInstance {Id}: RediscoverPolicy=Once — single discovery pass, not scheduling another", _driverInstanceId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop when the non-empty discovered SET has stabilised (its signature repeats), or the attempt cap
|
||||
// is hit. Keep retrying while empty (a FixedTree cache may still be populating). First tick carries "".
|
||||
var signature = string.Join('\u0001',
|
||||
nodes.Select(n => n.FullReference).Distinct(StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal));
|
||||
var stableNonEmpty = nodes.Count > 0 && string.Equals(signature, tick.PreviousSignature, StringComparison.Ordinal);
|
||||
if (tick.Attempt + 1 < _rediscoverMaxAttempts && !stableNonEmpty)
|
||||
Timers.StartSingleTimer("rediscover", new RediscoverTick(tick.Generation, tick.Attempt + 1, signature), _rediscoverInterval);
|
||||
else
|
||||
_log.Debug("DriverInstance {Id}: discovery settled after {Attempt} pass(es), {Count} node(s)", _driverInstanceId, tick.Attempt + 1, nodes.Count);
|
||||
}
|
||||
|
||||
/// <summary>Records the host's desired subscription set without touching the live subscription.
|
||||
/// The set is (re)applied by <see cref="ResubscribeDesired"/> on the next <c>Connected</c> entry.</summary>
|
||||
|
||||
Reference in New Issue
Block a user