Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs
T
Joseph Doherty 60a1e7e669 docs(historian): fix-wave docs and stale comments
- Historian.md: Empty samples always Bad (writer/updater/read clamp),
  long-name refusal, 5 s bootstrap failure cache, AtTime after now, info-bit
  masking, HistoryUpdate does NOT type-check (M-1 decision), DeleteRaw
  needs both bounds, reverse Raw paging, the historian/historization metric
  prefixes, and provisioning numeric-only lines qualified as Gateway.
- CLAUDE.md numeric-only lines qualified as Gateway backend.
- Stale 'sole historian' lines in DriverLifecycle, ServiceHosting and the
  retired Wonderware stub; Client.CLI exit-code contract.
- ServerHistorianOptions: Endpoint/Backend XML docs untangled, Validate()
  indented; Program.cs comments are backend-neutral; T10 week-test day count.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 14:33:30 -04:00

162 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
/// <summary>
/// Binds the <c>ServerHistorian</c> configuration section that gates the server-side
/// historian. <see cref="Backend"/> selects the provider (<c>Gateway</c> by default, or <c>SqlServer</c>,
/// whose own keys live in the <c>ServerHistorian:SqlServer</c> sub-section). When <see cref="Enabled"/> is
/// <c>true</c>, <c>AddServerHistorian</c> registers that backend's <c>IHistorianDataSource</c> (supplied by
/// the Host) in place of the <c>NullHistorianDataSource</c> default; otherwise the Null default
/// survives and HistoryRead returns <c>GoodNoData</c>-empty.
/// <para>
/// On the Gateway backend the read client talks gRPC to the <c>ZB.MOM.WW.HistorianGateway</c> sidecar
/// (<c>historian_gateway.v1</c>) over <see cref="Endpoint"/>, authenticating with the
/// peppered-HMAC <see cref="ApiKey"/> (<c>histgw_&lt;id&gt;_&lt;secret&gt;</c>) in the
/// <c>Authorization: Bearer</c> header. 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 <see cref="CallTimeout"/>
/// 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 selected backend's data source 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>
/// 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);
/// <summary>
/// Gateway backend only: absolute gateway endpoint URI the read client dials (e.g.
/// <c>https://host:5222</c>). The scheme selects the transport: <c>https://</c> = TLS, <c>http://</c> = h2c
/// plaintext. Required when the Gateway backend is consumed (<see cref="Enabled"/>, or an enabled
/// <c>AlarmHistorian</c>).
/// </summary>
public string Endpoint { get; init; } = "";
/// <summary>
/// The peppered-HMAC API key (<c>histgw_&lt;id&gt;_&lt;secret&gt;</c>) the gateway validates
/// in the <c>Authorization: Bearer</c> header. Supply via the environment variable
/// <c>ServerHistorian__ApiKey</c> — never commit it to config. The value may be a literal
/// key or a <c>${secret:otopcua/historian/api-key}</c> token, which the pre-host secrets
/// expander resolves fail-closed (throwing if the secret is absent) before this options
/// class binds. Required when <see cref="Enabled"/> is <c>true</c>.
/// </summary>
public string ApiKey { get; init; } = "";
/// <summary>When <c>true</c> (the default), the client connects over TLS; must match the <see cref="Endpoint"/> scheme.</summary>
public bool UseTls { get; init; } = true;
/// <summary>When <c>true</c>, the client accepts a self-signed / untrusted server certificate (dev / on-prem only).</summary>
public bool AllowUntrustedServerCertificate { get; init; }
/// <summary>Path to a PEM CA certificate that pins the gateway's TLS trust chain. Null or empty uses the OS trust store.</summary>
public string? CaCertificatePath { get; init; }
/// <summary>Per-call deadline applied to each unary gateway read. Defaults to 30 seconds.</summary>
public TimeSpan CallTimeout { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>
/// The upper bound on the bounded over-fetch the HistoryRead-Raw paging uses to page WITHIN an
/// oversized "tie cluster" (more raw samples sharing one SourceTimestamp than the client's per-page
/// cap). When a resume read stalls on such a cluster, the node manager over-fetches up to
/// <c>MaxTieClusterOverfetch + 1</c> ties at that single timestamp (a <c>start == end</c> read) and
/// pages through them; this bounds how large a single-timestamp burst the server will buffer in
/// memory before it gives up and surfaces <c>BadHistoryOperationUnsupported</c> for that node. The
/// default (65536) comfortably covers any realistic same-millisecond burst.
/// </summary>
public int MaxTieClusterOverfetch { get; init; } = 65536;
/// <summary>
/// Maximum number of historized nodes served CONCURRENTLY within one HistoryRead batch (arch-review
/// 03/S3). The SDK's <c>HistoryRead*</c> override surface is synchronous, so each arm block-bridges
/// once at its boundary; this bound turns the per-node fan-out inside that boundary from sequential
/// (worst case <c>N × CallTimeout</c>) into <c>⌈N / P⌉ × CallTimeout</c>. It also caps the gateway-load
/// multiplication a single client can command. Default 4.
/// </summary>
public int HistoryReadBatchParallelism { get; init; } = 4;
/// <summary>
/// Process-wide limit on the number of HistoryRead batches served CONCURRENTLY (arch-review 03/S3).
/// A flood of history clients degrades to <c>BadTooManyOperations</c> rather than exhausting the SDK
/// request-thread pool. Together with <see cref="HistoryReadBatchParallelism"/> this caps total
/// in-flight gateway reads at <c>MaxConcurrentHistoryReadBatches × HistoryReadBatchParallelism</c>
/// (default 16 × 4 = 64). Default 16.
/// </summary>
public int MaxConcurrentHistoryReadBatches { get; init; } = 16;
/// <summary>
/// Per-request wall-clock deadline for a HistoryRead batch (arch-review 03/S3). A single request can
/// no longer hold an SDK request thread longer than this regardless of node count; a node still
/// in flight at the deadline surfaces <c>BadTimeout</c>. Non-positive = unbounded (a
/// <see cref="Validate"/> warning). Default 60 seconds.
/// </summary>
public TimeSpan HistoryReadDeadline { get; init; } = TimeSpan.FromSeconds(60);
/// <summary>Returns operator-facing misconfiguration warnings for an <c>Enabled</c> historian
/// (empty when disabled or correctly configured). Pure — the registration logs each entry.
/// <para>
/// These are <b>warnings only</b>. The Host layers a fail-fast startup validator
/// (<c>ServerHistorianOptionsValidator</c>, wired via <c>AddValidatedOptions</c>) on top of this
/// for the <b>provably-crashing</b> subset — an empty/malformed <c>Endpoint</c> that is consumed
/// by an enabled historian or an enabled <c>AlarmHistorian</c> — so that config fails host start
/// with a named error instead of a raw <c>UriFormatException</c> crash-loop (archreview 06/S-11).
/// The empty-<c>ApiKey</c> / non-positive-<c>MaxTieClusterOverfetch</c> entries stay warnings
/// because they degrade rather than crash.
/// </para></summary>
/// <returns>Zero or more human-readable warning messages (never carrying secret values).</returns>
public IReadOnlyList<string> Validate()
{
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
// is just as fatal (it throws in the gateway client factory). Warn on it here so the Runtime-side
// registration logs describe malformed endpoints too, consistent with the Host's fail-fast
// ServerHistorianOptionsValidator (which turns this same condition into a startup failure).
else if (!Uri.TryCreate(Endpoint, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
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.
if (MaxTieClusterOverfetch <= 0)
warnings.Add($"ServerHistorian:MaxTieClusterOverfetch is {MaxTieClusterOverfetch} — must be > 0; HistoryRead-Raw cannot page within an oversized tie cluster and will surface BadHistoryOperationUnsupported for those reads.");
// S3 HistoryRead concurrency knobs — a non-positive value disables the corresponding bound, so warn
// (these gate the per-node fan-out / batch limiter that only run when a real data source is wired).
if (HistoryReadBatchParallelism <= 0)
warnings.Add($"ServerHistorian:HistoryReadBatchParallelism is {HistoryReadBatchParallelism} — must be > 0; HistoryRead falls back to serving each node sequentially.");
if (MaxConcurrentHistoryReadBatches <= 0)
warnings.Add($"ServerHistorian:MaxConcurrentHistoryReadBatches is {MaxConcurrentHistoryReadBatches} — must be > 0; the process-wide HistoryRead batch limiter is disabled.");
if (HistoryReadDeadline <= TimeSpan.Zero)
warnings.Add($"ServerHistorian:HistoryReadDeadline is {HistoryReadDeadline} — must be > 0; HistoryRead requests run unbounded (no per-request deadline).");
return warnings;
}
}