feat(historian): AddServerHistorian DI + Host wiring of IHistorianDataSource

This commit is contained in:
Joseph Doherty
2026-06-14 20:17:10 -04:00
parent e6ec0ad8be
commit a6f1f4ef15
7 changed files with 346 additions and 0 deletions
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using ZB.MOM.WW.OtOpcUa.Runtime;
@@ -30,6 +31,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
private readonly DeferredAddressSpaceSink _deferredSink;
private readonly DeferredServiceLevelPublisher _deferredServiceLevel;
private readonly IOpcUaUserAuthenticator _userAuthenticator;
private readonly IHistorianDataSource _historianDataSource;
private readonly Func<ActorSystem> _actorSystemAccessor;
private readonly ActorRegistry _actorRegistry;
private readonly ILoggerFactory _loggerFactory;
@@ -45,6 +47,11 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
/// <param name="deferredSink">The deferred address space sink that receives the real sink once the server is ready.</param>
/// <param name="deferredServiceLevel">The deferred service level publisher that receives the real publisher once the server is ready.</param>
/// <param name="userAuthenticator">The OPC UA user authenticator.</param>
/// <param name="historianDataSource">The server-side HistoryRead backend resolved from DI — the
/// <c>NullHistorianDataSource</c> default seeded by <c>AddOtOpcUaRuntime</c> (which runs on this driver
/// node, the same source the address-space sink + node-write gateway come from), or the configured
/// Wonderware read client when <c>AddServerHistorian</c> enabled it. Wired onto the node manager in
/// <see cref="StartAsync"/>.</param>
/// <param name="actorSystemAccessor">Lazy accessor for the running <see cref="ActorSystem"/>, used to
/// resolve the DistributedPubSub mediator the inbound alarm-command router publishes through. Resolved
/// lazily (mirroring <c>DpsScriptLogPublisher</c>) so construction never races Akka startup.</param>
@@ -57,6 +64,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
DeferredAddressSpaceSink deferredSink,
DeferredServiceLevelPublisher deferredServiceLevel,
IOpcUaUserAuthenticator userAuthenticator,
IHistorianDataSource historianDataSource,
Func<ActorSystem> actorSystemAccessor,
ActorRegistry actorRegistry,
ILoggerFactory loggerFactory)
@@ -65,6 +73,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_deferredSink = deferredSink;
_deferredServiceLevel = deferredServiceLevel;
_userAuthenticator = userAuthenticator;
_historianDataSource = historianDataSource;
_actorSystemAccessor = actorSystemAccessor;
_actorRegistry = actorRegistry;
_loggerFactory = loggerFactory;
@@ -148,6 +157,11 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
resolveDriverHost: () => _actorRegistry.TryGet<DriverHostActorKey>(out var driverHost) ? driverHost : null,
logger: _loggerFactory.CreateLogger<ActorNodeWriteGateway>()));
// Wire the server-side read backend resolved from DI — the NullHistorianDataSource default (when
// the ServerHistorian section is disabled) or the configured Wonderware read client (when enabled).
// The node manager's HistoryRead overrides block-bridge to whatever source is set here.
_server.SetHistorianDataSource(_historianDataSource);
// ServiceLevel publisher needs IServerInternal — only available after Start.
if (_server.CurrentInstance is { } serverInternal)
{
@@ -171,6 +185,8 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_deferredServiceLevel.SetInner(null);
// Restore the Null write gateway so a late client write doesn't Ask a stopping DriverHostActor.
_server?.SetNodeWriteGateway(null);
// Restore the Null historian so a late HistoryRead doesn't hit a disposed read client.
_server?.SetHistorianDataSource(null);
return Task.CompletedTask;
}
@@ -100,6 +100,20 @@ if (hasDriver)
},
sp.GetService<ILogger<WonderwareHistorianClient>>()));
// Config-gated server-side HistoryRead backend. When the ServerHistorian section is enabled this
// overrides the NullHistorianDataSource default from AddOtOpcUaRuntime (last registration wins) with
// a read-only WonderwareHistorianClient the node manager's HistoryRead overrides block-bridge to.
// The client is supplied here because the Host is the only project that references the Wonderware
// client — Runtime owns the gating, the Host supplies the concrete read downstream.
builder.Services.AddServerHistorian(
builder.Configuration,
(opts, sp) => new WonderwareHistorianClient(
new WonderwareHistorianClientOptions(opts.Host, opts.Port, opts.SharedSecret)
{
UseTls = opts.UseTls, ServerCertThumbprint = opts.ServerCertThumbprint,
},
sp.GetService<ILogger<WonderwareHistorianClient>>()));
// Bind every cross-platform driver factory before AddAkka resolves IDriverFactory — replaces
// the F7-default NullDriverFactory with a real DriverFactoryRegistryAdapter so DriverHostActor
// can materialise real IDriver instances on deploy.
@@ -22,5 +22,13 @@
"DrainIntervalSeconds": 5,
"Capacity": 1000000,
"DeadLetterRetentionDays": 30
},
"ServerHistorian": {
"Enabled": false,
"Host": "localhost",
"Port": 32569,
"UseTls": false,
"ServerCertThumbprint": null,
"SharedSecret": ""
}
}
@@ -1,6 +1,7 @@
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -59,6 +60,28 @@ public sealed class OtOpcUaSdkServer : StandardServer
return true;
}
/// <summary>
/// Wire the server-side HistoryRead backend (the <see cref="IHistorianDataSource"/> the node
/// manager's HistoryRead overrides block-bridge to) onto the created
/// <see cref="OtOpcUaNodeManager"/>. The host calls this after start with the DI-resolved source —
/// the <c>NullHistorianDataSource</c> default (GoodNoData-empty reads) or the configured Wonderware
/// read client. Passing <c>null</c> restores the Null default (the property setter null-coalesces),
/// i.e. "no historian". No-op (returns <c>false</c>) when the node manager has not been created yet,
/// so the caller can detect a too-early call (mirrors <see cref="SetNodeWriteGateway"/>).
/// </summary>
/// <param name="source">The read backend invoked by the node manager's HistoryRead overrides; may be
/// <c>null</c> to restore the Null default (GoodNoData-empty reads).</param>
/// <returns><c>true</c> when the source was set on a live node manager; <c>false</c> when no node
/// manager exists yet.</returns>
public bool SetHistorianDataSource(IHistorianDataSource? source)
{
if (_otOpcUaNodeManager is null) return false;
// The HistorianDataSource setter null-coalesces to the Null default, so a null source is intentional
// (restores GoodNoData-empty reads); forgive the nullable-in here.
_otOpcUaNodeManager.HistorianDataSource = source!;
return true;
}
/// <inheritdoc />
protected override MasterNodeManager CreateMasterNodeManager(
IServerInternal server, ApplicationConfiguration configuration)
@@ -0,0 +1,55 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
/// <summary>
/// Binds the <c>ServerHistorian</c> configuration section that gates the server-side
/// HistoryRead backend. When <see cref="Enabled"/> is <c>true</c>, <c>AddServerHistorian</c>
/// registers a read-only <c>WonderwareHistorianClient</c> (supplied by the Host) as the
/// <c>IHistorianDataSource</c> in place of the <c>NullHistorianDataSource</c> default;
/// otherwise the Null default survives and HistoryRead returns <c>GoodNoData</c>-empty.
/// <para>
/// This is the READ path only — there are no DatabasePath / drain / capacity / retention
/// knobs (those belong to the write-side <c>AlarmHistorian</c> store-and-forward sink). The
/// client's own <c>CallTimeout</c> bounds each read; the node manager adds no extra timeout.
/// </para>
/// </summary>
public sealed class ServerHistorianOptions
{
/// <summary>The configuration section name this options class binds.</summary>
public const string SectionName = "ServerHistorian";
/// <summary>
/// When <c>true</c>, the Wonderware read client is registered as the
/// <c>IHistorianDataSource</c>; when <c>false</c> (the default) the no-op
/// <c>NullHistorianDataSource</c> stays in place and HistoryRead returns empty.
/// </summary>
public bool Enabled { get; init; }
/// <summary>TCP hostname or IP address the Wonderware historian sidecar listens on.</summary>
public string Host { get; init; } = "localhost";
/// <summary>TCP port the Wonderware historian sidecar listens on.</summary>
public int Port { get; init; }
/// <summary>When <c>true</c>, the client connects over TLS.</summary>
public bool UseTls { get; init; }
/// <summary>Expected TLS server certificate thumbprint (hex, no spaces). Null or empty disables pinning.</summary>
public string? ServerCertThumbprint { get; init; }
/// <summary>Per-process shared secret the sidecar verifies in the Hello frame.</summary>
public string SharedSecret { get; init; } = "";
/// <summary>Returns operator-facing misconfiguration warnings for an <c>Enabled</c> historian
/// (empty when disabled or correctly configured). Pure — the registration logs each entry.</summary>
/// <returns>Zero or more human-readable warning messages.</returns>
public IEnumerable<string> Validate()
{
if (!Enabled) yield break;
if (string.IsNullOrWhiteSpace(SharedSecret))
yield return "ServerHistorian:SharedSecret is empty while the historian is enabled — the Wonderware sidecar Hello frame will carry an empty secret.";
if (Port <= 0)
yield return $"ServerHistorian:Port is {Port} — must be > 0; the read client cannot dial the sidecar.";
}
}
@@ -42,6 +42,7 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddOtOpcUaRuntime(this IServiceCollection services)
{
services.TryAddSingleton<IAlarmHistorianSink>(NullAlarmHistorianSink.Instance);
services.TryAddSingleton<IHistorianDataSource>(NullHistorianDataSource.Instance);
services.TryAddSingleton<IDriverFactory>(NullDriverFactory.Instance);
services.TryAddSingleton<IOpcUaAddressSpaceSink>(NullOpcUaAddressSpaceSink.Instance);
services.TryAddSingleton<IServiceLevelPublisher>(NullServiceLevelPublisher.Instance);
@@ -93,6 +94,38 @@ public static class ServiceCollectionExtensions
return services;
}
/// <summary>
/// Config-gated server-side HistoryRead backend. When the <c>ServerHistorian</c> section has
/// <c>Enabled=true</c>, registers the <paramref name="dataSourceFactory"/>-supplied
/// <see cref="IHistorianDataSource"/> (the read-only Wonderware client) overriding the
/// <see cref="NullHistorianDataSource"/> default from <see cref="AddOtOpcUaRuntime"/>. Otherwise
/// a no-op (the Null default stays and the node manager's HistoryRead returns
/// <c>GoodNoData</c>-empty). The data source is injected so the Wonderware client can be supplied
/// by the Host, which is the only project that references it.
/// </summary>
/// <param name="services">The service collection to register with.</param>
/// <param name="configuration">The configuration carrying the <c>ServerHistorian</c> section.</param>
/// <param name="dataSourceFactory">
/// Factory the Host supplies to build the concrete read <see cref="IHistorianDataSource"/>
/// (the Wonderware client) from the bound options + the resolving provider.
/// </param>
/// <returns>The same <paramref name="services"/> instance for chaining.</returns>
public static IServiceCollection AddServerHistorian(
this IServiceCollection services,
IConfiguration configuration,
Func<ServerHistorianOptions, IServiceProvider, IHistorianDataSource> dataSourceFactory)
{
var opts = configuration.GetSection(ServerHistorianOptions.SectionName).Get<ServerHistorianOptions>();
if (opts is not { Enabled: true }) return services; // leave the Null default from AddOtOpcUaRuntime
foreach (var warning in opts.Validate())
Serilog.Log.Logger.ForContext<ServerHistorianOptions>().Warning("ServerHistorian config: {ServerHistorianConfigWarning}", warning);
// Last-registration-wins over the TryAddSingleton Null default seeded by AddOtOpcUaRuntime.
services.AddSingleton<IHistorianDataSource>(sp => dataSourceFactory(opts, sp));
return services;
}
/// <summary>
/// Spawns the per-node driver-role actors on the host's <see cref="ActorSystem"/>:
/// <see cref="DriverHostActor"/> (one per node), <see cref="DbHealthProbeActor"/>
@@ -0,0 +1,197 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;
/// <summary>
/// Verifies the config-gated <c>AddServerHistorian</c> registration: when the
/// <c>ServerHistorian</c> section is absent or disabled the <see cref="NullHistorianDataSource"/>
/// default seeded by <c>AddOtOpcUaRuntime</c> survives (the factory is never invoked); when it is
/// enabled the factory's returned <see cref="IHistorianDataSource"/> wins
/// (last-registration-wins over the <c>TryAddSingleton</c> Null default).
/// </summary>
public sealed class AddServerHistorianTests
{
/// <summary>A trivial read source the factory hands back when enabled; never actually invoked.</summary>
private sealed class FakeHistorianDataSource : IHistorianDataSource
{
private static readonly HistoryReadResult EmptyRead = new(Array.Empty<DataValueSnapshot>(), null);
private static readonly HistoricalEventsResult EmptyEvents = new(Array.Empty<HistoricalEvent>(), null);
public Task<HistoryReadResult> ReadRawAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken) => Task.FromResult(EmptyRead);
public Task<HistoryReadResult> ReadProcessedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken) => Task.FromResult(EmptyRead);
public Task<HistoryReadResult> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc,
CancellationToken cancellationToken) => Task.FromResult(EmptyRead);
public Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
CancellationToken cancellationToken) => Task.FromResult(EmptyEvents);
public HistorianHealthSnapshot GetHealthSnapshot() => new(
TotalQueries: 0, TotalSuccesses: 0, TotalFailures: 0, ConsecutiveFailures: 0,
LastSuccessTime: null, LastFailureTime: null, LastError: null,
ProcessConnectionOpen: false, EventConnectionOpen: false,
ActiveProcessNode: null, ActiveEventNode: null,
Nodes: Array.Empty<HistorianClusterNodeState>());
public void Dispose()
{
// Stateless fake; nothing to release.
}
}
private static IConfiguration ConfigFrom(Dictionary<string, string?> values)
=> new ConfigurationBuilder().AddInMemoryCollection(values).Build();
[Fact]
public void Section_absent_keeps_null_source_and_factory_not_invoked()
{
var factoryInvoked = false;
var services = new ServiceCollection();
services.AddOtOpcUaRuntime();
var config = ConfigFrom(new Dictionary<string, string?>());
services.AddServerHistorian(config, (_, _) =>
{
factoryInvoked = true;
return new FakeHistorianDataSource();
});
using var provider = services.BuildServiceProvider();
provider.GetRequiredService<IHistorianDataSource>().ShouldBeSameAs(NullHistorianDataSource.Instance);
factoryInvoked.ShouldBeFalse();
}
[Fact]
public void Section_disabled_keeps_null_source_and_factory_not_invoked()
{
var factoryInvoked = false;
var services = new ServiceCollection();
services.AddOtOpcUaRuntime();
var config = ConfigFrom(new Dictionary<string, string?>
{
["ServerHistorian:Enabled"] = "false",
});
services.AddServerHistorian(config, (_, _) =>
{
factoryInvoked = true;
return new FakeHistorianDataSource();
});
using var provider = services.BuildServiceProvider();
provider.GetRequiredService<IHistorianDataSource>().ShouldBeSameAs(NullHistorianDataSource.Instance);
factoryInvoked.ShouldBeFalse();
}
[Fact]
public void Section_enabled_registers_factory_source()
{
var services = new ServiceCollection();
services.AddOtOpcUaRuntime();
var config = ConfigFrom(new Dictionary<string, string?>
{
["ServerHistorian:Enabled"] = "true",
["ServerHistorian:Host"] = "historian.example.com",
["ServerHistorian:Port"] = "32569",
["ServerHistorian:SharedSecret"] = "s",
});
services.AddServerHistorian(config, (_, _) => new FakeHistorianDataSource());
using var provider = services.BuildServiceProvider();
var resolved = provider.GetRequiredService<IHistorianDataSource>();
resolved.ShouldBeOfType<FakeHistorianDataSource>();
resolved.ShouldNotBeSameAs(NullHistorianDataSource.Instance);
}
[Fact]
public void Section_enabled_passes_bound_options_to_factory()
{
ServerHistorianOptions? seen = null;
var services = new ServiceCollection();
services.AddOtOpcUaRuntime();
var config = ConfigFrom(new Dictionary<string, string?>
{
["ServerHistorian:Enabled"] = "true",
["ServerHistorian:Host"] = "historian.example.com",
["ServerHistorian:Port"] = "12345",
["ServerHistorian:UseTls"] = "true",
["ServerHistorian:ServerCertThumbprint"] = "AABBCCDDEEFF",
["ServerHistorian:SharedSecret"] = "s",
});
services.AddServerHistorian(config, (opts, _) =>
{
seen = opts;
return new FakeHistorianDataSource();
});
using var provider = services.BuildServiceProvider();
_ = provider.GetRequiredService<IHistorianDataSource>();
seen.ShouldNotBeNull();
seen.Host.ShouldBe("historian.example.com");
seen.Port.ShouldBe(12345);
seen.UseTls.ShouldBeTrue();
seen.ServerCertThumbprint.ShouldBe("AABBCCDDEEFF");
}
[Fact]
public void Validate_warns_on_empty_shared_secret_when_enabled()
{
var opts = new ServerHistorianOptions { Enabled = true, SharedSecret = "", Port = 32569 };
opts.Validate().ShouldContain(w => w.Contains("SharedSecret"));
}
[Fact]
public void Validate_warns_on_non_positive_port_when_enabled()
{
var opts = new ServerHistorianOptions { Enabled = true, SharedSecret = "s", Port = 0 };
opts.Validate().ShouldContain(w => w.Contains("Port"));
}
[Fact]
public void Validate_is_silent_when_correctly_configured()
{
new ServerHistorianOptions { Enabled = true, SharedSecret = "s", Port = 32569 }.Validate().ShouldBeEmpty();
}
[Fact]
public void Validate_is_silent_when_disabled()
{
new ServerHistorianOptions { Enabled = false, SharedSecret = "", Port = 0 }.Validate().ShouldBeEmpty();
}
[Fact]
public void Section_binds_host_port_tls_fields()
{
var config = ConfigFrom(new Dictionary<string, string?>
{
["ServerHistorian:Host"] = "historian.example.com",
["ServerHistorian:Port"] = "12345",
["ServerHistorian:UseTls"] = "true",
["ServerHistorian:ServerCertThumbprint"] = "AABBCCDDEEFF",
});
var opts = config.GetSection(ServerHistorianOptions.SectionName).Get<ServerHistorianOptions>();
opts.ShouldNotBeNull();
opts.Host.ShouldBe("historian.example.com");
opts.Port.ShouldBe(12345);
opts.UseTls.ShouldBeTrue();
opts.ServerCertThumbprint.ShouldBe("AABBCCDDEEFF");
}
}