aaf869145a
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).
139 lines
6.9 KiB
C#
139 lines
6.9 KiB
C#
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
|
|
/// <summary>
|
|
/// Pure diff between two <see cref="Phase7CompositionResult"/> 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 Phase7Plan(
|
|
IReadOnlyList<EquipmentNode> AddedEquipment,
|
|
IReadOnlyList<EquipmentNode> RemovedEquipment,
|
|
IReadOnlyList<Phase7Plan.EquipmentDelta> ChangedEquipment,
|
|
IReadOnlyList<DriverInstancePlan> AddedDrivers,
|
|
IReadOnlyList<DriverInstancePlan> RemovedDrivers,
|
|
IReadOnlyList<Phase7Plan.DriverDelta> ChangedDrivers,
|
|
IReadOnlyList<ScriptedAlarmPlan> AddedAlarms,
|
|
IReadOnlyList<ScriptedAlarmPlan> RemovedAlarms,
|
|
IReadOnlyList<Phase7Plan.AlarmDelta> ChangedAlarms,
|
|
IReadOnlyList<GalaxyTagPlan> AddedGalaxyTags,
|
|
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 &&
|
|
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
|
|
{
|
|
/// <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 Phase7Plan Compute(Phase7CompositionResult previous, Phase7CompositionResult next)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(previous);
|
|
ArgumentNullException.ThrowIfNull(next);
|
|
|
|
var (addedEq, removedEq, changedEq) = DiffById(
|
|
previous.EquipmentNodes, next.EquipmentNodes,
|
|
n => n.EquipmentId,
|
|
(a, b) => new Phase7Plan.EquipmentDelta(a, b));
|
|
|
|
var (addedDrv, removedDrv, changedDrv) = DiffById(
|
|
previous.DriverInstancePlans, next.DriverInstancePlans,
|
|
d => d.DriverInstanceId,
|
|
(a, b) => new Phase7Plan.DriverDelta(a, b));
|
|
|
|
var (addedAlarm, removedAlarm, changedAlarm) = DiffById(
|
|
previous.ScriptedAlarmPlans, next.ScriptedAlarmPlans,
|
|
a => a.ScriptedAlarmId,
|
|
(a, b) => new Phase7Plan.AlarmDelta(a, b));
|
|
|
|
var (addedGalaxy, removedGalaxy, changedGalaxy) = DiffById(
|
|
previous.GalaxyTags, next.GalaxyTags,
|
|
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)
|
|
{
|
|
AddedEquipmentTags = addedEqTags,
|
|
RemovedEquipmentTags = removedEqTags,
|
|
ChangedEquipmentTags = changedEqTags,
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|