feat(historian): backend registry, ServerHistorian:Backend, Host wiring via provider
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+36
@@ -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()
|
||||
{
|
||||
|
||||
+62
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user