Replace full address space rebuild with incremental subtree sync
On Galaxy deploy changes, only the affected gobject subtrees are torn down and rebuilt instead of destroying the entire address space. Unchanged nodes, subscriptions, and alarm tracking continue uninterrupted. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
// Alarm tracking: maps InAlarm tag reference → alarm source info
|
||||
private readonly Dictionary<string, AlarmInfo> _alarmInAlarmTags = new Dictionary<string, AlarmInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Incremental sync: persistent node map and reverse lookup
|
||||
private readonly Dictionary<int, NodeState> _nodeMap = new Dictionary<int, NodeState>();
|
||||
private readonly Dictionary<int, List<string>> _gobjectToTagRefs = new Dictionary<int, List<string>>();
|
||||
private List<GalaxyObjectInfo>? _lastHierarchy;
|
||||
private List<GalaxyAttributeInfo>? _lastAttributes;
|
||||
|
||||
private sealed class AlarmInfo
|
||||
{
|
||||
public string SourceTagReference { get; set; } = "";
|
||||
@@ -164,6 +170,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
_tagToVariableNode.Clear();
|
||||
_tagMetadata.Clear();
|
||||
_alarmInAlarmTags.Clear();
|
||||
_nodeMap.Clear();
|
||||
_gobjectToTagRefs.Clear();
|
||||
VariableNodeCount = 0;
|
||||
ObjectNodeCount = 0;
|
||||
|
||||
@@ -193,12 +201,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
});
|
||||
|
||||
// Create nodes for each object in hierarchy
|
||||
var nodeMap = new Dictionary<int, NodeState>();
|
||||
|
||||
foreach (var obj in sorted)
|
||||
{
|
||||
NodeState parentNode;
|
||||
if (nodeMap.TryGetValue(obj.ParentGobjectId, out var p))
|
||||
if (_nodeMap.TryGetValue(obj.ParentGobjectId, out var p))
|
||||
parentNode = p;
|
||||
else
|
||||
parentNode = rootFolder;
|
||||
@@ -221,7 +227,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
}
|
||||
|
||||
AddPredefinedNode(SystemContext, node);
|
||||
nodeMap[obj.GobjectId] = node;
|
||||
_nodeMap[obj.GobjectId] = node;
|
||||
|
||||
// Create variable nodes for this object's attributes
|
||||
if (attrsByObject.TryGetValue(obj.GobjectId, out var objAttrs))
|
||||
@@ -347,7 +353,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
}
|
||||
|
||||
// Enable EventNotifier on object nodes that contain alarms
|
||||
if (hasAlarms && nodeMap.TryGetValue(obj.GobjectId, out var objNode))
|
||||
if (hasAlarms && _nodeMap.TryGetValue(obj.GobjectId, out var objNode))
|
||||
{
|
||||
if (objNode is BaseObjectState objState)
|
||||
objState.EventNotifier = EventNotifiers.SubscribeToEvents;
|
||||
@@ -360,6 +366,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
if (_alarmTrackingEnabled)
|
||||
SubscribeAlarmTags();
|
||||
|
||||
_lastHierarchy = new List<GalaxyObjectInfo>(hierarchy);
|
||||
_lastAttributes = new List<GalaxyAttributeInfo>(attributes);
|
||||
|
||||
Log.Information("Address space built: {Objects} objects, {Variables} variables, {Mappings} tag references, {Alarms} alarm tags",
|
||||
ObjectNodeCount, VariableNodeCount, _nodeIdToTagReference.Count, _alarmInAlarmTags.Count);
|
||||
}
|
||||
@@ -428,54 +437,66 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
/// <param name="hierarchy">The latest Galaxy object hierarchy to publish.</param>
|
||||
/// <param name="attributes">The latest Galaxy attributes to publish.</param>
|
||||
public void RebuildAddressSpace(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
|
||||
{
|
||||
SyncAddressSpace(hierarchy, attributes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incrementally syncs the address space by detecting changed gobjects and rebuilding only those subtrees. (OPC-010)
|
||||
/// </summary>
|
||||
public void SyncAddressSpace(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
Log.Information("Rebuilding address space...");
|
||||
var activeSubscriptions = new Dictionary<string, int>(_subscriptionRefCounts, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var tagRef in activeSubscriptions.Keys)
|
||||
if (_lastHierarchy == null || _lastAttributes == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_mxAccessClient.UnsubscribeAsync(tagRef).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to unsubscribe {TagRef} during rebuild", tagRef);
|
||||
}
|
||||
Log.Information("No previous state cached — performing full build");
|
||||
BuildAddressSpace(hierarchy, attributes);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unsubscribe auto-subscribed alarm tags
|
||||
foreach (var kvp in _alarmInAlarmTags)
|
||||
var changedIds = AddressSpaceDiff.FindChangedGobjectIds(
|
||||
_lastHierarchy, _lastAttributes, hierarchy, attributes);
|
||||
|
||||
if (changedIds.Count == 0)
|
||||
{
|
||||
foreach (var tag in new[] { kvp.Key, kvp.Value.PriorityTagReference, kvp.Value.DescAttrNameTagReference })
|
||||
Log.Information("No address space changes detected");
|
||||
_lastHierarchy = hierarchy;
|
||||
_lastAttributes = attributes;
|
||||
return;
|
||||
}
|
||||
|
||||
// Expand to include child subtrees in both old and new hierarchy
|
||||
changedIds = AddressSpaceDiff.ExpandToSubtrees(changedIds, _lastHierarchy);
|
||||
changedIds = AddressSpaceDiff.ExpandToSubtrees(changedIds, hierarchy);
|
||||
|
||||
Log.Information("Incremental sync: {Count} gobjects changed out of {Total}",
|
||||
changedIds.Count, hierarchy.Count);
|
||||
|
||||
// Snapshot subscriptions for changed tags before teardown
|
||||
var affectedSubscriptions = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var id in changedIds)
|
||||
{
|
||||
if (_gobjectToTagRefs.TryGetValue(id, out var tagRefs))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tag))
|
||||
foreach (var tagRef in tagRefs)
|
||||
{
|
||||
try { _mxAccessClient.UnsubscribeAsync(tag).GetAwaiter().GetResult(); }
|
||||
catch { /* ignore */ }
|
||||
if (_subscriptionRefCounts.TryGetValue(tagRef, out var count))
|
||||
affectedSubscriptions[tagRef] = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all predefined nodes
|
||||
foreach (var nodeId in PredefinedNodes.Keys.ToList())
|
||||
{
|
||||
try { DeleteNode(SystemContext, nodeId); }
|
||||
catch { /* ignore cleanup errors */ }
|
||||
}
|
||||
// Tear down changed subtrees
|
||||
TearDownGobjects(changedIds);
|
||||
|
||||
PredefinedNodes.Clear();
|
||||
_nodeIdToTagReference.Clear();
|
||||
_tagToVariableNode.Clear();
|
||||
_tagMetadata.Clear();
|
||||
_subscriptionRefCounts.Clear();
|
||||
// Rebuild changed subtrees from new data
|
||||
var changedHierarchy = hierarchy.Where(h => changedIds.Contains(h.GobjectId)).ToList();
|
||||
var changedAttributes = attributes.Where(a => changedIds.Contains(a.GobjectId)).ToList();
|
||||
BuildSubtree(changedHierarchy, changedAttributes);
|
||||
|
||||
// Rebuild
|
||||
BuildAddressSpace(hierarchy, attributes);
|
||||
|
||||
foreach (var kvp in activeSubscriptions)
|
||||
// Restore subscriptions for surviving tags
|
||||
foreach (var kvp in affectedSubscriptions)
|
||||
{
|
||||
if (!_tagToVariableNode.ContainsKey(kvp.Key))
|
||||
continue;
|
||||
@@ -487,11 +508,293 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to restore subscription for {TagRef} after rebuild", kvp.Key);
|
||||
Log.Warning(ex, "Failed to restore subscription for {TagRef} after sync", kvp.Key);
|
||||
}
|
||||
}
|
||||
|
||||
Log.Information("Address space rebuild complete");
|
||||
_lastHierarchy = new List<GalaxyObjectInfo>(hierarchy);
|
||||
_lastAttributes = new List<GalaxyAttributeInfo>(attributes);
|
||||
|
||||
Log.Information("Incremental sync complete: {Objects} objects, {Variables} variables, {Alarms} alarms",
|
||||
ObjectNodeCount, VariableNodeCount, _alarmInAlarmTags.Count);
|
||||
}
|
||||
}
|
||||
|
||||
private void TearDownGobjects(HashSet<int> gobjectIds)
|
||||
{
|
||||
foreach (var id in gobjectIds)
|
||||
{
|
||||
// Remove variable nodes and their tracking data
|
||||
if (_gobjectToTagRefs.TryGetValue(id, out var tagRefs))
|
||||
{
|
||||
foreach (var tagRef in tagRefs.ToList())
|
||||
{
|
||||
// Unsubscribe if actively subscribed
|
||||
if (_subscriptionRefCounts.ContainsKey(tagRef))
|
||||
{
|
||||
try { _mxAccessClient.UnsubscribeAsync(tagRef).GetAwaiter().GetResult(); }
|
||||
catch { /* ignore */ }
|
||||
_subscriptionRefCounts.Remove(tagRef);
|
||||
}
|
||||
|
||||
// Remove alarm tracking for this tag's InAlarm/Priority/DescAttrName
|
||||
var alarmKeysToRemove = _alarmInAlarmTags
|
||||
.Where(kvp => kvp.Value.SourceTagReference == tagRef)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
foreach (var alarmKey in alarmKeysToRemove)
|
||||
{
|
||||
var info = _alarmInAlarmTags[alarmKey];
|
||||
// Unsubscribe alarm auto-subscriptions
|
||||
foreach (var alarmTag in new[] { alarmKey, info.PriorityTagReference, info.DescAttrNameTagReference })
|
||||
{
|
||||
if (!string.IsNullOrEmpty(alarmTag))
|
||||
{
|
||||
try { _mxAccessClient.UnsubscribeAsync(alarmTag).GetAwaiter().GetResult(); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
_alarmInAlarmTags.Remove(alarmKey);
|
||||
}
|
||||
|
||||
// Delete variable node
|
||||
if (_tagToVariableNode.TryGetValue(tagRef, out var variable))
|
||||
{
|
||||
try { DeleteNode(SystemContext, variable.NodeId); }
|
||||
catch { /* ignore */ }
|
||||
_tagToVariableNode.Remove(tagRef);
|
||||
}
|
||||
|
||||
// Clean up remaining mappings
|
||||
var nodeIdStr = _nodeIdToTagReference.FirstOrDefault(kvp => kvp.Value == tagRef).Key;
|
||||
if (nodeIdStr != null)
|
||||
_nodeIdToTagReference.Remove(nodeIdStr);
|
||||
_tagMetadata.Remove(tagRef);
|
||||
|
||||
VariableNodeCount--;
|
||||
}
|
||||
_gobjectToTagRefs.Remove(id);
|
||||
}
|
||||
|
||||
// Delete the object/folder node itself
|
||||
if (_nodeMap.TryGetValue(id, out var objNode))
|
||||
{
|
||||
try { DeleteNode(SystemContext, objNode.NodeId); }
|
||||
catch { /* ignore */ }
|
||||
_nodeMap.Remove(id);
|
||||
if (!(objNode is FolderState))
|
||||
ObjectNodeCount--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildSubtree(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
|
||||
{
|
||||
if (hierarchy.Count == 0)
|
||||
return;
|
||||
|
||||
var sorted = TopologicalSort(hierarchy);
|
||||
var attrsByObject = attributes
|
||||
.GroupBy(a => a.GobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// Find root folder for orphaned nodes
|
||||
NodeState? rootFolder = null;
|
||||
if (PredefinedNodes.TryGetValue(new NodeId("ZB", NamespaceIndex), out var rootNode))
|
||||
rootFolder = rootNode;
|
||||
|
||||
foreach (var obj in sorted)
|
||||
{
|
||||
NodeState parentNode;
|
||||
if (_nodeMap.TryGetValue(obj.ParentGobjectId, out var p))
|
||||
parentNode = p;
|
||||
else if (rootFolder != null)
|
||||
parentNode = rootFolder;
|
||||
else
|
||||
continue; // no parent available
|
||||
|
||||
// Create node with final NodeId before adding to parent
|
||||
NodeState node;
|
||||
var nodeId = new NodeId(obj.TagName, NamespaceIndex);
|
||||
if (obj.IsArea)
|
||||
{
|
||||
var folder = new FolderState(parentNode)
|
||||
{
|
||||
SymbolicName = obj.BrowseName,
|
||||
ReferenceTypeId = ReferenceTypes.Organizes,
|
||||
TypeDefinitionId = ObjectTypeIds.FolderType,
|
||||
NodeId = nodeId,
|
||||
BrowseName = new QualifiedName(obj.BrowseName, NamespaceIndex),
|
||||
DisplayName = new LocalizedText("en", obj.BrowseName),
|
||||
WriteMask = AttributeWriteMask.None,
|
||||
UserWriteMask = AttributeWriteMask.None,
|
||||
EventNotifier = EventNotifiers.None
|
||||
};
|
||||
parentNode.AddChild(folder);
|
||||
node = folder;
|
||||
}
|
||||
else
|
||||
{
|
||||
var objNode = new BaseObjectState(parentNode)
|
||||
{
|
||||
SymbolicName = obj.BrowseName,
|
||||
ReferenceTypeId = ReferenceTypes.HasComponent,
|
||||
TypeDefinitionId = ObjectTypeIds.BaseObjectType,
|
||||
NodeId = nodeId,
|
||||
BrowseName = new QualifiedName(obj.BrowseName, NamespaceIndex),
|
||||
DisplayName = new LocalizedText("en", obj.BrowseName),
|
||||
WriteMask = AttributeWriteMask.None,
|
||||
UserWriteMask = AttributeWriteMask.None,
|
||||
EventNotifier = EventNotifiers.None
|
||||
};
|
||||
parentNode.AddChild(objNode);
|
||||
node = objNode;
|
||||
ObjectNodeCount++;
|
||||
}
|
||||
|
||||
AddPredefinedNode(SystemContext, node);
|
||||
_nodeMap[obj.GobjectId] = node;
|
||||
|
||||
parentNode.ClearChangeMasks(SystemContext, false);
|
||||
|
||||
// Create variable nodes (same logic as BuildAddressSpace)
|
||||
if (attrsByObject.TryGetValue(obj.GobjectId, out var objAttrs))
|
||||
{
|
||||
var byPrimitive = objAttrs
|
||||
.GroupBy(a => a.PrimitiveName ?? "")
|
||||
.OrderBy(g => g.Key);
|
||||
|
||||
var primitiveGroupNames = new HashSet<string>(
|
||||
byPrimitive.Select(g => g.Key).Where(k => !string.IsNullOrEmpty(k)),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var variableNodes = new Dictionary<string, BaseDataVariableState>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var directGroup = byPrimitive.FirstOrDefault(g => string.IsNullOrEmpty(g.Key));
|
||||
if (directGroup != null)
|
||||
{
|
||||
foreach (var attr in directGroup)
|
||||
{
|
||||
var variable = CreateAttributeVariable(node, attr);
|
||||
if (primitiveGroupNames.Contains(attr.AttributeName))
|
||||
variableNodes[attr.AttributeName] = variable;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var group in byPrimitive)
|
||||
{
|
||||
if (string.IsNullOrEmpty(group.Key))
|
||||
continue;
|
||||
|
||||
NodeState parentForAttrs;
|
||||
if (variableNodes.TryGetValue(group.Key, out var existingVariable))
|
||||
{
|
||||
parentForAttrs = existingVariable;
|
||||
}
|
||||
else
|
||||
{
|
||||
var primNode = CreateObject(node, group.Key, group.Key);
|
||||
primNode.NodeId = new NodeId(obj.TagName + "." + group.Key, NamespaceIndex);
|
||||
AddPredefinedNode(SystemContext, primNode);
|
||||
parentForAttrs = primNode;
|
||||
}
|
||||
|
||||
foreach (var attr in group)
|
||||
{
|
||||
CreateAttributeVariable(parentForAttrs, attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Alarm tracking for the new subtree
|
||||
if (_alarmTrackingEnabled)
|
||||
{
|
||||
foreach (var obj in sorted)
|
||||
{
|
||||
if (obj.IsArea) continue;
|
||||
if (!attrsByObject.TryGetValue(obj.GobjectId, out var objAttrs)) continue;
|
||||
|
||||
var hasAlarms = false;
|
||||
var alarmAttrs = objAttrs.Where(a => a.IsAlarm && string.IsNullOrEmpty(a.PrimitiveName)).ToList();
|
||||
foreach (var alarmAttr in alarmAttrs)
|
||||
{
|
||||
var inAlarmTagRef = alarmAttr.FullTagReference.TrimEnd('[', ']') + ".InAlarm";
|
||||
if (!_tagToVariableNode.ContainsKey(inAlarmTagRef))
|
||||
continue;
|
||||
|
||||
var alarmNodeIdStr = alarmAttr.FullTagReference.EndsWith("[]")
|
||||
? alarmAttr.FullTagReference.Substring(0, alarmAttr.FullTagReference.Length - 2)
|
||||
: alarmAttr.FullTagReference;
|
||||
|
||||
_tagToVariableNode.TryGetValue(alarmAttr.FullTagReference, out var sourceVariable);
|
||||
var sourceNodeId = new NodeId(alarmNodeIdStr, NamespaceIndex);
|
||||
|
||||
var conditionNodeId = new NodeId(alarmNodeIdStr + ".Condition", NamespaceIndex);
|
||||
var condition = new AlarmConditionState(sourceVariable);
|
||||
condition.Create(SystemContext, conditionNodeId,
|
||||
new QualifiedName(alarmAttr.AttributeName + "Alarm", NamespaceIndex),
|
||||
new LocalizedText("en", alarmAttr.AttributeName + " Alarm"),
|
||||
true);
|
||||
condition.SourceNode.Value = sourceNodeId;
|
||||
condition.SourceName.Value = alarmAttr.AttributeName;
|
||||
condition.ConditionName.Value = alarmAttr.AttributeName;
|
||||
condition.AutoReportStateChanges = true;
|
||||
condition.SetEnableState(SystemContext, true);
|
||||
condition.SetActiveState(SystemContext, false);
|
||||
condition.SetAcknowledgedState(SystemContext, true);
|
||||
condition.SetSeverity(SystemContext, EventSeverity.Medium);
|
||||
condition.Retain.Value = false;
|
||||
condition.OnReportEvent = (context, n, e) => Server.ReportEvent(context, e);
|
||||
|
||||
if (sourceVariable != null)
|
||||
{
|
||||
sourceVariable.AddReference(ReferenceTypeIds.HasCondition, false, conditionNodeId);
|
||||
condition.AddReference(ReferenceTypeIds.HasCondition, true, sourceNodeId);
|
||||
}
|
||||
|
||||
AddPredefinedNode(SystemContext, condition);
|
||||
|
||||
var baseTagRef = alarmAttr.FullTagReference.TrimEnd('[', ']');
|
||||
_alarmInAlarmTags[inAlarmTagRef] = new AlarmInfo
|
||||
{
|
||||
SourceTagReference = alarmAttr.FullTagReference,
|
||||
SourceNodeId = sourceNodeId,
|
||||
SourceName = alarmAttr.AttributeName,
|
||||
ConditionNode = condition,
|
||||
PriorityTagReference = baseTagRef + ".Priority",
|
||||
DescAttrNameTagReference = baseTagRef + ".DescAttrName"
|
||||
};
|
||||
hasAlarms = true;
|
||||
}
|
||||
|
||||
if (hasAlarms && _nodeMap.TryGetValue(obj.GobjectId, out var objNode))
|
||||
{
|
||||
if (objNode is BaseObjectState objState)
|
||||
objState.EventNotifier = EventNotifiers.SubscribeToEvents;
|
||||
else if (objNode is FolderState folderState)
|
||||
folderState.EventNotifier = EventNotifiers.SubscribeToEvents;
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe alarm tags for new subtree
|
||||
foreach (var kvp in _alarmInAlarmTags)
|
||||
{
|
||||
// Only subscribe tags that belong to the newly built subtree
|
||||
var gobjectIds = new HashSet<int>(hierarchy.Select(h => h.GobjectId));
|
||||
var sourceTagRef = kvp.Value.SourceTagReference;
|
||||
var ownerAttr = attributes.FirstOrDefault(a => a.FullTagReference == sourceTagRef);
|
||||
if (ownerAttr == null || !gobjectIds.Contains(ownerAttr.GobjectId))
|
||||
continue;
|
||||
|
||||
foreach (var tag in new[] { kvp.Key, kvp.Value.PriorityTagReference, kvp.Value.DescAttrNameTagReference })
|
||||
{
|
||||
if (string.IsNullOrEmpty(tag) || !_tagToVariableNode.ContainsKey(tag))
|
||||
continue;
|
||||
try { _mxAccessClient.SubscribeAsync(tag, (_, _) => { }); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,6 +862,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
IsArray = attr.IsArray,
|
||||
ArrayDimension = attr.ArrayDimension
|
||||
};
|
||||
|
||||
// Track gobject → tag references for incremental sync
|
||||
if (!_gobjectToTagRefs.TryGetValue(attr.GobjectId, out var tagList))
|
||||
{
|
||||
tagList = new List<string>();
|
||||
_gobjectToTagRefs[attr.GobjectId] = tagList;
|
||||
}
|
||||
tagList.Add(attr.FullTagReference);
|
||||
|
||||
VariableNodeCount++;
|
||||
return variable;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user