fix(opcua): equipment-tag planner diff + folder-scoped NodeIds (review findings)

Two bundle-review fixes + idempotency coverage:
- CRITICAL: the planner ignored EquipmentTags, so an incremental deploy changing only
  equipment tags produced an empty plan and HandleRebuild short-circuited before
  materialising them. Add TagId to EquipmentTagPlan + Added/Removed/ChangedEquipmentTags
  to Phase7Plan (diffed by TagId, in IsEmpty, driving Apply's needsRebuild) — mirroring
  the GalaxyTags treatment.
- IMPORTANT: equipment variable NodeId was the raw driver FullName, which collides across
  identical machines (e.g. two PLCs both exposing register 40001) — the second variable
  was silently dropped. NodeId is now folder-scoped (parent/Name); FullName stays on
  EquipmentTagPlan for the later values-routing milestone.
- Task 4: SDK-backed idempotency test (double-apply -> single variable); restart-safety
  confirmed (RestoreApplied reuses the same RebuildAddressSpace -> HandleRebuild path).
- Minor: align composer equipment-tag sort with the artifact decoder (coalesce FolderPath).
This commit is contained in:
Joseph Doherty
2026-06-06 15:02:50 -04:00
parent 08cddfe128
commit aaf869145a
9 changed files with 192 additions and 28 deletions
@@ -70,18 +70,19 @@ public sealed class Phase7Applier
var changedCount =
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
plan.ChangedGalaxyTags.Count;
plan.ChangedGalaxyTags.Count + plan.ChangedEquipmentTags.Count;
var addedCount =
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
plan.AddedGalaxyTags.Count;
plan.AddedGalaxyTags.Count + plan.AddedEquipmentTags.Count;
// Any add/remove of Equipment, ScriptedAlarm, or Galaxy tag topology requires a real
// address-space rebuild. Driver-instance changes don't touch the address-space topology
// directly — they go through DriverHostActor's spawn-plan in Runtime.
// Any add/remove of Equipment, ScriptedAlarm, Galaxy tag, or Equipment tag topology requires
// a real address-space rebuild. Driver-instance changes don't touch the address-space
// topology directly — they go through DriverHostActor's spawn-plan in Runtime.
var needsRebuild =
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 ||
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 ||
plan.AddedGalaxyTags.Count > 0 || plan.RemovedGalaxyTags.Count > 0;
plan.AddedGalaxyTags.Count > 0 || plan.RemovedGalaxyTags.Count > 0 ||
plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0;
if (needsRebuild)
{
@@ -211,15 +212,19 @@ public sealed class Phase7Applier
SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath);
}
// Variables: NodeId = FullName (the driver-side reference → read/write routing key). Parent
// is the FolderPath sub-folder when set, else the equipment folder directly. Like the Galaxy
// pass, per-variable idempotency relies on the sink's own EnsureVariable idempotency.
// Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), NOT the raw FullName — a driver
// ref (e.g. a Modbus register) is not unique across identical machines, so FullName-as-NodeId
// would collide in the sink (EnsureVariable keys on NodeId) and drop all but one machine's
// signal. The driver-side FullName lives on EquipmentTagPlan for the later values milestone to
// route by. Parent is the FolderPath sub-folder when set, else the equipment folder directly.
// Like the Galaxy pass, per-variable idempotency relies on the sink's own EnsureVariable.
foreach (var tag in composition.EquipmentTags)
{
var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
? tag.EquipmentId
: EquipmentSubFolderNodeId(tag.EquipmentId, tag.FolderPath);
SafeEnsureVariable(tag.FullName, parent, tag.Name, tag.DataType);
var nodeId = $"{parent}/{tag.Name}";
SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType);
}
_logger.LogInformation(
@@ -81,14 +81,18 @@ public sealed record GalaxyTagPlan(
/// <summary>
/// One Equipment-namespace tag from a <see cref="Tag"/> row whose <see cref="Tag.EquipmentId"/>
/// is non-null and whose owning driver's namespace is <c>Equipment</c>-kind. Carries the parent
/// <see cref="EquipmentId"/> folder (already materialised by <c>Phase7Applier.MaterialiseHierarchy</c>)
/// the variable hangs under, the optional <see cref="FolderPath"/> sub-folder, the leaf
/// <see cref="Name"/> display, the OPC UA <see cref="DataType"/>, and the driver-side
/// <see cref="FullName"/> reference (extracted from <c>Tag.TagConfig</c>) used as the variable
/// NodeId + read/write routing key. The equipment-signal analogue of <see cref="GalaxyTagPlan"/>.
/// is non-null and whose owning driver's namespace is <c>Equipment</c>-kind. Carries the stable
/// <see cref="TagId"/> (diff identity), the parent <see cref="EquipmentId"/> folder (already
/// materialised by <c>Phase7Applier.MaterialiseHierarchy</c>) the variable hangs under, the
/// optional <see cref="FolderPath"/> sub-folder, the leaf <see cref="Name"/> display, the OPC UA
/// <see cref="DataType"/>, and the driver-side <see cref="FullName"/> reference (extracted from
/// <c>Tag.TagConfig</c>) the later values milestone routes reads/writes by. The variable's NodeId
/// is folder-scoped (<c>parent/Name</c>), NOT <see cref="FullName"/>, because a raw driver ref
/// (e.g. a Modbus register) is not unique across identical machines. The equipment-signal
/// analogue of <see cref="GalaxyTagPlan"/>.
/// </summary>
public sealed record EquipmentTagPlan(
string TagId,
string EquipmentId,
string DriverInstanceId,
string FolderPath,
@@ -218,9 +222,10 @@ public static class Phase7Composer
&& namespacesById.TryGetValue(di.NamespaceId, out var ns)
&& ns.Kind == NamespaceKind.Equipment)
.OrderBy(t => t.EquipmentId, StringComparer.Ordinal)
.ThenBy(t => t.FolderPath, StringComparer.Ordinal)
.ThenBy(t => t.FolderPath ?? string.Empty, StringComparer.Ordinal) // coalesce so the sort matches the artifact-decode side exactly
.ThenBy(t => t.Name, StringComparer.Ordinal)
.Select(t => new EquipmentTagPlan(
TagId: t.TagId,
EquipmentId: t.EquipmentId!,
DriverInstanceId: t.DriverInstanceId,
FolderPath: t.FolderPath ?? string.Empty,
@@ -26,17 +26,33 @@ public sealed record Phase7Plan(
IReadOnlyList<GalaxyTagPlan> RemovedGalaxyTags,
IReadOnlyList<Phase7Plan.GalaxyTagDelta> ChangedGalaxyTags)
{
/// <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>Phase7Plan</c> construction sites compile unchanged — consistent with how
/// <see cref="Phase7CompositionResult.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>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 &&
AddedGalaxyTags.Count == 0 && RemovedGalaxyTags.Count == 0 && ChangedGalaxyTags.Count == 0;
AddedGalaxyTags.Count == 0 && RemovedGalaxyTags.Count == 0 && ChangedGalaxyTags.Count == 0 &&
AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.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 GalaxyTagDelta(GalaxyTagPlan Previous, GalaxyTagPlan Current);
public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current);
}
public static class Phase7Planner
@@ -74,11 +90,21 @@ public static class Phase7Planner
t => t.TagId,
(a, b) => new Phase7Plan.GalaxyTagDelta(a, b));
var (addedEqTags, removedEqTags, changedEqTags) = DiffById(
previous.EquipmentTags, next.EquipmentTags,
t => t.TagId,
(a, b) => new Phase7Plan.EquipmentTagDelta(a, b));
return new Phase7Plan(
addedEq, removedEq, changedEq,
addedDrv, removedDrv, changedDrv,
addedAlarm, removedAlarm, changedAlarm,
addedGalaxy, removedGalaxy, changedGalaxy);
addedGalaxy, removedGalaxy, changedGalaxy)
{
AddedEquipmentTags = addedEqTags,
RemovedEquipmentTags = removedEqTags,
ChangedEquipmentTags = changedEqTags,
};
}
private static (IReadOnlyList<T> Added, IReadOnlyList<T> Removed, IReadOnlyList<TDelta> Changed)
@@ -262,6 +262,7 @@ public static class DeploymentArtifact
var equipmentId = eqEl.GetString();
if (string.IsNullOrWhiteSpace(equipmentId)) continue;
var tagId = el.TryGetProperty("TagId", out var tidEl) ? tidEl.GetString() : null;
var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null;
var name = el.TryGetProperty("Name", out var nmEl) ? nmEl.GetString() : null;
var folder = el.TryGetProperty("FolderPath", out var fpEl) && fpEl.ValueKind != JsonValueKind.Null
@@ -270,11 +271,12 @@ public static class DeploymentArtifact
var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
? tcEl.GetString() : null;
if (string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name)) continue;
if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name)) continue;
if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue;
if (!equipmentNamespaces.Contains(nsId)) continue;
result.Add(new EquipmentTagPlan(
TagId: tagId!,
EquipmentId: equipmentId!,
DriverInstanceId: di!,
FolderPath: folder ?? string.Empty,