Files
lmxopcua/partial_update.md
Joseph Doherty 3c326e2d45 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>
2026-03-26 15:23:11 -04:00

8.5 KiB

Partial Address Space Update Plan

Problem

When the Galaxy detects a new deployment (time_of_last_deploy changes), the server performs a full rebuild: unsubscribes all MXAccess tags, deletes all OPC UA nodes, reconstructs the entire address space, then re-subscribes. This disrupts all connected clients even if only one object changed.

Goal

Replace the full rebuild with a subtree-level sync: detect which Galaxy objects changed, tear down and rebuild only those subtrees, and leave everything else untouched.

Current Flow (Full Rebuild)

ChangeDetectionService polls galaxy.time_of_last_deploy
  → timestamp changed
    → RebuildAddressSpace(newHierarchy, newAttributes)
      1. Unsubscribe ALL MXAccess tags
      2. Delete ALL OPC UA nodes
      3. Clear all dictionaries
      4. BuildAddressSpace() from scratch
      5. Re-subscribe surviving tags

Proposed Flow (Subtree Sync)

ChangeDetectionService polls galaxy.time_of_last_deploy
  → timestamp changed
    → SyncAddressSpace(newHierarchy, newAttributes)
      1. Compare old vs new by GobjectId
      2. Identify changed gobjects (added, removed, or any field/attribute difference)
      3. Expand changed set to include child gobjects (subtree)
      4. Tear down changed subtrees (delete nodes, unsubscribe, remove alarm tracking)
      5. Rebuild changed subtrees using existing BuildAddressSpace logic
      6. Update cache

Design

1. Cache Previous State

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

2. Detect Changed GobjectIds

Compare old vs new to find which gobjects have any difference:

static HashSet<int> FindChangedGobjectIds(
    List<GalaxyObjectInfo> oldH, List<GalaxyAttributeInfo> oldA,
    List<GalaxyObjectInfo> newH, List<GalaxyAttributeInfo> newA)

A gobject is "changed" if any of these differ:

  • Added: gobject_id exists in new but not old
  • Removed: gobject_id exists in old but not new
  • Object modified: any field differs (TagName, BrowseName, ParentGobjectId, IsArea, ContainedName)
  • Attributes modified: the set of attributes for that gobject_id differs (count, or any attribute field changed)

3. Expand to Subtrees

If a parent object changed, its children must also be rebuilt (they may reference the parent node). Expand the changed set:

// Walk children: if gobject X changed, all gobjects with ParentGobjectId == X are also changed
static HashSet<int> ExpandToSubtrees(HashSet<int> changed, List<GalaxyObjectInfo> hierarchy)

This is recursive — if TestArea changed, TestMachine_001, DelmiaReceiver_001, and MESReceiver_001 all get rebuilt.

4. Tear Down Changed Subtrees

For each changed gobject_id:

  • Find all variable nodes owned by this gobject (from _tagToVariableNode by matching tag prefix)
  • Unsubscribe active MXAccess subscriptions for those tags
  • Remove alarm tracking entries for those tags
  • Delete the variable nodes from OPC UA
  • Delete the object/folder node itself
  • Remove from all dictionaries

5. Rebuild Changed Subtrees

Reuse existing code:

  • Filter newHierarchy and newAttributes to only the changed gobject_ids
  • Run the same node creation logic (topological sort, CreateFolder/CreateObject, CreateAttributeVariable, alarm tracking)
  • The parent nodes for changed subtrees already exist (unchanged parents stay in place)
  • If a parent was also removed (whole subtree removed), skip — children under root folder

6. SyncAddressSpace Method

