Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs
T
Joseph Doherty 934909b430 fix(historian): #691 review — clamp Gateway at-or-after read, GoodNoData when nothing exists, HonorReturnBounds kill switch
1. The at-or-after bound read on a forward-only backend (Gateway) ends at
   now + ReverseRawReader.LookAhead instead of DateTime.MaxValue (the gateway
   only checks start <= end, so a year-9999 end reached AVEVA); a bound beyond
   the horizon is not found without a read. SqlServer/QuestDB keep MaxValue:
   they bind it safely and may hold samples further ahead. The Gateway fake now
   refuses far-future ends, so the missing-end-bound test is no longer vacuous.
3. No data and no bound found (whole read empty) => GoodNoData, no values —
   the pre-bounds answer (6.5.3.2: success only if one or both bounds exist).
   A one-value page whose start bound is missing first checks there is data or
   a found end bound.
4. ServerHistorian:HonorReturnBounds (default true); false ignores ReturnBounds
   as before #691, including the modified-read Bad_InvalidArgument.
2026-10-12 17:52:34 -04:00

195 lines
14 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>BadTooManyOperations</c> for that node (#686; was
/// <c>BadHistoryOperationUnsupported</c>). It is also the tie-cluster CEILING of a reverse read walked
/// back from forward reads on a backend that cannot read newest-first (the Gateway; segments are sized from the page, #689): one timestamp holding
/// more samples than this is refused <c>BadTooManyOperations</c>. The default (65536) comfortably covers
/// any realistic same-millisecond burst; a value below 1 is treated as 1.
/// </summary>
public int MaxTieClusterOverfetch { get; init; } = 65536;
/// <summary>
/// Gitea #691: whether HistoryRead-Raw honours <c>ReadRawModifiedDetails.ReturnBounds</c> (OPC UA Part 11 bounding
/// values). Default <c>true</c>. <c>false</c> is the kill switch back to the pre-#691 answer: the flag is ignored
/// (stored samples only) and a modified read carrying it is not refused <c>Bad_InvalidArgument</c>. Note the .NET
/// SDK's <c>new ReadRawModifiedDetails()</c> defaults <c>ReturnBounds</c> (and <c>IsReadModified</c>) to <c>true</c>.
/// </summary>
public bool HonorReturnBounds { get; init; } = true;
/// <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>
/// <c>SqlServer</c> / <c>QuestDb</c> backends only (#671): the most raw in-window samples one Part 13
/// time-weighted read (TimeAverage, TimeAverage2, Total, Total2, Interpolative) may stream into the server-side
/// evaluator — that family is not pushed down to SQL. A read past the cap stops at cap + 1 rows and fails THAT
/// node with <c>BadTooManyOperations</c> instead of holding a pooled connection until
/// <see cref="HistoryReadDeadline"/>. Non-positive = unbounded (a <see cref="Validate"/> warning). Default
/// 1 000 000 (≈ 11.5 days of a 1 Hz tag).
/// </summary>
public int MaxTimeWeightedRawRows { get; init; } = 1_000_000;
/// <summary>
/// Gitea #674: how far into the future an OPC UA HistoryUpdate (Insert / Replace / Update) item's
/// <c>SourceTimestamp</c> may lie, measured from the server clock. An item beyond <c>now + skew</c> is refused
/// <c>BadOutOfRange</c> by the node manager before any backend sees it — a far-future row is never a legitimate
/// history edit, and on a backend that judges retention against the newest timestamp it could age out every
/// older partition. Applies to every backend. The live recorder is not bounded. Non-positive disables the bound.
/// Default 1 day.
/// </summary>
public TimeSpan HistoryUpdateMaxFutureSkew { get; init; } = TimeSpan.FromDays(1);
/// <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 (treated as 1); HistoryRead-Raw and -Events cannot page within a tie cluster larger than that, and a Gateway reverse raw read cannot serve one timestamp holding more samples — those reads surface BadTooManyOperations.");
// 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 (MaxTimeWeightedRawRows <= 0)
warnings.Add($"ServerHistorian:MaxTimeWeightedRawRows is {MaxTimeWeightedRawRows} — must be > 0; time-weighted aggregate reads on the SqlServer/QuestDb backends stream an unbounded number of raw samples.");
if (HistoryReadDeadline <= TimeSpan.Zero)
warnings.Add($"ServerHistorian:HistoryReadDeadline is {HistoryReadDeadline} — must be > 0; HistoryRead requests run unbounded (no per-request deadline).");
return warnings;
}
}