feat(runtime): #109 OpcUaPublishActor — load artifact, compose, plan-diff, apply
v2-ci / build (push) Failing after 45s
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
v2-ci / build (push) Failing after 45s
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
Closes the loop between F10b (SDK NodeManager) and F14 (Phase7Plan +
Phase7Applier). DriverHostActor's successful apply now triggers a
RebuildAddressSpace on the publish actor, which loads the latest
deployment artifact + walks composer → planner → applier through the
sink. The OPC UA address space tracks the deployed composition.
DeploymentArtifact:
- New ParseComposition(blob) → Phase7CompositionResult that decodes
Equipment + DriverInstance + ScriptedAlarm arrays into the
projection records Phase7Planner consumes. Pascal-case property
names mirror ConfigComposer.SnapshotAndFlattenAsync's output.
- Each entity reader is tolerant: missing-id rows are dropped,
natural-key sort matches Phase7Composer's contract.
OpcUaPublishActor:
- New Props params: dbFactory + applier. When wired, RebuildAddressSpace
does:
1. LoadLatestArtifact (most recent Sealed Deployment.ArtifactBlob)
2. ParseComposition → Phase7CompositionResult
3. Phase7Planner.Compute(lastApplied, next) → Phase7Plan
4. Empty plan ⇒ no-op (deploy of unchanged composition is benign)
5. applier.Apply(plan) drives sink.RebuildAddressSpace +
WriteAlarmState for removed nodes
6. lastApplied = next so the next rebuild diffs forward
- Without dbFactory/applier wiring, falls back to raw
sink.RebuildAddressSpace — the dev/Mac path before #108 binds prod.
DriverHostActor:
- New Props param opcUaPublishActor (IActorRef?). After successful
ApplyAndAck (status Applied, ACK sent), tells the publish actor
RebuildAddressSpace with the same correlation id so the audit trail
threads through. Null publish actor ⇒ no trigger (admin-only nodes).
Tests: Runtime 63 -> 69 (+6):
- ParseComposition reads Equipment/Driver/Alarm sorted by natural key
- ParseComposition returns empty for empty blob
- Rebuild with dbFactory + sealed deployment artifact triggers exactly
one sink.Rebuild call (Equipment topology added)
- Rebuild with no artifact is idempotent no-op
- Second rebuild with same composition is empty-plan no-op
- Rebuild without dbFactory falls back to raw sink.Rebuild (legacy path)
All 6 v2 test suites green: 173 tests passing.
Closes #109. Engine-wiring data flow is now end-to-end through:
Deploy → DriverHostActor.ApplyAndAck → driver spawn + ACK +
RebuildAddressSpace → OpcUaPublishActor → Phase7Applier → SDK
NodeManager → subscribed OPC UA clients see the change.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
@@ -75,4 +76,98 @@ public static class DeploymentArtifact
|
||||
Enabled: enabled,
|
||||
DriverConfig: config ?? "{}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse the artifact into the projected <see cref="Phase7CompositionResult"/> used by
|
||||
/// <c>Phase7Planner</c> + <c>Phase7Applier</c>. Returns an empty composition for empty/
|
||||
/// malformed blobs so callers can treat parse failure as a no-op deploy.
|
||||
///
|
||||
/// The artifact JSON is produced by <c>ConfigComposer.SnapshotAndFlattenAsync</c> in the
|
||||
/// ControlPlane — its Pascal-case property names match the EF entities. We only need a
|
||||
/// subset of fields per entity class to drive the address-space rebuild on driver-role
|
||||
/// nodes.
|
||||
/// </summary>
|
||||
public static Phase7CompositionResult ParseComposition(ReadOnlySpan<byte> blob)
|
||||
{
|
||||
if (blob.IsEmpty)
|
||||
{
|
||||
return new Phase7CompositionResult(
|
||||
Array.Empty<EquipmentNode>(),
|
||||
Array.Empty<DriverInstancePlan>(),
|
||||
Array.Empty<ScriptedAlarmPlan>());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(blob.ToArray());
|
||||
var root = doc.RootElement;
|
||||
|
||||
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
|
||||
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
|
||||
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
|
||||
|
||||
return new Phase7CompositionResult(equipment, drivers, alarms);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new Phase7CompositionResult(
|
||||
Array.Empty<EquipmentNode>(),
|
||||
Array.Empty<DriverInstancePlan>(),
|
||||
Array.Empty<ScriptedAlarmPlan>());
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
|
||||
where T : class
|
||||
{
|
||||
if (!root.TryGetProperty(propertyName, out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
return Array.Empty<T>();
|
||||
|
||||
var result = new List<T>(arr.GetArrayLength());
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var item = reader(el);
|
||||
if (item is not null) result.Add(item);
|
||||
}
|
||||
// Match Phase7Composer's natural-key sort so plan diffs are deterministic across
|
||||
// artifact-decode + composer-compose passes.
|
||||
return result.OrderBy(IdentityOf, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
private static string IdentityOf<T>(T item) where T : class => item switch
|
||||
{
|
||||
EquipmentNode e => e.EquipmentId,
|
||||
DriverInstancePlan d => d.DriverInstanceId,
|
||||
ScriptedAlarmPlan a => a.ScriptedAlarmId,
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private static EquipmentNode? ReadEquipmentNode(JsonElement el)
|
||||
{
|
||||
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
|
||||
var displayName = el.TryGetProperty("MachineCode", out var mcEl) ? mcEl.GetString() : null;
|
||||
var lineId = el.TryGetProperty("UnsLineId", out var lineEl) ? lineEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(id)) return null;
|
||||
return new EquipmentNode(id!, displayName ?? id!, lineId ?? string.Empty);
|
||||
}
|
||||
|
||||
private static DriverInstancePlan? ReadDriverPlan(JsonElement el)
|
||||
{
|
||||
var id = el.TryGetProperty("DriverInstanceId", out var idEl) ? idEl.GetString() : null;
|
||||
var type = el.TryGetProperty("DriverType", out var typeEl) ? typeEl.GetString() : null;
|
||||
var config = el.TryGetProperty("DriverConfig", out var cfgEl) ? cfgEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(type)) return null;
|
||||
return new DriverInstancePlan(id!, type!, config ?? "{}");
|
||||
}
|
||||
|
||||
private static ScriptedAlarmPlan? ReadAlarmPlan(JsonElement el)
|
||||
{
|
||||
var id = el.TryGetProperty("ScriptedAlarmId", out var idEl) ? idEl.GetString() : null;
|
||||
var equipmentId = el.TryGetProperty("EquipmentId", out var eqEl) ? eqEl.GetString() : null;
|
||||
var script = el.TryGetProperty("PredicateScriptId", out var scEl) ? scEl.GetString() : null;
|
||||
var template = el.TryGetProperty("MessageTemplate", out var tmEl) ? tmEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(id)) return null;
|
||||
return new ScriptedAlarmPlan(id!, equipmentId ?? string.Empty, script ?? string.Empty, template ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
private readonly IDriverFactory _driverFactory;
|
||||
private readonly IReadOnlySet<string> _localRoles;
|
||||
private readonly IActorRef? _dependencyMux;
|
||||
private readonly IActorRef? _opcUaPublishActor;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
|
||||
private RevisionHash? _currentRevision;
|
||||
@@ -65,9 +66,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
IActorRef? coordinator = null,
|
||||
IDriverFactory? driverFactory = null,
|
||||
IReadOnlySet<string>? localRoles = null,
|
||||
IActorRef? dependencyMux = null) =>
|
||||
IActorRef? dependencyMux = null,
|
||||
IActorRef? opcUaPublishActor = null) =>
|
||||
Akka.Actor.Props.Create(() => new DriverHostActor(
|
||||
dbFactory, localNode, coordinator, driverFactory, localRoles, dependencyMux));
|
||||
dbFactory, localNode, coordinator, driverFactory, localRoles, dependencyMux, opcUaPublishActor));
|
||||
|
||||
public DriverHostActor(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
@@ -75,7 +77,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
IActorRef? coordinator,
|
||||
IDriverFactory? driverFactory = null,
|
||||
IReadOnlySet<string>? localRoles = null,
|
||||
IActorRef? dependencyMux = null)
|
||||
IActorRef? dependencyMux = null,
|
||||
IActorRef? opcUaPublishActor = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_localNode = localNode;
|
||||
@@ -83,6 +86,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
_driverFactory = driverFactory ?? NullDriverFactory.Instance;
|
||||
_localRoles = localRoles ?? new HashSet<string>(StringComparer.Ordinal);
|
||||
_dependencyMux = dependencyMux;
|
||||
_opcUaPublishActor = opcUaPublishActor;
|
||||
|
||||
// Default behavior is Steady — PreStart may flip to Stale or replay an orphan apply.
|
||||
Become(Steady);
|
||||
@@ -244,6 +248,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
_currentRevision = revision;
|
||||
UpsertNodeDeploymentState(deploymentId, NodeDeploymentStatus.Applied, failureReason: null);
|
||||
SendAck(deploymentId, ApplyAckOutcome.Applied, failureReason: null, correlation);
|
||||
// Trigger the OPC UA address-space rebuild so the local SDK reflects the new
|
||||
// composition. The publish actor handles the load-compose-diff-apply pipeline; we
|
||||
// just forward the same correlation id so the audit trail joins up.
|
||||
_opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.RebuildAddressSpace(correlation));
|
||||
_log.Info("DriverHost {Node}: applied deployment {Id} (rev {Rev}, children={Count})",
|
||||
_localNode, deploymentId, revision, _children.Count);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user