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>
This commit is contained in:
121
docs/IncrementalSync.md
Normal file
121
docs/IncrementalSync.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# 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:
|
||||
|
||||
```csharp
|
||||
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:
|
||||
|
||||
```csharp
|
||||
foreach (var id in newObjects.Keys)
|
||||
if (!oldObjects.ContainsKey(id))
|
||||
changed.Add(id);
|
||||
```
|
||||
|
||||
**Removed objects** -- Present in old hierarchy but not in new:
|
||||
|
||||
```csharp
|
||||
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:
|
||||
|
||||
```csharp
|
||||
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.
|
||||
Reference in New Issue
Block a user