23b42b424d
A rename-only deploy produced an IsEmpty plan that short-circuited before MaterialiseHierarchy, leaving the OPC UA folder DisplayName stale. AddressSpacePlanner now diffs UnsAreas/UnsLines by stable id into a RenamedFolders set (counted in IsEmpty); the applier refreshes the folder in place via a new UpdateFolderDisplayName on ISurgicalAddressSpaceSink (forwarded through DeferredAddressSpaceSink so it is NOT inert on driver hosts; falls back to rebuild when the sink is non-surgical). DeploymentArtifact byte-parity untouched (rename rides the existing Name round-trip). No EF migration, no serialized wire/proto contract change. +13 OpcUaServer tests, Runtime rebuild test.
221 lines
13 KiB
C#
221 lines
13 KiB
C#
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
|
|
/// <summary>
|
|
/// Pure diff between two <see cref="AddressSpaceComposition"/> snapshots — the
|
|
/// <c>previous</c> currently-applied composition and the <c>next</c> from a freshly-applied
|
|
/// deployment. Three lists per entity class (Equipment / DriverInstance / ScriptedAlarm)
|
|
/// captured by stable identity: added items are new, removed items have to be torn down,
|
|
/// changed items have the same identity but at least one field differs.
|
|
///
|
|
/// OpcUaPublishActor's <c>RebuildAddressSpace</c> consumes this against a real
|
|
/// <see cref="Commons.OpcUa.IOpcUaAddressSpaceSink"/> binding so re-applies only mutate the
|
|
/// nodes that actually changed — full tear-down + rebuild is reserved for first-boot or
|
|
/// drastic schema flips.
|
|
/// </summary>
|
|
public sealed record AddressSpacePlan(
|
|
IReadOnlyList<EquipmentNode> AddedEquipment,
|
|
IReadOnlyList<EquipmentNode> RemovedEquipment,
|
|
IReadOnlyList<AddressSpacePlan.EquipmentDelta> ChangedEquipment,
|
|
IReadOnlyList<DriverInstancePlan> AddedDrivers,
|
|
IReadOnlyList<DriverInstancePlan> RemovedDrivers,
|
|
IReadOnlyList<AddressSpacePlan.DriverDelta> ChangedDrivers,
|
|
IReadOnlyList<ScriptedAlarmPlan> AddedAlarms,
|
|
IReadOnlyList<ScriptedAlarmPlan> RemovedAlarms,
|
|
IReadOnlyList<AddressSpacePlan.AlarmDelta> ChangedAlarms)
|
|
{
|
|
/// <summary>
|
|
/// Equipment-namespace tag diff sets, keyed by <see cref="EquipmentTagPlan.TagId"/>. Added as
|
|
/// init-only members (defaulting empty) rather than positional parameters so existing
|
|
/// <c>AddressSpacePlan</c> construction sites compile unchanged — consistent with how
|
|
/// <see cref="AddressSpaceComposition.EquipmentTags"/> was added. Without these, an
|
|
/// incremental deploy that changes ONLY equipment tags produced an empty plan and
|
|
/// <c>OpcUaPublishActor.HandleRebuild</c> short-circuited before materialising them.
|
|
/// </summary>
|
|
public IReadOnlyList<EquipmentTagPlan> AddedEquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
|
|
/// <inheritdoc cref="AddedEquipmentTags"/>
|
|
public IReadOnlyList<EquipmentTagPlan> RemovedEquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
|
|
/// <inheritdoc cref="AddedEquipmentTags"/>
|
|
public IReadOnlyList<EquipmentTagDelta> ChangedEquipmentTags { get; init; } = Array.Empty<EquipmentTagDelta>();
|
|
|
|
/// <summary>
|
|
/// Equipment-namespace VirtualTag diff sets, keyed by <see cref="EquipmentVirtualTagPlan.VirtualTagId"/>.
|
|
/// The value-side analogue of <see cref="AddedEquipmentTags"/>: a VirtualTag carries an
|
|
/// <c>Expression</c> evaluated over <c>DependencyRefs</c>, so a deploy that changes ONLY
|
|
/// VirtualTags (e.g. a new computed signal or an edited formula) must still produce a
|
|
/// non-empty plan and drive a rebuild — without these the diff was blind to VirtualTags and
|
|
/// such a deploy silently no-op'd. Added as init-only members (defaulting empty) for the same
|
|
/// compile-compatibility reason as <see cref="AddedEquipmentTags"/>.
|
|
/// </summary>
|
|
public IReadOnlyList<EquipmentVirtualTagPlan> AddedEquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
|
|
/// <inheritdoc cref="AddedEquipmentVirtualTags"/>
|
|
public IReadOnlyList<EquipmentVirtualTagPlan> RemovedEquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
|
|
/// <inheritdoc cref="AddedEquipmentVirtualTags"/>
|
|
public IReadOnlyList<EquipmentVirtualTagDelta> ChangedEquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagDelta>();
|
|
|
|
/// <summary>
|
|
/// OpcUaServer-001 — UNS Area / Line folder renames: a folder whose stable id is unchanged but
|
|
/// whose <c>DisplayName</c> differs between the previous + next composition. A deploy whose ONLY
|
|
/// change is an Area or Line rename produces NO Equipment / Driver / Alarm / Tag / VirtualTag
|
|
/// delta, so without this set the plan would be <see cref="IsEmpty"/> and
|
|
/// <c>OpcUaPublishActor.HandleRebuild</c> would short-circuit BEFORE the apply path runs, leaving
|
|
/// the folder's stale OPC UA <c>DisplayName</c> until some unrelated structural change forced a
|
|
/// rebuild. <see cref="AddressSpaceApplier"/> applies each rename IN PLACE via
|
|
/// <see cref="Commons.OpcUa.ISurgicalAddressSpaceSink.UpdateFolderDisplayName"/> (folder NodeId =
|
|
/// the area's <c>UnsAreaId</c> / line's <c>UnsLineId</c>, the exact ids <c>MaterialiseHierarchy</c>
|
|
/// uses), preserving every client's subscriptions; a sink lacking the surgical capability or a
|
|
/// missing folder falls back to a full rebuild. Added as an init-only member (defaulting empty) so
|
|
/// every existing <c>AddressSpacePlan</c> construction site compiles unchanged — consistent with
|
|
/// the EquipmentTag / EquipmentVirtualTag diff sets above.
|
|
/// </summary>
|
|
public IReadOnlyList<FolderRename> RenamedFolders { get; init; } = Array.Empty<FolderRename>();
|
|
|
|
/// <summary>Gets a value indicating whether the composition plan contains no changes.</summary>
|
|
public bool IsEmpty =>
|
|
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
|
|
AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 &&
|
|
AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 &&
|
|
AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 &&
|
|
AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0 &&
|
|
RenamedFolders.Count == 0;
|
|
|
|
public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
|
|
public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current);
|
|
public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current);
|
|
public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current);
|
|
public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current);
|
|
|
|
/// <summary>OpcUaServer-001 — one renamed UNS Area / Line folder: the stable folder
|
|
/// <paramref name="FolderNodeId"/> (the area's <c>UnsAreaId</c> or line's <c>UnsLineId</c>, the exact
|
|
/// NodeId <c>MaterialiseHierarchy</c> placed the folder at) and the <paramref name="NewDisplayName"/>
|
|
/// to apply in place.</summary>
|
|
/// <param name="FolderNodeId">The folder's stable NodeId (area/line id) — unchanged by a rename.</param>
|
|
/// <param name="NewDisplayName">The new display name to apply.</param>
|
|
public sealed record FolderRename(string FolderNodeId, string NewDisplayName);
|
|
}
|
|
|
|
public static class AddressSpacePlanner
|
|
{
|
|
/// <summary>
|
|
/// Diff two compositions, emitting Added/Removed/Changed sets per entity class.
|
|
/// Identity is the entity's stable id (EquipmentId, DriverInstanceId, ScriptedAlarmId).
|
|
/// Element equality on the projection records doubles as the "did this change" check,
|
|
/// so any field difference moves an item from "stable" to ChangedX.
|
|
/// </summary>
|
|
/// <param name="previous">The previous composition result.</param>
|
|
/// <param name="next">The new composition result.</param>
|
|
public static AddressSpacePlan Compute(AddressSpaceComposition previous, AddressSpaceComposition next)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(previous);
|
|
ArgumentNullException.ThrowIfNull(next);
|
|
|
|
var (addedEq, removedEq, changedEq) = DiffById(
|
|
previous.EquipmentNodes, next.EquipmentNodes,
|
|
n => n.EquipmentId,
|
|
(a, b) => new AddressSpacePlan.EquipmentDelta(a, b));
|
|
|
|
var (addedDrv, removedDrv, changedDrv) = DiffById(
|
|
previous.DriverInstancePlans, next.DriverInstancePlans,
|
|
d => d.DriverInstanceId,
|
|
(a, b) => new AddressSpacePlan.DriverDelta(a, b));
|
|
|
|
var (addedAlarm, removedAlarm, changedAlarm) = DiffById(
|
|
previous.ScriptedAlarmPlans, next.ScriptedAlarmPlans,
|
|
a => a.ScriptedAlarmId,
|
|
(a, b) => new AddressSpacePlan.AlarmDelta(a, b));
|
|
|
|
var (addedEqTags, removedEqTags, changedEqTags) = DiffById(
|
|
previous.EquipmentTags, next.EquipmentTags,
|
|
t => t.TagId,
|
|
(a, b) => new AddressSpacePlan.EquipmentTagDelta(a, b));
|
|
|
|
// VirtualTags diff by VirtualTagId, mirroring the EquipmentTags pass. EquipmentVirtualTagPlan
|
|
// overrides record equality to compare ALL fields by value — scalars (Expression/DataType/
|
|
// Name/FolderPath) plus DependencyRefs element-wise (SequenceEqual). So a no-op redeploy (fresh
|
|
// list instances, identical contents) correctly diffs to empty; only a real content change is
|
|
// flagged as changed.
|
|
var (addedVTags, removedVTags, changedVTags) = DiffById(
|
|
previous.EquipmentVirtualTags, next.EquipmentVirtualTags,
|
|
t => t.VirtualTagId,
|
|
(a, b) => new AddressSpacePlan.EquipmentVirtualTagDelta(a, b));
|
|
|
|
// OpcUaServer-001 — UNS Area / Line renames: a folder whose stable id is unchanged but whose
|
|
// DisplayName differs. Diffed by stable id (UnsAreaId / UnsLineId) so an Area/Line whose ONLY
|
|
// change is its friendly name is no longer a silent no-op at the IsEmpty gate. The folder NodeId
|
|
// IS the area/line id (the exact scheme MaterialiseHierarchy uses), so the rename carries it
|
|
// directly. Areas first, then lines; each list is independently sorted by id for determinism.
|
|
var renamedFolders = DiffRenames(previous.UnsAreas, next.UnsAreas, a => a.UnsAreaId, a => a.DisplayName)
|
|
.Concat(DiffRenames(previous.UnsLines, next.UnsLines, l => l.UnsLineId, l => l.DisplayName))
|
|
.ToList();
|
|
|
|
return new AddressSpacePlan(
|
|
addedEq, removedEq, changedEq,
|
|
addedDrv, removedDrv, changedDrv,
|
|
addedAlarm, removedAlarm, changedAlarm)
|
|
{
|
|
AddedEquipmentTags = addedEqTags,
|
|
RemovedEquipmentTags = removedEqTags,
|
|
ChangedEquipmentTags = changedEqTags,
|
|
AddedEquipmentVirtualTags = addedVTags,
|
|
RemovedEquipmentVirtualTags = removedVTags,
|
|
ChangedEquipmentVirtualTags = changedVTags,
|
|
RenamedFolders = renamedFolders,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// OpcUaServer-001 — emit a <see cref="AddressSpacePlan.FolderRename"/> for every folder present in
|
|
/// BOTH snapshots (matched by stable <paramref name="identity"/>) whose <paramref name="displayName"/>
|
|
/// differs (ordinal). Added/removed folders are NOT renames — they're handled by the equipment /
|
|
/// hierarchy rebuild path — so this pass only flags an in-place display-name change on a surviving
|
|
/// folder. Sorted by id for deterministic ordering.
|
|
/// </summary>
|
|
private static List<AddressSpacePlan.FolderRename> DiffRenames<T>(
|
|
IReadOnlyList<T> previous,
|
|
IReadOnlyList<T> next,
|
|
Func<T, string> identity,
|
|
Func<T, string> displayName) where T : class
|
|
{
|
|
var prevById = previous.ToDictionary(identity, StringComparer.Ordinal);
|
|
var renames = new List<AddressSpacePlan.FolderRename>();
|
|
foreach (var n in next)
|
|
{
|
|
if (prevById.TryGetValue(identity(n), out var p)
|
|
&& !string.Equals(displayName(p), displayName(n), StringComparison.Ordinal))
|
|
{
|
|
renames.Add(new AddressSpacePlan.FolderRename(identity(n), displayName(n)));
|
|
}
|
|
}
|
|
renames.Sort((a, b) => string.CompareOrdinal(a.FolderNodeId, b.FolderNodeId));
|
|
return renames;
|
|
}
|
|
|
|
private static (IReadOnlyList<T> Added, IReadOnlyList<T> Removed, IReadOnlyList<TDelta> Changed)
|
|
DiffById<T, TDelta>(
|
|
IReadOnlyList<T> previous,
|
|
IReadOnlyList<T> next,
|
|
Func<T, string> identity,
|
|
Func<T, T, TDelta> deltaFactory) where T : class
|
|
{
|
|
var prevById = previous.ToDictionary(identity, StringComparer.Ordinal);
|
|
var nextById = next.ToDictionary(identity, StringComparer.Ordinal);
|
|
|
|
var added = new List<T>();
|
|
var removed = new List<T>();
|
|
var changed = new List<TDelta>();
|
|
|
|
foreach (var (id, p) in prevById)
|
|
{
|
|
if (!nextById.TryGetValue(id, out var n)) { removed.Add(p); continue; }
|
|
if (!EqualityComparer<T>.Default.Equals(p, n)) changed.Add(deltaFactory(p, n));
|
|
}
|
|
foreach (var (id, n) in nextById)
|
|
{
|
|
if (!prevById.ContainsKey(id)) added.Add(n);
|
|
}
|
|
|
|
added.Sort((a, b) => string.CompareOrdinal(identity(a), identity(b)));
|
|
removed.Sort((a, b) => string.CompareOrdinal(identity(a), identity(b)));
|
|
return (added, removed, changed);
|
|
}
|
|
}
|