server: Phase7Composer accepts DI-registered IAlarmHistorianWriter (PR B.4)

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) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-30 16:31:00 -04:00
parent f67b3b1b30
commit 6e282b9946
2 changed files with 162 additions and 11 deletions

View File

@@ -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<Phase7Composer> _logger;
@@ -59,12 +60,14 @@ public sealed class Phase7Composer : IAsyncDisposable
IAlarmHistorianSink historianSink,
ILoggerFactory loggerFactory,
Serilog.ILogger scriptLogger,
ILogger<Phase7Composer> logger)
ILogger<Phase7Composer> 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()
/// <summary>
/// Resolution order for the alarm-historian writer:
/// <list type="number">
/// <item><description>Any registered driver that implements <see cref="IAlarmHistorianWriter"/> (today: none — Galaxy used to via the legacy GalaxyProxyDriver).</description></item>
/// <item><description>The DI-registered <see cref="IAlarmHistorianWriter"/> (PR B.4 — the WonderwareHistorianClient sidecar writer when <c>Historian:Wonderware:Enabled=true</c>).</description></item>
/// <item><description><c>null</c> — caller falls back to the injected <see cref="IAlarmHistorianSink"/> (NullAlarmHistorianSink in the default registration).</description></item>
/// </list>
/// 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.
/// </summary>
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();

View File

@@ -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;
/// <summary>
/// PR B.4 — pins the precedence order Phase7Composer uses to pick an
/// <see cref="IAlarmHistorianWriter"/>:
/// 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.
/// </summary>
[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<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
{
var outcomes = new HistorianWriteOutcome[batch.Count];
for (var i = 0; i < outcomes.Length; i++) outcomes[i] = HistorianWriteOutcome.Ack;
return Task.FromResult<IReadOnlyList<HistorianWriteOutcome>>(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<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
=> _writer.WriteBatchAsync(batch, cancellationToken);
}
}