public void SyncAddressSpace(List<GalaxyObjectInfo> newHierarchy, List<GalaxyAttributeInfo> newAttributes)
{
    lock (Lock)
    {
        if (_lastHierarchy == null)
        {
            BuildAddressSpace(newHierarchy, newAttributes);
            _lastHierarchy = newHierarchy;
            _lastAttributes = newAttributes;
            return;
        }

        var changedIds = FindChangedGobjectIds(_lastHierarchy, _lastAttributes, newHierarchy, newAttributes);

        if (changedIds.Count == 0)
        {
            Log.Information("No address space changes detected");
            _lastHierarchy = newHierarchy;
            _lastAttributes = newAttributes;
            return;
        }

        // Expand to include child subtrees
        changedIds = ExpandToSubtrees(changedIds, _lastHierarchy);
        changedIds = ExpandToSubtrees(changedIds, newHierarchy);

        Log.Information("Incremental sync: {Count} gobjects changed", changedIds.Count);

        // Tear down changed subtrees
        TearDownGobjects(changedIds);

        // Rebuild changed subtrees from new data
        var changedHierarchy = newHierarchy.Where(h => changedIds.Contains(h.GobjectId)).ToList();
        var changedAttributes = newAttributes.Where(a => changedIds.Contains(a.GobjectId)).ToList();
        BuildSubtree(changedHierarchy, changedAttributes);

        _lastHierarchy = newHierarchy;
        _lastAttributes = newAttributes;

        Log.Information("Incremental sync complete: {Objects} objects, {Variables} variables, {Alarms} alarms",
            ObjectNodeCount, VariableNodeCount, _alarmInAlarmTags.Count);
    }
}

7. TearDownGobjects

private void TearDownGobjects(HashSet<int> gobjectIds)
{
    // Collect tag references owned by these gobjects
    // (match by TagName prefix from hierarchy)
    // For each: unsubscribe, remove alarm tracking, delete node, remove from dictionaries
}

Key: need a way to map gobject_id → set of tag references. Options:

  • Store a _gobjectIdToTagRefs dictionary during build
  • Or derive from _tagToVariableNode keys + hierarchy TagName matching

8. BuildSubtree

Reuse the same logic as BuildAddressSpace but:

  • Only process the filtered hierarchy/attributes
  • Parent nodes for the subtree roots already exist in nodeMap (they're unchanged)
  • Need access to the existing nodeMap — either keep it as a field or rebuild from PredefinedNodes

This means nodeMap (currently local to BuildAddressSpace) should become a class field _nodeMap:

private readonly Dictionary<int, NodeState> _nodeMap = new();

What Stays the Same

  • Nodes for unchanged gobjects → untouched
  • MXAccess subscriptions for unchanged tags → untouched
  • Alarm tracking for unchanged alarms → untouched
  • OPC UA client subscriptions on unchanged nodes → uninterrupted
  • _pendingDataChanges queue → continues processing (DispatchLoop skips missing nodes gracefully)

Edge Cases

Case Handling
First build (no cache) Full BuildAddressSpace
No changes detected Log and skip
Object removed Tear down subtree, children become orphaned → also removed
Object added Build new subtree under existing parent
Object re-parented Both old and new parent subtrees detected as changed → both rebuilt
All objects changed Equivalent to full rebuild (acceptable)
Root folder Never torn down — only child subtrees

Testing

Unit Tests (AddressSpaceDiffTests)

  • FindChangedGobjectIds — verify detection of added, removed, modified objects
  • FindChangedGobjectIds — verify attribute changes trigger gobject as changed
  • ExpandToSubtrees — verify children are included

Integration Tests (IncrementalSyncTests)

  • Add object → appears in browse, existing subscriptions unaffected
  • Remove object → disappears, subscriptions on surviving nodes continue
  • Modify attribute on one object → only that subtree rebuilds, others untouched
  • Verify subscription continuity: subscribe to node on Object A, modify Object B, subscription on A still delivers data

Files to Create/Modify

File Change
src/.../OpcUa/LmxNodeManager.cs Add SyncAddressSpace, TearDownGobjects, BuildSubtree; promote nodeMap to field; cache _lastHierarchy/_lastAttributes
src/.../OpcUa/AddressSpaceDiff.cs NEW — FindChangedGobjectIds, ExpandToSubtrees (static helpers)
tests/.../OpcUa/AddressSpaceDiffTests.cs NEW — unit tests for diff logic
tests/.../Integration/IncrementalSyncTests.cs NEW — integration tests

Comparison: Full Rebuild vs Subtree Sync

Aspect Full Rebuild Subtree Sync
Scope of disruption All nodes, all clients Only changed subtrees
MXAccess churn Unsubscribe/resubscribe all Only changed tags
Lock duration Long (rebuild everything) Short (rebuild subset)
Complexity Simple (clear + build) Moderate (diff + selective rebuild)
Correctness risk Low (clean slate) Medium (must handle orphans, partial state)
Fallback N/A Fall back to full rebuild on error