Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs
T
Joseph Doherty d33992034d fix(historian): #689 size reverse-walk segments from the page, not the tie budget
ReverseRawReader aimed every segment at half of MaxTieClusterOverfetch
(~32k rows by default) and carried that width page to page, so a paged
'latest N' read on a dense Gateway tag fetched ~32,770 rows per page
(2-5 s live) to return N. The walk now lives in a generic ReverseWalk
core: segments are aimed at ~N+8 rows (page budget 2N+16), a discovery
segment (first without a hint / after an empty one) may read 1,024, and
the tie budget is only the ceiling a single-timestamp cluster escalates
to. A truncated read never re-enters the stretch below its newest row
until the gap above it is bisected. Tests: per-page row counts on a
dense tag (node manager + walk), randomized exactness fuzz (walk and
node-manager paging), dense-tag-below-a-long-gap read bound.
2026-10-12 17:19:05 -04:00

187 lines
13 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>
/// 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;
}
}