Files
lmxopcua/docs/IncrementalSync.md
Joseph Doherty 965e430f48 Add component-level documentation for all 14 server subsystems
Provides technical documentation covering OPC UA server, address space,
Galaxy repository, MXAccess bridge, data types, read/write, subscriptions,
alarms, historian, incremental sync, configuration, dashboard, service
hosting, and CLI tool. Updates README with component documentation table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:47:59 -04:00

7.0 KiB

Incremental Sync

When a Galaxy redeployment is detected, the OPC UA address space must be updated to reflect the new hierarchy and attributes. Rather than tearing down the entire address space and rebuilding from scratch (which disconnects all clients and drops all subscriptions), LmxNodeManager performs an incremental sync that identifies changed objects and rebuilds only the affected subtrees.

Cached State

LmxNodeManager retains shallow copies of the last-published hierarchy and attributes:

private List<GalaxyObjectInfo>? _lastHierarchy;
private List<GalaxyAttributeInfo>? _lastAttributes;

These are updated at the end of every BuildAddressSpace or SyncAddressSpace call via new List<T>(source) to create independent copies. The copies serve as the baseline for the next diff comparison.

On the first call (when _lastHierarchy is null), SyncAddressSpace falls through to a full BuildAddressSpace since there is no baseline to diff against.

AddressSpaceDiff

AddressSpaceDiff is a static helper class that computes the set of changed Galaxy object IDs between two snapshots.

FindChangedGobjectIds

This method compares old and new hierarchy+attributes and returns a HashSet<int> of gobject IDs that have any difference. It detects three categories of changes:

Added objects -- Present in new hierarchy but not in old:

foreach (var id in newObjects.Keys)
    if (!oldObjects.ContainsKey(id))
        changed.Add(id);

Removed objects -- Present in old hierarchy but not in new:

foreach (var id in oldObjects.Keys)
    if (!newObjects.ContainsKey(id))
        changed.Add(id);

Modified objects -- Present in both but with different properties. ObjectsEqual compares TagName, BrowseName, ContainedName, ParentGobjectId, and IsArea.

Attribute set changes -- For objects that exist in both snapshots, attributes are grouped by GobjectId and compared pairwise. AttributeSetsEqual sorts both lists by FullTagReference and PrimitiveName, then checks each pair via AttributesEqual, which compares AttributeName, FullTagReference, MxDataType, IsArray, ArrayDimension, PrimitiveName, SecurityClassification, IsHistorized, and IsAlarm. A difference in count or any field mismatch marks the owning gobject as changed.

Objects already marked as changed by hierarchy comparison are skipped during attribute comparison to avoid redundant work.

ExpandToSubtrees

When a Galaxy object changes, its children must also be rebuilt because they may reference the parent's node or have inherited attribute changes. ExpandToSubtrees performs a BFS traversal from each changed ID, adding all descendants:

public static HashSet<int> ExpandToSubtrees(HashSet<int> changed,
    List<GalaxyObjectInfo> hierarchy)
{
    var childrenByParent = hierarchy.GroupBy(h => h.ParentGobjectId)
        .ToDictionary(g => g.Key, g => g.Select(h => h.GobjectId).ToList());

    var expanded = new HashSet<int>(changed);
    var queue = new Queue<int>(changed);
    while (queue.Count > 0)
    {
        var id = queue.Dequeue();
        if (childrenByParent.TryGetValue(id, out var children))
            foreach (var childId in children)
                if (expanded.Add(childId))
                    queue.Enqueue(childId);
    }
    return expanded;
}

The expansion runs against both the old and new hierarchy. This is necessary because a removed parent's children appear in the old hierarchy (for teardown) while an added parent's children appear in the new hierarchy (for construction).

SyncAddressSpace Flow

SyncAddressSpace orchestrates the incremental update inside the OPC UA framework Lock:

  1. Diff -- Call FindChangedGobjectIds with the cached and new snapshots. If no changes are detected, update the cached snapshots and return early.

  2. Expand -- Call ExpandToSubtrees on both old and new hierarchies to include descendant objects.

  3. Snapshot subscriptions -- Before teardown, iterate _gobjectToTagRefs for each changed gobject ID and record the current MXAccess subscription ref-counts. These are needed to restore subscriptions after rebuild.

  4. Teardown -- Call TearDownGobjects to remove the old nodes and clean up tracking state.

  5. Rebuild -- Filter the new hierarchy and attributes to only the changed gobject IDs, then call BuildSubtree to create the replacement nodes.

  6. Restore subscriptions -- For each previously subscribed tag reference that still exists in _tagToVariableNode after rebuild, re-open the MXAccess subscription and restore the original ref-count.

  7. Update cache -- Replace _lastHierarchy and _lastAttributes with shallow copies of the new data.

TearDownGobjects

TearDownGobjects removes all OPC UA nodes and tracking state for a set of gobject IDs:

For each gobject ID, it processes the associated tag references from _gobjectToTagRefs:

  1. Unsubscribe -- If the tag has an active MXAccess subscription (entry in _subscriptionRefCounts), call UnsubscribeAsync and remove the ref-count entry.

  2. Remove alarm tracking -- Find any _alarmInAlarmTags entries whose SourceTagReference matches the tag. For each, unsubscribe the InAlarm, Priority, and DescAttrName tags, then remove the alarm entry.

  3. Delete variable node -- Call DeleteNode on the variable's NodeId, remove from _tagToVariableNode, clean up _nodeIdToTagReference and _tagMetadata, and decrement VariableNodeCount.

  4. Delete object/folder node -- Remove the gobject's entry from _nodeMap and call DeleteNode. Non-folder nodes decrement ObjectNodeCount.

All MXAccess calls and DeleteNode calls are wrapped in try/catch with ignored exceptions, since teardown must complete even if individual cleanup steps fail.

BuildSubtree

BuildSubtree creates OPC UA nodes for a subset of the Galaxy hierarchy, reusing existing parent nodes from _nodeMap.

The method first topologically sorts the input hierarchy (same TopologicalSort used by BuildAddressSpace) to ensure parents are created before children. For each object:

  1. Find parent -- Look up ParentGobjectId in _nodeMap. If the parent was not part of the changed set, it already exists from the previous build. If no parent is found, fall back to the root ZB folder. This is the key difference from BuildAddressSpace -- subtree builds reuse the existing node tree rather than starting from the root.

  2. Create node -- Areas become FolderState with Organizes reference; non-areas become BaseObjectState with HasComponent reference. The node is added to _nodeMap.

  3. Create variable nodes -- Attributes are processed with the same primitive-grouping logic as BuildAddressSpace, creating BaseDataVariableState nodes via CreateAttributeVariable.

  4. Alarm tracking -- If _alarmTrackingEnabled is set, alarm attributes are detected and AlarmConditionState nodes are created using the same logic as the full build. EventNotifier flags are set on parent nodes, and alarm tags are auto-subscribed.