Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs
T
Joseph Doherty 3cf3576c75 fix(v3-batch4-wp4): alarm fan-out hardening — event-delivery test, resolution-failure meter, teardown guards (Wave C review M1/M2/M3/L1/L3)
M1 — over-the-wire event-delivery proof (new NativeAlarmMultiNotifierEventDeliveryTests
in OpcUaServer.IntegrationTests): boots the real server, wires one condition to two
equipment folders, fires ONE transition, and asserts a Server-object subscriber gets
EXACTLY ONE event (the shared-InstanceStateSnapshot queue dedup), plus overlapping
Server + equipment-folder subscribers each get exactly one copy. Captures via the
subscription FastEventCallback keyed by ClientHandle (the per-item Notification/DequeueEvents
path delivers nothing for these conditions in the SDK client — the working capture is the
fast callback).

M2 — resolution-failure signal + path-agreement tripwire (AddressSpaceApplier): when a
native alarm HAS ReferencingEquipmentPaths but NONE resolve to an equipment folder, count
it as a failed node (degraded-apply meter) + LogWarning instead of a silent skip; documented
the DisplayName==Name coupling as a binding invariant. Tests:
Composer_referencing_equipment_paths_resolve_back_to_equipment_ids_in_the_applier (real
composer output → applier inversion) + ..._unresolvable_referencing_equipment_counts_as_failed.

M3 — WireAlarmNotifiers is now a RECONCILE (unwires a folder dropped from the supplied set,
bidirectionally) so a future surgical ChangedRawTags path can't leave a de-referenced
equipment receiving the alarm; binding guard comment added on the classifier's
ChangedRawTags→Rebuild branch. Test: WireAlarmNotifiers_reconciles_a_dropped_folder_out_of_the_notifier_set.

L1 — MaterialiseAlarmCondition's kind-swap drop-and-recreate now calls
UnwireAlarmNotifiers(conditionKey) before discarding the old instance (teardown symmetry).

L3 — BuildEquipmentIdByFolderPath LogWarnings on a duplicate Area/Line/Name collision
(defense-in-depth; UNS uniqueness prevents it).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 12:52:34 -04:00

145 lines
9.0 KiB
C#

namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>How <see cref="AddressSpaceApplier"/> must mutate the address space for a given
/// <see cref="AddressSpacePlan"/>. A pure policy classification OVER the planner's diff (the planner stays
/// a pure diff; the kind is the applier's routing policy over that diff — see R2-07 03/P1).</summary>
public enum AddressSpaceChangeKind
{
/// <summary><see cref="AddressSpacePlan.IsEmpty"/> — nothing to do.</summary>
Empty,
/// <summary>Only surgical-eligible changed tags, node-irrelevant vtag changes, folder renames, and/or
/// node-inert driver deltas — the existing F10b in-place path. No adds, no removes.</summary>
AttributeOnly,
/// <summary>Only additions (any mix of Equipment/Alarms/Tags/VirtualTags) on top of at-most-AttributeOnly
/// changes — no removals, no node-affecting changes. Phase 1: the idempotent Materialise passes create
/// exactly the added nodes with no teardown, so every client subscription survives.</summary>
PureAdd,
/// <summary>Only removals on top of at-most-AttributeOnly changes — no additions, no node-affecting
/// changes. Phase 2: scoped in-place teardown of only the removed subtree.</summary>
PureRemove,
/// <summary>Additions AND removals, still no node-affecting changes. Phase 3 applies removes-then-adds
/// surgically; until Phase 3 it maps to a full rebuild.</summary>
AddRemoveMix,
/// <summary>Anything with a node-affecting change (ChangedEquipment, ChangedAlarms, non-surgical
/// ChangedEquipmentTags, node-relevant ChangedEquipmentVirtualTags) — the unclassifiable-safe default.
/// Full rebuild.</summary>
Rebuild,
}
/// <summary>
/// Pure static classifier over an <see cref="AddressSpacePlan"/> — names the delta class the applier
/// routes each plan through, so a pure-add deploy no longer tears down and recreates every node on the
/// server (03/P1). First-match-wins per the plan's classification table; the default-closed
/// <see cref="AddressSpaceChangeKind.Rebuild"/> catches every node-affecting change (including any future
/// plan field that makes a changed record unequal), so the worst outcome of any misclassification is
/// today's full-rebuild behaviour.
/// </summary>
public static class AddressSpaceChangeClassifier
{
/// <summary>Classify <paramref name="plan"/> into the delta class the applier routes it through.</summary>
/// <param name="plan">The plan to classify.</param>
/// <returns>The <see cref="AddressSpaceChangeKind"/> for the plan.</returns>
public static AddressSpaceChangeKind Classify(AddressSpacePlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
// 1 — nothing to do.
if (plan.IsEmpty) return AddressSpaceChangeKind.Empty;
// 2 — any node-affecting CHANGE forces a full rebuild (default-closed). ChangedEquipment /
// ChangedAlarms are always node-affecting; a changed tag is node-affecting UNLESS it is
// surgical-eligible, and a changed vtag is node-affecting UNLESS it is node-irrelevant. The v3
// Batch-4 Raw/UNS Changed sets (container re-shape, raw-tag attribute/alarm edit, UNS re-point /
// display-name-override) route to Rebuild as the safe default — WP3's applier owns any surgical
// in-place refinement for them. Evaluated BEFORE the add/remove split so a non-surgical change
// alongside pure adds still rebuilds.
//
// WP4 BINDING GUARD (Wave C review M3): ChangedRawTags → Rebuild is what keeps native-alarm notifier
// wiring correct today. A native alarm's set of referencing equipment lives in
// RawTagPlan.ReferencingEquipmentPaths (part of its record equality), so ADDING/DROPPING a reference
// makes the raw tag a ChangedRawTag → full rebuild → OtOpcUaNodeManager clears + re-wires the notifiers
// cleanly. ANY future surgical (non-rebuild) ChangedRawTags path MUST also re-wire/unwire the alarm
// notifiers (WireAlarmNotifiers is a reconcile — pass the tag's COMPLETE current referencing set — plus
// an explicit unwire on removal), else a de-referenced equipment keeps receiving the alarm's events.
if (plan.ChangedEquipment.Count > 0 ||
plan.ChangedAlarms.Count > 0 ||
plan.ChangedRawContainers.Count > 0 ||
plan.ChangedRawTags.Count > 0 ||
plan.ChangedUnsReferenceVariables.Count > 0 ||
plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) ||
plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d)))
{
return AddressSpaceChangeKind.Rebuild;
}
// Driver deltas (Added/Removed/ChangedDrivers) are node-INERT — they never touch the address-space
// topology (they route to DriverHostActor's spawn plan in Runtime), so they are excluded from the
// add/remove split below and leave a driver-only plan classified AttributeOnly.
var hasAdds =
plan.AddedEquipment.Count + plan.AddedAlarms.Count +
plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count +
plan.AddedRawContainers.Count + plan.AddedRawTags.Count +
plan.AddedUnsReferenceVariables.Count > 0;
var hasRemoves =
plan.RemovedEquipment.Count + plan.RemovedAlarms.Count +
plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count +
plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count +
plan.RemovedUnsReferenceVariables.Count > 0;
// 3 — additions AND removals.
if (hasAdds && hasRemoves) return AddressSpaceChangeKind.AddRemoveMix;
// 4 — additions only.
if (hasAdds) return AddressSpaceChangeKind.PureAdd;
// 5 — removals only.
if (hasRemoves) return AddressSpaceChangeKind.PureRemove;
// 6 — only surgical tag deltas / node-irrelevant vtag deltas / folder renames / driver deltas.
return AddressSpaceChangeKind.AttributeOnly;
}
// A VirtualTag's materialised OPC UA node (MaterialiseEquipmentVirtualTags) is derived ONLY from
// {EquipmentId, FolderPath, Name, DataType}. Expression/DependencyRefs/Historize are engine/write-side
// only and are adopted by VirtualTagHostActor's INDEPENDENT respawn (DriverHostActor → ApplyVirtualTags),
// so a delta changing ONLY those three leaves a byte-identical node and needs no address-space rebuild.
// Whitelist-of-may-differ via `with` + the record's custom Equals: any OTHER field difference (current
// or future) makes the override unequal → falls back to a full rebuild (safe default).
internal static bool VtagDeltaIsNodeIrrelevant(AddressSpacePlan.EquipmentVirtualTagDelta d) =>
(d.Previous with
{
Expression = d.Current.Expression,
DependencyRefs = d.Current.DependencyRefs,
Historize = d.Current.Historize,
}).Equals(d.Current);
// F10b: 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,
// avoiding a full rebuild (preserving subscriptions). The presentation-shape fields (DataType / IsArray /
// ArrayLength) join the whitelist now that the surgical sink swaps DataType + ValueRank + ArrayDimensions
// in place (and raises a GeneralModelChangeEvent). FullName / DriverInstanceId / Name / identity / alarm
// differences still fall through to a rebuild — FullName/DriverInstanceId re-route the node to a different
// driver point, Name re-derives the NodeId, and an alarm flip turns the node into a Part 9 condition. The
// override-unequal default also covers any future field.
// REACH (live-verified): the shape path only fires for drivers whose TagConfig carries a stable
// top-level "FullName" (Galaxy = tag_name.AttributeName; OpcUaClient = the node id) — there a DataType/array
// edit leaves FullName untouched ⇒ surgical. For structured-TagConfig protocol drivers (Modbus/S7/AbCip/…)
// TagConfigIntent.Parse falls back to the RAW TagConfig blob as FullName, so a DataType/
// array edit also mutates that blob ⇒ FullName differs ⇒ this returns false ⇒ full rebuild. That is the
// correct safe default (a protocol driver's subscription needs re-spawning for a new shape anyway).
internal static bool TagDeltaIsSurgicalEligible(AddressSpacePlan.EquipmentTagDelta d) =>
d.Previous.Alarm is null && d.Current.Alarm is null &&
(d.Previous with
{
Writable = d.Current.Writable,
IsHistorized = d.Current.IsHistorized,
HistorianTagname = d.Current.HistorianTagname,
DataType = d.Current.DataType,
IsArray = d.Current.IsArray,
ArrayLength = d.Current.ArrayLength,
}).Equals(d.Current);
}