From 6e282b9946cda4b32fcb1ab8fe95345b3e3bf806 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 30 Apr 2026 16:31:00 -0400 Subject: [PATCH] server: Phase7Composer accepts DI-registered IAlarmHistorianWriter (PR B.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixth PR of the alarms-over-gateway epic (docs/plans/alarms-over-gateway.md). Depends on PR C.2 (sidecar serves IAlarmEventWriter when enabled), already merged. Today Phase7Composer.ResolveHistorianSink only scans drivers for an IAlarmHistorianWriter — no Galaxy driver provides one since PR 7.2, so the resolution falls through to NullAlarmHistorianSink and scripted-alarm transitions are silently discarded. WonderwareHistorianClient already implements IAlarmHistorianWriter and Program.cs:178 already registers it as a singleton when Historian:Wonderware:Enabled=true. The gap was that Phase7Composer ignored DI: this PR adds an optional injectedWriter constructor parameter, and ASP.NET Core DI resolves it from the same registration when present. - Phase7Composer constructor: new optional IAlarmHistorianWriter? injectedWriter parameter (default null). Backward-compatible — existing callers don't need to change; DI populates it automatically when the singleton is registered. - New static SelectAlarmHistorianWriter helper — resolution order is driver → DI → null. Drivers win when both are present so a future GalaxyDriver-as-IAlarmHistorianWriter takes the write path directly, preserving the v1 invariant where a driver that natively owns the historian client doesn't bounce through the sidecar IPC. - ResolveHistorianSink uses the helper + emits a structured log line identifying which source provided the writer. Tests: - 4 SelectAlarmHistorianWriter precedence tests — no source / DI only / driver wins over DI / first-driver-with-writer wins. - Pre-existing 4 HostStatusPublisherTests SQL failures unrelated to this change (require the docker-host SQL Server at 10.100.0.35,14330 per CLAUDE.md). Phase7 + alarm tests all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Phase7/Phase7Composer.cs | 51 ++++++-- .../Phase7ComposerWriterSelectionTests.cs | 122 ++++++++++++++++++ 2 files changed, 162 insertions(+), 11 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/Phase7ComposerWriterSelectionTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs index 67a3af5..c834a37 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs @@ -41,6 +41,7 @@ public sealed class Phase7Composer : IAsyncDisposable private readonly DriverHost _driverHost; private readonly DriverEquipmentContentRegistry _equipmentRegistry; private readonly IAlarmHistorianSink _historianSink; + private readonly IAlarmHistorianWriter? _injectedWriter; private readonly ILoggerFactory _loggerFactory; private readonly Serilog.ILogger _scriptLogger; private readonly ILogger _logger; @@ -59,12 +60,14 @@ public sealed class Phase7Composer : IAsyncDisposable IAlarmHistorianSink historianSink, ILoggerFactory loggerFactory, Serilog.ILogger scriptLogger, - ILogger logger) + ILogger logger, + IAlarmHistorianWriter? injectedWriter = null) { _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)); + _injectedWriter = injectedWriter; _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); _scriptLogger = scriptLogger ?? throw new ArgumentNullException(nameof(scriptLogger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -131,27 +134,53 @@ public sealed class Phase7Composer : IAsyncDisposable return _sources; } - private IAlarmHistorianSink ResolveHistorianSink() + /// + /// Resolution order for the alarm-historian writer: + /// + /// Any registered driver that implements (today: none — Galaxy used to via the legacy GalaxyProxyDriver). + /// The DI-registered (PR B.4 — the WonderwareHistorianClient sidecar writer when Historian:Wonderware:Enabled=true). + /// null — caller falls back to the injected (NullAlarmHistorianSink in the default registration). + /// + /// Driver-provided writers win over the DI-registered sidecar so a future + /// GalaxyDriver-as-IAlarmHistorianWriter takes the write path directly, + /// preserving the v1 invariant where a driver that natively owns the + /// historian client doesn't bounce through the sidecar IPC. + /// + internal static IAlarmHistorianWriter? SelectAlarmHistorianWriter( + DriverHost driverHost, + IAlarmHistorianWriter? injectedWriter, + out string? selectedSourceDescription) { - IAlarmHistorianWriter? writer = null; - foreach (var driverId in _driverHost.RegisteredDriverIds) + foreach (var driverId in driverHost.RegisteredDriverIds) { - if (_driverHost.GetDriver(driverId) is IAlarmHistorianWriter w) + if (driverHost.GetDriver(driverId) is IAlarmHistorianWriter w) { - writer = w; - _logger.LogInformation( - "Phase 7 historian sink: driver {Driver} provides IAlarmHistorianWriter — wiring SqliteStoreAndForwardSink", - driverId); - break; + selectedSourceDescription = $"driver:{driverId}"; + return w; } } + if (injectedWriter is not null) + { + selectedSourceDescription = $"di:{injectedWriter.GetType().Name}"; + return injectedWriter; + } + selectedSourceDescription = null; + return null; + } + + private IAlarmHistorianSink ResolveHistorianSink() + { + var writer = SelectAlarmHistorianWriter(_driverHost, _injectedWriter, out var sourceDescription); if (writer is null) { _logger.LogInformation( - "Phase 7 historian sink: no driver provides IAlarmHistorianWriter — using {Sink}", + "Phase 7 historian sink: no driver or DI-registered IAlarmHistorianWriter — using {Sink}", _historianSink.GetType().Name); return _historianSink; } + _logger.LogInformation( + "Phase 7 historian sink: IAlarmHistorianWriter resolved from {Source} — SqliteStoreAndForwardSink active", + sourceDescription); var queueRoot = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); if (string.IsNullOrEmpty(queueRoot)) queueRoot = Path.GetTempPath(); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/Phase7ComposerWriterSelectionTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/Phase7ComposerWriterSelectionTests.cs new file mode 100644 index 0000000..904aa12 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/Phase7ComposerWriterSelectionTests.cs @@ -0,0 +1,122 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using ZB.MOM.WW.OtOpcUa.Server.Phase7; + +namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7; + +/// +/// PR B.4 — pins the precedence order Phase7Composer uses to pick an +/// : +/// driver-provided > DI-registered > none. Driver wins so a future +/// GalaxyDriver-as-IAlarmHistorianWriter takes the write path directly, +/// preserving the v1 invariant where a driver that natively owns the +/// historian client doesn't bounce through the sidecar IPC. +/// +[Trait("Category", "Unit")] +public sealed class Phase7ComposerWriterSelectionTests +{ + [Fact] + public async Task No_driver_no_injected_writer_returns_null() + { + await using var host = new DriverHost(); + + var writer = Phase7Composer.SelectAlarmHistorianWriter(host, injectedWriter: null, out var source); + + writer.ShouldBeNull(); + source.ShouldBeNull(); + } + + [Fact] + public async Task Injected_writer_only_is_selected() + { + await using var host = new DriverHost(); + var injected = new RecordingWriter("from-di"); + + var writer = Phase7Composer.SelectAlarmHistorianWriter(host, injected, out var source); + + writer.ShouldBeSameAs(injected); + source.ShouldStartWith("di:"); + } + + [Fact] + public async Task Driver_writer_wins_over_injected() + { + await using var host = new DriverHost(); + var driver = new FakeDriverWithWriter("drv-1", "drv-out"); + await host.RegisterAsync(driver, driverConfigJson: "{}", CancellationToken.None); + + var injected = new RecordingWriter("from-di"); + var writer = Phase7Composer.SelectAlarmHistorianWriter(host, injected, out var source); + + writer.ShouldBeSameAs(driver); + source.ShouldBe("driver:drv-1"); + } + + [Fact] + public async Task First_driver_implementing_writer_wins() + { + await using var host = new DriverHost(); + var driverNoWriter = new FakeDriverWithoutWriter("drv-1"); + var driverWithWriter = new FakeDriverWithWriter("drv-2", "drv-out"); + + await host.RegisterAsync(driverNoWriter, "{}", CancellationToken.None); + await host.RegisterAsync(driverWithWriter, "{}", CancellationToken.None); + + var writer = Phase7Composer.SelectAlarmHistorianWriter(host, injectedWriter: null, out var source); + + writer.ShouldBeSameAs(driverWithWriter); + source.ShouldBe("driver:drv-2"); + } + + private sealed class RecordingWriter : IAlarmHistorianWriter + { + public string Tag { get; } + public RecordingWriter(string tag) { Tag = tag; } + + public Task> WriteBatchAsync( + IReadOnlyList batch, CancellationToken cancellationToken) + { + var outcomes = new HistorianWriteOutcome[batch.Count]; + for (var i = 0; i < outcomes.Length; i++) outcomes[i] = HistorianWriteOutcome.Ack; + return Task.FromResult>(outcomes); + } + } + + private sealed class FakeDriverWithoutWriter : IDriver + { + public FakeDriverWithoutWriter(string id) { DriverInstanceId = id; } + public string DriverInstanceId { get; } + public string DriverType => "FakeNoWriter"; + public Task InitializeAsync(string c, CancellationToken ct) => Task.CompletedTask; + public Task ReinitializeAsync(string c, CancellationToken ct) => Task.CompletedTask; + public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask; + public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); + public long GetMemoryFootprint() => 0; + public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask; + } + + private sealed class FakeDriverWithWriter : IDriver, IAlarmHistorianWriter + { + private readonly RecordingWriter _writer; + public FakeDriverWithWriter(string id, string tag) + { + DriverInstanceId = id; + _writer = new RecordingWriter(tag); + } + public string DriverInstanceId { get; } + public string DriverType => "FakeWithWriter"; + public Task InitializeAsync(string c, CancellationToken ct) => Task.CompletedTask; + public Task ReinitializeAsync(string c, CancellationToken ct) => Task.CompletedTask; + public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask; + public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); + public long GetMemoryFootprint() => 0; + public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask; + + public Task> WriteBatchAsync( + IReadOnlyList batch, CancellationToken cancellationToken) + => _writer.WriteBatchAsync(batch, cancellationToken); + } +}