Merge pull request 'server: Phase7Composer accepts DI-registered IAlarmHistorianWriter (PR B.4)' (#412) from track-b4-sidecar-alarm-historian-writer into master
This commit was merged in pull request #412.
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user