feat(historian): ServerHistorian:MaxRawValuesPerRead caps raw pages and backend reads
Review R08:1.1: an uncapped HistoryRead-Raw (NumValuesPerNode = 0) read a tag's whole window into memory on every backend (SQL TOP(int.MaxValue), QuestDB LIMIT, Gateway max_values = int.MaxValue + a second snapshot copy). New ServerHistorian:MaxRawValuesPerRead (default 10 000; <= 0 unbounded with a Validate() warning; 1 raised to 2). Each backend data source takes it, reports it as IHistorianDataSource.MaxRawValuesPerRead (the Gateway: min with a strict gateway's learned ceiling), so the node manager's existing RawPageLimit paging serves N = 0 and N > cap as pages of at most the cap with a continuation point. Independently, a backend raw read asking for 0 or more than the cap reads cap + 1 rows and is REFUSED (HistoryReadLimitExceededException => BadTooManyOperations) when the window holds more - never answered truncated, since the server's paging reads a short answer as the last page. Bound through the Host registry, the SqlServer/QuestDb providers and GatewayHistorian.CreateDataSource. Tests: options (default/binding/warnings), provider binding, Gateway unit + through a real SDK node manager (uncapped -> 999/998/998/605 + CPs), and a conformance case for the SQL backends. Refs #736 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -45,8 +45,8 @@ public static class HistorianBackendRegistry
|
||||
new Dictionary<string, Func<ServerHistorianOptions, IConfiguration, IHistorianBackendProvider>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[ServerHistorianOptions.GatewayBackendName] = (o, _) => new GatewayHistorianProvider(o),
|
||||
[SqlServerBackendName] = (o, c) => new SqlServerHistorianProvider(BindSqlServer(c), o.MaxTimeWeightedRawRows),
|
||||
[QuestDbBackendName] = (o, c) => new QuestDbHistorianProvider(BindQuestDb(c), o.MaxTimeWeightedRawRows),
|
||||
[SqlServerBackendName] = (o, c) => new SqlServerHistorianProvider(BindSqlServer(c), o.MaxTimeWeightedRawRows, o.EffectiveMaxRawValuesPerRead),
|
||||
[QuestDbBackendName] = (o, c) => new QuestDbHistorianProvider(BindQuestDb(c), o.MaxTimeWeightedRawRows, o.EffectiveMaxRawValuesPerRead),
|
||||
};
|
||||
|
||||
/// <summary>The registered backend names, for validation messages.</summary>
|
||||
|
||||
@@ -154,6 +154,38 @@ public sealed class ServerHistorianOptions
|
||||
/// </summary>
|
||||
public int MaxTimeWeightedRawRows { get; init; } = 1_000_000;
|
||||
|
||||
/// <summary>The default of <see cref="MaxRawValuesPerRead"/>: 10 000 values per raw page / backend read.</summary>
|
||||
public const int DefaultMaxRawValuesPerRead = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// The smallest usable <see cref="MaxRawValuesPerRead"/>: a read must carry a page of at least one value AND the
|
||||
/// lookahead row that tells the server more data follows, so a cap of 1 is raised to 2 (<see cref="EffectiveMaxRawValuesPerRead"/>).
|
||||
/// </summary>
|
||||
public const int MinMaxRawValuesPerRead = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Every backend: the most raw values the server returns on ONE HistoryRead-Raw page, and the most rows one
|
||||
/// backend raw read may materialise — the server's own row ceiling (review R08:1.1, #736). A client's
|
||||
/// <c>NumValuesPerNode = 0</c> ("all values") no longer reads a whole window into memory: it is served as pages of at
|
||||
/// most this many values, each with a continuation point (Part 11 allows fewer values than requested with one), through
|
||||
/// the same tie-safe paging a capped read uses (<c>RawPageLimit</c>: page + boundary ties + lookahead ≤ cap, so a first
|
||||
/// page holds cap − 1 values). A <c>NumValuesPerNode</c> above it is paged at it too. Advertised as
|
||||
/// <c>HistoryServerCapabilities/MaxReturnDataValues</c>. The backends' own <c>TOP</c> / <c>LIMIT</c> / <c>max_values</c>
|
||||
/// are bounded by it independently: a raw read asking for more than this (or for everything) reads at most cap + 1 rows
|
||||
/// and is REFUSED (<c>BadTooManyOperations</c>) when the window holds more — never answered truncated — so a caller that
|
||||
/// bypasses the node manager's paging cannot bypass the bound. On the Gateway backend a strict gateway's learned row
|
||||
/// ceiling (less its boundary-row allowance) still applies when lower. The tie-cluster bound of a resumed page is also
|
||||
/// at most cap − 1 (as under a gateway ceiling; <see cref="MaxTieClusterOverfetch"/> above that is inert). Default
|
||||
/// 10 000. Non-positive = unbounded, the pre-#736 behaviour (a <see cref="Validate"/> warning); 1 is raised to 2.
|
||||
/// </summary>
|
||||
public int MaxRawValuesPerRead { get; init; } = DefaultMaxRawValuesPerRead;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="MaxRawValuesPerRead"/> as the backends and the node manager apply it: <c>0</c> (unbounded) when
|
||||
/// non-positive, otherwise at least <see cref="MinMaxRawValuesPerRead"/>. The one place the rule lives.
|
||||
/// </summary>
|
||||
public int EffectiveMaxRawValuesPerRead => MaxRawValuesPerRead <= 0 ? 0 : Math.Max(MinMaxRawValuesPerRead, MaxRawValuesPerRead);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
@@ -208,6 +240,10 @@ public sealed class ServerHistorianOptions
|
||||
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 (MaxRawValuesPerRead <= 0)
|
||||
warnings.Add($"ServerHistorian:MaxRawValuesPerRead is {MaxRawValuesPerRead} — must be > 0; HistoryRead-Raw pages and backend raw reads are unbounded, so a client asking for a whole window (NumValuesPerNode = 0) materialises every sample in memory.");
|
||||
else if (MaxRawValuesPerRead < MinMaxRawValuesPerRead)
|
||||
warnings.Add($"ServerHistorian:MaxRawValuesPerRead is {MaxRawValuesPerRead} — must be at least {MinMaxRawValuesPerRead} (a page plus its lookahead row); treated as {MinMaxRawValuesPerRead}.");
|
||||
if (EventReadHorizon < TimeSpan.Zero)
|
||||
warnings.Add($"ServerHistorian:EventReadHorizon is {EventReadHorizon} — negative is treated as no horizon (00:00:00); HistoryReadEvents reads every window in full.");
|
||||
if (HistoryReadDeadline <= TimeSpan.Zero)
|
||||
|
||||
Reference in New Issue
Block a user