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

220 lines
8.5 KiB
Markdown

# 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
```csharp
private List<GalaxyObjectInfo>? _lastHierarchy;
private List<GalaxyAttributeInfo>? _lastAttributes;
```
### 2. Detect Changed GobjectIds
Compare old vs new to find which gobjects have any difference:
```csharp
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:
```csharp
// 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
```csharp
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
```csharp
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`:
```csharp
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 |