f201f16fd4
v2-ci / unit-tests (push) Has been cancelled
Revises 963b0d66, which dropped a disabled VirtualTag from the runtime plan so a
bound client got BadNodeIdUnknown. Now the plan carries Enabled (part of value
equality): AddressSpaceComposer.Compose and DeploymentArtifact.BuildEquipmentVirtualTagPlans
keep the row with Enabled=false (byte-parity), the applier materialises the node as
normal, and VirtualTagHostActor spawns no VirtualTagActor for it but publishes
BadOutOfService (new OpcUaStatus constant) with a null value stamped at deploy, on
every apply. Enabled flips are node-irrelevant to the change classifier; enabled->disabled
stops the child, disabled->enabled resumes evaluation. Validator exemption and name
uniqueness unchanged. Scripted alarms keep their not-materialised convention.
Tests updated; new wire test DisabledVirtualTagWireTests. docs/VirtualTags.md updated.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
170 lines
12 KiB
C#
170 lines
12 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).</summary>
|
|
public enum AddressSpaceChangeKind
|
|
{
|
|
/// <summary><see cref="AddressSpacePlan.IsEmpty"/> — nothing to do.</summary>
|
|
Empty,
|
|
|
|
/// <summary>Only surgical-eligible changed raw tags, surgical UNS-reference deltas,
|
|
/// 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, ChangedRawContainers,
|
|
/// alarm-bearing/identity-changing ChangedRawTags, 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 / ChangedRawContainers are always node-affecting; a changed raw tag UNLESS it is
|
|
// RawTagDeltaIsSurgicalEligible, and a changed vtag UNLESS it is node-irrelevant. Evaluated BEFORE the
|
|
// add/remove split so a non-surgical change alongside pure adds still rebuilds.
|
|
//
|
|
// BINDING GUARD, as narrowed by the 2026-08-15 surgical-deploys work. The
|
|
// guard's subject is NATIVE-ALARM NOTIFIER WIRING: a native alarm's set of referencing equipment lives
|
|
// in RawTagPlan.ReferencingEquipmentPaths (part of its record equality), and only a rebuild clears +
|
|
// re-wires those notifiers, so an ALARM-BEARING raw tag (Alarm != null on EITHER side) still routes to
|
|
// Rebuild. What changed is the blast radius: ReferencingEquipmentPaths is node-INERT for a plain VALUE
|
|
// tag, yet adding/dropping ONE UnsTagReference flips its backing raw tag to Changed — so the blanket
|
|
// "ChangedRawTags ⇒ Rebuild" tore down every node on the server (killing every client subscription) for
|
|
// an edit that creates a single UNS variable. The new contract:
|
|
// • alarm-bearing raw-tag delta ⇒ Rebuild (notifier wiring is owned by the rebuild — unchanged);
|
|
// • non-alarm raw-tag delta over the in-place-updatable attributes and/or ReferencingEquipmentPaths
|
|
// ⇒ surgical (the applier's ISurgicalAddressSpaceSink.UpdateTagAttributes path);
|
|
// • any other raw-tag field difference ⇒ Rebuild, via RawTagDeltaIsSurgicalEligible's
|
|
// with-equality default-closed whitelist (which also closes over any FUTURE field);
|
|
// • ChangedUnsReferenceVariables ⇒ surgical remove-previous + recreate-current, applier-owned (a UNS
|
|
// reference variable is a mirror node binding no driver, so it carries no notifier wiring of its
|
|
// own — the alarm fan-out it participates in is re-derived from the raw tag's referencing set).
|
|
// No rebuild is forced for a UNS-reference delta in EITHER direction, and that is load-bearing for
|
|
// the repair path below.
|
|
// UNS-REFERENCE STALENESS, and why nothing here gates it (2026-08-15 review). Making non-alarm raw-tag
|
|
// deltas surgical meant a raw-side IsHistorized / HistorianTagname / IsArray / ArrayLength edit updated
|
|
// the raw node in place while every referencing UNS MIRROR node kept the old historian tagname + shape
|
|
// (EnsureVariable early-returns on an existing node) — wrong history series through the UNS NodeId, and
|
|
// a scalar→array flip left the mirror scalar AND writable. The fix is in the RECORD, not the classifier:
|
|
// UnsReferenceVariable now carries the backing tag's Backing{IsHistorized,HistorianTagname,IsArray,
|
|
// ArrayLength}, so the same edit makes the reference record unequal and the planner emits a COINCIDENT
|
|
// UnsReferenceDelta alongside the RawTagDelta. Both are non-rebuild kinds, so the plan stays
|
|
// AttributeOnly and the applier repairs the mirror surgically (remove-previous + materialise-recreate).
|
|
// Adding a new inherited field to UnsReferenceVariable therefore needs no change here — but OMITTING one
|
|
// silently reopens the staleness.
|
|
// Anything a future surgical raw-tag path lets through that DOES touch alarm notifiers must still
|
|
// re-wire/unwire them (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 events.
|
|
if (plan.ChangedEquipment.Count > 0 ||
|
|
plan.ChangedAlarms.Count > 0 ||
|
|
plan.ChangedRawContainers.Count > 0 ||
|
|
plan.ChangedRawTags.Any(d => !RawTagDeltaIsSurgicalEligible(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.AddedEquipmentVirtualTags.Count +
|
|
plan.AddedRawContainers.Count + plan.AddedRawTags.Count +
|
|
plan.AddedUnsReferenceVariables.Count > 0;
|
|
var hasRemoves =
|
|
plan.RemovedEquipment.Count + plan.RemovedAlarms.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/Enabled are engine/write-side
|
|
// only and are adopted by VirtualTagHostActor's INDEPENDENT respawn (DriverHostActor → ApplyVirtualTags),
|
|
// so a delta changing ONLY those four 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).
|
|
/// <summary>Whether a changed equipment virtual tag differs only in fields that do not affect its materialised node.</summary>
|
|
/// <param name="d">The virtual-tag delta to classify.</param>
|
|
/// <returns><see langword="true"/> when only Expression / DependencyRefs / Historize / Enabled differ; otherwise <see langword="false"/>.</returns>
|
|
internal static bool VtagDeltaIsNodeIrrelevant(AddressSpacePlan.EquipmentVirtualTagDelta d) =>
|
|
(d.Previous with
|
|
{
|
|
Expression = d.Current.Expression,
|
|
DependencyRefs = d.Current.DependencyRefs,
|
|
Historize = d.Current.Historize,
|
|
// Enabled (lmxopcua#787) is node-irrelevant too: the node exists either way; the VirtualTag host
|
|
// publishes BadOutOfService on disable / resumes evaluation on enable from its own apply.
|
|
Enabled = d.Current.Enabled,
|
|
}).Equals(d.Current);
|
|
|
|
// v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable
|
|
// attributes (Writable / IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength / ArrayWritable)
|
|
// and/or its ReferencingEquipmentPaths. ArrayWritable only feeds the node's writability — the
|
|
// same AccessLevel + OnWriteValue swap Writable already drives in place — so it must be listed here or
|
|
// every array-tag edit that flips it would fall through to a full rebuild. The referencing set is node-inert for a VALUE tag — it only drives native-alarm
|
|
// notifier wiring (the binding guard), which is why Alarm != null on EITHER side disqualifies. Identity fields
|
|
// (NodeId is the diff key; TagId / DriverInstanceId / Name / ParentNodeId / Realm) still force a rebuild via
|
|
// the with-equality default-closed pattern.
|
|
/// <summary>Whether a changed raw tag can be updated in place on its existing node instead of rebuilding.</summary>
|
|
/// <param name="d">The raw-tag delta to classify.</param>
|
|
/// <returns><see langword="true"/> when the tag has no alarm and differs only in the in-place-updatable attributes or its referencing equipment paths; otherwise <see langword="false"/>.</returns>
|
|
internal static bool RawTagDeltaIsSurgicalEligible(AddressSpacePlan.RawTagDelta 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,
|
|
ArrayWritable = d.Current.ArrayWritable,
|
|
ReferencingEquipmentPaths = d.Current.ReferencingEquipmentPaths,
|
|
}).Equals(d.Current);
|
|
}
|