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:
Joseph Doherty
2026-03-26 15:23:11 -04:00
parent bfd360a6db
commit 3c326e2d45
6 changed files with 1047 additions and 46 deletions

View File

@@ -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;
}