d33992034d
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.
187 lines
13 KiB
C#
187 lines
13 KiB
C#
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_<id>_<secret></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_<id>_<secret></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;
|
||
}
|
||
}
|