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>
220 lines
8.5 KiB
Markdown
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 |
|