From 5256761368b8adc4e64ca45a975690d1aa32c591 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 10 Jun 2026 15:17:29 -0400 Subject: [PATCH] feat(scripted-alarms): spawn + apply ScriptedAlarmHostActor in DriverHostActor (T10) --- .../Drivers/DriverHostActor.cs | 102 +++++++++++++++++- .../ServiceCollectionExtensions.cs | 9 +- .../Drivers/DriverHostActorTests.cs | 80 ++++++++++++++ 3 files changed, 187 insertions(+), 4 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 4d8954f7..a8f2a737 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -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(); /// The single VirtualTag-host child that spawns/reconciles Equipment-namespace @@ -64,6 +72,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// from . private IActorRef? _virtualTagHost; + /// The single ScriptedAlarm-host child that owns the per-node + /// , feeds it live tag values, and bridges its emissions onto the + /// OPC UA publish actor + the cluster alerts topic. Spawned in + /// alongside the VirtualTag host (requires both an OPC UA publish actor and a + /// ); receives + /// from . + 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 child, so tests /// can intercept the message. Null in /// production (the real host is spawned). + /// Optional logger factory used to create the + /// 's logger when spawning the ScriptedAlarm host; + /// defaults to when not provided. + /// 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. + /// Test seam: when supplied, this actor is used as the + /// ScriptedAlarm host instead of spawning a real child, so + /// tests can intercept the message. Null + /// in production (the real host is spawned). public static Props Props( IDbContextFactory 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)); /// Initializes a new DriverHostActor with the specified dependencies. /// Database context factory for configuration database access. @@ -131,6 +161,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// defaults to . /// Test seam: when supplied, used as the VirtualTag host /// instead of spawning a real child. + /// Optional logger factory used to create the + /// 's logger; defaults to . + /// Optional root script logger required to spawn the ScriptedAlarm + /// host; when null the host is left unspawned. + /// Test seam: when supplied, used as the ScriptedAlarm host + /// instead of spawning a real child. public DriverHostActor( IDbContextFactory 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"); } + /// + /// Spawns the single child that owns the per-node + /// , feeds it live tag values from the dependency mux, and + /// bridges its emissions onto the OPC UA publish actor + the cluster alerts topic. A + /// test-supplied scriptedAlarmHostOverride short-circuits the spawn so a probe can + /// intercept . The real host needs + /// both a non-null (the emission sink) and a non-null + /// (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 + /// + an ; + /// the host (spawned as a child) owns + disposes the engine in its PostStop, so it stops with + /// the driver host. + /// + 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()); + 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) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs index d17adae3..93301836 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs @@ -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() ?? NullServiceLevelPublisher.Instance; var loggerFactory = resolver.GetService() ?? NullLoggerFactory.Instance; var healthPublisher = resolver.GetService() ?? 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(); // 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(driverHost); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorTests.cs index cfcee34e..7dfc2fc3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorTests.cs @@ -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); } + /// Fresh apply: dispatching a deployment whose artifact carries one Equipment + /// ScriptedAlarm forwards a carrying that + /// plan to the injected ScriptedAlarm host (via the scriptedAlarmHostOverride seam, mirroring + /// the VirtualTag-host wiring). + [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 { "driver" }, + scriptedAlarmHostOverride: alarmHost.Ref)); + + actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); + + coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied); + + var apply = alarmHost.ExpectMsg(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 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 db, RevisionHash rev,