From 7352db28a6edae9c022c5ffd658a358cbd5ca8d8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 22:06:03 -0400 Subject: [PATCH] =?UTF-8?q?Phase=207=20follow-up=20#246=20=E2=80=94=20Phas?= =?UTF-8?q?e7Composer=20+=20Program.cs=20wire-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Activates the Phase 7 engines in production. Loads Script + VirtualTag + ScriptedAlarm rows from the bootstrapped generation, wires the engines through the Phase7EngineComposer kernel (#243), starts the DriverSubscriptionBridge feed (#244), and late-binds the resulting IReadable sources to OpcUaApplicationHost before OPC UA server start. ## Phase7Composer (Server.Phase7) Singleton orchestrator. PrepareAsync loads the three Phase 7 row sets in one DB scope, builds CachedTagUpstreamSource, calls Phase7EngineComposer.Compose, constructs DriverSubscriptionBridge with one DriverFeed per registered ISubscribable driver (path-to-fullRef map built from EquipmentNamespaceContent via MapPathsToFullRefs), starts the bridge. DisposeAsync tears down in the right order: bridge first (no more events fired into the cache), then engines (cascades + timers stop), then any disposable sink. MapPathsToFullRefs: deterministic path convention is /{areaName}/{lineName}/{equipmentName}/{tagName} matching exactly what EquipmentNodeWalker emits into the OPC UA browse tree, so script literals against the operator-visible UNS tree work without translation. Tags missing EquipmentId or pointing at unknown Equipment are skipped silently (Galaxy SystemPlatform-style tags + dangling references handled). ## OpcUaApplicationHost.SetPhase7Sources New late-bind setter. Throws InvalidOperationException if called after StartAsync because OtOpcUaServer + DriverNodeManagers capture the field values at construction; mutation post-start would silently fail. ## OpcUaServerService After bootstrap loads the current generation, calls phase7Composer.PrepareAsync + applicationHost.SetPhase7Sources before applicationHost.StartAsync. StopAsync disposes Phase7Composer first so the bridge stops feeding the cache before the OPC UA server tears down its node managers (avoids in-flight cascades surfacing as noisy shutdown warnings). ## Program.cs Registers IAlarmHistorianSink as NullAlarmHistorianSink.Instance (task #247 swaps in the real Galaxy.Host-writer-backed SqliteStoreAndForwardSink), Serilog root logger, and Phase7Composer singleton. ## Tests — 5 new Phase7ComposerMappingTests = 34 Phase 7 tests total Maps tag → walker UNS path, skips null EquipmentId, skips unknown Equipment reference, multiple tags under same equipment map distinctly, empty content yields empty map. Pure functions; no DI/DB needed. The real PrepareAsync DB query path can't be exercised without SQL Server in the test environment — it's exercised by the live E2E smoke (task #240) which unblocks once #247 lands. ## Phase 7 production wiring chain status - ✅ #243 composition kernel - ✅ #245 scripted-alarm IReadable adapter - ✅ #244 driver bridge - ✅ #246 this — Program.cs wire-in - 🟡 #247 — Galaxy.Host SqliteStoreAndForwardSink writer adapter (replaces NullSink) - 🟡 #240 — live E2E smoke (unblocks once #247 lands) --- .../OpcUa/OpcUaApplicationHost.cs | 26 ++- .../OpcUaServerService.cs | 22 ++- .../Phase7/Phase7Composer.cs | 183 ++++++++++++++++++ src/ZB.MOM.WW.OtOpcUa.Server/Program.cs | 10 + .../Phase7/Phase7ComposerMappingTests.cs | 93 +++++++++ 5 files changed, 327 insertions(+), 7 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/Phase7ComposerMappingTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs index c53b774..2dc9c4f 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs @@ -34,9 +34,11 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable // Phase 7 Stream G follow-up (task #239). When composed with the VirtualTagEngine + // ScriptedAlarmEngine sources these route node reads to the engines instead of the // driver. Null = Phase 7 engines not enabled for this deployment (identical to pre- - // Phase-7 behaviour). - private readonly ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _virtualReadable; - private readonly ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _scriptedAlarmReadable; + // Phase-7 behaviour). Late-bindable via SetPhase7Sources because the engines need + // the bootstrapped generation id before they can compose, which is only known after + // the host has been DI-constructed (task #246). + private ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _virtualReadable; + private ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _scriptedAlarmReadable; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; @@ -75,6 +77,24 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable public OtOpcUaServer? Server => _server; + /// + /// Late-bind the Phase 7 engine-backed IReadable sources. Must be + /// called BEFORE — once the OPC UA server starts, the + /// ctor captures the field values + per-node + /// s are constructed. Calling this after start has + /// no effect on already-materialized node managers. + /// + public void SetPhase7Sources( + ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? virtualReadable, + ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? scriptedAlarmReadable) + { + if (_server is not null) + throw new InvalidOperationException( + "Phase 7 sources must be set before OpcUaApplicationHost.StartAsync; the OtOpcUaServer + DriverNodeManagers have already captured the previous values."); + _virtualReadable = virtualReadable; + _scriptedAlarmReadable = scriptedAlarmReadable; + } + /// /// Builds the , validates/creates the application /// certificate, constructs + starts the , then drives diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs index c090bef..8b7705a 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Server.OpcUa; +using ZB.MOM.WW.OtOpcUa.Server.Phase7; namespace ZB.MOM.WW.OtOpcUa.Server; @@ -17,6 +18,7 @@ public sealed class OpcUaServerService( DriverHost driverHost, OpcUaApplicationHost applicationHost, DriverEquipmentContentRegistry equipmentContentRegistry, + Phase7Composer phase7Composer, IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService { @@ -34,12 +36,19 @@ public sealed class OpcUaServerService( // Skipped when no generation is Published yet — the fleet boots into a UNS-less // address space until the first publish, then the registry fills on next restart. if (result.GenerationId is { } gen) + { await PopulateEquipmentContentAsync(gen, stoppingToken); - // PR 17: stand up the OPC UA server + drive discovery per registered driver. Driver - // registration itself (RegisterAsync on DriverHost) happens during an earlier DI - // extension once the central config DB query + per-driver factory land; for now the - // server comes up with whatever drivers are in DriverHost at start time. + // Phase 7 follow-up #246 — load Script + VirtualTag + ScriptedAlarm rows, + // compose VirtualTagEngine + ScriptedAlarmEngine, start the driver-bridge + // feed. SetPhase7Sources MUST run before applicationHost.StartAsync because + // OtOpcUaServer + DriverNodeManager construction captures the field values + // — late binding after server start is rejected with InvalidOperationException. + // No-op when the generation has no virtual tags or scripted alarms. + var phase7 = await phase7Composer.PrepareAsync(gen, stoppingToken); + applicationHost.SetPhase7Sources(phase7.VirtualReadable, phase7.ScriptedAlarmReadable); + } + await applicationHost.StartAsync(stoppingToken); logger.LogInformation("OtOpcUa.Server running. Hosted drivers: {Count}", driverHost.RegisteredDriverIds.Count); @@ -57,6 +66,11 @@ public sealed class OpcUaServerService( public override async Task StopAsync(CancellationToken cancellationToken) { await base.StopAsync(cancellationToken); + // Dispose Phase 7 first so the bridge stops feeding the cache + the engines + // stop firing alarm/historian events before the OPC UA server tears down its + // node managers. Otherwise an in-flight cascade could try to push through a + // disposed source and surface as a noisy shutdown warning. + await phase7Composer.DisposeAsync(); await applicationHost.DisposeAsync(); await driverHost.DisposeAsync(); } diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs new file mode 100644 index 0000000..8fe81c0 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs @@ -0,0 +1,183 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using ZB.MOM.WW.OtOpcUa.Core.OpcUa; +using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; +using ZB.MOM.WW.OtOpcUa.Server.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.Server.Phase7; + +/// +/// Phase 7 follow-up (task #246) — orchestrates the runtime composition of virtual +/// tags + scripted alarms + the historian sink + the driver-bridge that feeds the +/// engines. Called by after the bootstrap generation +/// loads + before . +/// +/// +/// +/// reads Script / VirtualTag / ScriptedAlarm rows from +/// the central config DB at the bootstrapped generation, instantiates a +/// , runs , +/// starts a per registered driver feeding +/// 's tag rows into the cache, and returns +/// the engine-backed sources for +/// . +/// +/// +/// tears down the bridge first (so no more events +/// arrive at the cache), then the engines (so cascades + timer ticks stop), then +/// the SQLite sink (which flushes any in-flight drain). Lifetime is owned by the +/// host; calls dispose during graceful +/// shutdown. +/// +/// +public sealed class Phase7Composer : IAsyncDisposable +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly DriverHost _driverHost; + private readonly DriverEquipmentContentRegistry _equipmentRegistry; + private readonly IAlarmHistorianSink _historianSink; + private readonly ILoggerFactory _loggerFactory; + private readonly Serilog.ILogger _scriptLogger; + private readonly ILogger _logger; + + private DriverSubscriptionBridge? _bridge; + private Phase7ComposedSources _sources = Phase7ComposedSources.Empty; + private bool _disposed; + + public Phase7Composer( + IServiceScopeFactory scopeFactory, + DriverHost driverHost, + DriverEquipmentContentRegistry equipmentRegistry, + IAlarmHistorianSink historianSink, + ILoggerFactory loggerFactory, + Serilog.ILogger scriptLogger, + ILogger logger) + { + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + _driverHost = driverHost ?? throw new ArgumentNullException(nameof(driverHost)); + _equipmentRegistry = equipmentRegistry ?? throw new ArgumentNullException(nameof(equipmentRegistry)); + _historianSink = historianSink ?? throw new ArgumentNullException(nameof(historianSink)); + _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + _scriptLogger = scriptLogger ?? throw new ArgumentNullException(nameof(scriptLogger)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Phase7ComposedSources Sources => _sources; + + public async Task PrepareAsync(long generationId, CancellationToken ct) + { + if (_disposed) throw new ObjectDisposedException(nameof(Phase7Composer)); + + // Load the three Phase 7 row sets in one DB scope. + List