fix(code-review): resolve OpcUaServer-001 — UNS Area/Line rename refreshes folder DisplayName
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.
This commit is contained in:
@@ -87,4 +87,17 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
|
||||
=> _inner is ISurgicalAddressSpaceSink surgical
|
||||
&& surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength);
|
||||
|
||||
/// <summary>Forwards an in-place folder-display-name update (OpcUaServer-001 — UNS Area / Line
|
||||
/// rename) to the inner sink when it supports the surgical capability. Returns false otherwise —
|
||||
/// before the real <c>SdkAddressSpaceSink</c> is swapped in (inner is still the null sink), or any
|
||||
/// inner sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild.
|
||||
/// Without this forward the rename-refresh optimization is inert on every driver-role host, because
|
||||
/// actors inject THIS wrapper, not the inner sink.</summary>
|
||||
/// <param name="folderNodeId">The folder node id whose display name to update in place.</param>
|
||||
/// <param name="displayName">The new display name to apply.</param>
|
||||
/// <returns>True when the inner sink applied the update; false when it lacks the capability or the folder is missing.</returns>
|
||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
|
||||
=> _inner is ISurgicalAddressSpaceSink surgical
|
||||
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName);
|
||||
}
|
||||
|
||||
@@ -23,4 +23,15 @@ public interface ISurgicalAddressSpaceSink
|
||||
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true; ignored for scalars.</param>
|
||||
/// <returns>True when the in-place update was applied; false when the node is missing (caller rebuilds).</returns>
|
||||
bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength);
|
||||
|
||||
/// <summary>Update an existing folder node's <c>DisplayName</c> IN PLACE, notifying subscribers
|
||||
/// (ClearChangeMasks) without a rebuild — used by AddressSpaceApplier to apply a UNS Area / Line
|
||||
/// rename without tearing down + repopulating the whole address space (which would drop every
|
||||
/// client's MonitoredItems). The folder's NodeId is unchanged (a rename only touches the friendly
|
||||
/// browse/display name, not the logical id). Returns false if the folder does not exist (caller
|
||||
/// should rebuild instead).</summary>
|
||||
/// <param name="folderNodeId">The node id of the folder whose display name to update in place.</param>
|
||||
/// <param name="displayName">The new display name to apply.</param>
|
||||
/// <returns>True when the in-place update was applied; false when the folder is missing (caller rebuilds).</returns>
|
||||
bool UpdateFolderDisplayName(string folderNodeId, string displayName);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,9 @@ public sealed class AddressSpaceApplier
|
||||
var changedCount =
|
||||
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
|
||||
plan.ChangedEquipmentTags.Count +
|
||||
plan.ChangedEquipmentVirtualTags.Count;
|
||||
plan.ChangedEquipmentVirtualTags.Count +
|
||||
// OpcUaServer-001: a UNS Area/Line rename is an in-place change to an existing folder node.
|
||||
plan.RenamedFolders.Count;
|
||||
var addedCount =
|
||||
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
|
||||
plan.AddedEquipmentTags.Count +
|
||||
@@ -113,6 +115,11 @@ public sealed class AddressSpaceApplier
|
||||
plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d));
|
||||
|
||||
var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(TagDeltaIsSurgicalEligible).ToList();
|
||||
// OpcUaServer-001 — UNS Area / Line renames are surgically applicable (in-place DisplayName swap)
|
||||
// when no structural rebuild fires. When a rebuild DOES fire (any add/remove/structural change),
|
||||
// MaterialiseHierarchy re-creates every folder with the new display names, so the renames are
|
||||
// covered for free and need no separate surgical pass.
|
||||
var renamedFolders = plan.RenamedFolders;
|
||||
var rebuilt = false;
|
||||
|
||||
if (structuralRebuild)
|
||||
@@ -120,12 +127,24 @@ public sealed class AddressSpaceApplier
|
||||
SafeRebuild();
|
||||
rebuilt = true;
|
||||
}
|
||||
else if (surgicalTagDeltas.Count > 0)
|
||||
else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0)
|
||||
{
|
||||
if (_sink is ISurgicalAddressSpaceSink surgical)
|
||||
{
|
||||
var allApplied = true;
|
||||
foreach (var d in surgicalTagDeltas)
|
||||
// Folder renames first — an in-place DisplayName swap on the existing Area/Line folder.
|
||||
foreach (var rename in renamedFolders)
|
||||
{
|
||||
bool ok;
|
||||
try { ok = surgical.UpdateFolderDisplayName(rename.FolderNodeId, rename.NewDisplayName); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateFolderDisplayName threw for {Node}", rename.FolderNodeId);
|
||||
ok = false;
|
||||
}
|
||||
if (!ok) { allApplied = false; break; }
|
||||
}
|
||||
foreach (var d in allApplied ? surgicalTagDeltas : Enumerable.Empty<AddressSpacePlan.EquipmentTagDelta>())
|
||||
{
|
||||
// Compute the node id + writable + historian + shape EXACTLY as MaterialiseEquipmentTags
|
||||
// would so the in-place update matches what a rebuild would have produced. Array tags are
|
||||
@@ -155,8 +174,8 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"AddressSpaceApplier: applied plan (added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, rebuild={Rebuild})",
|
||||
addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count, rebuilt);
|
||||
"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);
|
||||
|
||||
return new AddressSpaceApplyOutcome(removedCount, addedCount, changedCount, rebuilt);
|
||||
}
|
||||
|
||||
@@ -52,19 +52,45 @@ public sealed record AddressSpacePlan(
|
||||
/// <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;
|
||||
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
|
||||
@@ -112,6 +138,15 @@ public static class AddressSpacePlanner
|
||||
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,
|
||||
@@ -123,9 +158,37 @@ public static class AddressSpacePlanner
|
||||
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,
|
||||
|
||||
@@ -1307,6 +1307,34 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update an EXISTING folder node's <see cref="FolderState.DisplayName"/> in place and notify
|
||||
/// subscribers, WITHOUT a rebuild — the in-place counterpart of <see cref="EnsureFolder"/> for a
|
||||
/// UNS Area / Line rename (OpcUaServer-001). <see cref="EnsureFolder"/> early-returns for an
|
||||
/// already-present folder id and never touches an existing folder's display name, so a
|
||||
/// rename-only deploy would otherwise be invisible until a full <see cref="RebuildAddressSpace"/>
|
||||
/// cleared <c>_folders</c>. The NodeId + BrowseName are unchanged (a rename only touches the
|
||||
/// friendly display name, not the logical id, so browse-path resolution + ACLs are unaffected).
|
||||
/// <c>ClearChangeMasks</c> is called so subscribed clients see the new DisplayName immediately,
|
||||
/// mirroring the <see cref="UpdateTagAttributes"/> surgical path. Returns false when the folder id
|
||||
/// is unknown (caller falls back to a full rebuild).
|
||||
/// </summary>
|
||||
/// <param name="folderNodeId">The node identifier of the folder to update in place.</param>
|
||||
/// <param name="displayName">The new display name to apply.</param>
|
||||
/// <returns>True when the in-place update was applied; false when the folder id is unknown.</returns>
|
||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(displayName);
|
||||
lock (Lock)
|
||||
{
|
||||
if (!_folders.TryGetValue(folderNodeId, out var folder)) return false;
|
||||
folder.DisplayName = displayName;
|
||||
folder.ClearChangeMasks(SystemContext, includeChildren: false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensure a Variable node exists at <paramref name="variableNodeId"/> parented under
|
||||
/// <paramref name="parentFolderNodeId"/> (or root when null). Initial value=null, quality=Bad,
|
||||
|
||||
@@ -77,6 +77,15 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
|
||||
=> _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength);
|
||||
|
||||
/// <summary>OpcUaServer-001: surgically update an existing folder node's display name in place (no
|
||||
/// rebuild) for a UNS Area / Line rename. Returns false when the folder does not exist (caller falls
|
||||
/// back to a full rebuild).</summary>
|
||||
/// <param name="folderNodeId">The folder node identifier whose display name to update in place.</param>
|
||||
/// <param name="displayName">The new display name to apply.</param>
|
||||
/// <returns>True when the in-place update was applied; false when the folder is missing.</returns>
|
||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
|
||||
=> _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName);
|
||||
|
||||
/// <summary>Rebuilds the entire OPC UA address space.</summary>
|
||||
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user