136 lines
7.2 KiB
C#
136 lines
7.2 KiB
C#
using System.Globalization;
|
|
using System.Text.RegularExpressions;
|
|
using Grpc.Core;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
|
|
|
|
/// <summary>
|
|
/// The HistorianGateway's raw-read row ceiling (<c>Historian:ReadLimits:MaxValuesCeiling</c>, default 1,000,000) as seen
|
|
/// from this side of the wire. With <c>AllowStreamingBeyondCeiling=false</c> (a "strict" gateway) the gateway refuses
|
|
/// any <c>ReadRaw</c> whose <c>max_values</c> exceeds the ceiling with <c>InvalidArgument</c> — BEFORE it streams a row
|
|
/// — and names the ceiling in the refusal (<c>"max_values exceeds the server ceiling of {n}; …"</c>). The gateway has
|
|
/// no RPC that reports the ceiling, so the refusal is how it is learned (#691.3).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A read whose budget the gateway refuses is re-issued ONCE with the ceiling as its budget, and the ceiling is
|
|
/// remembered per data source (<see cref="GatewayRowCeiling"/>); the caller treats a stream that delivered the whole
|
|
/// ceiling as possibly truncated (never answered from).
|
|
/// </remarks>
|
|
internal static class GatewayReadLimits
|
|
{
|
|
private const string CeilingRejectionText = "max_values exceeds the server ceiling";
|
|
|
|
private static readonly Regex CeilingNumber = new(@"max_values exceeds the server ceiling of (\d+)",
|
|
RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
|
|
|
/// <summary>
|
|
/// True when <paramref name="ex"/> (or an inner exception — the client package wraps the <see cref="RpcException"/>)
|
|
/// is the gateway's <c>InvalidArgument</c> "max_values exceeds the server ceiling" rejection.
|
|
/// </summary>
|
|
/// <param name="ex">The exception.</param>
|
|
/// <returns>Whether it is the ceiling rejection.</returns>
|
|
public static bool IsCeilingRejection(Exception ex) => Rejection(ex) is not null;
|
|
|
|
/// <summary>
|
|
/// The ceiling a ceiling rejection names, or — when <paramref name="ex"/> is a ceiling rejection that does not
|
|
/// carry a parseable number — the gateway's default (<see cref="RawAggregateReader.GatewayDefaultMaxValuesCeiling"/>).
|
|
/// </summary>
|
|
/// <param name="ex">The exception.</param>
|
|
/// <param name="ceiling">The ceiling (> 0) when this returns true.</param>
|
|
/// <returns>Whether <paramref name="ex"/> is a ceiling rejection.</returns>
|
|
public static bool TryGetCeiling(Exception ex, out int ceiling)
|
|
{
|
|
ceiling = 0;
|
|
if (Rejection(ex) is not { } rpc) return false;
|
|
var m = CeilingNumber.Match(rpc.Status.Detail);
|
|
ceiling = m.Success && int.TryParse(m.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var n) && n > 0
|
|
? n
|
|
: RawAggregateReader.GatewayDefaultMaxValuesCeiling;
|
|
return true;
|
|
}
|
|
|
|
private static RpcException? Rejection(Exception ex)
|
|
{
|
|
for (var e = ex; e is not null; e = e.InnerException)
|
|
if (e is RpcException { StatusCode: StatusCode.InvalidArgument } rpc
|
|
&& rpc.Status.Detail.Contains(CeilingRejectionText, StringComparison.Ordinal))
|
|
return rpc;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A strict gateway's row ceiling as learned by ONE data source (#691.3 review): every refused attempt counts as an
|
|
/// error in the gateway's own outcome metrics, so the ceiling a refusal names is remembered and later reads ask for at
|
|
/// most it — no refusal per read in the steady state. Thread-safe (one <see cref="int"/>, read/written atomically; a
|
|
/// race costs at most one extra refusal or re-probe, never a wrong answer).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Rules.</b> (1) Unknown ceiling: ask for the full budget; a refusal naming <c>N</c> < budget learns <c>N</c> and
|
|
/// the read is re-issued once at <c>N</c>. (2) Learned ceiling <c>C</c> below the budget: ask for <c>C</c> — a refusal
|
|
/// there (the gateway was LOWERED) re-learns its <c>N</c> as in (1). (3) If that <c>C</c>-bounded read came back
|
|
/// full but not provably complete, the gateway may have been RAISED (or switched to streaming) since <c>C</c> was
|
|
/// learned, and answering <c>BadTooManyOperations</c> from a stale ceiling would be wrong: the read is re-probed ONCE
|
|
/// at the full budget — served ⇒ the ceiling is forgotten; refused naming a larger <c>N</c> ⇒ <c>N</c> is learned and
|
|
/// the read is re-issued at <c>N</c>; refused naming <c>C</c> again ⇒ the <c>C</c>-bounded result stands (no repeat).
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Why no TTL.</b> A raise only matters to a read that needs more than <c>C</c>, and such a read re-probes anyway
|
|
/// (rule 3), so a timer would only add refusals for reads that fit. The cost of the rule is one refused re-probe per
|
|
/// genuinely over-ceiling read — a read that fails <c>BadTooManyOperations</c> whatever is done.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class GatewayRowCeiling
|
|
{
|
|
private int _learned; // 0 = unknown / not strict
|
|
|
|
/// <summary>The learned ceiling (0 = none).</summary>
|
|
public int Learned => Volatile.Read(ref _learned);
|
|
|
|
/// <summary>Runs a drained read under the rules above.</summary>
|
|
/// <typeparam name="T">The pass result.</typeparam>
|
|
/// <param name="budget">The <c>max_values</c> the read wants.</param>
|
|
/// <param name="pass">One drained read with the given budget.</param>
|
|
/// <param name="needsMore">True when a pass with the given budget delivered it all without proving completeness.</param>
|
|
/// <returns>The result and the budget it was read with.</returns>
|
|
public async Task<(T Result, int Budget)> ReadAsync<T>(int budget, Func<int, Task<T>> pass, Func<T, int, bool> needsMore)
|
|
{
|
|
var learned = Learned;
|
|
if (learned <= 0 || learned >= budget) return await AttemptAsync(budget, pass).ConfigureAwait(false);
|
|
|
|
var bounded = await AttemptAsync(learned, pass).ConfigureAwait(false);
|
|
if (bounded.Budget < learned || !needsMore(bounded.Result, bounded.Budget)) return bounded;
|
|
|
|
// Rule 3: full at a possibly stale ceiling — re-probe once at the full budget.
|
|
int fresh;
|
|
try
|
|
{
|
|
var full = await pass(budget).ConfigureAwait(false);
|
|
Interlocked.CompareExchange(ref _learned, 0, learned);
|
|
return (full, budget);
|
|
}
|
|
catch (Exception ex) when (GatewayReadLimits.TryGetCeiling(ex, out fresh) && fresh < budget)
|
|
{
|
|
// fall through (outside the catch)
|
|
}
|
|
Volatile.Write(ref _learned, fresh);
|
|
return fresh > learned ? (await pass(fresh).ConfigureAwait(false), fresh) : bounded;
|
|
}
|
|
|
|
private async Task<(T Result, int Budget)> AttemptAsync<T>(int budget, Func<int, Task<T>> pass)
|
|
{
|
|
int ceiling;
|
|
try
|
|
{
|
|
return (await pass(budget).ConfigureAwait(false), budget);
|
|
}
|
|
catch (Exception ex) when (GatewayReadLimits.TryGetCeiling(ex, out ceiling) && ceiling < budget)
|
|
{
|
|
// fall through to the retry (outside the catch, so a second refusal is not nested in this one)
|
|
}
|
|
Volatile.Write(ref _learned, ceiling);
|
|
return (await pass(ceiling).ConfigureAwait(false), ceiling);
|
|
}
|
|
}
|