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();
+ }
+}