From 31d342eff36bec994b2bd7a99cfd490a6370ba02 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 9 Oct 2026 13:05:53 -0400 Subject: [PATCH] feat(historian): backend registry, ServerHistorian:Backend, Host wiring via provider Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- .../Configuration/HistorianBackendRegistry.cs | 40 ++++++++++++ .../ServerHistorianOptionsValidator.cs | 10 +++ src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 24 ++++--- .../ContinuousHistorizationMetrics.cs | 5 ++ .../Historian/ServerHistorianOptions.cs | 18 ++++++ .../ServerHistorianOptionsValidatorTests.cs | 36 +++++++++++ .../HistorianBackendRegistryTests.cs | 62 +++++++++++++++++++ 7 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/HistorianBackendRegistry.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/HistorianBackendRegistry.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/HistorianBackendRegistry.cs new file mode 100644 index 00000000..d9a2719e --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/HistorianBackendRegistry.cs @@ -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; + +/// +/// Maps ServerHistorian:Backend to an . Names are +/// case-insensitive. To add a backend, add one entry to . +/// only constructs the provider; option validation belongs to +/// . +/// +public static class HistorianBackendRegistry +{ + private static readonly IReadOnlyDictionary> Factories = + new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + [ServerHistorianOptions.GatewayBackendName] = (o, _) => new GatewayHistorianProvider(o), + }; + + /// The registered backend names, for validation messages. + public static IReadOnlyCollection KnownBackends { get; } = Factories.Keys.ToArray(); + + /// True when names a registered backend. + public static bool IsKnown(string? backend) => + !string.IsNullOrWhiteSpace(backend) && Factories.ContainsKey(backend.Trim()); + + /// Builds the provider for options.Backend; throws if unknown. + 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); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs index a9ebcc86..c045b907 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ServerHistorianOptionsValidator.cs @@ -57,12 +57,22 @@ public sealed class ServerHistorianOptionsValidator : OptionsValidatorBase 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("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); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 7636529b..c737f962 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -229,11 +229,21 @@ if (hasDriver) if (builder.Configuration.GetValue("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(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(sp => - new GatewayHistorianValueWriter( - HistorianGatewayClientAdapter.Create( - serverHistorianOptions, sp.GetRequiredService()), - sp.GetRequiredService>())); + builder.Services.AddSingleton(sp => historianProvider.CreateValueWriter(sp)); } // Bind every cross-platform driver factory before AddAkka resolves IDriverFactory — replaces diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationMetrics.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationMetrics.cs index 6f702e78..9333ab1c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationMetrics.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationMetrics.cs @@ -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."); } /// Binds the process outbox the gauges observe. Called once by the Host when continuous diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs index cc41ce38..71ffcd03 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs @@ -35,6 +35,20 @@ public sealed class ServerHistorianOptions /// The scheme selects the transport: https:// = TLS, http:// = h2c plaintext. /// Required when is true. /// + /// + /// Selects the historian backend provider (case-insensitive). Default "Gateway"; the Host's + /// HistorianBackendRegistry owns the set of valid names. Gateway-specific keys + /// (, , ) are only required for Gateway. + /// + public string Backend { get; init; } = GatewayBackendName; + + /// The default backend name. + public const string GatewayBackendName = "Gateway"; + + /// True when selects the gateway backend (case-insensitive). + public bool IsGatewayBackend => + string.Equals(Backend?.Trim(), GatewayBackendName, StringComparison.OrdinalIgnoreCase); + public string Endpoint { get; init; } = ""; /// @@ -112,6 +126,9 @@ public sealed class ServerHistorianOptions { var warnings = new List(); 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. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs index 54baf356..74537e1c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs @@ -33,6 +33,42 @@ public sealed class ServerHistorianOptionsValidatorTests } /// Enabled historian with an empty endpoint fails (would crash-loop in the factory). + /// An unknown backend always fails, even when nothing consumes the section. + [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")); + } + + /// Backend names are case-insensitive; the gateway checks still apply (control: no ApiKey fails). + [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")); + } + + /// + /// 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 + /// and the registry's unknown-name failure only. + /// + [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() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs new file mode 100644 index 00000000..b2d9e3a2 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs @@ -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(); + } + + [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( + () => 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(); + p.CreateValueWriter(sp).ShouldBeOfType(); + p.CreateAlarmWriter(sp).ShouldBeOfType(); + p.CreateProvisioning(sp).ShouldBeOfType(); + p.CreateUpdater(sp).ShouldBeNull(); + } +}