feat(historian): backend registry, ServerHistorian:Backend, Host wiring via provider

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 13:05:53 -04:00
parent 65e6352dad
commit 31d342eff3
7 changed files with 186 additions and 9 deletions
@@ -0,0 +1,40 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// <summary>
/// Maps <c>ServerHistorian:Backend</c> to an <see cref="IHistorianBackendProvider"/>. Names are
/// case-insensitive. To add a backend, add one entry to <see cref="Factories"/>.
/// <see cref="Resolve"/> only constructs the provider; option validation belongs to
/// <see cref="ServerHistorianOptionsValidator"/>.
/// </summary>
public static class HistorianBackendRegistry
{
private static readonly IReadOnlyDictionary<string, Func<ServerHistorianOptions, IConfiguration, IHistorianBackendProvider>> Factories =
new Dictionary<string, Func<ServerHistorianOptions, IConfiguration, IHistorianBackendProvider>>(StringComparer.OrdinalIgnoreCase)
{
[ServerHistorianOptions.GatewayBackendName] = (o, _) => new GatewayHistorianProvider(o),
};
/// <summary>The registered backend names, for validation messages.</summary>
public static IReadOnlyCollection<string> KnownBackends { get; } = Factories.Keys.ToArray();
/// <summary>True when <paramref name="backend"/> names a registered backend.</summary>
public static bool IsKnown(string? backend) =>
!string.IsNullOrWhiteSpace(backend) && Factories.ContainsKey(backend.Trim());
/// <summary>Builds the provider for <c>options.Backend</c>; throws <see cref="OptionsValidationException"/> if unknown.</summary>
public static IHistorianBackendProvider Resolve(ServerHistorianOptions options, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(configuration);
if (!IsKnown(options.Backend))
throw new OptionsValidationException(
ServerHistorianOptions.SectionName, typeof(ServerHistorianOptions),
[$"ServerHistorian:Backend '{options.Backend}' is not a known historian backend. Allowed values: {string.Join(", ", KnownBackends)}."]);
return Factories[options.Backend.Trim()](options, configuration);
}
}
@@ -57,12 +57,22 @@ public sealed class ServerHistorianOptionsValidator : OptionsValidatorBase<Serve
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, ServerHistorianOptions options)
{
// Unknown backend always fails, with no I/O and regardless of the consumer gate.
builder.RequireThat(
HistorianBackendRegistry.IsKnown(options.Backend),
$"ServerHistorian:Backend '{options.Backend}' is not a known historian backend. "
+ $"Allowed values: {string.Join(", ", HistorianBackendRegistry.KnownBackends)}.");
var alarmHistorianEnabled = _configuration.GetValue<bool>("AlarmHistorian:Enabled");
var consumed = options.Enabled || alarmHistorianEnabled;
// A section nobody consumes may legitimately be empty (the docker-dev default) — no checks.
if (!consumed) return;
// Gateway connection checks apply only to the Gateway backend; other backends validate their own
// options (added with the backend) here, under the same consumer gate.
if (!options.IsGatewayBackend) return;
var endpointValid = Uri.TryCreate(options.Endpoint, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
+15 -9
View File
@@ -229,11 +229,21 @@ if (hasDriver)
if (builder.Configuration.GetValue<bool>("AlarmHistorian:Enabled") && !serverHistorianOptions.Enabled)
Log.Warning(
"AlarmHistorian is enabled but ServerHistorian is disabled — the alarm-history sink is sourcing its " +
"gateway connection (endpoint/key/TLS) from the disabled ServerHistorian section; HistoryReads stay GoodNoData.");
"{Backend} backend connection from the disabled ServerHistorian section; HistoryReads stay GoodNoData.",
serverHistorianOptions.Backend);
// Backend selection: resolve the provider once (unknown name => OptionsValidationException naming the
// allowed values) and build every historian seam through it.
var historianProvider = HistorianBackendRegistry.Resolve(serverHistorianOptions, builder.Configuration);
builder.Services.AddSingleton<IHistorianBackendProvider>(historianProvider);
// HistoryUpdate is gated like HistoryRead: None unconditionally, the real holder only when enabled.
builder.Services.AddSingleton(HistorianUpdaterHolder.None);
if (serverHistorianOptions.Enabled)
builder.Services.AddSingleton(sp => new HistorianUpdaterHolder(historianProvider.CreateUpdater(sp)));
builder.Services.AddAlarmHistorian(
builder.Configuration,
(_, sp) => GatewayHistorian.CreateAlarmWriter(serverHistorianOptions, sp));
(_, sp) => historianProvider.CreateAlarmWriter(sp));
// Node-local LocalDb: caches the deployed-configuration artifact so this node can boot from
// its last-known-good config when central SQL is unreachable, and optionally replicates that
@@ -260,7 +270,7 @@ if (hasDriver)
// and the ServerHistorianOptions -> client-options mapping); Runtime owns the gating.
builder.Services.AddServerHistorian(
builder.Configuration,
(opts, sp) => GatewayHistorian.CreateDataSource(opts, sp));
(_, sp) => historianProvider.CreateDataSource(sp));
// Config-gated historian tag provisioning. When the ServerHistorian section is enabled this registers the
// gateway-backed GatewayTagProvisioner (overriding the NullHistorianProvisioning default) so that deploying
@@ -271,7 +281,7 @@ if (hasDriver)
// ServerHistorian:Enabled flag as the read path — both target the same single gateway.
builder.Services.AddHistorianProvisioning(
builder.Configuration,
(opts, sp) => GatewayHistorian.CreateProvisioner(opts, sp));
(_, sp) => historianProvider.CreateProvisioning(sp));
// Continuous historization of driver (non-Galaxy) tag values. Gated on ContinuousHistorization:Enabled
// AND the ServerHistorian gateway being configured: the recorder drains driver-tag live values to the
@@ -309,11 +319,7 @@ if (hasDriver)
// Gateway-backed live-value writer over its OWN gRPC channel to the same single gateway (a second
// channel to a co-located sidecar is cheap — the gateway pools the historian sessions server-side).
builder.Services.AddSingleton<IHistorianValueWriter>(sp =>
new GatewayHistorianValueWriter(
HistorianGatewayClientAdapter.Create(
serverHistorianOptions, sp.GetRequiredService<ILoggerFactory>()),
sp.GetRequiredService<ILogger<GatewayHistorianValueWriter>>()));
builder.Services.AddSingleton<IHistorianValueWriter>(sp => historianProvider.CreateValueWriter(sp));
}
// Bind every cross-platform driver factory before AddAkka resolves IDriverFactory — replaces
@@ -37,6 +37,11 @@ public static class ContinuousHistorizationMetrics
ObserveDropped,
unit: "{entry}",
description: "Lifetime entries the continuous-historization outbox dropped on capacity overflow.");
OtOpcUaTelemetry.Meter.CreateObservableGauge(
"otopcua.historian.outbox.unreadable",
() => _outbox?.UnreadableCount ?? 0L,
unit: "{entry}",
description: "Lifetime undecodable records the continuous-historization outbox skipped.");
}
/// <summary>Binds the process outbox the gauges observe. Called once by the Host when continuous
@@ -35,6 +35,20 @@ public sealed class ServerHistorianOptions
/// The scheme selects the transport: <c>https://</c> = TLS, <c>http://</c> = h2c plaintext.
/// Required when <see cref="Enabled"/> is <c>true</c>.
/// </summary>
/// <summary>
/// Selects the historian backend provider (case-insensitive). Default <c>"Gateway"</c>; the Host's
/// <c>HistorianBackendRegistry</c> owns the set of valid names. Gateway-specific keys
/// (<see cref="Endpoint"/>, <see cref="ApiKey"/>, <see cref="UseTls"/>) are only required for <c>Gateway</c>.
/// </summary>
public string Backend { get; init; } = GatewayBackendName;
/// <summary>The default backend name.</summary>
public const string GatewayBackendName = "Gateway";
/// <summary>True when <see cref="Backend"/> selects the gateway backend (case-insensitive).</summary>
public bool IsGatewayBackend =>
string.Equals(Backend?.Trim(), GatewayBackendName, StringComparison.OrdinalIgnoreCase);
public string Endpoint { get; init; } = "";
/// <summary>
@@ -112,6 +126,9 @@ public sealed class ServerHistorianOptions
{
var warnings = new List<string>();
if (!Enabled) return warnings;
// Gateway-specific checks apply only to the Gateway backend (other backends carry their own options).
if (IsGatewayBackend)
{
if (string.IsNullOrWhiteSpace(Endpoint))
warnings.Add("ServerHistorian:Endpoint is empty while the historian is enabled — the read client has no gateway address to dial.");
// archreview 06/S-11 warning parity: a non-empty but unparseable / non-absolute-http(s) Endpoint
@@ -123,6 +140,7 @@ public sealed class ServerHistorianOptions
warnings.Add($"ServerHistorian:Endpoint ('{Endpoint}') is not an absolute http(s) URI while the historian is enabled — the read client cannot dial it (e.g. https://host:5222).");
if (string.IsNullOrWhiteSpace(ApiKey))
warnings.Add("ServerHistorian:ApiKey is empty while the historian is enabled — the gateway gRPC surface will reject unauthenticated calls.");
}
// MaxTieClusterOverfetch is intentionally checked AFTER the Enabled early-return above:
// the over-fetch code path only runs when a real IHistorianDataSource is wired in,
// so a zero/negative value is harmless (and noise-free) when the historian is disabled.
@@ -33,6 +33,42 @@ public sealed class ServerHistorianOptionsValidatorTests
}
/// <summary>Enabled historian with an empty endpoint fails (would crash-loop in the factory).</summary>
/// <summary>An unknown backend always fails, even when nothing consumes the section.</summary>
[Fact]
public void Unknown_backend_fails_even_when_not_consumed()
{
var result = Sut().Validate(null, new ServerHistorianOptions { Backend = "Nope" });
result.Failed.ShouldBeTrue();
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Backend") && f.Contains("Nope") && f.Contains("Gateway"));
}
/// <summary>Backend names are case-insensitive; the gateway checks still apply (control: no ApiKey fails).</summary>
[Fact]
public void Lowercase_gateway_backend_still_enforces_gateway_checks()
{
var result = Sut().Validate(null, new ServerHistorianOptions
{
Backend = "gateway", Enabled = true, Endpoint = "https://host:5222", ApiKey = "",
});
result.Failed.ShouldBeTrue();
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:ApiKey"));
}
/// <summary>
/// The gateway-only checks (Endpoint, ApiKey, UseTls) do not apply to a non-gateway backend. Needs a
/// registered non-gateway backend, which does not exist yet, so this asserts the gate through
/// <see cref="ServerHistorianOptions.IsGatewayBackend"/> and the registry's unknown-name failure only.
/// </summary>
[Fact]
public void Gateway_checks_are_gated_on_backend_being_gateway()
{
new ServerHistorianOptions { Backend = "SqlServer" }.IsGatewayBackend.ShouldBeFalse();
new ServerHistorianOptions { Backend = "gateway" }.IsGatewayBackend.ShouldBeTrue();
new ServerHistorianOptions { Backend = "SqlServer", Enabled = true }.Validate().ShouldBeEmpty();
}
[Fact]
public void Enabled_with_empty_endpoint_fails()
{
@@ -0,0 +1,62 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
public sealed class HistorianBackendRegistryTests
{
private static readonly IConfiguration Cfg = new ConfigurationBuilder().Build();
private static ServerHistorianOptions Gateway(string backend = "Gateway") => new()
{
Backend = backend, Enabled = true, Endpoint = "http://localhost:5222", ApiKey = "histgw_id_secret", UseTls = false,
};
[Fact]
public void Default_backend_is_Gateway_and_resolves_the_gateway_provider()
{
new ServerHistorianOptions().Backend.ShouldBe("Gateway");
HistorianBackendRegistry.Resolve(new ServerHistorianOptions(), Cfg).ShouldBeOfType<GatewayHistorianProvider>();
}
[Theory]
[InlineData("gateway")]
[InlineData("GATEWAY")]
[InlineData("Gateway")]
public void Resolve_is_case_insensitive(string name) =>
HistorianBackendRegistry.Resolve(Gateway(name), Cfg).Name.ShouldBe("Gateway");
[Fact]
public void Unknown_backend_throws_naming_the_value_and_the_allowed_names()
{
var ex = Should.Throw<OptionsValidationException>(
() => HistorianBackendRegistry.Resolve(Gateway("Nope"), Cfg));
var msg = string.Join(" ", ex.Failures);
msg.ShouldContain("Nope");
msg.ShouldContain("Gateway");
msg.ShouldContain("ServerHistorian:Backend");
}
[Fact]
public void KnownBackends_lists_Gateway() => HistorianBackendRegistry.KnownBackends.ShouldContain("Gateway");
[Fact]
public void Gateway_provider_builds_the_same_concrete_seams_Program_used_to_build()
{
var sp = new ServiceCollection().BuildServiceProvider();
var p = HistorianBackendRegistry.Resolve(Gateway(), Cfg);
p.CreateDataSource(sp).ShouldBeOfType<GatewayHistorianDataSource>();
p.CreateValueWriter(sp).ShouldBeOfType<GatewayHistorianValueWriter>();
p.CreateAlarmWriter(sp).ShouldBeOfType<GatewayAlarmHistorianWriter>();
p.CreateProvisioning(sp).ShouldBeOfType<GatewayTagProvisioner>();
p.CreateUpdater(sp).ShouldBeNull();
}
}