feat(opcua): F14 Phase7Plan + Phase7Applier
v2-ci / build (push) Failing after 34s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (push) Has been skipped

Splits the side-effecting half of Phase7Composer (deferred at Task 47)
into two pieces that mirror DriverHostActor's spawn-plan pattern:

Phase7Plan + Phase7Planner.Compute (pure):
  Diff two Phase7CompositionResult snapshots by stable id (EquipmentId,
  DriverInstanceId, ScriptedAlarmId). Emits Added/Removed/Changed lists
  per entity class. Added/Removed are sorted by id for deterministic
  apply order. Changed wraps both Previous and Current projections so
  consumers can decide between in-place mutation and tear-down +
  rebuild.

Phase7Applier (side-effecting):
  Drives an IOpcUaAddressSpaceSink against a plan. Removed equipment/
  alarms get an inactive AlarmState write per id; Added/Removed of
  Equipment or ScriptedAlarm triggers RebuildAddressSpace. Driver-only
  changes correctly skip the rebuild — those flow through DriverHost-
  Actor's spawn-plan in Runtime. Sink exceptions are caught + logged so
  one bad node doesn't abort the apply.

Tests: OpcUaServer 6 -> 20 (+14):
- Phase7PlannerTests x9 (empty-in/empty-out, add/remove/change per
  entity class, mixed changes, deterministic ordering)
- Phase7ApplierTests x5 (empty plan no-op, removal writes inactive
  states + rebuild, added equipment triggers rebuild, driver-only
  skips rebuild, sink fault is non-fatal)

The remaining piece is the EquipmentNodeWalker integration against a
real SDK NodeManager — split as F14b, gated on F10b's SDK builder.

All 6 v2 test suites green: 146 tests passing.
This commit is contained in:
Joseph Doherty
2026-05-26 09:16:08 -04:00
parent a1325299ce
commit c02f016f1d
5 changed files with 508 additions and 1 deletions
@@ -0,0 +1,109 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// Side-effecting orchestrator over <see cref="Phase7Plan"/>. Drives an
/// <see cref="IOpcUaAddressSpaceSink"/> to materialise the diff between two
/// <see cref="Phase7CompositionResult"/> snapshots:
///
/// <list type="bullet">
/// <item>RemovedEquipment / RemovedAlarms — write Bad-quality on every removed
/// node id then call <c>RebuildAddressSpace</c> at the end so the sink can
/// actually tear down the OPC UA folders + variables.</item>
/// <item>AddedEquipment / AddedAlarms — same Rebuild trigger (real SDK NodeManager
/// will repopulate from the persisted artifact). For now we record the work.</item>
/// <item>ChangedEquipment / ChangedAlarms — record what changed; the SDK adapter
/// that lands in F10b will decide between in-place property writes and
/// tear-down + rebuild.</item>
/// </list>
///
/// This is the side-effecting layer Task 47 deferred to F14. It stays pure-of-SDK so
/// production binds a real SDK sink, dev/Mac binds <see cref="NullOpcUaAddressSpaceSink"/>,
/// and tests can capture every call.
/// </summary>
public sealed class Phase7Applier
{
private readonly IOpcUaAddressSpaceSink _sink;
private readonly ILogger<Phase7Applier> _logger;
public Phase7Applier(IOpcUaAddressSpaceSink sink, ILogger<Phase7Applier> logger)
{
ArgumentNullException.ThrowIfNull(sink);
ArgumentNullException.ThrowIfNull(logger);
_sink = sink;
_logger = logger;
}
/// <summary>
/// Apply <paramref name="plan"/> to the sink. Returns a summary of what was applied so
/// callers (OpcUaPublishActor) can correlate the work back to the originating deployment.
/// </summary>
public Phase7ApplyOutcome Apply(Phase7Plan plan)
{
ArgumentNullException.ThrowIfNull(plan);
if (plan.IsEmpty)
{
_logger.LogDebug("Phase7Applier: plan is empty; skipping sink writes");
return new Phase7ApplyOutcome(RemovedNodes: 0, AddedNodes: 0, ChangedNodes: 0, RebuildCalled: false);
}
var ts = DateTime.UtcNow;
var removedCount = 0;
foreach (var eq in plan.RemovedEquipment)
{
SafeWriteAlarmState(eq.EquipmentId, active: false, acknowledged: false, ts);
removedCount++;
}
foreach (var alarm in plan.RemovedAlarms)
{
SafeWriteAlarmState(alarm.ScriptedAlarmId, active: false, acknowledged: false, ts);
removedCount++;
}
var changedCount =
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count;
var addedCount =
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count;
// Any add/remove of Equipment or ScriptedAlarm 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;
if (needsRebuild)
{
try
{
_sink.RebuildAddressSpace();
}
catch (Exception ex)
{
_logger.LogError(ex, "Phase7Applier: sink.RebuildAddressSpace threw");
}
}
_logger.LogInformation(
"Phase7Applier: applied plan (added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
addedCount, removedCount, changedCount, needsRebuild);
return new Phase7ApplyOutcome(removedCount, addedCount, changedCount, needsRebuild);
}
private void SafeWriteAlarmState(string nodeId, bool active, bool acknowledged, DateTime ts)
{
try { _sink.WriteAlarmState(nodeId, active, acknowledged, ts); }
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: WriteAlarmState threw for {Node}", nodeId); }
}
}
/// <summary>Summary of one apply pass. Useful for tests + audit-log entries on the deploy path.</summary>
public sealed record Phase7ApplyOutcome(
int RemovedNodes,
int AddedNodes,
int ChangedNodes,
bool RebuildCalled);