feat(opcua): route AddressSpaceApplier through the classifier — pure-add deploys no longer rebuild (R2-07 T2)
This commit is contained in:
@@ -7,22 +7,31 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
/// <summary>
|
||||
/// Side-effecting orchestrator over <see cref="AddressSpacePlan"/>. Drives an
|
||||
/// <see cref="IOpcUaAddressSpaceSink"/> to materialise the diff between two
|
||||
/// <see cref="AddressSpaceComposition"/> snapshots:
|
||||
/// <see cref="AddressSpaceComposition"/> snapshots, routing each delta class to its MINIMAL mutation
|
||||
/// (R2-07 03/P1) via <see cref="AddressSpaceChangeClassifier"/> instead of rebuilding the whole
|
||||
/// address space for any topology change (which would kill every client's server-wide subscriptions):
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item>RemovedEquipment / RemovedAlarms — write Bad-quality on every removed
|
||||
/// node id then call <c>RebuildAddressSpace</c> at the end so the sink can
|
||||
/// actually tear down the OPC UA folders + variables.</item>
|
||||
/// <item>AddedEquipment / AddedAlarms — same Rebuild trigger (real SDK NodeManager
|
||||
/// will repopulate from the persisted artifact). For now we record the work.</item>
|
||||
/// <item>ChangedEquipment / ChangedAlarms — record what changed; the SDK adapter
|
||||
/// that lands in F10b will decide between in-place property writes and
|
||||
/// tear-down + rebuild.</item>
|
||||
/// <item><b>PureAdd</b> — no rebuild. <c>OpcUaPublishActor</c>'s idempotent Materialise passes
|
||||
/// create exactly the added folders/variables/conditions (no-op'ing existing nodes), then
|
||||
/// <c>AnnounceAddedNodes</c> raises a Part 3 <c>NodeAdded</c> per affected parent so
|
||||
/// model-aware clients re-browse. Existing MonitoredItems are untouched.</item>
|
||||
/// <item><b>AttributeOnly</b> — no rebuild. Surgical-eligible changed tags + UNS folder renames
|
||||
/// are applied IN PLACE via <see cref="ISurgicalAddressSpaceSink"/> (F10b). Any <c>false</c>
|
||||
/// or throw from a surgical call falls back to a full rebuild (safe default).</item>
|
||||
/// <item><b>PureRemove</b> — scoped in-place teardown of only the removed subtree (writes a
|
||||
/// terminal Bad / <c>RemovedConditionState</c> first so in-flight MonitoredItems observe the
|
||||
/// removal). Maps to a full rebuild until R2-07 Phase 2 ships.</item>
|
||||
/// <item><b>AddRemoveMix</b> — removes-then-adds within one apply. Maps to a full rebuild until
|
||||
/// R2-07 Phase 3 ships.</item>
|
||||
/// <item><b>Rebuild</b> — the default-closed safety valve for any node-affecting change
|
||||
/// (ChangedEquipment / ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant
|
||||
/// ChangedEquipmentVirtualTags), so the worst outcome of any misclassification is today's
|
||||
/// full-rebuild behaviour.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// This is the side-effecting layer deferred to F14. It stays pure-of-SDK so
|
||||
/// production binds a real SDK sink, dev/Mac binds <see cref="NullOpcUaAddressSpaceSink"/>,
|
||||
/// and tests can capture every call.
|
||||
/// Stays pure-of-SDK so production binds a real SDK sink, dev/Mac binds
|
||||
/// <see cref="NullOpcUaAddressSpaceSink"/>, and tests can capture every call.
|
||||
/// </summary>
|
||||
public sealed class AddressSpaceApplier
|
||||
{
|
||||
@@ -111,36 +120,26 @@ public sealed class AddressSpaceApplier
|
||||
plan.AddedEquipmentTags.Count +
|
||||
plan.AddedEquipmentVirtualTags.Count;
|
||||
|
||||
// Any add / remove / in-place CHANGE of Equipment, ScriptedAlarm, Equipment tag, or Equipment
|
||||
// VirtualTag topology requires a real address-space rebuild — the materialise passes re-derive
|
||||
// every node from the composition, so a changed-only deploy (e.g. a renamed equipment, a
|
||||
// re-severitied alarm, a flipped tag dataType) must still rebuild or the running server keeps
|
||||
// the stale node.
|
||||
// ChangedDrivers is deliberately EXCLUDED: a driver-instance config change doesn't touch the
|
||||
// address-space topology — it routes through DriverHostActor's spawn-plan in Runtime, which
|
||||
// re-spawns the affected driver actor without re-materialising any nodes.
|
||||
// F10b (vtag skip): a CHANGED VirtualTag whose ONLY differences are Expression/DependencyRefs/
|
||||
// Historize is node-IRRELEVANT (see VtagDeltaIsNodeIrrelevant) — its materialised node is
|
||||
// byte-identical and the vtag engine adopts those edits via VirtualTagHostActor's INDEPENDENT
|
||||
// respawn (DriverHostActor → ApplyVirtualTags), so it skips the rebuild and PRESERVES every
|
||||
// client's server-wide subscriptions. Any structural / node-affecting vtag change (Name/
|
||||
// FolderPath/DataType) — or any non-vtag change anywhere — still forces a full rebuild.
|
||||
// F10b (surgical tag write): a CHANGED equipment tag whose ONLY differences are Writable /
|
||||
// IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no
|
||||
// alarm condition node) can be updated IN PLACE on the existing node via
|
||||
// ISurgicalAddressSpaceSink.UpdateTagAttributes (see TagDeltaIsSurgicalEligible), avoiding the full
|
||||
// rebuild and preserving subscriptions. The shape fields (DataType/IsArray/ArrayLength) are now
|
||||
// surgical because the sink swaps DataType + ValueRank + ArrayDimensions in place and raises a
|
||||
// GeneralModelChangeEvent. Any other tag difference (FullName/Name/DriverInstanceId/identity/alarm) —
|
||||
// or a sink that lacks the surgical capability, or a node that turns out missing — falls back to a
|
||||
// full rebuild (safe default).
|
||||
var structuralRebuild =
|
||||
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 || plan.ChangedEquipment.Count > 0 ||
|
||||
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 || plan.ChangedAlarms.Count > 0 ||
|
||||
plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 ||
|
||||
plan.ChangedEquipmentTags.Any(d => !AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible(d)) ||
|
||||
plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0 ||
|
||||
plan.ChangedEquipmentVirtualTags.Any(d => !AddressSpaceChangeClassifier.VtagDeltaIsNodeIrrelevant(d));
|
||||
// R2-07 03/P1 — classify the plan and route each delta class to its MINIMAL mutation instead of
|
||||
// rebuilding the world for any topology change. AddressSpaceChangeClassifier is a pure policy over
|
||||
// the planner's diff (the planner stays a pure diff). The routing here:
|
||||
// • PureAdd / AttributeOnly ⇒ NO rebuild. The idempotent Materialise passes in
|
||||
// OpcUaPublishActor.HandleRebuild create exactly the added nodes (no-op'ing existing ones), so
|
||||
// every client subscription server-wide survives; coincident surgical tag deltas + folder
|
||||
// renames are applied IN PLACE via ISurgicalAddressSpaceSink below (any false/throw there falls
|
||||
// back to a full rebuild — the F10b contract).
|
||||
// • PureRemove / AddRemoveMix ⇒ full rebuild UNTIL their phases ship (R2-07 T11 / T13); mapping
|
||||
// them here keeps each phase independently shippable with today's behaviour as the floor.
|
||||
// • Rebuild ⇒ the default-closed safety valve for any node-affecting change (ChangedEquipment /
|
||||
// ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant ChangedEquipmentVirtualTags)
|
||||
// — the classifier's rule 2, which also catches any future plan field that makes a changed
|
||||
// record unequal, so the worst outcome of a misclassification is today's full rebuild.
|
||||
// ChangedDrivers is node-inert (routes through DriverHostActor's spawn plan) — the classifier leaves
|
||||
// a driver-only plan AttributeOnly, so it never rebuilds here.
|
||||
var kind = AddressSpaceChangeClassifier.Classify(plan);
|
||||
var mustRebuild = kind is AddressSpaceChangeKind.Rebuild
|
||||
or AddressSpaceChangeKind.PureRemove
|
||||
or AddressSpaceChangeKind.AddRemoveMix;
|
||||
|
||||
var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible).ToList();
|
||||
// UNS Area / Line renames are surgically applicable (in-place DisplayName swap)
|
||||
@@ -151,7 +150,7 @@ public sealed class AddressSpaceApplier
|
||||
var rebuilt = false;
|
||||
var rebuildFailed = false;
|
||||
|
||||
if (structuralRebuild)
|
||||
if (mustRebuild)
|
||||
{
|
||||
rebuildFailed = !SafeRebuild();
|
||||
rebuilt = true;
|
||||
@@ -203,8 +202,8 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"AddressSpaceApplier: applied plan (added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, renamedFolders={Renamed}, rebuild={Rebuild})",
|
||||
addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count, rebuilt ? 0 : renamedFolders.Count, rebuilt);
|
||||
"AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, renamedFolders={Renamed}, rebuild={Rebuild})",
|
||||
kind, addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count, rebuilt ? 0 : renamedFolders.Count, rebuilt);
|
||||
|
||||
// After the address-space work has completed, auto-provision the historian for the added
|
||||
// historized tags. This is fully detached (fire-and-forget) and wrapped so it can NEVER block
|
||||
|
||||
Reference in New Issue
Block a user