feat(scripted-alarms): spawn + apply ScriptedAlarmHostActor in DriverHostActor (T10)
This commit is contained in:
@@ -3,6 +3,8 @@ using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.Event;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
@@ -14,7 +16,10 @@ using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
using CommonsNodeId = ZB.MOM.WW.OtOpcUa.Commons.Types.NodeId;
|
||||
|
||||
@@ -56,6 +61,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
private readonly IDriverHealthPublisher _healthPublisher;
|
||||
private readonly IVirtualTagEvaluator _virtualTagEvaluator;
|
||||
private readonly IActorRef? _virtualTagHostOverride;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ScriptRootLogger? _scriptRootLogger;
|
||||
private readonly IActorRef? _scriptedAlarmHostOverride;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
|
||||
/// <summary>The single VirtualTag-host child that spawns/reconciles Equipment-namespace
|
||||
@@ -64,6 +72,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// <see cref="VirtualTagHostActor.ApplyVirtualTags"/> from <see cref="PushDesiredSubscriptions"/>.</summary>
|
||||
private IActorRef? _virtualTagHost;
|
||||
|
||||
/// <summary>The single ScriptedAlarm-host child that owns the per-node
|
||||
/// <see cref="ScriptedAlarmEngine"/>, feeds it live tag values, and bridges its emissions onto the
|
||||
/// OPC UA publish actor + the cluster <c>alerts</c> topic. Spawned in <see cref="PreStart"/>
|
||||
/// alongside the VirtualTag host (requires both an OPC UA publish actor and a
|
||||
/// <see cref="ScriptRootLogger"/>); receives <see cref="ScriptedAlarmHostActor.ApplyScriptedAlarms"/>
|
||||
/// from <see cref="PushDesiredSubscriptions"/>.</summary>
|
||||
private IActorRef? _scriptedAlarmHost;
|
||||
|
||||
private RevisionHash? _currentRevision;
|
||||
private DeploymentId? _applyingDeploymentId;
|
||||
|
||||
@@ -103,6 +119,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// VirtualTag host instead of spawning a real <see cref="VirtualTagHostActor"/> child, so tests
|
||||
/// can intercept the <see cref="VirtualTagHostActor.ApplyVirtualTags"/> message. Null in
|
||||
/// production (the real host is spawned).</param>
|
||||
/// <param name="loggerFactory">Optional logger factory used to create the
|
||||
/// <see cref="EfAlarmConditionStateStore"/>'s logger when spawning the ScriptedAlarm host;
|
||||
/// defaults to <see cref="NullLoggerFactory"/> when not provided.</param>
|
||||
/// <param name="scriptRootLogger">Optional root script logger required to spawn the ScriptedAlarm
|
||||
/// host (the engine + its script logging hang off it). When null the ScriptedAlarm host is left
|
||||
/// unspawned — the graceful dev/None-deployment path.</param>
|
||||
/// <param name="scriptedAlarmHostOverride">Test seam: when supplied, this actor is used as the
|
||||
/// ScriptedAlarm host instead of spawning a real <see cref="ScriptedAlarmHostActor"/> child, so
|
||||
/// tests can intercept the <see cref="ScriptedAlarmHostActor.ApplyScriptedAlarms"/> message. Null
|
||||
/// in production (the real host is spawned).</param>
|
||||
public static Props Props(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
CommonsNodeId localNode,
|
||||
@@ -113,10 +139,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
IActorRef? opcUaPublishActor = null,
|
||||
IDriverHealthPublisher? healthPublisher = null,
|
||||
IVirtualTagEvaluator? virtualTagEvaluator = null,
|
||||
IActorRef? virtualTagHostOverride = null) =>
|
||||
IActorRef? virtualTagHostOverride = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
ScriptRootLogger? scriptRootLogger = null,
|
||||
IActorRef? scriptedAlarmHostOverride = null) =>
|
||||
Akka.Actor.Props.Create(() => new DriverHostActor(
|
||||
dbFactory, localNode, coordinator, driverFactory, localRoles, dependencyMux, opcUaPublishActor,
|
||||
healthPublisher, virtualTagEvaluator, virtualTagHostOverride));
|
||||
healthPublisher, virtualTagEvaluator, virtualTagHostOverride,
|
||||
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride));
|
||||
|
||||
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
|
||||
/// <param name="dbFactory">Database context factory for configuration database access.</param>
|
||||
@@ -131,6 +161,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// defaults to <see cref="NullVirtualTagEvaluator"/>.</param>
|
||||
/// <param name="virtualTagHostOverride">Test seam: when supplied, used as the VirtualTag host
|
||||
/// instead of spawning a real <see cref="VirtualTagHostActor"/> child.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory used to create the
|
||||
/// <see cref="EfAlarmConditionStateStore"/>'s logger; defaults to <see cref="NullLoggerFactory"/>.</param>
|
||||
/// <param name="scriptRootLogger">Optional root script logger required to spawn the ScriptedAlarm
|
||||
/// host; when null the host is left unspawned.</param>
|
||||
/// <param name="scriptedAlarmHostOverride">Test seam: when supplied, used as the ScriptedAlarm host
|
||||
/// instead of spawning a real <see cref="ScriptedAlarmHostActor"/> child.</param>
|
||||
public DriverHostActor(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
CommonsNodeId localNode,
|
||||
@@ -141,7 +177,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
IActorRef? opcUaPublishActor = null,
|
||||
IDriverHealthPublisher? healthPublisher = null,
|
||||
IVirtualTagEvaluator? virtualTagEvaluator = null,
|
||||
IActorRef? virtualTagHostOverride = null)
|
||||
IActorRef? virtualTagHostOverride = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
ScriptRootLogger? scriptRootLogger = null,
|
||||
IActorRef? scriptedAlarmHostOverride = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_localNode = localNode;
|
||||
@@ -153,6 +192,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
|
||||
_virtualTagEvaluator = virtualTagEvaluator ?? NullVirtualTagEvaluator.Instance;
|
||||
_virtualTagHostOverride = virtualTagHostOverride;
|
||||
_loggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
|
||||
_scriptRootLogger = scriptRootLogger;
|
||||
_scriptedAlarmHostOverride = scriptedAlarmHostOverride;
|
||||
|
||||
// Default behavior is Steady — PreStart may flip to Stale or replay an orphan apply.
|
||||
Become(Steady);
|
||||
@@ -168,6 +210,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// Spawn the VirtualTag host BEFORE Bootstrap so the bootstrap-restore path (which routes
|
||||
// through PushDesiredSubscriptions and Tells ApplyVirtualTags) has a live host to target.
|
||||
SpawnVirtualTagHost();
|
||||
// Same rationale for the ScriptedAlarm host — the bootstrap-restore path Tells it
|
||||
// ApplyScriptedAlarms through the same PushDesiredSubscriptions pass.
|
||||
SpawnScriptedAlarmHost();
|
||||
Bootstrap();
|
||||
}
|
||||
|
||||
@@ -199,6 +244,46 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
"virtual-tag-host");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawns the single <see cref="ScriptedAlarmHostActor"/> child that owns the per-node
|
||||
/// <see cref="ScriptedAlarmEngine"/>, feeds it live tag values from the dependency mux, and
|
||||
/// bridges its emissions onto the OPC UA publish actor + the cluster <c>alerts</c> topic. A
|
||||
/// test-supplied <c>scriptedAlarmHostOverride</c> short-circuits the spawn so a probe can
|
||||
/// intercept <see cref="ScriptedAlarmHostActor.ApplyScriptedAlarms"/>. The real host needs
|
||||
/// both a non-null <see cref="_opcUaPublishActor"/> (the emission sink) and a non-null
|
||||
/// <see cref="_scriptRootLogger"/> (the engine + its script logging hang off it); when either
|
||||
/// is missing (legacy ControlPlane test harnesses, dev/None deployments) the host is left
|
||||
/// null and ApplyScriptedAlarms becomes a no-op. The engine is built around a fresh
|
||||
/// <see cref="DependencyMuxTagUpstreamSource"/> + an <see cref="EfAlarmConditionStateStore"/>;
|
||||
/// the host (spawned as a child) owns + disposes the engine in its PostStop, so it stops with
|
||||
/// the driver host.
|
||||
/// </summary>
|
||||
private void SpawnScriptedAlarmHost()
|
||||
{
|
||||
if (_scriptedAlarmHostOverride is not null)
|
||||
{
|
||||
_scriptedAlarmHost = _scriptedAlarmHostOverride;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_opcUaPublishActor is null || _scriptRootLogger is null)
|
||||
{
|
||||
_log.Debug(
|
||||
"DriverHost {Node}: skipping ScriptedAlarm host spawn (no publish actor / root logger)",
|
||||
_localNode);
|
||||
return;
|
||||
}
|
||||
|
||||
var upstream = new DependencyMuxTagUpstreamSource();
|
||||
var store = new EfAlarmConditionStateStore(
|
||||
_dbFactory, _loggerFactory.CreateLogger<EfAlarmConditionStateStore>());
|
||||
var engine = new ScriptedAlarmEngine(
|
||||
upstream, store, new ScriptLoggerFactory(_scriptRootLogger.Logger), _scriptRootLogger.Logger);
|
||||
_scriptedAlarmHost = Context.ActorOf(
|
||||
ScriptedAlarmHostActor.Props(_opcUaPublishActor, _dependencyMux, upstream, engine),
|
||||
"scripted-alarm-host");
|
||||
}
|
||||
|
||||
private void Bootstrap()
|
||||
{
|
||||
// Read the most-recent NodeDeploymentState for this node; if it's Applied, jump
|
||||
@@ -535,6 +620,17 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
_log.Info("DriverHost {Node}: applied {Count} Equipment VirtualTag(s) to the VirtualTag host",
|
||||
_localNode, composition.EquipmentVirtualTags.Count);
|
||||
}
|
||||
|
||||
// Same pass for Equipment-namespace ScriptedAlarms: hand the plans to the host so it
|
||||
// (re)loads its engine + re-registers mux interest for the union of dependency refs. Covers
|
||||
// both the fresh-apply and bootstrap-restore paths (both call this method); the Stale-recovery
|
||||
// path deliberately does not, matching driver + VirtualTag recovery.
|
||||
_scriptedAlarmHost?.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(composition.EquipmentScriptedAlarms));
|
||||
if (composition.EquipmentScriptedAlarms.Count > 0)
|
||||
{
|
||||
_log.Info("DriverHost {Node}: applied {Count} Equipment ScriptedAlarm(s) to the ScriptedAlarm host",
|
||||
_localNode, composition.EquipmentScriptedAlarms.Count);
|
||||
}
|
||||
}
|
||||
|
||||
private void SpawnChild(DriverInstanceSpec spec)
|
||||
|
||||
@@ -11,6 +11,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
|
||||
@@ -90,6 +91,10 @@ public static class ServiceCollectionExtensions
|
||||
var serviceLevel = resolver.GetService<IServiceLevelPublisher>() ?? NullServiceLevelPublisher.Instance;
|
||||
var loggerFactory = resolver.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance;
|
||||
var healthPublisher = resolver.GetService<IDriverHealthPublisher>() ?? NullDriverHealthPublisher.Instance;
|
||||
// Root script logger backs the ScriptedAlarm host's engine + script logging. Registered in
|
||||
// Host DI inside the hasDriver block; may be absent in some role configs / test harnesses,
|
||||
// in which case the DriverHostActor gracefully skips spawning the ScriptedAlarm host.
|
||||
var scriptRootLogger = resolver.GetService<ScriptRootLogger>();
|
||||
// Production evaluator is the Host's RoslynVirtualTagEvaluator (registered as
|
||||
// IVirtualTagEvaluator); fall back to the null evaluator for test harnesses that don't
|
||||
// register one (VirtualTagActor children then evaluate to nothing).
|
||||
@@ -131,7 +136,9 @@ public static class ServiceCollectionExtensions
|
||||
dependencyMux: mux,
|
||||
opcUaPublishActor: publishActor,
|
||||
healthPublisher: healthPublisher,
|
||||
virtualTagEvaluator: virtualTagEvaluator),
|
||||
virtualTagEvaluator: virtualTagEvaluator,
|
||||
loggerFactory: loggerFactory,
|
||||
scriptRootLogger: scriptRootLogger),
|
||||
DriverHostActorName);
|
||||
registry.Register<DriverHostActorKey>(driverHost);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
@@ -7,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
@@ -126,6 +128,84 @@ public sealed class DriverHostActorTests : RuntimeActorTestBase
|
||||
.Status.ShouldBe(NodeDeploymentStatus.Applied);
|
||||
}
|
||||
|
||||
/// <summary>Fresh apply: dispatching a deployment whose artifact carries one Equipment
|
||||
/// ScriptedAlarm forwards a <see cref="ScriptedAlarmHostActor.ApplyScriptedAlarms"/> carrying that
|
||||
/// plan to the injected ScriptedAlarm host (via the <c>scriptedAlarmHostOverride</c> seam, mirroring
|
||||
/// the VirtualTag-host wiring).</summary>
|
||||
[Fact]
|
||||
public void Apply_forwards_EquipmentScriptedAlarms_to_scripted_alarm_host()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDeploymentWithScriptedAlarm(db, RevA);
|
||||
|
||||
var coordinator = CreateTestProbe();
|
||||
var alarmHost = CreateTestProbe();
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
db, TestNode, coordinator.Ref,
|
||||
localRoles: new HashSet<string> { "driver" },
|
||||
scriptedAlarmHostOverride: alarmHost.Ref));
|
||||
|
||||
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
||||
|
||||
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||
|
||||
var apply = alarmHost.ExpectMsg<ScriptedAlarmHostActor.ApplyScriptedAlarms>(TimeSpan.FromSeconds(5));
|
||||
var plan = apply.Plans.ShouldHaveSingleItem();
|
||||
plan.ScriptedAlarmId.ShouldBe("al-1");
|
||||
plan.EquipmentId.ShouldBe("eq-1");
|
||||
plan.Name.ShouldBe("Overheat");
|
||||
plan.PredicateSource.ShouldContain("ctx.GetTag");
|
||||
}
|
||||
|
||||
private static DeploymentId SeedDeploymentWithScriptedAlarm(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev)
|
||||
{
|
||||
// Artifact carries a ScriptedAlarm joined (by PredicateScriptId) to its predicate Script —
|
||||
// the same shape ConfigComposer emits and DeploymentArtifact.ParseComposition decodes into
|
||||
// composition.EquipmentScriptedAlarms.
|
||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
Scripts = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
ScriptId = "scr-1",
|
||||
SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Mach1.Temp\").Value) > 80;",
|
||||
},
|
||||
},
|
||||
ScriptedAlarms = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
ScriptedAlarmId = "al-1",
|
||||
EquipmentId = "eq-1",
|
||||
Name = "Overheat",
|
||||
AlarmType = "LimitAlarm",
|
||||
Severity = 700,
|
||||
MessageTemplate = "Machine 1 hot",
|
||||
PredicateScriptId = "scr-1",
|
||||
HistorizeToAveva = true,
|
||||
Retain = true,
|
||||
Enabled = true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
var id = DeploymentId.NewId();
|
||||
using var ctx = db.CreateDbContext();
|
||||
ctx.Deployments.Add(new Configuration.Entities.Deployment
|
||||
{
|
||||
DeploymentId = id.Value,
|
||||
RevisionHash = rev.Value,
|
||||
Status = DeploymentStatus.Sealed,
|
||||
CreatedBy = "test",
|
||||
SealedAtUtc = DateTime.UtcNow,
|
||||
ArtifactBlob = artifact,
|
||||
});
|
||||
ctx.SaveChanges();
|
||||
return id;
|
||||
}
|
||||
|
||||
private static DeploymentId SeedDeployment(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db,
|
||||
RevisionHash rev,
|
||||
|
||||
Reference in New Issue
Block a user